feat: 模板切换重置AI对话+Diff间距修复+API密钥DOM安全+模型切换(20260419_2344)

- 切换模板时同步清空 chatMessages/chatInput/aiUploadedImages/aiSelectedFrames
- 修复stripHtml双换行导致diff modal原始版本段落间距过大
- API密钥input改为ref非受控组件, DOM中不再出现value=sk-xxx属性
- 默认模型名改为 moonshot-v1-32k-vision-preview
This commit is contained in:
2026-04-19 23:50:51 +08:00
parent 18d81cb4a6
commit e549419a4c
6 changed files with 231 additions and 6 deletions

View File

@@ -833,7 +833,7 @@ export default function ReportEditor() {
const stripHtml = (html: string): string => { const stripHtml = (html: string): string => {
const tmp = document.createElement('div'); const tmp = document.createElement('div');
tmp.innerHTML = html.replace(/<\/p>/gi, '</p>\n').replace(/<br\s*\/?>/gi, '\n'); tmp.innerHTML = html.replace(/<br\s*\/?>/gi, '\n');
return (tmp.innerText || tmp.textContent || '').trim(); return (tmp.innerText || tmp.textContent || '').trim();
}; };
@@ -898,7 +898,7 @@ export default function ReportEditor() {
const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi']; const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi'];
const apiKey = provider?.apiKey || getDefaultApiKey(); const apiKey = provider?.apiKey || getDefaultApiKey();
const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, ''); const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, '');
const modelName = provider?.modelName || 'moonshot-v1-auto'; const modelName = provider?.modelName || 'moonshot-v1-32k-vision-preview';
if (!apiKey) { if (!apiKey) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】尚未配置 AI API Key请前往系统设置填写。' }]); setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】尚未配置 AI API Key请前往系统设置填写。' }]);
setIsGenerating(false); setIsGenerating(false);
@@ -1239,6 +1239,10 @@ export default function ReportEditor() {
setVideos([]); setVideos([]);
setCapturedFrames([]); setCapturedFrames([]);
setCurrentVideoIndex(-1); setCurrentVideoIndex(-1);
setChatMessages([]);
setChatInput('');
setAiUploadedImages([]);
setAiSelectedFrames([]);
prevVideoCountRef.current = 0; prevVideoCountRef.current = 0;
stateRef.current = { stateRef.current = {
...stateRef.current, ...stateRef.current,
@@ -1246,6 +1250,8 @@ export default function ReportEditor() {
reportData: nextReportData, reportData: nextReportData,
videos: [], videos: [],
capturedFrames: [], capturedFrames: [],
chatMessages: [],
chatInput: '',
activeTab: stateRef.current.activeTab activeTab: stateRef.current.activeTab
}; };
updatePageHeight(); updatePageHeight();

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar'; import Sidebar from '../components/Sidebar';
import { Video, Globe, Layout, Check, Plus, X } from 'lucide-react'; import { Video, Globe, Layout, Check, Plus, X } from 'lucide-react';
@@ -21,6 +21,16 @@ export default function SystemSettings() {
const [pendingFrameCount, setPendingFrameCount] = useState<number | null>(null); const [pendingFrameCount, setPendingFrameCount] = useState<number | null>(null);
const [modeModalOpen, setModeModalOpen] = useState(false); const [modeModalOpen, setModeModalOpen] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]); const [availableModels, setAvailableModels] = useState<string[]>([]);
const apiKeyInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (apiKeyInputRef.current) {
const targetValue = settings.aiProviders[settings.activeAiProvider]?.apiKey || '';
if (apiKeyInputRef.current.value !== targetValue) {
apiKeyInputRef.current.value = targetValue;
}
}
}, [settings.aiProviders[settings.activeAiProvider]?.apiKey]);
useEffect(() => { useEffect(() => {
const user = storage.get<User | null>('currentUser', null); const user = storage.get<User | null>('currentUser', null);
@@ -38,7 +48,7 @@ export default function SystemSettings() {
providers.kimi = { providers.kimi = {
endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint, endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint,
apiKey: (savedSettings as any).kimiApiKey || '', apiKey: (savedSettings as any).kimiApiKey || '',
modelName: 'moonshot-v1-auto' modelName: 'moonshot-v1-32k-vision-preview'
}; };
} }
savedSettings.aiProviders = providers; savedSettings.aiProviders = providers;
@@ -362,8 +372,8 @@ export default function SystemSettings() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">API </label> <label className="block text-xs font-bold text-text-main uppercase tracking-wider">API </label>
<input <input
ref={apiKeyInputRef}
type="password" type="password"
value={settings.aiProviders[settings.activeAiProvider]?.apiKey || ''}
onChange={(e) => { onChange={(e) => {
const next = { ...settings.aiProviders }; const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], apiKey: e.target.value }; next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], apiKey: e.target.value };

View File

@@ -89,7 +89,7 @@ export interface SystemSettings {
} }
export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = { export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = {
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'moonshot-v1-auto' }, kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'moonshot-v1-32k-vision-preview' },
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' }, deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' }, openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
custom: { endpoint: '', apiKey: '', modelName: '' } custom: { endpoint: '', apiKey: '', modelName: '' }

View File

@@ -0,0 +1,98 @@
# 功能变更实现方案文档20260419_2344
## 实现方案 A切换模板时重置 AI 对话
### 变更点
`ReportEditor.tsx` line 1240-1251模板应用 useEffect 中)。
### 具体实现
`setCapturedFrames([])` 之后、`updatePageHeight()` 之前,添加:
```tsx
setChatMessages([]);
setChatInput('');
setAiUploadedImages([]);
setAiSelectedFrames([]);
stateRef.current = {
...stateRef.current,
chatMessages: [],
chatInput: '',
videos: [],
capturedFrames: []
};
```
同时更新 `stateRef.current`,确保草稿保存时不会残留旧 AI 数据。
---
## 实现方案 B修复 diff modal 段落间距
### 变更点
`ReportEditor.tsx` line 835-838 的 `stripHtml` 函数。
### 具体实现
从:
```ts
tmp.innerHTML = html.replace(/<\/p>/gi, '</p>\n').replace(/<br\s*\/?>/gi, '\n');
```
改为:
```ts
tmp.innerHTML = html.replace(/<br\s*\/?>/gi, '\n');
```
`innerText` 本身会在块级 `<p>` 元素之间自动插入换行,无需手动添加。去掉手动插入后,段落间距恢复正常。
---
## 实现方案 CAPI 密钥 DOM 暴露修复
### 变更点
`src/pages/SystemSettings.tsx` line 362-377 的 API Key input。
### 具体实现
1. 导入 `useRef`
2. 创建 `apiKeyInputRef = useRef<HTMLInputElement>(null)`
3. 使用 `useEffect` 在 apiKey 变化时通过 ref 设置 DOM value
```tsx
const apiKeyInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (apiKeyInputRef.current) {
const targetValue = settings.aiProviders[settings.activeAiProvider]?.apiKey || '';
if (apiKeyInputRef.current.value !== targetValue) {
apiKeyInputRef.current.value = targetValue;
}
}
}, [settings.aiProviders[settings.activeAiProvider]?.apiKey]);
```
4. input 标签移除 `value` prop
```tsx
<input
ref={apiKeyInputRef}
type="password"
onChange={...}
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
placeholder="sk-xxxxxxxxxxxxxxxx"
className="input-minimal"
/>
```
DOM 中不再出现 `value="sk-..."` HTML 属性。
---
## 实现方案 D默认模型名切换
### 变更点
- `src/types.ts` line 92`modelName: 'moonshot-v1-32k-vision-preview'`
- `src/pages/SystemSettings.tsx` migration fallback line 41同步修改
- `src/pages/ReportEditor.tsx` fallback line 901同步修改
---
## 依赖与兼容性
- 无新增 npm 依赖
- API Key input 改为非受控后,不影响现有 onChange 保存逻辑
- stripHtml 修改仅影响 diff modal 渲染,不影响编辑器本身

View File

@@ -0,0 +1,53 @@
# 功能变更测试文档20260419_2344
## 测试项 1切换模板时重置 AI 对话
### 测试场景
1. 打开报告编辑器,在 AI 面板发送几条消息,上传几张图片,选择几个视频帧
2. 点击顶部模板下拉框,选择另一个模板并确认
3. **预期结果**
- 右侧 AI 面板聊天历史完全清空
- 输入框为空
- 已上传图片和已选视频帧清零
- 新模板内容正确加载
---
## 测试项 2Diff Modal 段落间距
### 测试场景
1. 在 AI 可编辑区域(如"手术步骤")发送修改请求
2. AI 返回修改内容后,查看 diff 弹窗
3. **预期结果**
- 左侧"原始版本"段落间距与右侧"AI 提议版本"一致
- 段落之间没有多余的大段空白
- 删除/添加的高亮标记正常显示
---
## 测试项 3API 密钥 DOM 安全
### 测试场景
1. 进入系统设置 → AI 接口集成
2. 打开浏览器 DevTools → Elements 面板
3. 找到 API 密钥的 `<input>` 元素
4. **预期结果**
- `<input>` 标签中不存在 `value="sk-..."` 属性
- 页面上显示密码圆点(正常视觉)
- 输入新密钥、切换 provider 后功能正常
---
## 测试项 4默认模型名
### 测试场景
1. 清除 localStorage 或以新用户登录
2. 进入系统设置 → AI 接口集成
3. **预期结果**:模型名称默认为 `moonshot-v1-32k-vision-preview`
---
## 回归测试
- `tsc --noEmit` 零错误
- `npm run build` 构建成功
- 预览服务器正常启动并可访问

View File

@@ -0,0 +1,58 @@
# 功能变更需求文档20260419_2344
## 需求 1切换模板时重置 AI 对话内容
### 问题背景
当前在顶部下拉框切换模板时,代码只替换了编辑器内容和 reportData但右侧 AI 聊天面板中的历史对话chatMessages和输入框内容chatInput仍然残留与新模板不匹配。
### 需求描述
`ReportEditor.tsx` 模板切换的 `useEffect` 中,同步清空 AI 相关状态:
- `setChatMessages([])`
- `setChatInput('')`
- `setAiUploadedImages([])`
- `setAiSelectedFrames([])`
---
## 需求 2修复"AI 修改确认"弹窗原始版本段落间距过大
### 问题背景
Diff 弹窗左侧"原始版本"中,段落之间的间距明显过大(截图显示有大段空白),而右侧"AI 提议版本"间距正常。
### 根因分析
`stripHtml` 函数中手动在每个 `</p>` 后插入 `\n`
```ts
tmp.innerHTML = html.replace(/<\/p>/gi, '</p>\n').replace(/<br\s*\/?>/gi, '\n');
```
而浏览器 `innerText` 本身就会在块级 `<p>` 元素之间自动插入换行。两者叠加导致双换行 → `computeDiffHtml``\n` 转为 `<br>` → 左侧显示双 `<br>` 间距。
### 需求描述
移除 `stripHtml``</p>\n` 的手动插入,仅保留 `<br>``\n` 的逻辑,让 `innerText` 自然处理段落间距。
---
## 需求 3修复 API 密钥在 DOM 源码中暴露
### 问题背景
即使 `type="password"`,浏览器的 Elements 面板中仍可直接看到 `<input value="sk-2IAFn8ORoSdUcCxYX6DmXJWbH7BxftSSA8kN88mD1KUDTmkv">` 的完整明文。
### 需求描述
将 API Key 输入框从 React 受控组件(`value={...}`)改为 ref 控制的非受控组件:
- JSX 中不写 `value` / `defaultValue` prop
- 通过 `useEffect` + `useRef` 在 JavaScript 层面设置 DOM 的 `value` property非 HTML attribute
- 当 provider 切换或 apiKey 变化时ref 同步更新 input 值
- 添加安全检查:仅当 ref 当前值与目标值不同时才设置,避免覆盖用户正在输入的字符
---
## 需求 4默认模型名改为 moonshot-v1-32k-vision-preview
### 需求描述
将 Kimi 默认模型从 `moonshot-v1-auto` 改为 `moonshot-v1-32k-vision-preview`,支持视觉输入。
---
## 影响范围
- `src/pages/ReportEditor.tsx`
- `src/pages/SystemSettings.tsx`
- `src/types.ts`