Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35d8dd4ce2 | |||
| 3827d09ad3 | |||
| 49886e5080 | |||
| 5f68f4b820 | |||
| c544483705 | |||
| 9aec836e93 | |||
| 75e4e56cb3 | |||
| b07bcfaad2 | |||
| 4f27edcc92 | |||
| d235ced187 | |||
| ea789cee26 | |||
| 9f2b5dce21 | |||
| 2dbdbe02b2 | |||
| 963a7541c9 | |||
| e549419a4c | |||
| 18d81cb4a6 | |||
| 0039b18a26 | |||
| 3bec69986e | |||
| 2e634ff832 | |||
| 1ec25065ad | |||
| 7275906f3c | |||
| b24ba08658 | |||
| 6abd7d1e3a | |||
| a3cafcb672 | |||
| c7e7033e7d | |||
| 9f73d8595c | |||
| c1d2438d2b | |||
| 854a00c2fa | |||
| a065f6af27 | |||
| da2ecdc224 | |||
| 9173aa7733 | |||
| d5cbbf9137 | |||
| 221daf61a5 | |||
| 96b295f919 | |||
| 1dc3d60248 |
24
package-lock.json
generated
24
package-lock.json
generated
@@ -10,7 +10,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"diff": "^9.0.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
@@ -1491,6 +1494,12 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1903,6 +1912,12 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
@@ -1957,6 +1972,15 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.4.2",
|
"version": "17.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
|
|||||||
@@ -13,7 +13,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"diff": "^9.0.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
|
|||||||
@@ -204,6 +204,15 @@
|
|||||||
.print-content .smart-field-wrapper .delete-btn {
|
.print-content .smart-field-wrapper .delete-btn {
|
||||||
display: none !important;
|
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 {
|
.report-signature-img {
|
||||||
max-width: 120px !important;
|
max-width: 120px !important;
|
||||||
max-height: 40px !important;
|
max-height: 40px !important;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { defaultReportContent } from '../utils/defaultContent';
|
||||||
import { storage } from '../utils/storage';
|
import { storage, getDefaultApiKey } from '../utils/storage';
|
||||||
import { User as UserIcon, Lock } from 'lucide-react';
|
import { User as UserIcon, Lock } from 'lucide-react';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@@ -63,21 +63,17 @@ export default function Login() {
|
|||||||
|
|
||||||
const settingsRaw = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
const settingsRaw = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
||||||
if (!settingsRaw.frameCount) {
|
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 = {
|
const defaultSettings = {
|
||||||
frameCount: 12,
|
frameCount: 12,
|
||||||
framePositions: positions,
|
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],
|
||||||
apiEndpoint: '',
|
|
||||||
apiKey: '',
|
|
||||||
defaultTemplate: savedTemplates[0]?.id || '',
|
defaultTemplate: savedTemplates[0]?.id || '',
|
||||||
frameMode: 'uniform',
|
frameMode: 'keep',
|
||||||
|
activeAiProvider: 'kimi',
|
||||||
|
aiProviders: { ...DEFAULT_AI_PROVIDERS, kimi: { ...DEFAULT_AI_PROVIDERS.kimi, apiKey: getDefaultApiKey() } },
|
||||||
autoInsertFrames: true,
|
autoInsertFrames: true,
|
||||||
autoInsertDelay: 1,
|
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);
|
storage.set('systemSettings', defaultSettings);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import Sidebar from '../components/Sidebar';
|
|||||||
import {
|
import {
|
||||||
Check, Printer, Undo, Redo, Bold, Italic, Underline,
|
Check, Printer, Undo, Redo, Bold, Italic, Underline,
|
||||||
AlignLeft, AlignCenter, AlignRight, Table, Image as ImageIcon,
|
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';
|
} 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 { defaultReportContent } from '../utils/defaultContent';
|
||||||
import { printDocument } from '../utils/print';
|
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() {
|
export default function ReportEditor() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -53,9 +56,78 @@ export default function ReportEditor() {
|
|||||||
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
|
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null);
|
||||||
const prevVideoCountRef = useRef(0);
|
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);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
const allFields = editorRef.current.querySelectorAll('.field-value');
|
const allFields = editorRef.current.querySelectorAll('.field-value');
|
||||||
@@ -101,7 +173,7 @@ export default function ReportEditor() {
|
|||||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||||
const contentLoadedRef = useRef(false);
|
const contentLoadedRef = useRef(false);
|
||||||
const contentRef = useRef('');
|
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}` : '';
|
const draftKey = currentUser ? `reportEditorDraft_${currentUser.username}` : '';
|
||||||
|
|
||||||
@@ -126,7 +198,9 @@ export default function ReportEditor() {
|
|||||||
videos: stateRef.current.videos,
|
videos: stateRef.current.videos,
|
||||||
capturedFrames: stateRef.current.capturedFrames,
|
capturedFrames: stateRef.current.capturedFrames,
|
||||||
activeTab: stateRef.current.activeTab,
|
activeTab: stateRef.current.activeTab,
|
||||||
loadedTemplateId: stateRef.current.loadedTemplateId
|
loadedTemplateId: stateRef.current.loadedTemplateId,
|
||||||
|
chatMessages: stateRef.current.chatMessages,
|
||||||
|
chatInput: stateRef.current.chatInput
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [reportId]);
|
}, [reportId]);
|
||||||
@@ -170,12 +244,16 @@ export default function ReportEditor() {
|
|||||||
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
|
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
|
||||||
}
|
}
|
||||||
if (draft.activeTab) setActiveTab(draft.activeTab);
|
if (draft.activeTab) setActiveTab(draft.activeTab);
|
||||||
|
if (draft.chatMessages) setChatMessages(draft.chatMessages);
|
||||||
|
if (typeof draft.chatInput === 'string') setChatInput(draft.chatInput);
|
||||||
stateRef.current = {
|
stateRef.current = {
|
||||||
...stateRef.current,
|
...stateRef.current,
|
||||||
reportData: draft.reportData,
|
reportData: draft.reportData,
|
||||||
videos: draft.videos,
|
videos: draft.videos,
|
||||||
capturedFrames: draft.capturedFrames,
|
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) {
|
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
|
||||||
editorRef.current.innerHTML = draft.content;
|
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));
|
setCapturedFrames(draft.capturedFrames.sort((a: CapturedFrame, b: CapturedFrame) => a.time - b.time));
|
||||||
}
|
}
|
||||||
if (draft.activeTab) setActiveTab(draft.activeTab);
|
if (draft.activeTab) setActiveTab(draft.activeTab);
|
||||||
|
if (draft.chatMessages) setChatMessages(draft.chatMessages);
|
||||||
stateRef.current = {
|
stateRef.current = {
|
||||||
...stateRef.current,
|
...stateRef.current,
|
||||||
reportData: draft.reportData,
|
reportData: draft.reportData,
|
||||||
videos: draft.videos,
|
videos: draft.videos,
|
||||||
capturedFrames: draft.capturedFrames,
|
capturedFrames: draft.capturedFrames,
|
||||||
loadedTemplateId: draft.loadedTemplateId || ''
|
loadedTemplateId: draft.loadedTemplateId || '',
|
||||||
|
chatMessages: draft.chatMessages || []
|
||||||
};
|
};
|
||||||
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
|
if (editorRef.current && typeof draft.content === 'string' && draft.content.trim().length > 0) {
|
||||||
editorRef.current.innerHTML = draft.content;
|
editorRef.current.innerHTML = draft.content;
|
||||||
@@ -604,6 +684,20 @@ export default function ReportEditor() {
|
|||||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
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;">​</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 handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []) as File[];
|
const files = Array.from(e.target.files || []) as File[];
|
||||||
const newVideos = files.map(file => ({
|
const newVideos = files.map(file => ({
|
||||||
@@ -681,7 +775,7 @@ export default function ReportEditor() {
|
|||||||
if (!videoRef.current || currentVideoIndex === -1) return;
|
if (!videoRef.current || currentVideoIndex === -1) return;
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
|
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 dur = video.duration || 1;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -767,6 +861,361 @@ export default function ReportEditor() {
|
|||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
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, '<').replace(/>/g, '>').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(/​/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) => {
|
const handleDragStart = (e: React.DragEvent, frame: CapturedFrame) => {
|
||||||
e.dataTransfer.setData('frameId', frame.id.toString());
|
e.dataTransfer.setData('frameId', frame.id.toString());
|
||||||
};
|
};
|
||||||
@@ -941,6 +1390,10 @@ export default function ReportEditor() {
|
|||||||
setVideos([]);
|
setVideos([]);
|
||||||
setCapturedFrames([]);
|
setCapturedFrames([]);
|
||||||
setCurrentVideoIndex(-1);
|
setCurrentVideoIndex(-1);
|
||||||
|
setChatMessages([]);
|
||||||
|
setChatInput('');
|
||||||
|
setAiUploadedImages([]);
|
||||||
|
setAiSelectedEditorImages([]);
|
||||||
prevVideoCountRef.current = 0;
|
prevVideoCountRef.current = 0;
|
||||||
stateRef.current = {
|
stateRef.current = {
|
||||||
...stateRef.current,
|
...stateRef.current,
|
||||||
@@ -948,6 +1401,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();
|
||||||
@@ -976,7 +1431,9 @@ export default function ReportEditor() {
|
|||||||
reportData: draft.reportData,
|
reportData: draft.reportData,
|
||||||
videos: draft.videos,
|
videos: draft.videos,
|
||||||
capturedFrames: draft.capturedFrames,
|
capturedFrames: draft.capturedFrames,
|
||||||
loadedTemplateId: draft.loadedTemplateId || ''
|
loadedTemplateId: draft.loadedTemplateId || '',
|
||||||
|
chatMessages: draft.chatMessages || [],
|
||||||
|
chatInput: draft.chatInput || ''
|
||||||
};
|
};
|
||||||
setTimeout(() => updatePageHeight(), 0);
|
setTimeout(() => updatePageHeight(), 0);
|
||||||
return;
|
return;
|
||||||
@@ -1482,6 +1939,7 @@ export default function ReportEditor() {
|
|||||||
<div className="flex gap-1">
|
<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={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 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1509,15 +1967,16 @@ export default function ReportEditor() {
|
|||||||
{/* Right Sidebar */}
|
{/* Right Sidebar */}
|
||||||
<aside className="w-[380px] bg-sidebar-bg flex flex-col shrink-0 overflow-hidden">
|
<aside className="w-[380px] bg-sidebar-bg flex flex-col shrink-0 overflow-hidden">
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
{(['info', 'video'] as const).map(tab => (
|
{(['info', 'video', 'ai'] as const).map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => { setActiveTab(tab); stateRef.current = { ...stateRef.current, activeTab: tab }; saveDraftToStorage(); }}
|
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'
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2009,6 +2468,191 @@ export default function ReportEditor() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -2219,6 +2863,61 @@ export default function ReportEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,55 @@
|
|||||||
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';
|
||||||
import { User, SystemSettings as ISystemSettings, Template } from '../types';
|
import { User, SystemSettings as ISystemSettings, Template, DEFAULT_AI_PROVIDERS, AiProviderConfig } from '../types';
|
||||||
import { storage } from '../utils/storage';
|
import { storage, getDefaultApiKey } from '../utils/storage';
|
||||||
|
|
||||||
export default function SystemSettings() {
|
export default function SystemSettings() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
const [settings, setSettings] = useState<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>({
|
const [settings, setSettings] = useState<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>({
|
||||||
frameCount: 12,
|
frameCount: 12,
|
||||||
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
|
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],
|
||||||
apiEndpoint: '',
|
|
||||||
apiKey: '',
|
|
||||||
defaultTemplate: '',
|
defaultTemplate: '',
|
||||||
frameMode: 'uniform'
|
frameMode: 'keep',
|
||||||
|
activeAiProvider: 'kimi',
|
||||||
|
aiProviders: { ...DEFAULT_AI_PROVIDERS },
|
||||||
|
xfSpeechConfig: { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' }
|
||||||
});
|
});
|
||||||
const [templates, setTemplates] = useState<Template[]>([]);
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
const [isSaved, setIsSaved] = useState(false);
|
const [isSaved, setIsSaved] = useState(false);
|
||||||
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 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(() => {
|
useEffect(() => {
|
||||||
const user = storage.get<User | null>('currentUser', null);
|
const user = storage.get<User | null>('currentUser', null);
|
||||||
@@ -30,17 +60,37 @@ export default function SystemSettings() {
|
|||||||
setCurrentUser(user);
|
setCurrentUser(user);
|
||||||
|
|
||||||
const savedSettings = storage.get<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>('systemSettings', {} as ISystemSettings & { frameMode?: 'uniform' | 'keep' });
|
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', []);
|
const savedTemplates = storage.get<Template[]>('templates', []);
|
||||||
if (savedSettings.frameCount) {
|
if (savedSettings.frameCount) {
|
||||||
if (!savedSettings.defaultTemplate && savedTemplates.length > 0) {
|
if (!savedSettings.defaultTemplate && savedTemplates.length > 0) {
|
||||||
savedSettings.defaultTemplate = savedTemplates[0].id;
|
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.autoInsertFrames !== 'boolean') savedSettings.autoInsertFrames = false;
|
||||||
if (typeof savedSettings.autoInsertDelay !== 'number') savedSettings.autoInsertDelay = 0;
|
if (typeof savedSettings.autoInsertDelay !== 'number') savedSettings.autoInsertDelay = 0;
|
||||||
|
if (!savedSettings.xfSpeechConfig) savedSettings.xfSpeechConfig = { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' };
|
||||||
setSettings(savedSettings);
|
setSettings(savedSettings);
|
||||||
} else if (savedTemplates.length > 0) {
|
} 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);
|
setTemplates(savedTemplates);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
@@ -79,25 +129,49 @@ export default function SystemSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const testApi = async () => {
|
const testApi = async () => {
|
||||||
if (!settings.apiEndpoint) {
|
const provider = settings.aiProviders[settings.activeAiProvider];
|
||||||
alert('请先输入 API 接口地址');
|
if (!provider?.apiKey) {
|
||||||
|
alert('请先输入 API 密钥');
|
||||||
return;
|
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 = () => {
|
const resetToDefault = () => {
|
||||||
if (window.confirm('确定要恢复系统设置出厂设置吗?所有自定义配置将被清除。')) {
|
if (window.confirm('确定要恢复系统设置出厂设置吗?所有自定义配置将被清除。')) {
|
||||||
const defaultSettings: ISystemSettings & { frameMode?: 'uniform' | 'keep' } = {
|
const defaultSettings: ISystemSettings & { frameMode?: 'uniform' | 'keep' } = {
|
||||||
frameCount: 12,
|
frameCount: 12,
|
||||||
framePositions: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60],
|
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],
|
||||||
apiEndpoint: '',
|
|
||||||
apiKey: '',
|
|
||||||
defaultTemplate: templates[0]?.id || '',
|
defaultTemplate: templates[0]?.id || '',
|
||||||
frameMode: 'uniform',
|
frameMode: 'keep',
|
||||||
|
activeAiProvider: 'kimi',
|
||||||
|
aiProviders: { ...DEFAULT_AI_PROVIDERS, kimi: { ...DEFAULT_AI_PROVIDERS.kimi, apiKey: getDefaultApiKey() } },
|
||||||
autoInsertFrames: true,
|
autoInsertFrames: true,
|
||||||
autoInsertDelay: 1,
|
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);
|
setSettings(defaultSettings);
|
||||||
storage.set('systemSettings', defaultSettings);
|
storage.set('systemSettings', defaultSettings);
|
||||||
@@ -288,26 +362,143 @@ export default function SystemSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<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 接口 (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
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={settings.apiEndpoint}
|
value={settings.aiProviders[settings.activeAiProvider]?.endpoint || ''}
|
||||||
onChange={(e) => setSettings({ ...settings, apiEndpoint: e.target.value })}
|
onChange={(e) => {
|
||||||
placeholder="https://api.example.com/v1/generate"
|
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"
|
className="input-minimal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 密钥 (Secret Key)</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.apiKey}
|
onChange={(e) => {
|
||||||
onChange={(e) => setSettings({ ...settings, apiKey: e.target.value })}
|
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"
|
placeholder="sk-xxxxxxxxxxxxxxxx"
|
||||||
className="input-minimal"
|
className="input-minimal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } 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 { 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 { User, Template, FormField, FieldType, DEFAULT_FORM_FIELDS } from '../types';
|
||||||
import { defaultReportContent } from '../utils/defaultContent';
|
import { defaultReportContent } from '../utils/defaultContent';
|
||||||
import { printDocument } from '../utils/print';
|
import { printDocument } from '../utils/print';
|
||||||
@@ -419,9 +419,16 @@ export default function TemplateManage() {
|
|||||||
|
|
||||||
const saveTemplateContent = () => {
|
const saveTemplateContent = () => {
|
||||||
if (!currentTemplateId || !editorRef.current) return;
|
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 allTemplates = storage.get<Template[]>('templates', []);
|
||||||
const updated = allTemplates.map(t =>
|
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));
|
setTemplates(prevTemplates => prevTemplates.map(t => updated.find(u => u.id === t.id) || t));
|
||||||
storage.set('templates', updated);
|
storage.set('templates', updated);
|
||||||
@@ -587,12 +594,33 @@ export default function TemplateManage() {
|
|||||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
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;">​</div></div><p><br></p>`;
|
||||||
|
document.execCommand('insertHTML', false, html);
|
||||||
|
saveTemplateContent();
|
||||||
|
};
|
||||||
|
|
||||||
const saveCurrentTemplate = () => {
|
const saveCurrentTemplate = () => {
|
||||||
if (!currentTemplateId || !editorRef.current) return;
|
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 allTemplates = storage.get<Template[]>('templates', []);
|
||||||
const updated = allTemplates.map(t => {
|
const updated = allTemplates.map(t => {
|
||||||
if (t.id === currentTemplateId) {
|
if (t.id === currentTemplateId) {
|
||||||
return { ...t, content: editorRef.current!.innerHTML, updatedAt: new Date().toISOString() };
|
return { ...t, content: cleanContent, updatedAt: new Date().toISOString() };
|
||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
@@ -978,6 +1006,7 @@ export default function TemplateManage() {
|
|||||||
<div className="flex gap-1">
|
<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={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={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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
30
src/types.ts
30
src/types.ts
@@ -70,18 +70,44 @@ export interface Template {
|
|||||||
fields?: FormField[];
|
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 {
|
export interface SystemSettings {
|
||||||
frameCount: number;
|
frameCount: number;
|
||||||
framePositions: number[];
|
framePositions: number[];
|
||||||
apiEndpoint: string;
|
|
||||||
apiKey: string;
|
|
||||||
defaultTemplate?: string;
|
defaultTemplate?: string;
|
||||||
frameMode?: 'uniform' | 'keep';
|
frameMode?: 'uniform' | 'keep';
|
||||||
autoInsertFrames?: boolean;
|
autoInsertFrames?: boolean;
|
||||||
autoInsertFrameIndices?: number[];
|
autoInsertFrameIndices?: number[];
|
||||||
autoInsertDelay?: 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 {
|
export interface BindableField {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -55,25 +55,10 @@ export const defaultReportContent = `
|
|||||||
<strong>手术步骤、术中出现的情况及处理:</strong>
|
<strong>手术步骤、术中出现的情况及处理:</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">
|
<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;">
|
||||||
1.患者仰卧位,麻醉成功后,常规消毒术野、铺无菌巾,于脐下穿刺建立CO2气腹,气腹压力为12mmHg,进镜探查无穿刺损伤,分别于剑突下2.0cm、右锁中线肋缘下2.0cm各点穿刺置穿刺器,插入相应手术器械。
|
<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>
|
||||||
</p>
|
<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>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 手术图片说明表格 -->
|
<!-- 手术图片说明表格 -->
|
||||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">
|
<table style="width: 100%; border-collapse: collapse; margin: 20px 0; table-layout: fixed;">
|
||||||
|
|||||||
@@ -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-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; }
|
.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; }
|
.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 {
|
@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 { 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; }
|
.smart-field-wrapper .field-value.no-underline { border-bottom: none !important; }
|
||||||
|
|||||||
@@ -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 = {
|
export const storage = {
|
||||||
get<T>(key: string, fallback: T): T {
|
get<T>(key: string, fallback: T): T {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
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 {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -10,7 +44,11 @@ export const storage = {
|
|||||||
|
|
||||||
set<T>(key: string, value: T): void {
|
set<T>(key: string, value: T): void {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error('Storage save failed (possibly quota exceeded):', e);
|
console.error('Storage save failed (possibly quota exceeded):', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,5 +22,6 @@
|
|||||||
},
|
},
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
}
|
},
|
||||||
|
"exclude": ["参考信息", "dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user