35 Commits

Author SHA1 Message Date
35d8dd4ce2 release: v2.0.0 - AI手术图文报告系统(最小可部署包)
- 移除工程分析文档、参考信息、过往经验等非部署内容
- 保留 src/ public/ Dockerfile docker-compose.yaml nginx.conf 等核心文件
2026-04-20 02:41:15 +08:00
3827d09ad3 security: 讯飞配置输入框改用ref非受控设置,防止DOM value属性暴露 2026-04-20 02:28:08 +08:00
49886e5080 ui: AI撰写tab增加机器人头像图标 2026-04-20 02:24:34 +08:00
5f68f4b820 security: 讯飞配置输入框增加密码隐藏+防复制+防自动填充 2026-04-20 02:21:09 +08:00
c544483705 fix: AI修改确认弹窗左侧字体与右侧统一 2026-04-20 02:16:38 +08:00
9aec836e93 fix: 讯飞语音识别文字覆盖+麦克风未释放
- 使用setChatInput(prev => prev + seg)函数式更新,彻底修复文字覆盖问题
- 提取stopMicrophone函数,在手动停止/VAD自动挂断/报错/关闭时统一释放物理麦克风
2026-04-20 02:10:38 +08:00
75e4e56cb3 fix: 讯飞语音识别停止后内容未写入输入框
- 停止录音时不再立即关闭WebSocket,仅发送结束帧+关闭麦克风
- 移除dwa: wpgs动态修正,避免返回数据结构复杂导致拼接混乱
- ws.onmessage检测到服务端status:2时才彻底断开连接并写入最终文字
2026-04-20 01:54:50 +08:00
b07bcfaad2 fix: 讯飞鉴权签名与官方demo对齐+结束帧+文字修改
- ReportEditor: getXfAuthUrl signatureOrigin去掉引号,Base64改用CryptoJS.enc.Base64.stringify与demo完全一致
- ReportEditor: 第一帧增加dwa: wpgs动态修正参数
- ReportEditor: 停止录音时先发送status:2结束帧再关闭WebSocket
- SystemSettings: 标题改为讯飞语音听写Websocket接口配置
2026-04-20 01:43:58 +08:00
4f27edcc92 fix: 讯飞配置字段名统一+WebSocket错误码拦截
- SystemSettings: state初始化和表单绑定中xfIatConfig统一改为xfSpeechConfig,与编辑器读取字段一致
- ReportEditor: ws.onmessage增加jsonData.code错误码拦截,展示具体讯飞报错信息
2026-04-20 01:33:14 +08:00
d235ced187 fix: 讯飞鉴权HTTP兼容+AI段落紧凑+对话模式JSON降级
- ReportEditor: 讯飞鉴权改用crypto-js HMAC-SHA256,兼容HTTP非安全环境
- defaultContent.ts+ReportEditor: AI区域<p>标签去掉margin-bottom,段落紧密排布
- ReportEditor: 纯对话模式下JSON解析失败时降级为直接文本回复
- ReportEditor: 对话模式systemPrompt强化JSON格式约束
- deps: 新增crypto-js用于讯飞鉴权
2026-04-20 01:18:57 +08:00
ea789cee26 fix: 讯飞配置默认值+AI图片同步+TemplateManage空行清理
- ReportEditor: 讯飞语音读取xfSpeechConfig增加DEFAULT_XF_SPEECH后备,旧用户无需手动保存
- ReportEditor: 切换到AI面板时强制同步编辑器图片到视觉参考上下文
- TemplateManage: saveCurrentTemplate/saveTemplateContent保存前清理空段落和标签间空白
- types.ts: 新增XfSpeechConfig接口和DEFAULT_XF_SPEECH常量
2026-04-20 01:01:01 +08:00
9f2b5dce21 fix: AI修改确认弹窗段落分段修复
- defaultContent.ts: 手术步骤模板<p>改为紧凑HTML,统一style添加margin: 0 0 8px 0
- ReportEditor.tsx: stripHtml在</p>与<p>之间插入\\n\\n,diff modal可正确分段
- ReportEditor.tsx: AI返回的cleanHtml<p>样式与模板一致,避免标签差异干扰diff
2026-04-20 00:41:53 +08:00
2dbdbe02b2 feat: AI面板图片联动+聊天图片展示+讯飞语音识别
- AI图片选择区改为仅展示编辑器中已插入的占位图
- 用户发送的图片在聊天气泡中展示并包含在导出日志中
- 接入讯飞Spark IAT流式听写WebSocket替换原生语音识别
2026-04-20 00:36:55 +08:00
963a7541c9 fix: Diff modal原始版本大段空白修复(20260420_0009)
- currentHtml提取后添加 replace(/>(\s+)</g, '><') 压缩标签间空白
- cleanHtml处理后同样压缩, 确保diff两侧格式对齐
- 消除模板源码排版换行被diff算法误识别为删除内容的问题
2026-04-20 00:10:31 +08:00
e549419a4c 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
2026-04-19 23:50:51 +08:00
18d81cb4a6 security: API密钥源码级字符码混淆(20260419_2316+)
- 将 DEFAULT_AI_PROVIDERS.kimi.apiKey 从明文改为空字符串
- storage.ts新增 getDefaultApiKey(): 字符码数组→String.fromCharCode 运行时还原
- SystemSettings.tsx/Login.tsx/ReportEditor.tsx 在 apiKey为空时自动填充默认值
- 彻底消除源码中 sk-xxx 明文字符串的直接暴露
2026-04-19 23:30:13 +08:00
0039b18a26 feat: 模板AI区域化+默认配置优化+API密钥安全(20260419_2316)
- 默认模板: 手术步骤段落包裹进 .ai-region AI可编辑区域
- API密钥: DEFAULT_AI_PROVIDERS.kimi.apiKey 预设默认值,
  输入框增加onCopy/onCut防复制, storage.ts增加XOR+Base64透明加密
- 默认模型: kimi modelName改为 moonshot-v1-auto
- 抽帧配置: 12个位置改为指定百分比[7.9,9.3,46.2,49.1,63.9,64.8,
  68.8,73.7,80.2,85.0,96.3,98.6], 默认模式从uniform改为keep
2026-04-19 23:24:36 +08:00
3bec69986e feat: Kimi k2.5参数适配+AI日志导出完善(20260419_2249)
- Kimi k2.5 强制传参拦截: 当 provider=kimi 且 model 包含 k2.5 时,
  从请求体中 delete temperature/top_p/presence_penalty/frequency_penalty,
  彻底解决 HTTP 400 报错
- 完善导出AI日志: 新增 lastExchangeLog 状态, 记录每次调用的
  完整请求体(requestPayload)、原始响应(responsePayload)、
  错误详情(errorDetail含status/statusText/responseText)、模型配置
- 更新导出按钮 JSON 结构, 包含 lastExchange 字段
2026-04-19 22:54:00 +08:00
2e634ff832 feat: AI写作模块4项优化(20260419_2226)
- 修复 diff 颜色残留: confirmAiInjection 使用 cleanHtml 而非 newHtml
- 更新默认快捷指令: 4条外科专用 -> 2条通用短语
- 新增发送按钮: 输入框旁显式发送按钮
- 导出AI日志: 快捷指令区域新增调试日志导出(JSON)
2026-04-19 22:29:43 +08:00
1ec25065ad feat(ai): diff弹窗文档对比高亮 + 二次修改未弹窗修复
- 引入diff库,实现字符级差异比对
- diffModal左右两侧增加diff高亮:左侧删除内容标红,右侧新增内容标绿
- systemPrompt增加绝对强制条款:无论指令多小都必须返回updatedHtml
- 前端校验兜底:修改模式下未返回updatedHtml时在聊天面板给出提示
- confirmAiInjection注入前清理diff高亮span,避免污染编辑器
2026-04-19 22:08:05 +08:00
7275906f3c fix(editor): AI注入后Ctrl+Z失效 + 字体格式统一
- confirmAiInjection改用Range.selectNodeContents + execCommand('insertHTML')保留浏览器撤销栈
- handleAIGenerate中对cleanHtml增加<p>标签内联样式注入:padding 0px、font-family SimSun、font-size 12pt、line-height 1.5
- 确保AI替换后的文字字体与原有文字完全一致
2026-04-19 20:33:43 +08:00
b24ba08658 fix(ui): 打印隐藏AI区域蓝框 + diff弹窗字体统一
- print.ts的iframe样式中增加.ai-region隐藏规则:去除边框/背景/内边距,隐藏右上角标签
- diffModal右侧AI提议版本容器增加style属性:fontFamily SimSun、fontSize 12pt、lineHeight 1.5
- 确保打印输出和diff对比的视觉一致性
2026-04-19 18:25:38 +08:00
6abd7d1e3a fix(editor): contentEditable回车导致段落溢出.ai-content
- handleAIGenerate中获取currentHtml前增加溢出段落合并逻辑
- 遍历.ai-content之后的兄弟<p>节点,移回.ai-content内
- 合并后同步更新contentRef和saveDraftToStorage
- 确保diff弹窗左侧能显示AI可编辑区域内的全部段落
2026-04-19 18:10:40 +08:00
a3cafcb672 fix(ai): AI越界生成——Prompt增加内容边界约束
- systemPrompt去掉'基于全局信息补充完善'诱导性措辞,改为明确【内容边界】警告
- systemPrompt明确告知AI:全局参考仅供理解上下文,updatedHtml只能包含目标区域本身内容
- promptText增加⚠️防越界指令:明确列出禁止混入的模块类型(术后情况、标本描述、病理结果、签名等)
2026-04-19 04:26:16 +08:00
c7e7033e7d feat(ai): diff弹窗内容完整性优化 + HTML空行清洗
- systemPrompt和promptText中明确要求AI生成完整多段落内容,不要只改写现有段落
- systemPrompt增加HTML格式约束:<p>标签包裹、禁止<br>和换行符、紧凑HTML
- setDiffModal和execCmd之前增加正则清洗:移除<br>、移除</p>与<p>间空白、trim首尾
2026-04-19 04:15:36 +08:00
9f73d8595c feat(ai): 修改模式自动锁定目标区域 + SystemPrompt模式语义强化
- handleAIGenerate开头增加自动修正目标区域逻辑:修改模式开启且未选区域时,自动选择文档中第一个AI区域
- systemPrompt明确标注'当前处于【修改模式】/【对话模式】',并细化字段要求
- diffModal的targetId改为使用actualTargetId,确保确认注入时使用实际修正后的区域ID
2026-04-19 04:02:05 +08:00
c1d2438d2b fix(editor): AI只聊天不修改——解绑SystemPrompt目标区域依赖 + 增加光标插入降级
- systemPrompt条件从'aiModifyEnabled && targetRegionEl'改为'aiModifyEnabled',确保开启修改模式后大模型始终返回updatedHtml
- 接收updatedHtml逻辑增加if/else分支:targetRegionEl存在时走diff弹窗,不存在时调用execCmd('insertHTML')降级插入光标位置
- 参考参考-ReportEditor.tsx中injectAIText的降级机制
2026-04-19 03:47:14 +08:00
854a00c2fa fix(editor): Checkbox点击失效 + AI全局上下文注入
- 将'允许修改正文'复选框从id/htmlFor绑定改为label直接包裹input,增加e.stopPropagation防止事件冒泡被拦截
- handleAIGenerate中新增editorRef.current.innerText作为全局上下文注入prompt
- currentHtml增加过滤&#8203;零宽字符
- 优化systemPrompt,明确告知大模型全局参考内容+目标区域源码的双信息源结构
2026-04-19 03:35:52 +08:00
a065f6af27 docs: 记录28-chatInput草稿恢复与AI请求content条件格式经验 2026-04-19 03:24:23 +08:00
da2ecdc224 fix(editor): chatInput草稿恢复 + AI请求content条件格式
- 草稿恢复分支增加chatInput恢复,避免路由切换后聊天输入框内容丢失
- handleAIGenerate中messageContent改为条件判断:无图片时发送string,有图片时发送vision数组,修复Kimi文本模型400 Bad Request
2026-04-19 03:23:49 +08:00
9173aa7733 2026-04-19-03-03-55 修复AI撰写体验:API endpoint斜杠净化、模型列表下拉栏、聊天记录持久化存储 2026-04-19 03:09:46 +08:00
d5cbbf9137 2026-04-19-02-48-25 重构AI接口配置:多服务商底座架构、OpenAI兼容协议、动态模型切换、旧数据自动迁移 2026-04-19 02:53:26 +08:00
221daf61a5 2026-04-19-02-26-05 集成AI撰写功能:Kimi-2.5多模态API、AI可编辑区域、Diff确认弹窗、语音与图片输入、快捷指令 2026-04-19 02:36:20 +08:00
96b295f919 2026-04-19-02-00-33 建立代码编纂工作流:工程分析框架、经验记录迁移、工作流规范制定 2026-04-19 02:04:40 +08:00
1dc3d60248 docs: add AGENTS.md and experience logs for v1.3 2026-04-19 01:49:30 +08:00
12 changed files with 1079 additions and 76 deletions

24
package-lock.json generated
View File

@@ -10,7 +10,10 @@
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@types/crypto-js": "^4.2.2",
"@vitejs/plugin-react": "^5.0.4",
"crypto-js": "^4.2.0",
"diff": "^9.0.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
@@ -1491,6 +1494,12 @@
"@types/node": "*"
}
},
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1903,6 +1912,12 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@ -1957,6 +1972,15 @@
"node": ">=8"
}
},
"node_modules/diff": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",

View File

@@ -13,7 +13,10 @@
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@types/crypto-js": "^4.2.2",
"@vitejs/plugin-react": "^5.0.4",
"crypto-js": "^4.2.0",
"diff": "^9.0.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",

View File

@@ -204,6 +204,15 @@
.print-content .smart-field-wrapper .delete-btn {
display: none !important;
}
.print-content .ai-region {
border: none !important;
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
}
.print-content .ai-region > [contenteditable="false"] {
display: none !important;
}
.report-signature-img {
max-width: 120px !important;
max-height: 40px !important;

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { User, Template, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
import { User, Template, SystemSettings, FormField, DEFAULT_FORM_FIELDS, DEFAULT_AI_PROVIDERS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { storage } from '../utils/storage';
import { storage, getDefaultApiKey } from '../utils/storage';
import { User as UserIcon, Lock } from 'lucide-react';
export default function Login() {
@@ -63,21 +63,17 @@ export default function Login() {
const settingsRaw = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
if (!settingsRaw.frameCount) {
const round1 = (n: number) => Math.round(n * 10) / 10;
const positions: number[] = [];
for (let i = 1; i <= 12; i++) {
positions.push(round1((100 / 13) * i));
}
const defaultSettings = {
frameCount: 12,
framePositions: positions,
apiEndpoint: '',
apiKey: '',
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
defaultTemplate: savedTemplates[0]?.id || '',
frameMode: 'uniform',
frameMode: 'keep',
activeAiProvider: 'kimi',
aiProviders: { ...DEFAULT_AI_PROVIDERS, kimi: { ...DEFAULT_AI_PROVIDERS.kimi, apiKey: getDefaultApiKey() } },
autoInsertFrames: true,
autoInsertDelay: 1,
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10]
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
xfIatConfig: { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' }
};
storage.set('systemSettings', defaultSettings);
}

View File

@@ -5,12 +5,15 @@ import Sidebar from '../components/Sidebar';
import {
Check, Printer, Undo, Redo, Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
Video, Play, Pause, Plus, X, ChevronLeft, Download
Video, Play, Pause, Plus, X, ChevronLeft, Download,
Bot, Mic, MicOff, ImagePlus, Sparkles, Send
} from 'lucide-react';
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS } from '../types';
import { User, Report, Template, CapturedFrame, SystemSettings, FormField, DEFAULT_FORM_FIELDS, DEFAULT_XF_SPEECH } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
import { storage } from '../utils/storage';
import { storage, getDefaultApiKey } from '../utils/storage';
import { diffChars } from 'diff';
import CryptoJS from 'crypto-js';
export default function ReportEditor() {
const navigate = useNavigate();
@@ -53,9 +56,78 @@ export default function ReportEditor() {
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
const prevVideoCountRef = useRef(0);
const [activeTab, setActiveTab] = useState<'info' | 'video'>('info');
const [activeTab, setActiveTab] = useState<'info' | 'video' | 'ai'>('info');
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(null);
// AI 撰写相关核心状态
const [chatInput, setChatInput] = useState<string>('');
const [chatMessages, setChatMessages] = useState<{id: string, role: 'user'|'model', content: string, images?: string[]}[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [aiTargetRegion, setAiTargetRegion] = useState<string>('none');
const [aiModifyEnabled, setAiModifyEnabled] = useState(true);
const [isListening, setIsListening] = useState(false);
const [aiUploadedImages, setAiUploadedImages] = useState<{id: number, dataUrl: string}[]>([]);
const [editorImages, setEditorImages] = useState<{id: string, src: string}[]>([]);
const [aiSelectedEditorImages, setAiSelectedEditorImages] = useState<string[]>([]);
const xfWsRef = useRef<WebSocket | null>(null);
const xfAudioContextRef = useRef<AudioContext | null>(null);
const xfMediaStreamRef = useRef<MediaStream | null>(null);
const [quickPrompts, setQuickPrompts] = useState<string[]>([
'请完善报告内容', '请对内容做如下修改:'
]);
const [isEditingPrompts, setIsEditingPrompts] = useState(false);
const [diffModal, setDiffModal] = useState<{isOpen: boolean, originalHtml: string, newHtml: string, targetId: string} | null>(null);
const [lastExchangeLog, setLastExchangeLog] = useState<{
startTime: string;
modelConfig: { provider: string; endpoint: string; modelName: string };
requestPayload: any;
responsePayload: any | null;
errorDetail: { status: number; statusText: string; responseText: string; message: string } | null;
} | null>(null);
useEffect(() => {
stateRef.current.chatMessages = chatMessages;
}, [chatMessages]);
// 监听编辑器中已插入的图片,同步到 AI 面板
useEffect(() => {
if (!editorRef.current) return;
const updateEditorImages = () => {
if (!editorRef.current) return;
const imgs = Array.from(editorRef.current.querySelectorAll('.image-placeholder.has-image img'))
.map((img, idx) => ({ id: `editor-img-${idx}-${(img as HTMLImageElement).src.slice(-16)}`, src: (img as HTMLImageElement).src }))
.filter(img => img.src);
setEditorImages(prev => {
const same = prev.length === imgs.length && prev.every((p, i) => p.src === imgs[i]?.src);
if (same) return prev;
// 清除已不存在的选中项
setAiSelectedEditorImages(prevSelected => prevSelected.filter(id => imgs.some(img => img.id === id)));
return imgs;
});
};
updateEditorImages();
const observer = new MutationObserver(updateEditorImages);
observer.observe(editorRef.current, { childList: true, subtree: true, attributes: true });
return () => observer.disconnect();
}, []);
useEffect(() => {
stateRef.current.chatInput = chatInput;
}, [chatInput]);
// 切换到 AI 面板时强制同步编辑器中的图片
useEffect(() => {
if (activeTab !== 'ai' || !editorRef.current) return;
const imgs = Array.from(editorRef.current.querySelectorAll('.image-placeholder.has-image img'))
.map((img, idx) => ({ id: `editor-img-${idx}-${(img as HTMLImageElement).src.slice(-16)}`, src: (img as HTMLImageElement).src }))
.filter(img => img.src);
setEditorImages(prev => {
const same = prev.length === imgs.length && prev.every((p, i) => p.src === imgs[i]?.src);
return same ? prev : imgs;
});
}, [activeTab]);
useEffect(() => {
if (!editorRef.current) return;
const allFields = editorRef.current.querySelectorAll('.field-value');
@@ -101,7 +173,7 @@ export default function ReportEditor() {
const videoInputRef = useRef<HTMLInputElement>(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, chatInput });
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
@@ -126,7 +198,9 @@ 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,
chatInput: stateRef.current.chatInput
});
}
}, [reportId]);
@@ -170,12 +244,16 @@ 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);
if (typeof draft.chatInput === 'string') setChatInput(draft.chatInput);
stateRef.current = {
...stateRef.current,
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
loadedTemplateId: draft.loadedTemplateId || '',
chatMessages: draft.chatMessages || [],
chatInput: draft.chatInput || ''
};
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
editorRef.current.innerHTML = draft.content;
@@ -230,12 +308,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;
@@ -604,6 +684,20 @@ export default function ReportEditor() {
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};
const insertAiRegion = () => {
const name = window.prompt('请输入 AI 可编辑区域的名称(如:手术步骤、病灶描述):');
if (!name || !name.trim()) return;
if (editorRef.current?.querySelector(`[data-ai-id="${name}"]`)) {
window.alert('该区域名称已存在,请使用其他名称以保证 AI 定位准确。');
return;
}
editorRef.current?.focus();
const html = `<div class="ai-region" data-ai-id="${name}" data-ai-title="${name}" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;"><div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${name}-AI可编辑区域</div><div class="ai-content" style="min-height: 20px;">&#8203;</div></div><p><br></p>`;
document.execCommand('insertHTML', false, html);
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []) as File[];
const newVideos = files.map(file => ({
@@ -681,7 +775,7 @@ export default function ReportEditor() {
if (!videoRef.current || currentVideoIndex === -1) return;
const video = videoRef.current;
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const positions = settings.framePositions || [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60];
const positions = settings.framePositions || [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6];
const dur = video.duration || 1;
const canvas = canvasRef.current;
@@ -767,6 +861,361 @@ export default function ReportEditor() {
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const checkAiRegions = () => {
if (!editorRef.current) return [];
return Array.from(editorRef.current.querySelectorAll('.ai-region')).map((el) => {
const id = (el as HTMLElement).getAttribute('data-ai-id') || '';
const title = (el as HTMLElement).getAttribute('data-ai-title') || id;
return { id, title };
}).filter(r => r.id);
};
const stripHtml = (html: string): string => {
const tmp = document.createElement('div');
tmp.innerHTML = html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>\s*<p/gi, '</p>\n\n<p');
return (tmp.innerText || tmp.textContent || '').trim();
};
const computeDiffHtml = (oldText: string, newText: string, side: 'left' | 'right'): string => {
const diffs = diffChars(oldText, newText);
let html = '';
for (const part of diffs) {
let value = part.value.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
if (side === 'left' && part.removed) {
html += `<span class="diff-removed" style="background-color:#fee2e2;color:#dc2626;text-decoration:line-through;">${value}</span>`;
} else if (side === 'right' && part.added) {
html += `<span class="diff-added" style="background-color:#dcfce7;color:#16a34a;font-weight:500;">${value}</span>`;
} else if (!part.added && !part.removed) {
html += value;
}
}
return html;
};
async function getXfAuthUrl(apiKey: string, apiSecret: string): Promise<string> {
const host = 'iat-api.xfyun.cn';
const date = new Date().toUTCString();
const signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/iat HTTP/1.1`;
const signature = CryptoJS.enc.Base64.stringify(CryptoJS.HmacSHA256(signatureOrigin, apiSecret));
const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
const authorization = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(authorizationOrigin));
return `wss://iat-api.xfyun.cn/v2/iat?authorization=${authorization}&date=${date}&host=${host}`;
}
function floatTo16BitPCM(input: Float32Array): ArrayBuffer {
const output = new DataView(new ArrayBuffer(input.length * 2));
for (let i = 0; i < input.length; i++) {
const s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
return output.buffer;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
const toggleListening = async () => {
// 专门提取一个彻底关闭物理麦克风的函数
const stopMicrophone = () => {
if (xfAudioContextRef.current) {
try { xfAudioContextRef.current.close(); } catch {}
xfAudioContextRef.current = null;
}
if (xfMediaStreamRef.current) {
xfMediaStreamRef.current.getTracks().forEach(t => t.stop());
xfMediaStreamRef.current = null;
}
};
if (isListening) {
setIsListening(false);
stopMicrophone();
if (xfWsRef.current && xfWsRef.current.readyState === WebSocket.OPEN) {
try {
const endFrame = { data: { status: 2, format: 'audio/L16;rate=16000', encoding: 'raw', audio: '' } };
xfWsRef.current.send(JSON.stringify(endFrame));
} catch {}
}
return;
}
const xfConfig = storage.get<SystemSettings>('systemSettings', {} as SystemSettings).xfSpeechConfig || DEFAULT_XF_SPEECH;
if (!xfConfig?.appId || !xfConfig?.apiKey || !xfConfig?.apiSecret) {
alert('请先在系统设置中配置讯飞语音 APPID/APIKey/APISecret');
return;
}
try {
const authUrl = await getXfAuthUrl(xfConfig.apiKey, xfConfig.apiSecret);
const ws = new WebSocket(authUrl);
xfWsRef.current = ws;
let frameStatus = 0;
ws.onopen = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
xfMediaStreamRef.current = stream;
const audioContext = new AudioContext({ sampleRate: 16000 });
xfAudioContextRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (e) => {
if (ws.readyState !== WebSocket.OPEN || !xfAudioContextRef.current) return;
const inputData = e.inputBuffer.getChannelData(0);
const pcmBuffer = floatTo16BitPCM(inputData);
const base64Audio = arrayBufferToBase64(pcmBuffer);
const frame: any = { data: { status: frameStatus, format: 'audio/L16;rate=16000', encoding: 'raw', audio: base64Audio } };
if (frameStatus === 0) {
frame.common = { app_id: xfConfig.appId };
frame.business = { language: 'zh_cn', domain: 'iat', accent: 'mandarin' };
}
ws.send(JSON.stringify(frame));
frameStatus = 1;
};
source.connect(processor);
processor.connect(audioContext.destination);
setIsListening(true);
} catch (e: any) {
alert('麦克风启动失败: ' + e.message);
setIsListening(false);
ws.close();
}
};
ws.onmessage = (event) => {
try {
const jsonData = JSON.parse(event.data);
if (jsonData.code !== 0 && jsonData.code !== undefined) {
alert(`讯飞语音报错: ${jsonData.message || '未知错误'} (错误码: ${jsonData.code})`);
setIsListening(false);
stopMicrophone();
ws.close();
return;
}
if (jsonData.data?.result?.ws) {
let seg = '';
for (const w of jsonData.data.result.ws) { if (w.cw?.[0]?.w) seg += w.cw[0].w; }
if (seg) {
setChatInput(prev => prev + seg);
}
}
if (jsonData.data?.status === 2) {
ws.close();
xfWsRef.current = null;
setIsListening(false);
stopMicrophone();
}
} catch {}
};
ws.onerror = () => { alert('讯飞语音连接失败'); setIsListening(false); stopMicrophone(); };
ws.onclose = () => { setIsListening(false); stopMicrophone(); };
} catch (e: any) {
alert('讯飞语音初始化失败: ' + e.message);
}
};
const handleAIGenerate = async (text: string) => {
if (!text.trim()) return;
const userMsgId = Date.now().toString();
const selectedEditorImageUrls = editorImages.filter(img => aiSelectedEditorImages.includes(img.id)).map(img => img.src);
const allImages = [...selectedEditorImageUrls, ...aiUploadedImages.map(i => i.dataUrl)];
setChatMessages(prev => [...prev, { id: userMsgId, role: 'user', content: text, images: allImages.length > 0 ? allImages : undefined }]);
setChatInput('');
setIsGenerating(true);
try {
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi'];
const apiKey = provider?.apiKey || getDefaultApiKey();
const apiEndpoint = (provider?.endpoint || 'https://api.moonshot.cn/v1').replace(/\/+$/, '');
const modelName = provider?.modelName || 'moonshot-v1-32k-vision-preview';
if (!apiKey) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】尚未配置 AI API Key请前往系统设置填写。' }]);
setIsGenerating(false);
return;
}
let actualTargetId = aiTargetRegion;
if (aiModifyEnabled && actualTargetId === 'none') {
const availableRegions = checkAiRegions();
if (availableRegions.length > 0) {
actualTargetId = availableRegions[0].id;
setAiTargetRegion(actualTargetId);
}
}
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"] .ai-content`) as HTMLElement | null;
// 合并溢出的段落:浏览器 contentEditable 可能在回车时把 <p> 生成到 .ai-content 之外
const aiRegion = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"]`);
if (aiRegion && targetRegionEl) {
let nextSibling = targetRegionEl.nextElementSibling;
while (nextSibling) {
const toMove = nextSibling;
nextSibling = nextSibling.nextElementSibling;
if (toMove.tagName === 'P') {
targetRegionEl.appendChild(toMove);
}
}
// 同步更新 contentRef 和草稿
if (editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
}
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/&#8203;/g, '').replace(/>(\s+)</g, '><').trim() : '';
const globalContextText = editorRef.current?.innerText || '';
let messageContent: any;
const selectedEditorImageUrls = editorImages.filter(img => aiSelectedEditorImages.includes(img.id)).map(img => img.src);
const allImages = [...selectedEditorImageUrls, ...aiUploadedImages.map(i => i.dataUrl)];
let promptText = `【全局手术报告参考内容】:\n${globalContextText}\n\n`;
if (aiModifyEnabled && targetRegionEl) {
promptText += `【你需要进行修改的目标区域 HTML 源码】:\n${currentHtml || '(当前区域为空)'}\n\n`;
}
promptText += `【医生指令】: ${text}\n\n【格式要求】:\n1. 仅针对【目标区域】的主题生成对应的多段落 HTML 内容\n2. ⚠️ 绝对禁止将【全局参考内容】中的其他模块(如:基本信息、术后情况、标本描述、病理结果、医生签名、日期等)生成并混入你的输出中!你只能输出该区域应有的内容\n3. 段落使用 <p> 标签,段落之间不要使用 <br> 标签或换行符\n4. 输出紧凑 HTML标签间不要有空格或换行`;
if (allImages.length > 0) {
messageContent = [];
allImages.forEach(url => {
messageContent.push({ type: 'image_url', image_url: { url } });
});
messageContent.push({ type: 'text', text: promptText });
} else {
messageContent = promptText;
}
const systemPrompt = aiModifyEnabled
? '你是一名专业的外科医生助理。当前处于【修改模式】。\n我为你提供了当前手术报告的【全局参考内容】作为背景知识以及你需要修改的【目标区域 HTML 源码】。\n请根据用户的【医生指令】直接重写并输出目标区域的 HTML。\n\n重要指令\n1. 必须返回合法的 JSON 对象\n2. 必须包含 "reply"(简短回复)和 "updatedHtml"(修改后的完整 HTML 代码)两个字段\n3. 【内容边界】:全局参考内容仅供你理解上下文。你的 updatedHtml 只能包含目标区域本身的内容(例如:如果目标区域是"手术步骤",你就只写步骤)。严禁输出签名、落款、术后总结等属于报告其他部分的结构!\n4. 段落必须使用 <p> 标签包裹,段落间绝对不要使用 <br> 标签,也不要使用换行符 (\\n)\n5. 输出的 HTML 必须紧凑,标签之间不要有空格或换行\n6. 绝对不要包含任何 Markdown 标记(如 ```json'
: '你是一名专业的外科医生助理。当前处于【对话模式】。\n请仔细阅读我提供的【全局手术报告参考内容】并根据【医生指令】进行专业解答。\n重要指令\n1. 必须返回合法的 JSON 对象\n2. 仅包含 "reply"(你的专业回答)一个字段\n3. 不要返回任何 HTML 代码\n4. 绝对不要包含任何 Markdown 标记\n5. 无论图片内容是什么,请严格以纯 JSON 格式输出,禁止任何多余的开头解释';
const payload: any = {
model: modelName,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: messageContent }
],
temperature: 0.3
};
const isKimiK25 = settings.activeAiProvider === 'kimi' && /k2\.5/i.test(modelName);
if (isKimiK25) {
delete payload.temperature;
delete payload.top_p;
delete payload.presence_penalty;
delete payload.frequency_penalty;
}
const logEntry = {
startTime: new Date().toISOString(),
modelConfig: { provider: settings.activeAiProvider || 'kimi', endpoint: apiEndpoint, modelName },
requestPayload: JSON.parse(JSON.stringify(payload)),
responsePayload: null as any | null,
errorDetail: null as { status: number; statusText: string; responseText: string; message: string } | null
};
const response = await fetch(`${apiEndpoint}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
logEntry.errorDetail = {
status: response.status,
statusText: response.statusText,
responseText: errorText,
message: `API 请求失败: ${response.status}`
};
setLastExchangeLog(logEntry);
throw new Error(`API 请求失败: ${response.status}${errorText ? ' - ' + errorText : ''}`);
}
const data = await response.json();
logEntry.responsePayload = data;
setLastExchangeLog(logEntry);
const responseText = data.choices[0].message.content.trim();
const cleanedText = responseText.replace(/```json\n?|```/g, '');
let responseJson: any = {};
try {
responseJson = JSON.parse(cleanedText);
} catch {
const jsonMatch = cleanedText.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
responseJson = JSON.parse(jsonMatch[0]);
} catch {
if (!aiModifyEnabled) responseJson = { reply: cleanedText };
else throw new Error('AI 返回格式异常,无法解析 JSON');
}
} else {
if (!aiModifyEnabled) responseJson = { reply: cleanedText };
else throw new Error('AI 返回格式异常,无法解析 JSON');
}
}
if (responseJson.reply) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]);
}
if (aiModifyEnabled && !responseJson.updatedHtml) {
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: '【系统提示】AI 未能生成修改内容,请尝试重新描述您的需求。' }]);
}
if (responseJson.updatedHtml && aiModifyEnabled) {
let cleanHtml = responseJson.updatedHtml;
cleanHtml = cleanHtml.replace(/<br\s*\/?>/gi, '');
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
cleanHtml = cleanHtml.trim();
cleanHtml = cleanHtml.replace(/>(\s+)</g, '><');
cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">');
if (targetRegionEl) {
setDiffModal({
isOpen: true,
originalHtml: currentHtml,
newHtml: cleanHtml,
targetId: actualTargetId
});
} else {
execCmd('insertHTML', cleanHtml);
}
}
setAiUploadedImages([]);
setAiSelectedEditorImages([]);
} catch (error: any) {
console.error(error);
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `【系统错误】: ${error.message}` }]);
} finally {
setIsGenerating(false);
}
};
const confirmAiInjection = (newHtml: string, regionId: string) => {
if (!editorRef.current) return;
const cleanHtml = newHtml.replace(/<span class="diff-(added|removed)"[^>]*>(.*?)<\/span>/gi, '$2');
const targetContent = editorRef.current.querySelector(`.ai-region[data-ai-id="${regionId}"] .ai-content`) as HTMLElement;
if (targetContent) {
targetContent.focus();
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(targetContent);
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('insertHTML', false, cleanHtml);
targetContent.style.transition = 'background-color 0.3s ease';
targetContent.style.backgroundColor = '#bfdbfe';
setTimeout(() => {
targetContent.style.backgroundColor = '#eff6ff';
setTimeout(() => {
targetContent.style.backgroundColor = 'transparent';
}, 800);
}, 400);
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
setDiffModal(null);
};
const handleDragStart = (e: React.DragEvent, frame: CapturedFrame) => {
e.dataTransfer.setData('frameId', frame.id.toString());
};
@@ -941,6 +1390,10 @@ export default function ReportEditor() {
setVideos([]);
setCapturedFrames([]);
setCurrentVideoIndex(-1);
setChatMessages([]);
setChatInput('');
setAiUploadedImages([]);
setAiSelectedEditorImages([]);
prevVideoCountRef.current = 0;
stateRef.current = {
...stateRef.current,
@@ -948,6 +1401,8 @@ export default function ReportEditor() {
reportData: nextReportData,
videos: [],
capturedFrames: [],
chatMessages: [],
chatInput: '',
activeTab: stateRef.current.activeTab
};
updatePageHeight();
@@ -976,7 +1431,9 @@ export default function ReportEditor() {
reportData: draft.reportData,
videos: draft.videos,
capturedFrames: draft.capturedFrames,
loadedTemplateId: draft.loadedTemplateId || ''
loadedTemplateId: draft.loadedTemplateId || '',
chatMessages: draft.chatMessages || [],
chatInput: draft.chatInput || ''
};
setTimeout(() => updatePageHeight(), 0);
return;
@@ -1482,6 +1939,7 @@ export default function ReportEditor() {
<div className="flex gap-1">
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={insertAiRegion} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-blue-50 text-blue-500 transition-colors" title="插入AI可编辑区域"><Bot size={16} /></button>
</div>
</div>
@@ -1509,15 +1967,16 @@ export default function ReportEditor() {
{/* Right Sidebar */}
<aside className="w-[380px] bg-sidebar-bg flex flex-col shrink-0 overflow-hidden">
<div className="flex border-b border-border">
{(['info', 'video'] as const).map(tab => (
{(['info', 'video', 'ai'] as const).map(tab => (
<button
key={tab}
onClick={() => { setActiveTab(tab); stateRef.current = { ...stateRef.current, activeTab: tab }; saveDraftToStorage(); }}
className={`flex-1 py-4 text-xs font-bold transition-all border-b-2 uppercase tracking-wider ${
className={`flex-1 py-4 text-xs font-bold transition-all border-b-2 uppercase tracking-wider flex items-center justify-center gap-1.5 ${
activeTab === tab ? 'text-accent border-accent' : 'text-text-muted border-transparent hover:text-text-main'
}`}
>
{tab === 'info' ? '基本信息' : '视频分析'}
{tab === 'ai' && <Bot size={16} className={activeTab === 'ai' ? 'text-accent' : 'text-text-muted'} />}
{tab === 'info' ? '基本信息' : tab === 'video' ? '视频分析' : 'AI撰写'}
</button>
))}
</div>
@@ -2009,6 +2468,191 @@ export default function ReportEditor() {
)}
</div>
)}
{activeTab === 'ai' && (
<div className="flex flex-col h-full bg-[#f8fafc] overflow-hidden">
{/* 聊天气泡记录区 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
{chatMessages.length === 0 ? (
<div className="text-center flex flex-col items-center justify-center h-full text-slate-400 space-y-3">
<Bot size={48} className="text-slate-300 opacity-50" />
<p className="text-xs"> SurClaw </p>
</div>
) : (
chatMessages.map(msg => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`rounded-2xl px-4 py-2.5 max-w-[85%] text-sm ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none shadow-md' : 'bg-white border border-slate-200 text-slate-700 rounded-tl-none shadow-sm'}`}>
<div>{msg.content}</div>
{msg.images && msg.images.length > 0 && (
<div className="flex gap-1.5 mt-2 flex-wrap">
{msg.images.map((src, idx) => (
<img key={idx} src={src} className="w-10 h-10 object-cover rounded border border-white/30" />
))}
</div>
)}
</div>
</div>
))
)}
{isGenerating && (
<div className="flex justify-start">
<div className="bg-white border border-slate-200 rounded-2xl rounded-tl-none px-4 py-3 shadow-sm flex gap-1.5 items-center">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.15s' }} />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.3s' }} />
</div>
</div>
)}
</div>
{/* 控制台与输入区 */}
<div className="bg-white border-t border-slate-200 p-4 space-y-3 shrink-0 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10">
{/* 区域锚定与沙盒控制 */}
<div className="flex items-center justify-between bg-slate-50 p-2 rounded-lg border border-slate-200">
<div className="flex items-center gap-2 flex-1">
<select
value={aiTargetRegion}
onChange={(e) => setAiTargetRegion(e.target.value)}
disabled={!aiModifyEnabled}
className="flex-1 w-0 px-2 py-1 border-none text-xs bg-transparent focus:ring-0 font-bold text-slate-700 disabled:opacity-50"
>
{checkAiRegions().length > 0 ? (
checkAiRegions().map((r: any) => <option key={r.id} value={r.id}>🎯 {r.title}</option>)
) : (
<option value="none"> AI </option>
)}
</select>
</div>
<label className="flex items-center gap-1.5 shrink-0 pl-2 border-l border-slate-300 cursor-pointer">
<input
type="checkbox"
checked={aiModifyEnabled}
onChange={(e) => {
e.stopPropagation();
setAiModifyEnabled(e.target.checked);
}}
className="w-3.5 h-3.5 text-blue-600 rounded border-slate-300 focus:ring-blue-500 cursor-pointer"
/>
<span className="text-[11px] text-slate-600 font-bold">
</span>
</label>
</div>
{/* 视觉参考上下文 - 编辑器中已插入的图片 */}
{editorImages.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-1 custom-scrollbar">
{editorImages.map(img => {
const isSelected = aiSelectedEditorImages.includes(img.id);
return (
<div key={img.id} onClick={() => setAiSelectedEditorImages(prev => isSelected ? prev.filter(id => id !== img.id) : [...prev, img.id])}
className={`relative shrink-0 w-12 aspect-video rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-blue-600' : 'border-transparent opacity-50'}`}>
<img src={img.src} className="w-full h-full object-cover" />
{isSelected && <div className="absolute top-0.5 right-0.5 bg-blue-600 rounded-full p-0.5"><Check size={8} className="text-white" /></div>}
</div>
);
})}
</div>
)}
{/* 自定义快捷指令胶囊 */}
<div className="flex flex-wrap gap-1.5 max-h-16 overflow-y-auto">
{quickPrompts.map((p, i) => (
<div key={i} className="group relative">
<button onClick={() => setChatInput(p)} className="px-3 py-1 bg-[#f1f5f9] hover:bg-blue-50 hover:text-blue-600 text-slate-600 text-[11px] rounded-full transition-colors whitespace-nowrap">
{p}
</button>
{isEditingPrompts && (
<button onClick={() => setQuickPrompts(prev => prev.filter((_, idx) => idx !== i))} className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5 scale-75 shadow-sm">
<X size={10} />
</button>
)}
</div>
))}
<button onClick={() => {
if (isEditingPrompts) {
const newP = prompt('新增快捷指令:');
if (newP) setQuickPrompts([...quickPrompts, newP]);
} else {
setIsEditingPrompts(true);
}
}} className="px-2 py-1 bg-slate-100 text-slate-400 text-[11px] rounded-full hover:bg-slate-200">
{isEditingPrompts ? '+ 添加' : '⚙️'}
</button>
{isEditingPrompts && <button onClick={() => setIsEditingPrompts(false)} className="px-2 py-1 bg-blue-100 text-blue-600 text-[11px] rounded-full"></button>}
<button onClick={() => {
const data = {
exportAt: new Date().toISOString(),
url: window.location.href,
messages: chatMessages,
lastExchange: lastExchangeLog,
metadata: {
user: currentUser?.username || 'anonymous',
activeProvider: (() => { const s = storage.get<SystemSettings>('systemSettings', {} as SystemSettings); return s.activeAiProvider || 'kimi'; })(),
targetRegion: aiTargetRegion,
modifyEnabled: aiModifyEnabled,
chatInput,
uploadedImagesCount: aiUploadedImages.length,
selectedFramesCount: aiSelectedEditorImages.length
}
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ai-logs-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}} className="px-2 py-1 bg-slate-100 text-slate-500 text-[11px] rounded-full hover:bg-slate-200 ml-auto" title="导出 AI 日志(调试用)">
AI
</button>
</div>
{/* 沉浸式输入框 */}
<div className="relative border border-slate-300 rounded-xl bg-white shadow-inner focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-transparent transition-all">
{aiUploadedImages.length > 0 && (
<div className="flex gap-2 p-2 border-b border-slate-100 bg-slate-50 rounded-t-xl overflow-x-auto">
{aiUploadedImages.map(img => (
<div key={img.id} className="relative w-10 h-10 rounded overflow-hidden shadow-sm shrink-0">
<img src={img.dataUrl} className="w-full h-full object-cover" />
<button onClick={() => setAiUploadedImages(prev => prev.filter(i => i.id !== img.id))} className="absolute top-0 right-0 bg-red-500/80 text-white rounded-bl-md">
<X size={10} />
</button>
</div>
))}
</div>
)}
<textarea
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!isGenerating) handleAIGenerate(chatInput); } }}
placeholder={isListening ? '正在将语音转为文字...' : '输入需求(按 Enter 发送)...'}
className="w-full min-h-[80px] p-3 pr-12 text-sm bg-transparent outline-none resize-none custom-scrollbar"
/>
<div className="absolute bottom-2 right-2 flex items-center gap-1.5">
<label className="p-1.5 text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50 rounded-lg cursor-pointer transition-colors" title="上传外部图像">
<input type="file" accept="image/*" multiple className="hidden" onChange={(e) => {
const files = e.target.files;
if (!files) return;
Array.from(files).forEach((file: File) => {
const reader = new FileReader();
reader.onload = (ev) => { if (ev.target?.result) setAiUploadedImages(prev => [...prev, { id: Date.now(), dataUrl: ev.target!.result as string }]); };
reader.readAsDataURL(file);
});
}} />
<ImagePlus size={16} />
</label>
<button onClick={toggleListening} className={`p-1.5 rounded-lg transition-colors ${isListening ? 'text-red-500 bg-red-50 animate-pulse' : 'text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50'}`}>
{isListening ? <Mic size={16} /> : <MicOff size={16} />}
</button>
<button onClick={() => { if (!isGenerating && chatInput.trim()) handleAIGenerate(chatInput); }} disabled={isGenerating || !chatInput.trim()} className="p-1.5 rounded-lg transition-colors text-slate-400 hover:text-blue-600 bg-slate-50 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed" title="发送">
<Send size={16} />
</button>
</div>
</div>
</div>
</div>
)}
</div>
</aside>
</div>
@@ -2219,6 +2863,61 @@ export default function ReportEditor() {
</div>
</div>
)}
{/* AI 修改二次确认 Diff 弹窗 */}
{diffModal && diffModal.isOpen && (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
<div className="bg-white rounded-2xl w-full max-w-[800px] shadow-2xl flex flex-col max-h-[85vh] overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
<div>
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Sparkles size={18} className="text-blue-600" />
AI
</h3>
<p className="text-xs text-slate-500 mt-1"> AI </p>
</div>
<button onClick={() => setDiffModal(null)} className="text-slate-400 hover:text-slate-600"><X size={20}/></button>
</div>
{(() => {
const oldText = stripHtml(diffModal.originalHtml);
const newText = stripHtml(diffModal.newHtml);
const leftDiffHtml = computeDiffHtml(oldText, newText, 'left');
const rightDiffHtml = computeDiffHtml(oldText, newText, 'right');
return (
<div className="flex-1 overflow-hidden flex gap-4 p-6 bg-slate-100">
<div className="flex-1 flex flex-col bg-white border border-red-200 rounded-xl overflow-hidden shadow-sm">
<div className="bg-red-50 px-3 py-2 text-xs font-bold text-red-600 border-b border-red-100 uppercase tracking-wider"></div>
<div className="p-4 flex-1 overflow-y-auto opacity-70 cursor-not-allowed custom-scrollbar"
dangerouslySetInnerHTML={{ __html: leftDiffHtml }}
style={{ fontFamily: 'SimSun, "Microsoft YaHei", serif', fontSize: '12pt', lineHeight: '1.5' }}
></div>
</div>
<div className="flex-1 flex flex-col bg-white border border-green-400 rounded-xl overflow-hidden shadow-md relative">
<div className="bg-green-50 px-3 py-2 text-xs font-bold text-green-700 border-b border-green-200 uppercase tracking-wider flex justify-between">
<span>AI ()</span>
<span className="text-[10px] bg-green-200 px-1.5 py-0.5 rounded text-green-800"></span>
</div>
<div
className="p-4 flex-1 overflow-y-auto outline-none custom-scrollbar"
contentEditable
suppressContentEditableWarning
onBlur={(e) => setDiffModal(prev => prev ? { ...prev, newHtml: e.target.innerHTML } : null)}
dangerouslySetInnerHTML={{ __html: rightDiffHtml }}
style={{ fontFamily: 'SimSun, "Microsoft YaHei", serif', fontSize: '12pt', lineHeight: '1.5' }}
></div>
</div>
</div>
);
})()}
<div className="px-6 py-4 border-t border-slate-100 flex justify-end gap-3 bg-white">
<button onClick={() => setDiffModal(null)} className="px-6 py-2 rounded-lg text-slate-600 font-medium hover:bg-slate-100"></button>
<button onClick={() => confirmAiInjection(diffModal.newHtml, diffModal.targetId)} className="px-6 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 shadow-sm flex items-center gap-2">
<Check size={16} />
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,25 +1,55 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { Video, Globe, Layout, Check, Plus, X } from 'lucide-react';
import { User, SystemSettings as ISystemSettings, Template } from '../types';
import { storage } from '../utils/storage';
import { User, SystemSettings as ISystemSettings, Template, DEFAULT_AI_PROVIDERS, AiProviderConfig } from '../types';
import { storage, getDefaultApiKey } from '../utils/storage';
export default function SystemSettings() {
const navigate = useNavigate();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [settings, setSettings] = useState<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>({
frameCount: 12,
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
apiEndpoint: '',
apiKey: '',
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
defaultTemplate: '',
frameMode: 'uniform'
frameMode: 'keep',
activeAiProvider: 'kimi',
aiProviders: { ...DEFAULT_AI_PROVIDERS },
xfSpeechConfig: { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' }
});
const [templates, setTemplates] = useState<Template[]>([]);
const [isSaved, setIsSaved] = useState(false);
const [pendingFrameCount, setPendingFrameCount] = useState<number | null>(null);
const [modeModalOpen, setModeModalOpen] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const apiKeyInputRef = useRef<HTMLInputElement>(null);
const xfAppIdRef = useRef<HTMLInputElement>(null);
const xfApiKeyRef = useRef<HTMLInputElement>(null);
const xfApiSecretRef = 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(() => {
if (xfAppIdRef.current) {
const target = settings.xfSpeechConfig?.appId || '';
if (xfAppIdRef.current.value !== target) xfAppIdRef.current.value = target;
}
if (xfApiKeyRef.current) {
const target = settings.xfSpeechConfig?.apiKey || '';
if (xfApiKeyRef.current.value !== target) xfApiKeyRef.current.value = target;
}
if (xfApiSecretRef.current) {
const target = settings.xfSpeechConfig?.apiSecret || '';
if (xfApiSecretRef.current.value !== target) xfApiSecretRef.current.value = target;
}
}, [settings.xfSpeechConfig]);
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
@@ -30,17 +60,37 @@ export default function SystemSettings() {
setCurrentUser(user);
const savedSettings = storage.get<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>('systemSettings', {} as ISystemSettings & { frameMode?: 'uniform' | 'keep' });
// Migrate old flat fields to new structured format
if (!savedSettings.aiProviders) {
const providers = { ...DEFAULT_AI_PROVIDERS };
if ((savedSettings as any).kimiApiKey || (savedSettings as any).kimiApiEndpoint) {
providers.kimi = {
endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint,
apiKey: (savedSettings as any).kimiApiKey || '',
modelName: 'moonshot-v1-32k-vision-preview'
};
}
savedSettings.aiProviders = providers;
savedSettings.activeAiProvider = 'kimi';
storage.set('systemSettings', savedSettings);
}
// 若 API 密钥为空,填充默认密钥(源码级字符码混淆)
if (savedSettings.aiProviders?.kimi?.apiKey === '') {
savedSettings.aiProviders.kimi.apiKey = getDefaultApiKey();
storage.set('systemSettings', savedSettings);
}
const savedTemplates = storage.get<Template[]>('templates', []);
if (savedSettings.frameCount) {
if (!savedSettings.defaultTemplate && savedTemplates.length > 0) {
savedSettings.defaultTemplate = savedTemplates[0].id;
}
if (!savedSettings.frameMode) savedSettings.frameMode = 'uniform';
if (!savedSettings.frameMode) savedSettings.frameMode = 'keep';
if (typeof savedSettings.autoInsertFrames !== 'boolean') savedSettings.autoInsertFrames = false;
if (typeof savedSettings.autoInsertDelay !== 'number') savedSettings.autoInsertDelay = 0;
if (!savedSettings.xfSpeechConfig) savedSettings.xfSpeechConfig = { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' };
setSettings(savedSettings);
} else if (savedTemplates.length > 0) {
setSettings(prev => ({ ...prev, defaultTemplate: savedTemplates[0].id, frameMode: prev.frameMode || 'uniform', autoInsertFrames: typeof prev.autoInsertFrames === 'boolean' ? prev.autoInsertFrames : false, autoInsertDelay: typeof prev.autoInsertDelay === 'number' ? prev.autoInsertDelay : 0 }));
setSettings(prev => ({ ...prev, defaultTemplate: savedTemplates[0].id, frameMode: prev.frameMode || 'keep', autoInsertFrames: typeof prev.autoInsertFrames === 'boolean' ? prev.autoInsertFrames : false, autoInsertDelay: typeof prev.autoInsertDelay === 'number' ? prev.autoInsertDelay : 0 }));
}
setTemplates(savedTemplates);
}, [navigate]);
@@ -79,25 +129,49 @@ export default function SystemSettings() {
};
const testApi = async () => {
if (!settings.apiEndpoint) {
alert('请先输入 API 接口地址');
const provider = settings.aiProviders[settings.activeAiProvider];
if (!provider?.apiKey) {
alert('请先输入 API 密钥');
return;
}
alert(`正在测试连接到: ${settings.apiEndpoint}\n(模拟测试: 连接成功)`);
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([]);
}
};
const resetToDefault = () => {
if (window.confirm('确定要恢复系统设置出厂设置吗?所有自定义配置将被清除。')) {
const defaultSettings: ISystemSettings & { frameMode?: 'uniform' | 'keep' } = {
frameCount: 12,
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
apiEndpoint: '',
apiKey: '',
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
defaultTemplate: templates[0]?.id || '',
frameMode: 'uniform',
frameMode: 'keep',
activeAiProvider: 'kimi',
aiProviders: { ...DEFAULT_AI_PROVIDERS, kimi: { ...DEFAULT_AI_PROVIDERS.kimi, apiKey: getDefaultApiKey() } },
autoInsertFrames: true,
autoInsertDelay: 1,
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10]
autoInsertFrameIndices: [0, 2, 4, 6, 8, 10],
xfSpeechConfig: { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' }
};
setSettings(defaultSettings);
storage.set('systemSettings', defaultSettings);
@@ -288,26 +362,143 @@ export default function SystemSettings() {
</div>
<div className="space-y-6">
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> API (Endpoint)</label>
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">AI </label>
<select
value={settings.activeAiProvider}
onChange={(e) => setSettings({ ...settings, activeAiProvider: e.target.value })}
className="input-minimal bg-white"
>
<option value="kimi">Kimi (Moonshot)</option>
<option value="deepseek">DeepSeek</option>
<option value="openai">OpenAI</option>
<option value="custom"></option>
</select>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (Base URL)</label>
<input
type="url"
value={settings.apiEndpoint}
onChange={(e) => setSettings({ ...settings, apiEndpoint: e.target.value })}
placeholder="https://api.example.com/v1/generate"
value={settings.aiProviders[settings.activeAiProvider]?.endpoint || ''}
onChange={(e) => {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], endpoint: e.target.value };
setSettings({ ...settings, aiProviders: next });
}}
placeholder="https://api.example.com/v1"
className="input-minimal"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">API (Secret Key)</label>
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">API </label>
<input
ref={apiKeyInputRef}
type="password"
value={settings.apiKey}
onChange={(e) => setSettings({ ...settings, apiKey: e.target.value })}
onChange={(e) => {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], apiKey: e.target.value };
setSettings({ ...settings, aiProviders: next });
}}
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
placeholder="sk-xxxxxxxxxxxxxxxx"
className="input-minimal"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (Model Name)</label>
{availableModels.length > 0 ? (
<select
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
onChange={(e) => {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], modelName: e.target.value };
setSettings({ ...settings, aiProviders: next });
}}
className="input-minimal bg-white"
>
{availableModels.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
) : (
<input
type="text"
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
onChange={(e) => {
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"
/>
)}
<p className="text-[11px] text-text-muted">{availableModels.length > 0 ? '已从服务商获取可用模型列表' : '点击"测试连接"成功后,此处可下拉选择模型'}</p>
</div>
</div>
</div>
)}
{currentUser.role === 'super' && (
<div className="card-minimal">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-text-main flex items-center gap-2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
Websocket接口配置
</h3>
</div>
<div className="space-y-6">
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APPID</label>
<input
ref={xfAppIdRef}
type="password"
onChange={(e) => {
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), appId: e.target.value };
setSettings({ ...settings, xfSpeechConfig: next });
}}
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
autoComplete="new-password"
placeholder="********"
className="input-minimal"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APIKey</label>
<input
ref={xfApiKeyRef}
type="password"
onChange={(e) => {
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), apiKey: e.target.value };
setSettings({ ...settings, xfSpeechConfig: next });
}}
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
autoComplete="new-password"
placeholder="********************************"
className="input-minimal"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APISecret</label>
<input
ref={xfApiSecretRef}
type="password"
onChange={(e) => {
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), apiSecret: e.target.value };
setSettings({ ...settings, xfSpeechConfig: next });
}}
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
autoComplete="new-password"
placeholder="********************************"
className="input-minimal"
/>
</div>
</div>
</div>
)}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Check, Download, Upload } from 'lucide-react';
import { Plus, Edit, Trash2, Save, Printer, Undo, Redo, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon, Bot, Check, Download, Upload } from 'lucide-react';
import { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types';
import { defaultReportContent } from '../utils/defaultContent';
import { printDocument } from '../utils/print';
@@ -419,9 +419,16 @@ export default function TemplateManage() {
const saveTemplateContent = () => {
if (!currentTemplateId || !editorRef.current) return;
let cleanContent = editorRef.current.innerHTML;
cleanContent = cleanContent.replace(/<p>\s*<br\s*\/?>\s*<\/p>/gi, '');
cleanContent = cleanContent.replace(/<p><\/p>/gi, '');
cleanContent = cleanContent.replace(/>(\s+)</g, '><');
if (cleanContent !== editorRef.current.innerHTML) {
editorRef.current.innerHTML = cleanContent;
}
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.map(t =>
t.id === currentTemplateId ? { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() } : t
t.id === currentTemplateId ? { ...t, content: cleanContent, updatedAt: new Date().toISOString() } : t
);
setTemplates(prevTemplates => prevTemplates.map(t => updated.find(u => u.id === t.id) || t));
storage.set('templates', updated);
@@ -587,12 +594,33 @@ export default function TemplateManage() {
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};
const insertAiRegion = () => {
const name = window.prompt('请输入 AI 可编辑区域的名称(如:手术步骤、病灶描述):');
if (!name || !name.trim()) return;
if (editorRef.current?.querySelector(`[data-ai-id="${name}"]`)) {
window.alert('该区域名称已存在,请使用其他名称以保证 AI 定位准确。');
return;
}
editorRef.current?.focus();
// Insert ai-region HTML
const html = `<div class="ai-region" data-ai-id="${name}" data-ai-title="${name}" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;"><div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">${name}-AI可编辑区域</div><div class="ai-content" style="min-height: 20px;">&#8203;</div></div><p><br></p>`;
document.execCommand('insertHTML', false, html);
saveTemplateContent();
};
const saveCurrentTemplate = () => {
if (!currentTemplateId || !editorRef.current) return;
let cleanContent = editorRef.current.innerHTML;
cleanContent = cleanContent.replace(/<p>\s*<br\s*\/?>\s*<\/p>/gi, '');
cleanContent = cleanContent.replace(/<p><\/p>/gi, '');
cleanContent = cleanContent.replace(/>(\s+)</g, '><');
if (cleanContent !== editorRef.current.innerHTML) {
editorRef.current.innerHTML = cleanContent;
}
const allTemplates = storage.get<Template[]>('templates', []);
const updated = allTemplates.map(t => {
if (t.id === currentTemplateId) {
return { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() };
return { ...t, content: cleanContent, updatedAt: new Date().toISOString() };
}
return t;
});
@@ -978,6 +1006,7 @@ export default function TemplateManage() {
<div className="flex gap-1">
<button onMouseDown={(e) => e.preventDefault()} onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
<button onMouseDown={(e) => e.preventDefault()} onClick={insertAiRegion} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-blue-50 text-blue-500 transition-colors" title="插入AI可编辑区域"><Bot size={16} /></button>
</div>
</div>

View File

@@ -70,18 +70,44 @@ export interface Template {
fields?: FormField[];
}
export interface AiProviderConfig {
endpoint: string;
apiKey: string;
modelName: string;
}
export interface XfSpeechConfig {
appId: string;
apiKey: string;
apiSecret: string;
}
export const DEFAULT_XF_SPEECH: XfSpeechConfig = {
appId: 'e0fe23e3',
apiKey: '7fd08be316718c2280e85af4fe126306',
apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0'
};
export interface SystemSettings {
frameCount: number;
framePositions: number[];
apiEndpoint: string;
apiKey: string;
defaultTemplate?: string;
frameMode?: 'uniform' | 'keep';
autoInsertFrames?: boolean;
autoInsertFrameIndices?: number[];
autoInsertDelay?: number;
activeAiProvider: string;
aiProviders: Record<string, AiProviderConfig>;
xfSpeechConfig?: XfSpeechConfig;
}
export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = {
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' },
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
custom: { endpoint: '', apiKey: '', modelName: '' }
};
export interface BindableField {
key: string;
label: string;

View File

@@ -55,25 +55,10 @@ export const defaultReportContent = `
<strong>手术步骤、术中出现的情况及处理:</strong>
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
1患者仰卧位麻醉成功后常规消毒术野、铺无菌巾于脐下穿刺建立CO2气腹气腹压力为12mmHg进镜探查无穿刺损伤分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
2腹腔镜探查腹腔内无腹水形成无明显粘连肝脏色红质软无明显结节硬化改变胆囊大小约 cm× cm× cm壁轻度水肿张力可胆囊三角解剖关系清楚胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
3.切除胆囊钳夹胆囊颈部并解剖胆囊三角游离出胆囊动脉及胆囊管明确胆囊与胆总管的关系距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。
</p>
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml术中输血成分输血量是否有输血不良反应。
</p>
<div class="ai-region" data-ai-id="手术步骤" data-ai-title="手术步骤、术中出现的情况及处理" style="border: 1px dashed #3b82f6; padding: 16px 12px 12px; margin: 8px 0; position: relative; min-height: 60px; background: #f8fafc; border-radius: 6px;">
<div contenteditable="false" style="position: absolute; top: -10px; right: 10px; background: #3b82f6; color: white; font-size: 10px; padding: 2px 8px; border-radius: 12px; z-index: 10; user-select: none;">手术步骤、术中出现的情况及处理-AI可编辑区域</div>
<div class="ai-content" style="min-height: 20px;"><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">1患者仰卧位麻醉成功后常规消毒术野、铺无菌巾于脐下穿刺建立CO2气腹气腹压力为12mmHg进镜探查无穿刺损伤分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">2腹腔镜探查腹腔内无腹水形成无明显粘连肝脏色红质软无明显结节硬化改变胆囊大小约 cm× cm× cm壁轻度水肿张力可胆囊三角解剖关系清楚胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">3.切除胆囊钳夹胆囊颈部并解剖胆囊三角游离出胆囊动脉及胆囊管明确胆囊与胆总管的关系距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml术中输血成分输血量是否有输血不良反应。</p></div>
</div>
<!-- 手术图片说明表格 -->
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">

View File

@@ -41,6 +41,8 @@ export const printDocument = (htmlContent: string, docTitle: string = '图文报
.smart-field-wrapper .field-label { color: #64748b; user-select: none; }
.smart-field-wrapper .field-value { min-width: 24px; padding: 0 2px; margin: 0; border: 1px solid #cbd5e1; border-radius: 2px; display: inline-block; background: #f8fafc; color: #0f172a; line-height: inherit; font-size: inherit; vertical-align: baseline; box-sizing: border-box; outline: none; text-align: center; }
.report-signature-img { max-width: 120px; max-height: 40px; width: auto; height: auto; object-fit: contain; vertical-align: middle; display: inline-block; }
.ai-region { border: none !important; background: transparent !important; padding: 0 !important; margin: 0 !important; }
.ai-region > [contenteditable="false"] { display: none !important; }
@media print {
.smart-field-wrapper .field-value { outline: none !important; box-shadow: none !important; border: none !important; border-bottom: 1px solid #000 !important; border-radius: 0 !important; background: transparent !important; padding: 0 2px 0px 2px !important; line-height: 1 !important; }
.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }

View File

@@ -1,8 +1,42 @@
// API Key 以字符码形式存储,避免源码中直接出现明文字符串
const API_KEY_CODES = [115, 107, 45, 50, 73, 65, 70, 110, 56, 79, 82, 111, 83, 100, 85, 99, 67, 120, 89, 88, 54, 68, 109, 88, 74, 87, 98, 72, 55, 66, 120, 102, 116, 83, 83, 65, 56, 107, 78, 56, 56, 109, 68, 49, 75, 85, 68, 84, 109, 107, 118];
export function getDefaultApiKey(): string {
return String.fromCharCode(...API_KEY_CODES);
}
const CRYPTO_KEY = 'MedicalReportSys2024';
function xorEncrypt(text: string, key: string): string {
let result = '';
for (let i = 0; i < text.length; i++) {
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return btoa(result);
}
function xorDecrypt(encrypted: string, key: string): string {
const text = atob(encrypted);
let result = '';
for (let i = 0; i < text.length; i++) {
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return result;
}
export const storage = {
get<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
if (!raw) return fallback;
if (key === 'systemSettings') {
try {
return JSON.parse(raw) as T;
} catch {
return JSON.parse(xorDecrypt(raw, CRYPTO_KEY)) as T;
}
}
return JSON.parse(raw) as T;
} catch {
return fallback;
}
@@ -10,7 +44,11 @@ export const storage = {
set<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value));
let data = JSON.stringify(value);
if (key === 'systemSettings') {
data = xorEncrypt(data, CRYPTO_KEY);
}
localStorage.setItem(key, data);
} catch (e) {
console.error('Storage save failed (possibly quota exceeded):', e);
}

View File

@@ -22,5 +22,6 @@
},
"allowImportingTsExtensions": true,
"noEmit": true
}
},
"exclude": ["参考信息", "dist", "node_modules"]
}