前后端桥接 - 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 中的前端开发体验:
- 修改
App.tsx→ Vite HMR 秒级热更新,窗口内容立即变化 - 新增 Service 方法 → 重新运行
wails3 dev→ Wails3 重新生成 bindings - 修改 CSS → Vite HMR,样式即时生效
💡 知识点: 前端走 HMR 热更新(不刷新页面状态),后端走重编译重启。两者的结合让开发效率极高。
本章总结
| 你已经学会 | 对应能力 |
|---|---|
| Wails3 绑定生成规则 | 理解前后端类型映射 |
React useState 多状态管理 |
表单 + 结果展示 |
AppState 枚举状态机 |
防止 UI 状态混乱 |
条件渲染({x && <Comp/>}) |
面板显隐控制 |
e.target.value 双向绑定 |
表单数据流 |
disabled 属性 |
创作中禁止操作 |
loading="lazy" |
图片懒加载 |
🔧 动手练习
- 增加"预览模式":在右侧显示文章预览,左侧显示编辑表单(分栏布局)
- 实现"历史记录":将每次生成的结果存到
localStorage,用列表展示 - 增加字数统计:实时显示当前输入的字数
- 实现"重新生成"按钮:保留当前输入参数,重新调用
CreatorService.Create