From 9173aa77334a4280f4a0318767a06d51f502e7fa Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sun, 19 Apr 2026 03:09:46 +0800 Subject: [PATCH] =?UTF-8?q?2026-04-19-03-03-55=20=E4=BF=AE=E5=A4=8DAI?= =?UTF-8?q?=E6=92=B0=E5=86=99=E4=BD=93=E9=AA=8C=EF=BC=9AAPI=20endpoint?= =?UTF-8?q?=E6=96=9C=E6=9D=A0=E5=87=80=E5=8C=96=E3=80=81=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E4=B8=8B=E6=8B=89=E6=A0=8F=E3=80=81=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E8=AE=B0=E5=BD=95=E6=8C=81=E4=B9=85=E5=8C=96=E5=AD=98?= =?UTF-8?q?=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ReportEditor.tsx | 22 +++-- src/pages/SystemSettings.tsx | 54 ++++++++--- 工程分析/实现方案-2026-04-19-03-03-55.md | 118 +++++++++++++++++++++++ 工程分析/测试方案-2026-04-19-03-03-55.md | 38 ++++++++ 工程分析/经验记录.md | 44 +++++++++ 工程分析/需求分析-2026-04-19-03-03-55.md | 37 +++++++ 6 files changed, 293 insertions(+), 20 deletions(-) create mode 100644 工程分析/实现方案-2026-04-19-03-03-55.md create mode 100644 工程分析/测试方案-2026-04-19-03-03-55.md create mode 100644 工程分析/需求分析-2026-04-19-03-03-55.md diff --git a/src/pages/ReportEditor.tsx b/src/pages/ReportEditor.tsx index 3b3d725..8e01979 100644 --- a/src/pages/ReportEditor.tsx +++ b/src/pages/ReportEditor.tsx @@ -73,6 +73,10 @@ export default function ReportEditor() { const [isEditingPrompts, setIsEditingPrompts] = useState(false); const [diffModal, setDiffModal] = useState<{isOpen: boolean, originalHtml: string, newHtml: string, targetId: string} | null>(null); + useEffect(() => { + stateRef.current.chatMessages = chatMessages; + }, [chatMessages]); + useEffect(() => { if (!editorRef.current) return; const allFields = editorRef.current.querySelectorAll('.field-value'); @@ -118,7 +122,7 @@ export default function ReportEditor() { const videoInputRef = useRef(null); const contentLoadedRef = useRef(false); const contentRef = useRef(''); - const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId }); + const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId, chatMessages }); const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : ''; @@ -143,7 +147,8 @@ export default function ReportEditor() { videos: stateRef.current.videos, capturedFrames: stateRef.current.capturedFrames, activeTab: stateRef.current.activeTab, - loadedTemplateId: stateRef.current.loadedTemplateId + loadedTemplateId: stateRef.current.loadedTemplateId, + chatMessages: stateRef.current.chatMessages }); } }, [reportId]); @@ -187,12 +192,14 @@ export default function ReportEditor() { setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time)); } if (draft.activeTab) setActiveTab(draft.activeTab); + if (draft.chatMessages) setChatMessages(draft.chatMessages); stateRef.current = { ...stateRef.current, reportData: draft.reportData, videos: draft.videos, capturedFrames: draft.capturedFrames, - loadedTemplateId: draft.loadedTemplateId || '' + loadedTemplateId: draft.loadedTemplateId || '', + chatMessages: draft.chatMessages || [] }; if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) { editorRef.current.innerHTML = draft.content; @@ -247,12 +254,14 @@ export default function ReportEditor() { setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time)); } if (draft.activeTab) setActiveTab(draft.activeTab); + if (draft.chatMessages) setChatMessages(draft.chatMessages); stateRef.current = { ...stateRef.current, reportData: draft.reportData, videos: draft.videos, capturedFrames: draft.capturedFrames, - loadedTemplateId: draft.loadedTemplateId || '' + loadedTemplateId: draft.loadedTemplateId || '', + chatMessages: draft.chatMessages || [] }; if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) { editorRef.current.innerHTML = draft.content; @@ -851,7 +860,7 @@ export default function ReportEditor() { const settings = storage.get('systemSettings', {} as SystemSettings); const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi']; const apiKey = provider?.apiKey || ''; - const apiEndpoint = provider?.endpoint || 'https://api.moonshot.cn/v1'; + const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, ''); const modelName = provider?.modelName || 'kimi-k2-5'; if (!apiKey) { setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】尚未配置 AI API Key,请前往系统设置填写。' }]); @@ -1150,7 +1159,8 @@ export default function ReportEditor() { reportData: draft.reportData, videos: draft.videos, capturedFrames: draft.capturedFrames, - loadedTemplateId: draft.loadedTemplateId || '' + loadedTemplateId: draft.loadedTemplateId || '', + chatMessages: draft.chatMessages || [] }; setTimeout(() => updatePageHeight(), 0); return; diff --git a/src/pages/SystemSettings.tsx b/src/pages/SystemSettings.tsx index abeac70..dce9b39 100644 --- a/src/pages/SystemSettings.tsx +++ b/src/pages/SystemSettings.tsx @@ -20,6 +20,7 @@ export default function SystemSettings() { const [isSaved, setIsSaved] = useState(false); const [pendingFrameCount, setPendingFrameCount] = useState(null); const [modeModalOpen, setModeModalOpen] = useState(false); + const [availableModels, setAvailableModels] = useState([]); useEffect(() => { const user = storage.get('currentUser', null); @@ -99,18 +100,27 @@ export default function SystemSettings() { return; } try { - const res = await fetch(`${provider.endpoint}/models`, { + const res = await fetch(`${provider.endpoint.replace(/\/+$/, '')}/models`, { method: 'GET', headers: { 'Authorization': `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json' } }); if (res.ok) { const data = await res.json(); - alert(`连接成功!可用模型数: ${data.data?.length || '未知'}`); + const models = data.data?.map((m: any) => m.id).filter((id: string) => id) || []; + setAvailableModels(models); + if (models.length > 0 && !provider.modelName) { + const next = { ...settings.aiProviders }; + next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: models[0] }; + setSettings({ ...settings, aiProviders: next }); + } + alert(`连接成功!可用模型数: ${models.length}`); } else { alert(`连接失败: ${res.status} ${res.statusText}`); + setAvailableModels([]); } } catch (e: any) { alert(`连接失败: ${e.message}`); + setAvailableModels([]); } }; @@ -361,18 +371,34 @@ export default function SystemSettings() {
- { - const next = { ...settings.aiProviders }; - next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value }; - setSettings({ ...settings, aiProviders: next }); - }} - placeholder="kimi-k2-5" - className="input-minimal" - /> -

当前服务商使用的具体模型版本,可随时修改以切换模型。

+ {availableModels.length > 0 ? ( + + ) : ( + { + const next = { ...settings.aiProviders }; + next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value }; + setSettings({ ...settings, aiProviders: next }); + }} + placeholder="kimi-k2-5" + className="input-minimal" + /> + )} +

{availableModels.length > 0 ? '已从服务商获取可用模型列表' : '点击"测试连接"成功后,此处可下拉选择模型'}

diff --git a/工程分析/实现方案-2026-04-19-03-03-55.md b/工程分析/实现方案-2026-04-19-03-03-55.md new file mode 100644 index 0000000..6133fb1 --- /dev/null +++ b/工程分析/实现方案-2026-04-19-03-03-55.md @@ -0,0 +1,118 @@ +# 实现方案 — 2026-04-19-03-03-55 + +## 1. 方案概述 +三处修补:① endpoint 尾部斜杠净化防止 404;② testApi 捕获模型列表并动态切换 Model Name 输入框为下拉栏;③ 将 `chatMessages` 纳入现有草稿持久化生命周期。 + +## 2. 详细步骤 + +### 步骤 1:`src/pages/ReportEditor.tsx` — endpoint 净化 +**目标文件**:`src/pages/ReportEditor.tsx` +**修改内容**: +在 `handleAIGenerate` 中,将: +```ts +const apiEndpoint = provider?.endpoint || 'https://api.moonshot.cn/v1'; +``` +改为: +```ts +const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, ''); +``` + +### 步骤 2:`src/pages/ReportEditor.tsx` — 聊天记录持久化 +**目标文件**:`src/pages/ReportEditor.tsx` +**修改内容**: +1. `stateRef` 增加 `chatMessages`: + ```ts + const stateRef = useRef({ reportData, videos, capturedFrames, activeTab, loadedTemplateId, chatMessages }); + ``` +2. `saveDraftToStorage` 增加 `chatMessages`: + ```ts + storage.set(key, { + content: currentContent, + draftReportId: reportId || null, + reportData: stateRef.current.reportData, + videos: stateRef.current.videos, + capturedFrames: stateRef.current.capturedFrames, + activeTab: stateRef.current.activeTab, + loadedTemplateId: stateRef.current.loadedTemplateId, + chatMessages: stateRef.current.chatMessages + }); + ``` +3. 在 `setChatMessages` 的调用处同步更新 `stateRef.current.chatMessages`: + - `handleAIGenerate` 中发送 user 消息时 + - `handleAIGenerate` 中收到 model 消息时 + - 也可在 `setChatInput('')` 之后统一用 `useEffect` 监听 `chatMessages` 变化来同步 ref + + 更简单的方案:增加一个 `useEffect` 监听 `chatMessages`: + ```ts + useEffect(() => { + stateRef.current.chatMessages = chatMessages; + }, [chatMessages]); + ``` +4. 初始化 `useEffect`(draft 恢复分支)中恢复 `chatMessages`: + ```ts + if (draft.chatMessages) { + setChatMessages(draft.chatMessages); + } + ``` + +### 步骤 3:`src/pages/SystemSettings.tsx` — 模型名称下拉栏 +**目标文件**:`src/pages/SystemSettings.tsx` +**修改内容**: +1. 新增 state: + ```ts + const [availableModels, setAvailableModels] = useState([]); + ``` +2. 修改 `testApi`: + ```ts + const testApi = async () => { + const provider = settings.aiProviders[settings.activeAiProvider]; + if (!provider?.apiKey) { alert('请先输入 API 密钥'); return; } + try { + const res = await fetch(`${provider.endpoint.replace(/\/+$/, '')}/models`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json' } + }); + if (res.ok) { + const data = await res.json(); + const models = data.data?.map((m: any) => m.id).filter((id: string) => id) || []; + setAvailableModels(models); + if (models.length > 0 && !provider.modelName) { + const next = { ...settings.aiProviders }; + next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: models[0] }; + setSettings({ ...settings, aiProviders: next }); + } + alert(`连接成功!可用模型数: ${models.length}`); + } else { + alert(`连接失败: ${res.status} ${res.statusText}`); + setAvailableModels([]); + } + } catch (e: any) { + alert(`连接失败: ${e.message}`); + setAvailableModels([]); + } + }; + ``` +3. Model Name UI 改为条件渲染: + ```tsx + {availableModels.length > 0 ? ( + + ) : ( + + )} + ``` + +## 3. 依赖关系 +- 步骤 1 和步骤 2 可并行(都在 ReportEditor.tsx) +- 步骤 3 独立(SystemSettings.tsx) + +## 4. 风险预案 +- 若 `/models` 接口返回格式非标准 OpenAI 格式(无 `data` 数组),`models` 列表为空,自动回退到 input 输入框 +- 若 draft 中没有 `chatMessages`(旧 draft),`setChatMessages` 不执行,保持空数组 diff --git a/工程分析/测试方案-2026-04-19-03-03-55.md b/工程分析/测试方案-2026-04-19-03-03-55.md new file mode 100644 index 0000000..5e80bd3 --- /dev/null +++ b/工程分析/测试方案-2026-04-19-03-03-55.md @@ -0,0 +1,38 @@ +# 测试方案 — 2026-04-19-03-03-55 + +## 1. 测试范围 +- API 404 修复(endpoint 尾部斜杠) +- 模型名称下拉栏动态切换 +- AI 聊天记录路由切换持久化 + +## 2. 测试步骤与预期结果 + +### 场景 1:Endpoint 尾部斜杠修复 +1. 在 SystemSettings 中故意将 Base URL 末尾多加一个 `/`:`https://api.moonshot.cn/v1/` +2. 保存,进入 ReportEditor,发送 AI 消息 + 预期:请求 URL 应为 `https://api.moonshot.cn/v1/chat/completions`(只有 1 个斜杠),不应 404 + +### 场景 2:模型名称下拉栏 +1. 在 SystemSettings 中填写正确的 Base URL 和 API Key +2. 点击"测试连接" + 预期:alert 显示连接成功,下方模型名称自动变为下拉栏,列出所有可用模型 +3. 选择其中一个模型,保存 +4. 刷新页面 + 预期:模型名称仍为下拉栏,选中值保留 + +### 场景 3:聊天记录持久化 +1. 进入 ReportEditor,切换到 AI撰写 Tab +2. 发送 2-3 条消息(user + model) +3. 切换到 `/report-manage`,再返回 `/report-editor` + 预期:AI撰写 Tab 中聊天记录仍然存在,与离开前一致 +4. 刷新页面(模拟完全重载) + 预期:聊天记录仍然从 draft 中恢复 + +### 场景 4:类型检查与构建 +1. `npm run lint` + 预期:0 errors +2. `npm run build` + 预期:成功 + +## 3. 回滚检查 +- 若测试失败,执行 `git checkout main` 恢复到上一个 commit diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 8d41fe4..bdb0fa0 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -544,3 +544,47 @@ SystemSettings 需要支持 4 个服务商(Kimi/DeepSeek/OpenAI/自定义) **D. 后续如何避免问题** - 当一组配置具有"同构多实例"特征时(多个服务商、多个环境、多个账号),优先使用 `Record` 而非平铺字段。 - 动态表单的 `onChange` 必须注意不可变更新:先浅拷贝外层字典,再浅拷贝当前项,最后修改目标字段。直接 `settings.aiProviders[k].endpoint = x` 会触发 React 引用比较优化导致不刷新。 + + +--- + +## 记录 26:API Endpoint 尾部斜杠导致 404 + +**A. 具体问题** +SystemSettings 中测试连接成功(`/models` 返回 200),但 ReportEditor 中调用 `/chat/completions` 报 404。用户输入的 Base URL 末尾带有 `/`,导致拼接后路径为 `https://api.xxx.com/v1//chat/completions`。 + +**B. 产生问题原因** +用户从文档复制 Base URL 时,末尾可能带斜杠;代码中直接做字符串拼接 `${apiEndpoint}/chat/completions`,未做净化处理。 + +**C. 解决问题方案** +在 `handleAIGenerate` 和 `testApi` 中统一对 endpoint 做尾部斜杠移除: +```ts +const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, ''); +``` + +**D. 后续如何避免问题** +- 任何从用户输入拼接 URL 的场景,都必须先对基础路径做 `.replace(/\/+$/, '')` 或 `new URL(path, base)` 标准化处理。 +- 测试连通性(`/models`)和实际业务调用(`/chat/completions`)应使用同一套 endpoint 净化逻辑,避免"测试通过、调用失败"的认知落差。 + +--- + +## 记录 27:State 未纳入 Ref 导致自动保存遗漏 + +**A. 具体问题** +AI 撰写面板的 `chatMessages` 在路由切换后全部丢失。因为 `saveDraftToStorage` 从 `stateRef.current` 读取数据快照,而 `chatMessages` 从未被同步到 `stateRef`。 + +**B. 产生问题原因** +ReportEditor 采用 `useRef` 作为自动保存的数据快照机制(避免 React state 闭包陷阱)。新增 `chatMessages` state 时,只关注了 UI 渲染,遗漏了与 `stateRef` 的同步。 + +**C. 解决问题方案** +1. `stateRef` 初始化时包含 `chatMessages` +2. `saveDraftToStorage` 保存对象中增加 `chatMessages: stateRef.current.chatMessages` +3. 增加 `useEffect` 监听 `chatMessages` 变化,实时同步到 `stateRef.current.chatMessages` +4. 所有草稿恢复分支(初始化 useEffect 的 2 处 + useLayoutEffect 的 2 处)均增加 `chatMessages` 的恢复和 ref 同步 + +**D. 后续如何避免问题** +- 在 `ReportEditor.tsx` 中新增任何 `useState` 时,必须同时问自己三个问题: + 1. 这个 state 是否需要持久化到 draft? + 2. 若需要,是否已加入 `stateRef` 初始化? + 3. 若需要,是否已在 `saveDraftToStorage`、所有恢复分支、以及 state→ref 同步 effect 中补齐? +- 建议维护一份 "Draft 持久化字段清单" 注释在 `stateRef` 定义附近,作为新增 state 时的检查单。 diff --git a/工程分析/需求分析-2026-04-19-03-03-55.md b/工程分析/需求分析-2026-04-19-03-03-55.md new file mode 100644 index 0000000..236f616 --- /dev/null +++ b/工程分析/需求分析-2026-04-19-03-03-55.md @@ -0,0 +1,37 @@ +# 需求分析 — 2026-04-19-03-03-55 + +## 1. 需求背景 +AI 撰写功能上线后出现三个体验问题: +1. **SystemSettings 测试连接成功(返回 13 个模型),但 ReportEditor 调用报 404**:`POST https://api.moonshot.cn/v1/chat/completions 404` +2. **模型名称只能手动输入**:用户希望测试连接成功后,模型名称自动变为下拉选择(从 `/models` 返回的列表中选择) +3. **AI 聊天记录不持久**:从 ReportEditor 切换到其他页面再返回,AI 撰写面板中的聊天记录全部丢失 + +## 2. 需求拆解 +- [ ] **Task 1:修复 404 错误** + - 原因分析:测试按钮读取的是 React state(界面临时值),ReportEditor 读取的是 localStorage。若测试后未点击"保存",两者不一致。另外 `apiEndpoint` 末尾多余斜杠可能导致路径拼接错误(`v1//chat/completions`) + - 修复:在 `handleAIGenerate` 中对 `apiEndpoint` 做 `.replace(/\/+$/, '')` 净化 +- [ ] **Task 2:模型名称下拉栏** + - SystemSettings.tsx 增加 `availableModels` 状态 + - `testApi` 成功后解析 `/models` 响应,填充 `availableModels` + - Model Name 输入框在有 availableModels 时自动变为 `