前后端桥接 - Wails绑定与React UI


06 - 前后端桥接 — Wails 绑定与 React UI


本章目标

  • 理解 Wails3 自动生成 TypeScript 绑定的机制
  • 完成 React 前端 UI 的完整开发
  • 实现配置面板、输入表单、结果展示三大模块
  • 掌握前后端数据交互的错误处理模式
  • 管理应用的三种状态:闲置 → 创作中 → 完成

6.1 Wails3 绑定机制回顾

当你运行 wails3 dev 后,Wails3 会自动扫描 Services 中注册的 Go struct,提取所有公开方法,并在 frontend/bindings/ 下生成 TypeScript 绑定文件。

绑定生成规则

Go 方法 生成的 TS 方法
func (cs *ConfigService) GetConfig() (*Config, error) ConfigService.GetConfig(): Promise<Config | null>
func (cs *ConfigService) SaveConfig(cfg *Config) error ConfigService.SaveConfig(cfg: Config): Promise<void>
func (as *AgentService) GenerateArticle(ctx context.Context, req ArtRequest) (*ArticleResult, error) AgentService.GenerateArticle(req: ArtRequest): Promise<ArticleResult | null>
func (ps *PexelsService) SearchImages(query string, perPage int) ([]PexelsPhoto, error) PexelsService.SearchImages(query: string, perPage: number): Promise<PexelsPhoto[] | null>

规律:

  • ctx context.Context 参数被自动省略(Wails 内部维护上下文)
  • Go 的返回值 (...T, error) 映射为 TS 的 Promise<T | null>
  • Go 的 error 转换为 JS 的异常抛出

6.2 前端模块划分

我们的 UI 分为 3 个逻辑模块:

┌──────────────────────────────────────────┐
│  🐾 今日头条宠物内容创作工具    [⚙️ 配置]  │ ← Header
├──────────────────────────────────────────┤
│  [1] config-panel (条件渲染)              │
│      API Key 输入 / 模型选择 / 保存        │
├──────────────────────────────────────────┤
│  [2] input-panel                         │
│      主题 / 宠物 / 风格 / 字数 / 配图数     │
│      [🚀 一键生成]                        │
├──────────────────────────────────────────┤
│  [3] result-panel (条件渲染)              │
│      文章标题 / 正文 / 配图 / Word 链接     │
└──────────────────────────────────────────┘

6.3 完整前端代码

以下是 frontend/src/App.tsx 的完整实现(约 370 行):

导入和类型定义

import { useState, useEffect } from "react";
import { ConfigService, CreatorService, PexelsService } from "../bindings/pet-content-creator/backend";
import "./App.css";

// TypeScript 类型定义(与 Go 结构体对应)
interface Config {
  deepseek_api_key: string;
  deepseek_model: string;
  pexels_api_key: string;
}

interface PexelsPhoto {
  id: number;
  src: { medium: string; large: string; small: string };
  photographer: string;
  alt: string;
  url: string;
}

interface CreateRequest {
  topic: string;
  pet_type: string;
  style: string;
  keywords: string;
  word_count: number;
  image_count: number;
}

interface CreateResponse {
  title: string;
  content: string;
  photos: PexelsPhoto[];
  word_path: string;
  error?: string;
}

// 应用状态枚举
type AppState = "idle" | "creating" | "done";

💡 知识点: type AppState = "idle" | "creating" | "done" 是 TypeScript 的字面量联合类型。它强制应用状态只能是这三个值之一,编译期防止非法状态。


状态管理

function App() {
  // === 配置状态 ===
  const [showConfig, setShowConfig] = useState(false);
  const [config, setConfig] = useState<Config>({
    deepseek_api_key: "",
    deepseek_model: "deepseek-chat",
    pexels_api_key: "",
  });

  // === 输入状态 ===
  const [topic, setTopic] = useState("");
  const [petType, setPetType] = useState("狗");
  const [style, setStyle] = useState("科普");
  const [keywords, setKeywords] = useState("");
  const [wordCount, setWordCount] = useState(800);
  const [imageCount, setImageCount] = useState(3);

  // === 结果状态 ===
  const [appState, setAppState] = useState<AppState>("idle");
  const [response, setResponse] = useState<CreateResponse | null>(null);
  const [error, setError] = useState("");

  // 页面加载时读取配置
  useEffect(() => {
    ConfigService.GetConfig()
      .then((cfg: Config | null) => {
        if (cfg) setConfig(cfg);
      })
      .catch(() => {});
  }, []);

配置保存逻辑

  const saveConfig = async () => {
    try {
      await ConfigService.SaveConfig(config);
      alert("配置已保存");
      setShowConfig(false);
    } catch (e: any) {
      alert("保存失败: " + (e.message || e));
    }
  };

⚠️ 注意: e 的类型是 any,因为 Wails 抛出的错误类型在 TS 侧没有精确定义。生产中建议用 unknown + 类型守卫。


一键创作逻辑

  const handleCreate = async () => {
    // 输入校验
    if (!topic.trim()) {
      setError("请输入写作主题");
      return;
    }

    setAppState("creating");
    setError("");
    setResponse(null);

    const req: CreateRequest = {
      topic: topic.trim(),
      pet_type: petType,
      style: style,
      keywords: keywords.trim(),
      word_count: wordCount,
      image_count: imageCount,
    };

    try {
      const result = await CreatorService.Create(req);
      if (!result) {
        setError("创作返回空结果");
        setAppState("idle");
        return;
      }
      // Wails 返回的 object 需要类型断言
      const cr = result as unknown as CreateResponse;
      if (cr.error) {
        setError(cr.error);
        setAppState("idle");
      } else {
        setResponse(cr);
        setAppState("done");
      }
    } catch (e: any) {
      setError("创作失败: " + (e.message || e));
      setAppState("idle");
    }
  };

三种应用状态及其 UI 映射

状态 UI 表现
idle 输入表单可编辑,“一键生成"按钮可点击
creating 按钮禁用 + 显示"⏳ AI 正在创作中…”
done 显示文章、配图、Word 链接

搜索更多图片

  const searchMoreImages = async (query: string) => {
    try {
      const photos = await PexelsService.SearchImages(query, 6);
      if (response && photos) {
        const p = photos as unknown as PexelsPhoto[];
        setResponse({
          ...response,
          photos: [...response.photos, ...p].slice(0, 12),
        });
      }
    } catch (e: any) {
      setError("搜索图片失败: " + (e.message || e));
    }
  };

这里用到了展开运算符 ...response 来保留原有字段,然后用 slice(0, 12) 限制最多显示 12 张图片。


JSX 渲染(Header + 配置面板)

  return (
    <div className="app">
      {/* 顶部标题栏 */}
      <header className="header">
        <h1>🐾 今日头条宠物内容创作工具</h1>
        <div className="header-actions">
          <button className="btn-sm" onClick={() => setShowConfig(!showConfig)}>
            ⚙️ 配置
          </button>
        </div>
      </header>

      {/* 配置面板(条件渲染) */}
      {showConfig && (
        <div className="config-panel">
          <h3>⚙️ API 配置</h3>
          <div className="form-group">
            <label>DeepSeek API Key</label>
            <input
              type="password"
              value={config.deepseek_api_key}
              onChange={(e) => setConfig({ ...config, deepseek_api_key: e.target.value })}
              placeholder="sk-..."
            />
          </div>
          {/* ... 其他配置项 ... */}
          <button className="btn-primary" onClick={saveConfig}>💾 保存配置</button>
          <p className="hint">
            Pexels API Key 可在{" "}
            <a href="https://www.pexels.com/api/" target="_blank" rel="noreferrer">
              pexels.com/api
            </a>{" "}
            免费获取
          </p>
        </div>
      )}

JSX 渲染(输入面板)

      <div className="main-content">
        <div className="input-panel">
          <h2>📝 写作要求</h2>

          <div className="form-row">
            <label>写作主题 *</label>
            <input
              value={topic}
              onChange={(e) => setTopic(e.target.value)}
              placeholder="例如:如何训练狗狗上厕所"
              disabled={appState === "creating"}
            />
          </div>

          <div className="form-row">
            <label>宠物类型</label>
            <select value={petType} onChange={(e) => setPetType(e.target.value)}
                    disabled={appState === "creating"}>
              {["狗", "猫", "兔子", "仓鼠", "鸟类", "鱼类", "爬行动物", "其他"]
                .map(p => <option key={p} value={p}>{p}</option>)}
            </select>
          </div>

          <div className="form-row">
            <label>写作风格</label>
            <select value={style} onChange={(e) => setStyle(e.target.value)}
                    disabled={appState === "creating"}>
              {["科普", "指南", "故事", "评测", "资讯"]
                .map(s => <option key={s} value={s}>{s}</option>)}
            </select>
          </div>

          <div className="form-row inline">
            <label>字数</label>
            <input type="number" value={wordCount}
                   onChange={(e) => setWordCount(Number(e.target.value))}
                   disabled={appState === "creating"} />
            <label>配图</label>
            <input type="number" value={imageCount}
                   onChange={(e) => setImageCount(Number(e.target.value))}
                   disabled={appState === "creating"} />
          </div>

          <div className="form-row">
            <label>关键词提示(可选)</label>
            <input value={keywords}
                   onChange={(e) => setKeywords(e.target.value)}
                   placeholder="额外的写作方向提示"
                   disabled={appState === "creating"} />
          </div>

          <button
            className="btn-primary btn-large"
            onClick={handleCreate}
            disabled={appState === "creating"}
          >
            {appState === "creating" ? "⏳ AI 正在创作中..." : "🚀 一键生成"}
          </button>
        </div>

JSX 渲染(结果面板)

        {/* 结果面板 */}
        {appState === "done" && response && (
          <div className="result-panel">
            <h2>📊 生成结果</h2>

            {/* 文章标题 */}
            <h3 className="article-title">{response.title}</h3>

            {/* 文章正文 */}
            <div className="article-content">
              {response.content.split("\n").map((para, i) => {
                if (!para.trim()) return null;
                if (para.startsWith("## ") || para.startsWith("# ")) {
                  return <h4 key={i}>{para.replace(/^#+ /, "")}</h4>;
                }
                return <p key={i}>{para}</p>;
              })}
            </div>

            {/* 配图 */}
            {response.photos && response.photos.length > 0 && (
              <div className="image-gallery">
                <h3>🖼️ 配图</h3>
                <div className="image-grid">
                  {response.photos.map((photo, i) => (
                    <div key={i} className="image-card">
                      <img src={photo.src.medium} alt={photo.alt} loading="lazy" />
                      <span>Photo by {photo.photographer}</span>
                    </div>
                  ))}
                </div>
              </div>
            )}

            {/* Word 文档 */}
            <div className="word-section">
              <p>📄 Word 文档已生成: <code>{response.word_path}</code></p>
              <button className="btn-sm" onClick={() => alert("文档位置: " + response.word_path)}>
                📂 打开所在目录
              </button>
            </div>
          </div>
        )}

        {/* 错误信息 */}
        {error && <div className="error-message"> {error}</div>}
      </div>
    </div>
  );
}

export default App;

6.4 样式设计要点

创建 frontend/src/App.css(关键样式):

.app {
  max-width: 1000px;
  margin: 0 auto;
  padding: 20px;
  font-family: "Microsoft YaHei", system-ui, sans-serif;
  color: #333;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-bottom: 16px;
  border-bottom: 2px solid #f0f0f0;
}

.config-panel {
  background: #fafafa;
  border: 1px solid #e8e8e8;
  border-radius: 8px;
  padding: 20px;
  margin: 16px 0;
}

.image-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 12px;
}

.image-card img {
  width: 100%;
  height: auto;
  border-radius: 4px;
}

.btn-primary {
  background: #1677ff;
  color: white;
  border: none;
  padding: 10px 24px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 16px;
}

.btn-primary:disabled {
  background: #91caff;
  cursor: not-allowed;
}

6.5 前端开发工作流

在 Wails3 中的前端开发体验:

  1. 修改 App.tsx → Vite HMR 秒级热更新,窗口内容立即变化
  2. 新增 Service 方法 → 重新运行 wails3 dev → Wails3 重新生成 bindings
  3. 修改 CSS → Vite HMR,样式即时生效

💡 知识点: 前端走 HMR 热更新(不刷新页面状态),后端走重编译重启。两者的结合让开发效率极高。


本章总结

你已经学会 对应能力
Wails3 绑定生成规则 理解前后端类型映射
React useState 多状态管理 表单 + 结果展示
AppState 枚举状态机 防止 UI 状态混乱
条件渲染({x && <Comp/>} 面板显隐控制
e.target.value 双向绑定 表单数据流
disabled 属性 创作中禁止操作
loading="lazy" 图片懒加载

🔧 动手练习

  1. 增加"预览模式":在右侧显示文章预览,左侧显示编辑表单(分栏布局)
  2. 实现"历史记录":将每次生成的结果存到 localStorage,用列表展示
  3. 增加字数统计:实时显示当前输入的字数
  4. 实现"重新生成"按钮:保留当前输入参数,重新调用 CreatorService.Create

👉 下一章:编排服务 — 串联完整创作流水线

wx

关注公众号

©2017-2023 鲁ICP备17023316号-1 Powered by Hugo