Compare commits
9 Commits
ea789cee26
...
3827d09ad3
| Author | SHA1 | Date | |
|---|---|---|---|
| 3827d09ad3 | |||
| 49886e5080 | |||
| 5f68f4b820 | |||
| c544483705 | |||
| 9aec836e93 | |||
| 75e4e56cb3 | |||
| b07bcfaad2 | |||
| 4f27edcc92 | |||
| d235ced187 |
14
package-lock.json
generated
14
package-lock.json
generated
@@ -10,7 +10,9 @@
|
|||||||
"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",
|
"diff": "^9.0.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
@@ -1492,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",
|
||||||
@@ -1904,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",
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
"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",
|
"diff": "^9.0.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { defaultReportContent } from '../utils/defaultContent';
|
|||||||
import { printDocument } from '../utils/print';
|
import { printDocument } from '../utils/print';
|
||||||
import { storage, getDefaultApiKey } from '../utils/storage';
|
import { storage, getDefaultApiKey } from '../utils/storage';
|
||||||
import { diffChars } from 'diff';
|
import { diffChars } from 'diff';
|
||||||
|
import CryptoJS from 'crypto-js';
|
||||||
|
|
||||||
export default function ReportEditor() {
|
export default function ReportEditor() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -896,14 +897,11 @@ export default function ReportEditor() {
|
|||||||
async function getXfAuthUrl(apiKey: string, apiSecret: string): Promise<string> {
|
async function getXfAuthUrl(apiKey: string, apiSecret: string): Promise<string> {
|
||||||
const host = 'iat-api.xfyun.cn';
|
const host = 'iat-api.xfyun.cn';
|
||||||
const date = new Date().toUTCString();
|
const date = new Date().toUTCString();
|
||||||
const signatureOrigin = `host: "${host}"\ndate: "${date}"\nGET /v2/iat HTTP/1.1`;
|
const signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/iat HTTP/1.1`;
|
||||||
const encoder = new TextEncoder();
|
const signature = CryptoJS.enc.Base64.stringify(CryptoJS.HmacSHA256(signatureOrigin, apiSecret));
|
||||||
const key = await crypto.subtle.importKey('raw', encoder.encode(apiSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(signatureOrigin));
|
const authorization = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(authorizationOrigin));
|
||||||
const signatureBase64 = btoa(String.fromCharCode(...new Uint8Array(signature)));
|
return `wss://iat-api.xfyun.cn/v2/iat?authorization=${authorization}&date=${date}&host=${host}`;
|
||||||
const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signatureBase64}"`;
|
|
||||||
const authorization = btoa(authorizationOrigin);
|
|
||||||
return `wss://iat-api.xfyun.cn/v2/iat?authorization=${encodeURIComponent(authorization)}&date=${encodeURIComponent(date)}&host=${encodeURIComponent(host)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function floatTo16BitPCM(input: Float32Array): ArrayBuffer {
|
function floatTo16BitPCM(input: Float32Array): ArrayBuffer {
|
||||||
@@ -925,24 +923,43 @@ export default function ReportEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleListening = async () => {
|
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) {
|
if (isListening) {
|
||||||
setIsListening(false);
|
setIsListening(false);
|
||||||
if (xfWsRef.current) { try { xfWsRef.current.close(); } catch {} xfWsRef.current = null; }
|
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 (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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const xfConfig = storage.get<SystemSettings>('systemSettings', {} as SystemSettings).xfSpeechConfig || DEFAULT_XF_SPEECH;
|
const xfConfig = storage.get<SystemSettings>('systemSettings', {} as SystemSettings).xfSpeechConfig || DEFAULT_XF_SPEECH;
|
||||||
if (!xfConfig?.appId || !xfConfig?.apiKey || !xfConfig?.apiSecret) {
|
if (!xfConfig?.appId || !xfConfig?.apiKey || !xfConfig?.apiSecret) {
|
||||||
alert('请先在系统设置中配置讯飞语音 APPID/APIKey/APISecret');
|
alert('请先在系统设置中配置讯飞语音 APPID/APIKey/APISecret');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authUrl = await getXfAuthUrl(xfConfig.apiKey, xfConfig.apiSecret);
|
const authUrl = await getXfAuthUrl(xfConfig.apiKey, xfConfig.apiSecret);
|
||||||
const ws = new WebSocket(authUrl);
|
const ws = new WebSocket(authUrl);
|
||||||
xfWsRef.current = ws;
|
xfWsRef.current = ws;
|
||||||
let frameStatus = 0;
|
let frameStatus = 0;
|
||||||
let transcript = chatInput;
|
|
||||||
ws.onopen = async () => {
|
ws.onopen = async () => {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
@@ -951,16 +968,21 @@ export default function ReportEditor() {
|
|||||||
xfAudioContextRef.current = audioContext;
|
xfAudioContextRef.current = audioContext;
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
|
|
||||||
processor.onaudioprocess = (e) => {
|
processor.onaudioprocess = (e) => {
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
if (ws.readyState !== WebSocket.OPEN || !xfAudioContextRef.current) return;
|
||||||
const inputData = e.inputBuffer.getChannelData(0);
|
const inputData = e.inputBuffer.getChannelData(0);
|
||||||
const pcmBuffer = floatTo16BitPCM(inputData);
|
const pcmBuffer = floatTo16BitPCM(inputData);
|
||||||
const base64Audio = arrayBufferToBase64(pcmBuffer);
|
const base64Audio = arrayBufferToBase64(pcmBuffer);
|
||||||
const frame: any = { data: { status: frameStatus, format: 'audio/L16;rate=16000', encoding: 'raw', audio: base64Audio } };
|
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' }; }
|
if (frameStatus === 0) {
|
||||||
|
frame.common = { app_id: xfConfig.appId };
|
||||||
|
frame.business = { language: 'zh_cn', domain: 'iat', accent: 'mandarin' };
|
||||||
|
}
|
||||||
ws.send(JSON.stringify(frame));
|
ws.send(JSON.stringify(frame));
|
||||||
frameStatus = 1;
|
frameStatus = 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
source.connect(processor);
|
source.connect(processor);
|
||||||
processor.connect(audioContext.destination);
|
processor.connect(audioContext.destination);
|
||||||
setIsListening(true);
|
setIsListening(true);
|
||||||
@@ -970,19 +992,35 @@ export default function ReportEditor() {
|
|||||||
ws.close();
|
ws.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const jsonData = JSON.parse(event.data);
|
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) {
|
if (jsonData.data?.result?.ws) {
|
||||||
let seg = '';
|
let seg = '';
|
||||||
for (const w of jsonData.data.result.ws) { if (w.cw?.[0]?.w) seg += w.cw[0].w; }
|
for (const w of jsonData.data.result.ws) { if (w.cw?.[0]?.w) seg += w.cw[0].w; }
|
||||||
if (jsonData.data.result.ls) { transcript += seg; setChatInput(transcript); }
|
if (seg) {
|
||||||
else { setChatInput(transcript + seg); }
|
setChatInput(prev => prev + seg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonData.data?.status === 2) {
|
||||||
|
ws.close();
|
||||||
|
xfWsRef.current = null;
|
||||||
|
setIsListening(false);
|
||||||
|
stopMicrophone();
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
ws.onerror = () => { alert('讯飞语音连接失败'); setIsListening(false); };
|
|
||||||
ws.onclose = () => { setIsListening(false); };
|
ws.onerror = () => { alert('讯飞语音连接失败'); setIsListening(false); stopMicrophone(); };
|
||||||
|
ws.onclose = () => { setIsListening(false); stopMicrophone(); };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert('讯飞语音初始化失败: ' + e.message);
|
alert('讯飞语音初始化失败: ' + e.message);
|
||||||
}
|
}
|
||||||
@@ -1054,7 +1092,7 @@ export default function ReportEditor() {
|
|||||||
}
|
}
|
||||||
const systemPrompt = aiModifyEnabled
|
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我为你提供了当前手术报告的【全局参考内容】作为背景知识,以及你需要修改的【目标区域 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 标记';
|
: '你是一名专业的外科医生助理。当前处于【对话模式】。\n请仔细阅读我提供的【全局手术报告参考内容】,并根据【医生指令】进行专业解答。\n重要指令:\n1. 必须返回合法的 JSON 对象\n2. 仅包含 "reply"(你的专业回答)一个字段\n3. 不要返回任何 HTML 代码\n4. 绝对不要包含任何 Markdown 标记\n5. 无论图片内容是什么,请严格以纯 JSON 格式输出,禁止任何多余的开头解释';
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
model: modelName,
|
model: modelName,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -1106,8 +1144,17 @@ export default function ReportEditor() {
|
|||||||
responseJson = JSON.parse(cleanedText);
|
responseJson = JSON.parse(cleanedText);
|
||||||
} catch {
|
} catch {
|
||||||
const jsonMatch = cleanedText.match(/\{[\s\S]*\}/);
|
const jsonMatch = cleanedText.match(/\{[\s\S]*\}/);
|
||||||
if (jsonMatch) responseJson = JSON.parse(jsonMatch[0]);
|
if (jsonMatch) {
|
||||||
else throw new Error('AI 返回格式异常,无法解析 JSON');
|
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) {
|
if (responseJson.reply) {
|
||||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]);
|
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: responseJson.reply }]);
|
||||||
@@ -1121,7 +1168,7 @@ export default function ReportEditor() {
|
|||||||
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
|
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
|
||||||
cleanHtml = cleanHtml.trim();
|
cleanHtml = cleanHtml.trim();
|
||||||
cleanHtml = cleanHtml.replace(/>(\s+)</g, '><');
|
cleanHtml = cleanHtml.replace(/>(\s+)</g, '><');
|
||||||
cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">');
|
cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0; padding: 0;">');
|
||||||
if (targetRegionEl) {
|
if (targetRegionEl) {
|
||||||
setDiffModal({
|
setDiffModal({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
@@ -1924,10 +1971,11 @@ export default function ReportEditor() {
|
|||||||
<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 === 'ai' && <Bot size={16} className={activeTab === 'ai' ? 'text-accent' : 'text-text-muted'} />}
|
||||||
{tab === 'info' ? '基本信息' : tab === 'video' ? '视频分析' : 'AI撰写'}
|
{tab === 'info' ? '基本信息' : tab === 'video' ? '视频分析' : 'AI撰写'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -2840,7 +2888,9 @@ export default function ReportEditor() {
|
|||||||
<div className="flex-1 flex flex-col bg-white border border-red-200 rounded-xl overflow-hidden shadow-sm">
|
<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="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"
|
<div className="p-4 flex-1 overflow-y-auto opacity-70 cursor-not-allowed custom-scrollbar"
|
||||||
dangerouslySetInnerHTML={{ __html: leftDiffHtml }}></div>
|
dangerouslySetInnerHTML={{ __html: leftDiffHtml }}
|
||||||
|
style={{ fontFamily: 'SimSun, "Microsoft YaHei", serif', fontSize: '12pt', lineHeight: '1.5' }}
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex flex-col bg-white border border-green-400 rounded-xl overflow-hidden shadow-md relative">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function SystemSettings() {
|
|||||||
frameMode: 'keep',
|
frameMode: 'keep',
|
||||||
activeAiProvider: 'kimi',
|
activeAiProvider: 'kimi',
|
||||||
aiProviders: { ...DEFAULT_AI_PROVIDERS },
|
aiProviders: { ...DEFAULT_AI_PROVIDERS },
|
||||||
xfIatConfig: { appId: 'e0fe23e3', apiKey: '7fd08be316718c2280e85af4fe126306', apiSecret: 'ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0' }
|
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);
|
||||||
@@ -23,6 +23,9 @@ export default function SystemSettings() {
|
|||||||
const [modeModalOpen, setModeModalOpen] = useState(false);
|
const [modeModalOpen, setModeModalOpen] = useState(false);
|
||||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||||
const apiKeyInputRef = useRef<HTMLInputElement>(null);
|
const apiKeyInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const xfAppIdRef = useRef<HTMLInputElement>(null);
|
||||||
|
const xfApiKeyRef = useRef<HTMLInputElement>(null);
|
||||||
|
const xfApiSecretRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (apiKeyInputRef.current) {
|
if (apiKeyInputRef.current) {
|
||||||
@@ -33,6 +36,21 @@ export default function SystemSettings() {
|
|||||||
}
|
}
|
||||||
}, [settings.aiProviders[settings.activeAiProvider]?.apiKey]);
|
}, [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);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -429,46 +447,55 @@ export default function SystemSettings() {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h3 className="text-lg font-bold text-text-main flex items-center gap-2">
|
<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>
|
<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>
|
</h3>
|
||||||
</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">APPID</label>
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APPID</label>
|
||||||
<input
|
<input
|
||||||
|
ref={xfAppIdRef}
|
||||||
type="password"
|
type="password"
|
||||||
value={settings.xfIatConfig?.appId || ''}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = { ...(settings.xfIatConfig || { appId: '', apiKey: '', apiSecret: '' }), appId: e.target.value };
|
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), appId: e.target.value };
|
||||||
setSettings({ ...settings, xfIatConfig: next });
|
setSettings({ ...settings, xfSpeechConfig: next });
|
||||||
}}
|
}}
|
||||||
placeholder="e0fe23e3"
|
onCopy={(e) => e.preventDefault()}
|
||||||
|
onCut={(e) => e.preventDefault()}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="********"
|
||||||
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">APIKey</label>
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APIKey</label>
|
||||||
<input
|
<input
|
||||||
|
ref={xfApiKeyRef}
|
||||||
type="password"
|
type="password"
|
||||||
value={settings.xfIatConfig?.apiKey || ''}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = { ...(settings.xfIatConfig || { appId: '', apiKey: '', apiSecret: '' }), apiKey: e.target.value };
|
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), apiKey: e.target.value };
|
||||||
setSettings({ ...settings, xfIatConfig: next });
|
setSettings({ ...settings, xfSpeechConfig: next });
|
||||||
}}
|
}}
|
||||||
placeholder="7fd08be316718c2280e85af4fe126306"
|
onCopy={(e) => e.preventDefault()}
|
||||||
|
onCut={(e) => e.preventDefault()}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="********************************"
|
||||||
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">APISecret</label>
|
<label className="block text-xs font-bold text-text-main uppercase tracking-wider">APISecret</label>
|
||||||
<input
|
<input
|
||||||
|
ref={xfApiSecretRef}
|
||||||
type="password"
|
type="password"
|
||||||
value={settings.xfIatConfig?.apiSecret || ''}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = { ...(settings.xfIatConfig || { appId: '', apiKey: '', apiSecret: '' }), apiSecret: e.target.value };
|
const next = { ...(settings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' }), apiSecret: e.target.value };
|
||||||
setSettings({ ...settings, xfIatConfig: next });
|
setSettings({ ...settings, xfSpeechConfig: next });
|
||||||
}}
|
}}
|
||||||
placeholder="ZGI5MjAzZDA0YzYwNDhjMWZiNTM2NDE0"
|
onCopy={(e) => e.preventDefault()}
|
||||||
|
onCut={(e) => e.preventDefault()}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="********************************"
|
||||||
className="input-minimal"
|
className="input-minimal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const defaultReportContent = `
|
|||||||
|
|
||||||
<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 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 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 0 8px 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 0 8px 0; padding: 0;">2.腹腔镜探查:腹腔内无腹水形成,无明显粘连,肝脏色红质软,无明显结节硬化改变,胆囊大小约 cm× cm× cm,壁轻度水肿,张力可,胆囊三角解剖关系清楚,胆囊管及胆总管无明显扩张。胃、十二指肠、小肠、结肠、脾脏及盆腔未见明显异常。术中诊断:胆囊结石伴慢性胆囊炎。遂行腹腔镜胆囊切除术。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">3.切除胆囊:钳夹胆囊颈部并解剖胆囊三角,游离出胆囊动脉及胆囊管,明确胆囊与胆总管的关系,距胆总管0.3cm处近端以一枚可吸收夹,远端夹一枚钛夹夹闭胆囊管,两夹间以剪刀剪断胆囊管,另用一枚可吸收夹夹闭胆囊动脉后离断。顺行游离胆囊浆膜,完整切除胆囊后装入标本袋取出。胆囊床严密止血并覆盖止血材料。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">4.检查腹腔内无活动性出血及漏胆后,清点器械纱布无误,拔除腔镜器械,排出腹腔残余气体,缝合各刺孔,术毕。</p><p style="font-family: SimSun; font-size: 12pt; line-height: 1.5; margin: 0 0 8px 0; padding: 0;">5.手术顺利,麻醉满意。切除的标本经家属过目后送病理。术中出血约 ml,术中输血成分,输血量,是否有输血不良反应。</p></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>
|
</div>
|
||||||
|
|
||||||
<!-- 手术图片说明表格 -->
|
<!-- 手术图片说明表格 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user