3 Commits

Author SHA1 Message Date
李琦
443e501da5 feat: 语音开方 2026-08-31 16:35:57 +08:00
李琦
c2040c2b15 feat: 更新前端时间范围组件默认开始时间和结束时间的秒 2026-08-27 17:00:51 +08:00
李琦
0d18089f71 feat: 包邮费用设置 2026-08-27 09:38:11 +08:00
11 changed files with 1067 additions and 116 deletions

View File

@@ -3,6 +3,7 @@
* 带快捷预设的搜索时间范围选择器
* - 上方 RangePicker 手选,下方快捷 link今天 / 本周 / 本月 / 上一个月 / 下一个月
* - 支持 valueFormat='YYYY-MM-DD'VbenForm或 Dayjs 二元组(对账页)
* - 界面按天展示;写出值时开始为当天 00:00:00、结束为当天 23:59:59避免漏查当天末笔
*/
import type { Dayjs } from 'dayjs';
@@ -69,8 +70,9 @@ const innerRange = computed<[Dayjs, Dayjs] | null>({
emit('change', null);
return;
}
// formatRangeByValueFormat 内会规范到 00:00:00 / 23:59:59
const formatted = formatRangeByValueFormat(
[next[0].startOf('day'), next[1].startOf('day')],
[next[0], next[1]],
props.valueFormat,
);
mValue.value = formatted as [string, string] | [Dayjs, Dayjs];
@@ -145,7 +147,6 @@ const pickerValue = computed<[Dayjs, Dayjs] | undefined>({
v-bind="$attrs"
:disabled="disabled"
:format="format"
:value-format="valueFormat"
class="search-time-range-picker__picker w-full"
@change="onPickerChange"
/>

View File

@@ -9,9 +9,21 @@ export type SearchTimePreset =
| 'prevMonth'
| 'nextMonth';
/** 界面展示用纯日期;提交给后端时再拼时分秒 */
const YMD = 'YYYY-MM-DD';
/** 查询边界:开始日 00:00:00、结束日 23:59:59避免当天末笔被滤掉 */
const YMD_START = 'YYYY-MM-DD 00:00:00';
const YMD_END = 'YYYY-MM-DD 23:59:59';
/** 今天日期Dayjs */
/**
* 将区间规范为「开始日 00:00:00 ~ 结束日 23:59:59」
* 搜索按天选时,结束若不落到当天最后一秒,当天 0 点之后的数据会被漏掉
*/
export function toSearchDayBounds(range: [Dayjs, Dayjs]): [Dayjs, Dayjs] {
return [range[0].startOf('day'), range[1].endOf('day')];
}
/** 今天日期Dayjs当天 00:00:00 */
export function todayDayjs(): Dayjs {
return dayjs().startOf('day');
}
@@ -24,30 +36,27 @@ export function startOfWeekMonday(base: Dayjs = todayDayjs()): Dayjs {
return d.subtract(diff, 'day');
}
/** 本月1日 ~ 今天(字符串,供 RangePicker valueFormat */
/** 本月1日 00:00:00 ~ 今天 23:59:59字符串供表单默认值 / 接口 */
export function monthToTodayRangeString(): [string, string] {
const today = todayDayjs();
return [today.startOf('month').format(YMD), today.format(YMD)];
return rangeDayjsToString(monthToTodayRangeDayjs());
}
/** 本月1日 ~ 今天Dayjs */
/** 本月1日 ~ 今天Dayjs,已含起止边界 */
export function monthToTodayRangeDayjs(): [Dayjs, Dayjs] {
const today = todayDayjs();
return [today.startOf('month'), today];
return toSearchDayBounds([today.startOf('month'), today]);
}
/** 近 N 天(含今天),默认 7 天 */
/** 近 N 天(含今天),默认 7 天;字符串已含 00:00:00 / 23:59:59 */
export function lastNDaysRangeString(days = 7): [string, string] {
const today = todayDayjs();
const start = today.subtract(Math.max(days - 1, 0), 'day');
return [start.format(YMD), today.format(YMD)];
return rangeDayjsToString(lastNDaysRangeDayjs(days));
}
/** 近 N 天Dayjs */
/** 近 N 天Dayjs,已含起止边界 */
export function lastNDaysRangeDayjs(days = 7): [Dayjs, Dayjs] {
const today = todayDayjs();
const start = today.subtract(Math.max(days - 1, 0), 'day');
return [start, today];
return toSearchDayBounds([start, today]);
}
/** 将 Dayjs / 字符串 / 时间戳统一为 YYYY-MM-DD */
@@ -98,17 +107,17 @@ export function getMonthAnchorDayjs(current: unknown): Dayjs {
return todayDayjs();
}
/** 自然月整月,结束日不超过今天 */
/** 自然月整月,结束日不超过今天(返回值已含起止边界) */
function fullMonthRangeCapped(
month: Dayjs,
): [Dayjs, Dayjs] {
const start = month.startOf('month');
const end = month.endOf('month').startOf('day');
const today = todayDayjs();
return [start, end.isAfter(today) ? today : end];
return toSearchDayBounds([start, end.isAfter(today) ? today : end]);
}
/** 按预设计算区间Dayjs */
/** 按预设计算区间Dayjs,已含 00:00:00 / 23:59:59 边界 */
export function resolveSearchTimePreset(
preset: SearchTimePreset,
current: unknown,
@@ -116,11 +125,11 @@ export function resolveSearchTimePreset(
const today = todayDayjs();
switch (preset) {
case 'today':
return [today, today];
return toSearchDayBounds([today, today]);
case 'thisWeek':
return [startOfWeekMonday(today), today];
return toSearchDayBounds([startOfWeekMonday(today), today]);
case 'thisMonth':
return [today.startOf('month'), today];
return toSearchDayBounds([today.startOf('month'), today]);
case 'prevMonth': {
const anchor = getMonthAnchorDayjs(current);
return fullMonthRangeCapped(anchor.subtract(1, 'month'));
@@ -134,36 +143,44 @@ export function resolveSearchTimePreset(
}
}
/** 按预设计算区间(字符串) */
/** 按预设计算区间(字符串,已含时分秒 */
export function resolveSearchTimePresetString(
preset: SearchTimePreset,
current: unknown,
): [string, string] {
const [a, b] = resolveSearchTimePreset(preset, current);
return [a.format(YMD), b.format(YMD)];
return rangeDayjsToString(resolveSearchTimePreset(preset, current));
}
/** Dayjs 区间转字符串 */
/**
* Dayjs 区间转接口字符串
* 固定开始 00:00:00、结束 23:59:59与按天筛选的业务口径一致
*/
export function rangeDayjsToString(
range: [Dayjs, Dayjs],
): [string, string] {
return [range[0].format(YMD), range[1].format(YMD)];
const [start, end] = toSearchDayBounds(range);
return [start.format(YMD_START), end.format(YMD_END)];
}
/** 字符串区间转 Dayjs */
/** 字符串区间转 Dayjs(兼容纯日期或带时分秒) */
export function rangeStringToDayjs(
range: [string, string],
): [Dayjs, Dayjs] {
return [dayjs(range[0], YMD), dayjs(range[1], YMD)];
return toSearchDayBounds([dayjs(range[0]), dayjs(range[1])]);
}
/** 将区间按 valueFormat 输出 */
/**
* 将区间按表单模式输出
* valueFormat=YYYY-MM-DD 表示「按天选择」,值仍带完整时分秒边界供接口使用
* 未传 valueFormat 时返回 Dayjs同样规范到起止边界
*/
export function formatRangeByValueFormat(
range: [Dayjs, Dayjs],
valueFormat?: string,
): [string, string] | [Dayjs, Dayjs] {
const bounded = toSearchDayBounds(range);
if (valueFormat === YMD) {
return rangeDayjsToString(range);
return rangeDayjsToString(bounded);
}
return range;
return bounded;
}

View File

@@ -442,10 +442,49 @@ export async function aiGeneratePrescriptionApi(data: {
use_entrusted_process?: 0 | 1 | boolean;
/** 中药委托调剂:制剂要求 idyii_process_rule pid=0 */
process_rule_id?: number;
/** 语音辨证dialectic */
voice_mode?: string;
transcript?: string;
asr_provider?: string;
asr_duration_ms?: number;
return_dosage_process_detail?: 0 | 1 | boolean;
}) {
return requestClient.post<any>(`${prefix}ai-generate-prescription`, data);
}
/**
* 语音转写multipartaudio 文件)
* 字段名必须为 audio与后端 request()->file('audio') 对齐
*/
export async function aiVoiceAsrApi(file: File, durationMs = 0) {
const fd = new FormData();
fd.append('audio', file);
if (durationMs > 0) {
fd.append('duration_ms', String(durationMs));
}
return requestClient.post<any>(`${prefix}ai-voice-asr`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
/** 语音口述落方(只抽药名剂量) */
export async function aiGenerateVoicePrescriptionApi(data: {
register_id: number;
store_id?: number;
prescription_type: number;
transcript: string;
asr_provider?: string;
asr_duration_ms?: number;
use_entrusted_process?: 0 | 1 | boolean;
process_rule_id?: number;
return_dosage_process_detail?: 0 | 1 | boolean;
}) {
return requestClient.post<any>(
`${prefix}ai-generate-voice-prescription`,
data,
);
}
/** AI 功能门闸(醒目提示 + 是否已签署知情同意) */
export async function aiFeatureGateApi(data: { feature_code: string }) {
return requestClient.get<any>(`${prefix}ai-feature-gate`, { params: data });

View File

@@ -11,12 +11,15 @@ import { Button, Checkbox, Descriptions, Input, Radio, Spin, Switch, Tabs, Tag,
import {
aiGeneratePrescriptionApi,
aiGenerateVoicePrescriptionApi,
aiListGenerationsApi,
aiMarkGenerationReadApi,
aiMatchPrescriptionDrugsApi,
aiSavePrescriptionDrugSelectionApi,
aiVoiceAsrApi,
getProcessRuleList,
} from '../api';
import { encodeBlobToWav16k } from '../utils/encodeWav';
import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue';
import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate';
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
@@ -137,6 +140,19 @@ const editingKey = ref('');
/** 醒目提示文案(系统配置 / 门闸下发) */
const disclaimerText = ref('');
/** 语音开方:录音 / 转写 / A·B 模式 */
const voiceRecording = ref(false);
const voiceAsrLoading = ref(false);
const voiceTranscript = ref('');
const voiceAsrProvider = ref('');
const voiceAsrDurationMs = ref(0);
/** dialectic | dictation默认口述落方医生口述药味更常见 */
const voiceMode = ref<'dialectic' | 'dictation'>('dictation');
let voiceMediaRecorder: MediaRecorder | null = null;
let voiceAudioChunks: Blob[] = [];
let voiceStream: MediaStream | null = null;
let voiceRecordStartedAt = 0;
const sexLabel = computed(() => {
const s = Number(patientSex.value);
if (s === 1) return '男';
@@ -193,6 +209,8 @@ const [Drawer, drawerApi] = useVbenDrawer({
patientAge?: number;
chiefComplaint?: string;
medicalRecord?: Record<string, any>;
/** assist=携带病历辅助出方voice=工具栏语音入口,同意后自动开语音弹窗 */
entry?: 'assist' | 'voice';
}>();
registerId.value = Number(data?.registerId || 0);
storeId.value = Number(data?.storeId || 0);
@@ -205,6 +223,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
data?.medicalRecord && typeof data.medicalRecord === 'object'
? { ...data.medicalRecord }
: {};
const entryMode = data?.entry === 'voice' ? 'voice' : 'assist';
resetPreview();
// 首次使用对应 VIP 功能需签署知情同意;拒绝则关闭抽屉
try {
@@ -223,6 +242,14 @@ const [Drawer, drawerApi] = useVbenDrawer({
return;
}
void bootstrapHistory();
// 工具栏「语音开方」:同意后直接进入语音弹窗,默认口述落方
if (entryMode === 'voice') {
voiceMode.value = 'dictation';
voiceTranscript.value = '';
voiceAsrProvider.value = '';
voiceAsrDurationMs.value = 0;
voiceModalApi.open();
}
},
});
@@ -235,6 +262,28 @@ const [GenModal, genModalApi] = useVbenModal({
onConfirm: async () => confirmGenerateAndClose(),
});
/**
* 语音开方弹窗:录音转写 → 卡片选 A/B → 确认生成
* 未选模式时确认按钮禁用(由 onConfirm 内校验兜底)
*/
const [VoiceModal, voiceModalApi] = useVbenModal({
title: '语音开方',
class: 'w-[640px]',
confirmText: '确认生成',
cancelText: '取消',
onConfirm: async () => confirmVoiceGenerate(),
onOpenChange(isOpen: boolean) {
if (!isOpen) {
stopVoiceRecording(true);
voiceTranscript.value = '';
voiceMode.value = 'dictation';
voiceAsrProvider.value = '';
voiceAsrDurationMs.value = 0;
voiceAsrLoading.value = false;
}
},
});
function resetPreview() {
matchedList.value = [];
unmatchedList.value = [];
@@ -268,8 +317,12 @@ function resetState() {
disclaimerText.value = '';
genProcessRuleId.value = undefined;
processRuleOptions.value = [];
stopVoiceRecording(true);
voiceTranscript.value = '';
voiceMode.value = 'dictation';
resetPreview();
genModalApi.close();
voiceModalApi.close();
}
function formatTime(ts: number) {
@@ -617,6 +670,217 @@ async function confirmGenerateAndClose(): Promise<boolean> {
return true;
}
/**
* 打开语音开方弹窗(录音 → 转写 → 选 A/B
*/
async function openVoiceModal() {
if (generating.value) return;
if (!prescriptionType.value) {
message.warning('请先选择处方类型');
return;
}
if (!registerId.value) {
message.warning('挂号无效');
return;
}
voiceTranscript.value = '';
voiceMode.value = 'dictation';
voiceAsrProvider.value = '';
voiceAsrDurationMs.value = 0;
// 与生成弹窗一致:恢复委托调剂本地记忆并拉制剂要求
if (isTcmRx.value && useEntrustedProcess.value && !processRuleOptions.value.length) {
void loadTopProcessRules();
}
voiceModalApi.open();
}
/**
* 开始 / 停止录音(点击切换)
*/
async function toggleVoiceRecording() {
if (voiceAsrLoading.value) return;
if (voiceRecording.value) {
stopVoiceRecording(false);
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
voiceStream = stream;
voiceAudioChunks = [];
voiceRecordStartedAt = Date.now();
const mime = MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: '';
voiceMediaRecorder = mime
? new MediaRecorder(stream, { mimeType: mime })
: new MediaRecorder(stream);
voiceMediaRecorder.ondataavailable = (ev) => {
if (ev.data && ev.data.size > 0) voiceAudioChunks.push(ev.data);
};
voiceMediaRecorder.onstop = () => {
void handleVoiceRecordStop();
};
voiceMediaRecorder.start();
voiceRecording.value = true;
} catch (e: any) {
message.error(e?.message || '无法访问麦克风');
voiceRecording.value = false;
}
}
/**
* 停止录音discard=true 时丢弃数据不转写
*/
function stopVoiceRecording(discard = false) {
if (!voiceRecording.value && !voiceMediaRecorder) {
cleanupVoiceStream();
return;
}
voiceRecording.value = false;
if (discard) {
if (voiceMediaRecorder && voiceMediaRecorder.state !== 'inactive') {
voiceMediaRecorder.ondataavailable = null;
voiceMediaRecorder.onstop = () => cleanupVoiceStream();
try {
voiceMediaRecorder.stop();
} catch {
cleanupVoiceStream();
}
} else {
cleanupVoiceStream();
}
voiceAudioChunks = [];
voiceMediaRecorder = null;
return;
}
if (voiceMediaRecorder && voiceMediaRecorder.state !== 'inactive') {
try {
voiceMediaRecorder.stop();
} catch {
cleanupVoiceStream();
}
}
}
function cleanupVoiceStream() {
if (voiceStream) {
voiceStream.getTracks().forEach((t) => t.stop());
voiceStream = null;
}
voiceMediaRecorder = null;
}
/**
* 录音结束 → 转 wav → ASR
*/
async function handleVoiceRecordStop() {
const chunks = voiceAudioChunks.slice();
const started = voiceRecordStartedAt;
cleanupVoiceStream();
voiceAudioChunks = [];
if (!chunks.length) {
message.warning('未录到有效音频');
return;
}
const rawBlob = new Blob(chunks, { type: chunks[0]?.type || 'audio/webm' });
const durationMs = Math.max(0, Date.now() - started);
voiceAsrLoading.value = true;
try {
const wavBlob = await encodeBlobToWav16k(rawBlob);
const file = new File([wavBlob], `voice_${Date.now()}.wav`, {
type: 'audio/wav',
});
const data = await aiVoiceAsrApi(file, durationMs);
const text = String(data?.text || '').trim();
if (!text) {
message.warning('未识别到有效语音');
return;
}
voiceTranscript.value = text;
voiceAsrProvider.value = String(data?.provider || 'xunfei');
voiceAsrDurationMs.value = Number(data?.duration_ms || durationMs);
message.success('转写完成,请选择开方方式');
} catch (e: any) {
message.error(e?.message || e?.msg || '语音转写失败');
} finally {
voiceAsrLoading.value = false;
}
}
/**
* 语音弹窗确认:按 A/B 调对应生成接口
*/
async function confirmVoiceGenerate() {
const text = voiceTranscript.value.trim();
if (!text) {
message.warning('请先完成语音转写或填写文本');
return;
}
if (!voiceMode.value) {
message.warning('请选择辨证出方或口述落方');
return;
}
if (isTcmRx.value && useEntrustedProcess.value && !genProcessRuleId.value) {
message.warning('请选择制剂要求');
return;
}
const mode = voiceMode.value;
const transcript = text;
const asrProvider = voiceAsrProvider.value;
const asrDuration = voiceAsrDurationMs.value;
voiceModalApi.close();
if (mode === 'dialectic') {
// A转写当主诉走现有辨证出方
chiefComplaint.value = transcript;
void runGenerateInDrawer(transcript, {
voice_mode: 'dialectic',
transcript,
asr_provider: asrProvider,
asr_duration_ms: asrDuration,
});
return;
}
// B口述落方
void runVoiceDictationGenerate(transcript, asrProvider, asrDuration);
}
/**
* B 口述落方生成
*/
async function runVoiceDictationGenerate(
transcript: string,
asrProvider: string,
asrDuration: number,
) {
beginGeneratingPlaceholder();
generating.value = true;
multiPrescriptions.value = [];
multiActiveIndex.value = 0;
try {
const req: Record<string, any> = {
register_id: registerId.value,
store_id: storeId.value || undefined,
prescription_type: prescriptionType.value,
transcript,
asr_provider: asrProvider || undefined,
asr_duration_ms: asrDuration || undefined,
};
if (isTcmRx.value && useEntrustedProcess.value && genProcessRuleId.value) {
req.use_entrusted_process = 1;
req.process_rule_id = genProcessRuleId.value;
req.return_dosage_process_detail = returnDosageProcessDetail.value ? 1 : 0;
}
const data = await aiGenerateVoicePrescriptionApi(req);
await applyGenerateResponse(data, '口述识别');
} catch (e: any) {
endGeneratingPlaceholder();
resetPreview();
message.error(e?.message || e?.msg || '口述识别失败');
} finally {
generating.value = false;
}
}
/**
* 本地把某条历史标为已读(同步 multi / historyList
*/
@@ -682,7 +946,15 @@ function onMultiTabChange(key: string | number) {
applyMultiSlot(slot, idx);
}
async function runGenerateInDrawer(chief: string) {
async function runGenerateInDrawer(
chief: string,
voiceMeta?: {
voice_mode?: string;
transcript?: string;
asr_provider?: string;
asr_duration_ms?: number;
},
) {
beginGeneratingPlaceholder();
generating.value = true;
multiPrescriptions.value = [];
@@ -704,50 +976,16 @@ async function runGenerateInDrawer(chief: string) {
req.process_rule_id = genProcessRuleId.value;
req.return_dosage_process_detail = returnDosageProcessDetail.value ? 1 : 0;
}
if (voiceMeta?.voice_mode) {
req.voice_mode = voiceMeta.voice_mode;
req.transcript = voiceMeta.transcript || chief;
if (voiceMeta.asr_provider) req.asr_provider = voiceMeta.asr_provider;
if (voiceMeta.asr_duration_ms) {
req.asr_duration_ms = voiceMeta.asr_duration_ms;
}
}
const data = await aiGeneratePrescriptionApi(req);
const durationText = formatDurationMs(data?.duration_ms);
const list = Array.isArray(data?.prescriptions) ? data.prescriptions : [];
// 业务软失败HTTP 成功但 ok=false警告提示而非 error
if (data?.ok === false) {
endGeneratingPlaceholder();
resetPreview();
if (list.length > 1) {
multiPrescriptions.value = list;
const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false);
const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0;
applyMultiSlot(list[useIdx], useIdx);
}
if (data?.generation_id) {
await loadHistory();
activeId.value = Number(data.generation_id);
}
message.warning(String(data?.message || '生成未成功'));
return;
}
await loadHistory();
if (list.length > 1) {
multiPrescriptions.value = list;
const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false);
const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0;
applyMultiSlot(list[useIdx], useIdx);
const okN = list.filter((p: any) => p && p.ok !== false).length;
const failN = list.length - okN;
const baseMsg =
failN > 0
? `已生成 ${okN} 套可用方案(${failN} 套失败),可切换对比`
: `已生成 ${okN} 套方案,可切换对比`;
message.success(durationText ? `${baseMsg}(耗时 ${durationText}` : baseMsg);
return;
}
activeId.value = Number(data?.generation_id || 0);
applyMatch(data?.match || null);
applyConflict(data?.conflict || null);
applySafetyFlags(data?.safety_flags || null);
applyRxMeta(data);
void markReadIfNeeded(activeId.value, 0);
message.success(
durationText ? `已生成(耗时 ${durationText}` : '已生成,请确认导入',
);
await applyGenerateResponse(data, '生成');
} catch (e: any) {
endGeneratingPlaceholder();
resetPreview();
@@ -757,6 +995,62 @@ async function runGenerateInDrawer(chief: string) {
}
}
/**
* 统一处理生成/口述接口返回(多方案 / 单方案 / 软失败)
*/
async function applyGenerateResponse(data: any, actionLabel: string) {
const durationText = formatDurationMs(data?.duration_ms);
const list = Array.isArray(data?.prescriptions) ? data.prescriptions : [];
if (data?.ok === false) {
endGeneratingPlaceholder();
resetPreview();
if (list.length > 1) {
multiPrescriptions.value = list;
const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false);
const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0;
applyMultiSlot(list[useIdx], useIdx);
}
if (data?.generation_id) {
await loadHistory();
activeId.value = Number(data.generation_id);
}
message.warning(String(data?.message || `${actionLabel}未成功`));
return;
}
await loadHistory();
if (list.length > 1) {
multiPrescriptions.value = list;
const firstOkIdx = list.findIndex((p: any) => p && p.ok !== false);
const useIdx = firstOkIdx >= 0 ? firstOkIdx : 0;
applyMultiSlot(list[useIdx], useIdx);
const okN = list.filter((p: any) => p && p.ok !== false).length;
const failN = list.length - okN;
const baseMsg =
failN > 0
? `已生成 ${okN} 套可用方案(${failN} 套失败),可切换对比`
: `已生成 ${okN} 套方案,可切换对比`;
message.success(durationText ? `${baseMsg}(耗时 ${durationText}` : baseMsg);
return;
}
activeId.value = Number(data?.generation_id || 0);
applyMatch(data?.match || null);
applyConflict(data?.conflict || null);
applySafetyFlags(data?.safety_flags || null);
applyRxMeta(data);
void markReadIfNeeded(activeId.value, 0);
const unmatchedN = Array.isArray(data?.match?.unmatched)
? data.match.unmatched.length
: unmatchedList.value.length;
const okMsg = durationText
? `${actionLabel}(耗时 ${durationText}`
: `${actionLabel},请确认导入`;
if (unmatchedN > 0) {
message.warning(`${okMsg}${unmatchedN} 味未在药库找到,已标红`);
} else {
message.success(okMsg);
}
}
async function onSelectHistory(row: any) {
if (!row?.id || Number(row.id) === PENDING_GEN_ID || row._pending) return;
// 点历史时退出「本次多方案」对比态,避免 Tab 与历史选中互相干扰
@@ -866,6 +1160,10 @@ function onConfirmImport() {
message.warning('请先勾选已对照药品');
return;
}
const skipN = unmatchedList.value.length;
if (skipN > 0) {
message.warning(`已跳过 ${skipN} 味未对照药品(标红项不会导入)`);
}
const importPayload: {
rows: typeof drugs;
dosage?: number;
@@ -914,8 +1212,13 @@ defineExpose({
patientAge?: number;
chiefComplaint?: string;
medicalRecord?: Record<string, any>;
/** assist 默认voice=工具栏语音入口 */
entry?: 'assist' | 'voice';
}) {
drawerApi.setData(payload);
drawerApi.setData({
...payload,
entry: payload.entry === 'voice' ? 'voice' : 'assist',
});
drawerApi.open();
},
});
@@ -1175,16 +1478,23 @@ defineExpose({
</div>
<div
v-if="unmatchedList.length"
class="mb-1.5 mt-3 text-xs font-medium text-muted-foreground"
class="mb-1.5 mt-3 text-xs font-medium text-red-500"
>
未对照({{ unmatchedList.length }}
未对照({{ unmatchedList.length }}— 药库未找到,已标红,不会导入
</div>
<div
v-for="(u, ui) in unmatchedList"
:key="ui"
class="mb-1.5 rounded border border-orange-500/30 bg-orange-500/10 px-2 py-1.5 text-sm"
class="mb-1.5 rounded border border-red-500/40 px-2 py-1.5 text-sm shadow-[0_0_6px_hsl(0_84%_60%/0.18)]"
>
<div class="font-medium">{{ u.ai_name }}</div>
<div class="font-medium text-red-500">{{ u.ai_name }}</div>
<div
v-if="u.dose || u.unit"
class="text-xs text-muted-foreground"
>
{{ u.dose }}{{ u.unit }}
<span v-if="u.usage"> · {{ u.usage }}</span>
</div>
<Button
type="link"
size="small"
@@ -1369,6 +1679,106 @@ defineExpose({
</div>
</div>
</GenModal>
<!-- 语音开方:录音转写 + A/B 模式卡片 -->
<VoiceModal>
<div class="space-y-3 text-sm text-foreground">
<AiDisclaimerBanner :text="disclaimerText" />
<div class="flex items-center gap-2">
<Button
:type="voiceRecording ? 'primary' : 'default'"
:danger="voiceRecording"
:loading="voiceAsrLoading"
@click="toggleVoiceRecording"
>
{{
voiceAsrLoading
? '转写中…'
: voiceRecording
? '点击结束录音'
: '点击开始录音'
}}
</Button>
<span class="text-xs text-muted-foreground">
说完后点结束,系统会转写成文字供修改
</span>
</div>
<div>
<div class="mb-1 text-xs text-muted-foreground">转写文本(可改)</div>
<Input.TextArea
v-model:value="voiceTranscript"
:rows="4"
placeholder="录音转写结果会显示在这里,也可直接粘贴/修改"
:disabled="voiceRecording || voiceAsrLoading"
/>
</div>
<div v-if="voiceTranscript.trim()" class="space-y-2">
<div class="text-xs font-medium text-muted-foreground">
请选择开方方式
</div>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-lg border p-3 text-left transition-colors"
:class="
voiceMode === 'dialectic'
? 'border-primary/30 shadow-[0_0_6px_hsl(var(--primary)/0.18)] bg-primary/5'
: 'border-border hover:border-primary/20'
"
@click="voiceMode = 'dialectic'"
>
<div class="font-medium">辨证出方</div>
<div class="mt-1 text-xs text-muted-foreground">
根据口述病情AI 辅助生成处方
</div>
</button>
<button
type="button"
class="rounded-lg border p-3 text-left transition-colors"
:class="
voiceMode === 'dictation'
? 'border-primary/30 shadow-[0_0_6px_hsl(var(--primary)/0.18)] bg-primary/5'
: 'border-border hover:border-primary/20'
"
@click="voiceMode = 'dictation'"
>
<div class="font-medium">口述落方</div>
<div class="mt-1 text-xs text-muted-foreground">
识别口述药名剂量,对照药库后导入
</div>
</button>
</div>
</div>
<div
v-if="isTcmRx"
class="rounded-lg border border-border bg-muted/30 p-3"
>
<Checkbox
v-model:checked="useEntrustedProcess"
@update:checked="onEntrustedToggle"
>
委托调剂
</Checkbox>
<div v-if="useEntrustedProcess" class="mt-2 space-y-3">
<div>
<div class="mb-1 text-xs text-muted-foreground">制剂要求</div>
<div v-if="processRuleLoading" class="text-xs text-muted-foreground">
加载中…
</div>
<div v-else class="ai-process-pill-group">
<span
v-for="opt in processRuleOptions"
:key="opt.value"
class="ai-process-pill"
:class="{ active: genProcessRuleId === opt.value }"
@click="onProcessRuleTagClick(opt.value)"
>{{ opt.label }}</span>
</div>
</div>
</div>
</div>
</div>
</VoiceModal>
</template>
<style scoped>

View File

@@ -19,6 +19,7 @@ import {
BankOutlined,
ReloadOutlined,
RobotOutlined,
AudioOutlined,
UserAddOutlined,
SaveOutlined,
StopOutlined,
@@ -1665,7 +1666,7 @@ function handleImportGoldenFormula(payload: { rows?: any[]; formulaName?: string
const aiPrescriptionDrawerRef = ref<InstanceType<typeof AiPrescriptionDrawer> | null>(null);
/** 打开 AI 给处方抽屉:先确认患者与主诉,再生成预览 */
/** 打开 AI 给处方抽屉:先确认患者与主诉,再生成预览(携带病历) */
function openAiPrescriptionDrawer() {
if (!canUseAiPrescription.value) {
message.warning('当前门店未开通AI辅助出方VIP功能');
@@ -1702,6 +1703,41 @@ function openAiPrescriptionDrawer() {
patientAge: Number(activePatient.value?.age || 0),
chiefComplaint: String(payload.chief_complaint || ''),
medicalRecord: { ...payload },
entry: 'assist',
});
}
/**
* 工具栏「语音开方」:打开同一抽屉后自动进语音弹窗,默认口述落方
* 为什么不强依赖病历:口述落方按转写解析药味,患者上下文由服务端按 register 拉取
*/
function openVoicePrescriptionDrawer() {
if (!canUseAiPrescription.value) {
message.warning('当前门店未开通AI辅助出方VIP功能');
return;
}
if (guardSpecialPrescriptionCartEdit()) return;
const registerId = Number.parseInt(
localStorage.getItem(`doctorReception-id`) || '0',
);
if (!registerId) {
message.warning('请先选择患者');
return;
}
if (!activeCategory.value) {
message.warning('请先选择处方类型');
return;
}
aiPrescriptionDrawerRef.value?.open({
registerId,
storeId: Number(myStoreId.value || 0) || undefined,
prescriptionType: Number(activeCategory.value),
patientName: String(activePatient.value?.name || ''),
patientSex: Number(activePatient.value?.sex || 0),
patientAge: Number(activePatient.value?.age || 0),
chiefComplaint: '',
medicalRecord: {},
entry: 'voice',
});
}
@@ -3736,6 +3772,15 @@ function onStoreSelectOpenChange(open: boolean) {
<RobotOutlined />
AI辅助出方
</Button>
<Button
v-if="!isSpecialPrescriptionCartLocked && canUseAiPrescription"
type="link"
size="small"
@click="openVoicePrescriptionDrawer"
>
<AudioOutlined />
语音开方
</Button>
<Button
v-if="canUseCommonPrescription"
type="link"

View File

@@ -0,0 +1,73 @@
/**
* 浏览器录音 → 16k 单声道 WAV讯飞一句话识别 raw/pcm 要求)
* 为什么MediaRecorder 默认 webmASR 不支持,需解码后重编码
*/
export async function encodeBlobToWav16k(blob: Blob): Promise<Blob> {
const arrayBuffer = await blob.arrayBuffer();
const audioCtx = new AudioContext();
let decoded: AudioBuffer;
try {
decoded = await audioCtx.decodeAudioData(arrayBuffer.slice(0));
} finally {
await audioCtx.close().catch(() => undefined);
}
const targetRate = 16000;
const offline = new OfflineAudioContext(
1,
Math.ceil(decoded.duration * targetRate),
targetRate,
);
const source = offline.createBufferSource();
// 混成单声道
const mono = offline.createBuffer(1, decoded.length, decoded.sampleRate);
const ch0 = mono.getChannelData(0);
const channels = decoded.numberOfChannels;
for (let i = 0; i < decoded.length; i++) {
let sum = 0;
for (let c = 0; c < channels; c++) {
sum += decoded.getChannelData(c)[i] || 0;
}
ch0[i] = sum / channels;
}
source.buffer = mono;
source.connect(offline.destination);
source.start(0);
const rendered = await offline.startRendering();
const pcm = rendered.getChannelData(0);
return pcmToWavBlob(pcm, targetRate);
}
/**
* Float32 PCM → 16bit LE WAV Blob
*/
function pcmToWavBlob(samples: Float32Array, sampleRate: number): Blob {
const dataLength = samples.length * 2;
const buffer = new ArrayBuffer(44 + dataLength);
const view = new DataView(buffer);
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + dataLength, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeString(view, 36, 'data');
view.setUint32(40, dataLength, true);
let offset = 44;
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i] || 0));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
offset += 2;
}
return new Blob([buffer], { type: 'audio/wav' });
}
function writeString(view: DataView, offset: number, str: string) {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
}

View File

@@ -10,6 +10,7 @@ export const AI_GENERATION_STATUS_OPTIONS = [
export const AI_GENERATION_SCENE_OPTIONS = [
{ label: '写病历', value: 'medical_record' },
{ label: '出方', value: 'prescription' },
{ label: '语音口述开方', value: 'voice_prescription' },
{ label: '每日简报', value: 'daily_brief' },
];

View File

@@ -32,6 +32,20 @@ export async function getSystemConfigByKeys(keys: string[]) {
});
}
/** 满额包邮门槛(老库 yii_system_config config_type=2 type=2返回 { id, name, rule, value } */
export async function getFreeShippingConfig() {
return requestClient.get<any>(`${prefix}free-shipping-config`);
}
/** 保存满额包邮门槛,只传 value 时后端保留 name/rule 原值 */
export async function saveFreeShippingConfig(data: {
name?: string;
rule?: string;
value: number;
}) {
return requestClient.post<any>(`${prefix}save-free-shipping-config`, data);
}
const wxEntryPrefix = 'wx-workbench-entry/';
/** 某角色已分配的医生端功能入口(含 is_default_fav */

View File

@@ -1,9 +1,8 @@
<script lang="ts" setup>
/**
* 系统配置 - AI二级 Tab
* 1) 模型配置:选用平台 / 密钥 / 模型
* 2) 病历配置:勾选 AI 写病历需要生成的字段
* 3) 提醒文案VIP 关联的醒目提示与知情同意
* 1) 模型配置 2) 病历配置 3) 提醒文案
* 4) Agent 中转 5) 语音配置 6) Agent 高级
*/
import { computed, onMounted, ref, watch } from 'vue';
@@ -14,6 +13,7 @@ import {
Input,
InputNumber,
message,
Radio,
Spin,
Switch,
Tabs,
@@ -83,13 +83,12 @@ const mrFieldOptions = ref<{ label: string; value: string }[]>([]);
const mrSelectedFields = ref<string[]>([]);
const mrSaving = ref(false);
// ===== Agent 高级配置(走 getSystemConfigList写入 xk_system_config =====
// 这组配置与上方"模型配置"独立:
// - 上方"模型配置"决定用什么 provider/key/model(写入 ai_active_provider/_api_key_id/_model
// - 这里只决定"是否走 Go Agent 中转",用独立开关 ai_agent_via_agent 控制
// 这样切换 agent 模式时不会污染模型组合(模型永远在「模型配置 Tab」选)
// 分组 0Agent 接入(独立开关 + 服务地址 + 鉴权密钥)
// aiActiveProviderAdv 不再使用——只保留模型 Tab 里的 activeProvider 作为真实 provider
// ===== Agent 配置(走 getSystemConfigList写入 xk_system_config =====
// 与上方模型配置独立:
// - 模型配置决定 provider/key/model
// - 「Agent 中转」Tab全局直连/中转 + Agent 服务地址
// - 「语音配置Tab:讯飞 ASR + 口述落方 LLM 路由
// - 「Agent 高级」TabKB / ReAct / Token / 流量实验
const aiAgentViaAgent = ref(false);
const aiAgentBaseUrl = ref('http://127.0.0.1:18123');
const aiAgentSecret = ref('');
@@ -116,9 +115,24 @@ const aiShadowTrafficEnabled = ref(false);
const aiShadowTrafficRatio = ref(0);
const aiAbTestEnabled = ref(false);
const aiAbTestExperimentRatio = ref(10);
// 高级配置保存状态
/** Agent 中转 Tab 保存中 */
const agentViaSaving = ref(false);
/** 语音配置 Tab 保存中 */
const voiceSaving = ref(false);
/** Agent 高级 Tab 保存中 */
const advSaving = ref(false);
/** 讯飞 ASR语音转写 */
const asrXunfeiAppId = ref('');
const asrXunfeiApiKey = ref('');
const asrXunfeiApiSecret = ref('');
const asrXunfeiApiUrl = ref('wss://iat-api.xfyun.cn/v2/iat');
const asrXunfeiDomain = ref('iat');
const asrXunfeiAccent = ref('mandarin');
const asrXunfeiTimeout = ref(60);
/** 口述落方 LLMinherit | agent | direct */
const aiVoiceViaAgentMode = ref<'inherit' | 'agent' | 'direct'>('inherit');
const currentPlatform = computed(() =>
platforms.value.find((p) => p.code === activeProvider.value),
);
@@ -291,6 +305,42 @@ async function loadAdvancedCfg() {
aiAgentViaAgent.value = parseBoolAdv(row.config_value);
break;
}
case 'ai_asr_xunfei_app_id': {
asrXunfeiAppId.value = String(row.config_value || '');
break;
}
case 'ai_asr_xunfei_api_key': {
asrXunfeiApiKey.value = String(row.config_value || '');
break;
}
case 'ai_asr_xunfei_api_secret': {
asrXunfeiApiSecret.value = String(row.config_value || '');
break;
}
case 'ai_asr_xunfei_api_url': {
asrXunfeiApiUrl.value = String(
row.config_value || 'wss://iat-api.xfyun.cn/v2/iat',
);
break;
}
case 'ai_asr_xunfei_domain': {
asrXunfeiDomain.value = String(row.config_value || 'iat');
break;
}
case 'ai_asr_xunfei_accent': {
asrXunfeiAccent.value = String(row.config_value || 'mandarin');
break;
}
case 'ai_asr_xunfei_timeout': {
asrXunfeiTimeout.value = Number(row.config_value) || 60;
break;
}
case 'ai_voice_via_agent_mode': {
const m = String(row.config_value || 'inherit').toLowerCase();
aiVoiceViaAgentMode.value =
m === 'agent' || m === 'direct' ? m : 'inherit';
break;
}
case 'ai_prescription_generate_count': {
// 钳制 15非法回落默认 1
const n = Number(row.config_value);
@@ -353,14 +403,14 @@ async function loadAdvancedCfg() {
}
}
/** 保存 Agent 高级配置(接入 / 知识库 / ReAct / Token / 流量实验)一次性 POST */
async function handleSaveAdvanced() {
advSaving.value = true;
/**
* 保存「Agent 中转」Tab直连/中转开关 + 服务地址 + 密钥 + 出方份数
* 为什么单独存:运维高频改中转路径,与 ReAct/流量实验解耦,避免误改高级项
*/
async function handleSaveAgentVia() {
agentViaSaving.value = true;
try {
await saveSystemConfig([
// 分组 0Agent 接入
// 关键:用 ai_agent_via_agent 独立开关,不再写 ai_active_provider避免污染模型 Tab 的真实 provider
// 这样后台"模型配置 Tab"选什么 provider/key/modelGo Agent 中转就用什么,互不影响
{
config_key: 'ai_agent_via_agent',
config_value: boolStr(aiAgentViaAgent.value),
@@ -379,7 +429,82 @@ async function handleSaveAdvanced() {
),
),
},
// 分组 0.5:知识库检索(与「是否中转」独立;必须 value_type=bool否则 PHP (bool)"0" 会当成 true
]);
message.success('Agent 中转配置已保存');
} finally {
agentViaSaving.value = false;
}
}
/**
* 保存「语音配置」Tab讯飞 ASR + 口述落方 LLM 路由
*/
async function handleSaveVoiceConfig() {
voiceSaving.value = true;
try {
await saveSystemConfig([
{
config_key: 'ai_asr_xunfei_app_id',
config_value: asrXunfeiAppId.value.trim(),
config_group: 'ai',
description: '讯飞 ASR AppID空则回落 env XUNFEI_ASR_APP_ID',
},
{
config_key: 'ai_asr_xunfei_api_key',
config_value: asrXunfeiApiKey.value.trim(),
config_group: 'ai',
description: '讯飞 ASR APIKey',
},
{
config_key: 'ai_asr_xunfei_api_secret',
config_value: asrXunfeiApiSecret.value.trim(),
config_group: 'ai',
description: '讯飞 ASR APISecret',
},
{
config_key: 'ai_asr_xunfei_api_url',
config_value:
asrXunfeiApiUrl.value.trim() || 'wss://iat-api.xfyun.cn/v2/iat',
config_group: 'ai',
description: '讯飞流式听写 WebSocket 地址',
},
{
config_key: 'ai_asr_xunfei_domain',
config_value: asrXunfeiDomain.value.trim() || 'iat',
config_group: 'ai',
description: '听写领域 iat|medicalmedical 需控制台授权)',
},
{
config_key: 'ai_asr_xunfei_accent',
config_value: asrXunfeiAccent.value.trim() || 'mandarin',
config_group: 'ai',
description: '口音 mandarin 等',
},
{
config_key: 'ai_asr_xunfei_timeout',
config_value: String(Math.max(10, Number(asrXunfeiTimeout.value) || 60)),
config_group: 'ai',
description: 'ASR 超时秒数',
},
{
config_key: 'ai_voice_via_agent_mode',
config_value: aiVoiceViaAgentMode.value,
config_group: 'ai',
description: '口述落方 LLMinherit|agent|direct',
},
]);
message.success('语音配置已保存');
} finally {
voiceSaving.value = false;
}
}
/** 保存 Agent 高级配置(知识库 / ReAct / Token / 流量实验) */
async function handleSaveAdvanced() {
advSaving.value = true;
try {
await saveSystemConfig([
// 知识库检索(与「是否中转」独立;必须 value_type=bool否则 PHP (bool)"0" 会当成 true
{
config_key: 'ai_agent_kb_enabled_medical_record',
config_value: boolStr(aiAgentKbEnabledMedicalRecord.value),
@@ -394,7 +519,7 @@ async function handleSaveAdvanced() {
config_group: 'ai',
description: '处方场景是否启用 Go Agent 知识库检索',
},
// 分组 1ReAct
// ReAct
{
config_key: 'ai_react_enabled',
config_value: boolStr(aiReactEnabled.value),
@@ -419,7 +544,7 @@ async function handleSaveAdvanced() {
config_key: 'ai_react_json_repair_enabled',
config_value: boolStr(aiReactJsonRepairEnabled.value),
},
// 分组 2Token 预算
// Token 预算
{
config_key: 'ai_token_budget_enabled',
config_value: boolStr(aiTokenBudgetEnabled.value),
@@ -432,7 +557,7 @@ async function handleSaveAdvanced() {
config_key: 'ai_token_max_per_call',
config_value: String(aiTokenMaxPerCall.value),
},
// 分组 3流量实验
// 流量实验
{
config_key: 'ai_shadow_traffic_enabled',
config_value: boolStr(aiShadowTrafficEnabled.value),
@@ -596,17 +721,19 @@ async function handleSaveCopies() {
onMounted(() => {
const stored = localStorage.getItem(SUB_TAB_STORAGE_KEY);
// 新增 advanced 二级 Tab 后,缓存值合法性扩展
// 缓存值合法性:含 Agent 中转 / 语音配置 / Agent 高级
if (
stored === 'model' ||
stored === 'copy' ||
stored === 'mr' ||
stored === 'agent' ||
stored === 'voice' ||
stored === 'advanced'
) {
subTab.value = stored;
}
load();
// 加载 Agent 高级配置(独立接口,失败不阻塞主流程)
// 加载 Agent / 语音 / 高级配置(独立接口,失败不阻塞主流程)
loadAdvancedCfg();
});
</script>
@@ -862,22 +989,22 @@ onMounted(() => {
</div>
</Tabs.TabPane>
<!-- Agent 高级Go Agent 接入 / ReAct / Token 预算 / 流量实验 -->
<Tabs.TabPane key="advanced" tab="Agent 高级">
<!-- Agent 中转直连/中转开关 + 服务地址独立 Tab运维高频 -->
<Tabs.TabPane key="agent" tab="Agent 中转">
<div class="py-4 space-y-6">
<!-- 分组 0Agent 接入配置独立开关 + 服务地址 + 鉴权密钥 -->
<div
class="rounded border border-solid border-blue-200 bg-blue-50/30 p-4 dark:border-blue-900 dark:bg-blue-950/20"
>
<div class="mb-3 flex items-center justify-between">
<div>
<div class="font-medium text-[hsl(var(--foreground))]">
Agent 接入配置
直连 / Agent 中转
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
独立开关开启后所有 AI 调用走 Go Agent 中转ReAct
多轮由下方单独控制知识库检索也是下方单独开关模型/密钥/平台始终由上方模型配置Tab
决定
开启后 AI 写病历AI 辅助辨证出方等经 AiAgentFactory
的调用走 Go Agent 中转关闭则为 PHP
直连厂商语音转写与口述落方见语音配置TabReAct /
知识库等见Agent 高级中转开启时须保证下方服务地址可达
</div>
</div>
<Switch
@@ -974,8 +1101,125 @@ onMounted(() => {
</div>
</div>
</div>
</div>
</Tabs.TabPane>
<!-- 分组 0.5知识库检索是否中转独立关掉时 Go 仍会记一条跳过步骤生成记录时间轴可见 -->
<!-- 语音配置ASR 转写 + 口述落方 LLM Agent 中转分离 -->
<Tabs.TabPane key="voice" tab="语音配置">
<div class="py-4 space-y-6">
<div
class="rounded border border-solid border-[hsl(var(--primary)/30%)] p-4 shadow-[0_0_6px_hsl(var(--primary)/18%)]"
>
<div class="mb-3">
<div class="font-medium text-[hsl(var(--foreground))]">
语音转写讯飞 ASR
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
接诊语音开方录音转文字使用字段留空时回落 .env
XUNFEI_ASR_*须开通控制台语音听写流式版v2 WebSocket
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
AppID
</div>
<Input
v-model:value="asrXunfeiAppId"
placeholder="讯飞控制台 AppID"
/>
</div>
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
APIKey
</div>
<Input
v-model:value="asrXunfeiApiKey"
placeholder="APIKey"
/>
</div>
<div class="md:col-span-2">
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
APISecret
</div>
<Input.Password
v-model:value="asrXunfeiApiSecret"
placeholder="APISecret"
autocomplete="new-password"
/>
</div>
<div class="md:col-span-2">
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
WebSocket 地址
</div>
<Input
v-model:value="asrXunfeiApiUrl"
placeholder="wss://iat-api.xfyun.cn/v2/iat"
/>
</div>
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
领域 domain
</div>
<Input
v-model:value="asrXunfeiDomain"
placeholder="iat 或 medical"
/>
</div>
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
口音 accent
</div>
<Input
v-model:value="asrXunfeiAccent"
placeholder="mandarin"
/>
</div>
<div>
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">
超时
</div>
<InputNumber
v-model:value="asrXunfeiTimeout"
:min="10"
:max="120"
style="width: 100%"
/>
</div>
</div>
</div>
<div
class="rounded border border-solid border-[hsl(var(--border))] p-4"
>
<div class="mb-3">
<div class="font-medium text-[hsl(var(--foreground))]">
口述落方 LLM 路由
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
口述落方解析药味时生效辨证出方语音选 A仍走Agent
中转Tab 的全局设置Agent 宕机时可设为强制直连而不影响其它 AI
</div>
</div>
<Radio.Group v-model:value="aiVoiceViaAgentMode">
<div class="flex flex-col gap-2">
<Radio value="inherit">
跟随 Agent 中转 Tab当前全局{{
aiAgentViaAgent ? '中转' : '直连'
}}
</Radio>
<Radio value="agent">强制 Go Agent 中转</Radio>
<Radio value="direct">强制 PHP 直连厂商</Radio>
</div>
</Radio.Group>
</div>
</div>
</Tabs.TabPane>
<!-- Agent 高级知识库 / ReAct / Token / 流量实验不含中转开关 -->
<Tabs.TabPane key="advanced" tab="Agent 高级">
<div class="py-4 space-y-6">
<!-- 知识库检索是否中转独立关掉时 Go 仍会记一条跳过步骤 -->
<div
class="rounded border border-solid border-[hsl(var(--border))] p-4"
>
@@ -984,9 +1228,9 @@ onMounted(() => {
知识库检索
</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
上方Agent 中转独立中转只决定走 Go本开关才决定 Go
Agent 中转Tab 独立中转只决定走 Go本开关才决定 Go
是否检索本地知识库 /
MaxKB需先开启中转才生效生成记录时间轴会出现检索知识库步骤
MaxKB需先Agent 中转开启中转才生效生成记录时间轴会出现检索知识库步骤
</div>
</div>
<div class="flex flex-wrap items-center gap-6">
@@ -1011,7 +1255,7 @@ onMounted(() => {
</div>
</div>
<!-- 分组 1ReAct 多轮推理循环 -->
<!-- ReAct 多轮推理循环 -->
<div
class="rounded border border-solid border-[hsl(var(--border))] p-4"
>
@@ -1095,7 +1339,7 @@ onMounted(() => {
</div>
</div>
<!-- 分组 2Token 预算管理 -->
<!-- Token 预算管理 -->
<div
class="rounded border border-solid border-[hsl(var(--border))] p-4"
>
@@ -1151,7 +1395,7 @@ onMounted(() => {
</div>
</div>
<!-- 分组 3流量实验 -->
<!-- 流量实验 -->
<div
class="rounded border border-solid border-[hsl(var(--border))] p-4"
>
@@ -1254,6 +1498,22 @@ onMounted(() => {
>
保存 AI 功能文案
</Button>
<Button
v-else-if="subTab === 'agent'"
type="primary"
:loading="agentViaSaving"
@click="handleSaveAgentVia"
>
保存 Agent 中转配置
</Button>
<Button
v-else-if="subTab === 'voice'"
type="primary"
:loading="voiceSaving"
@click="handleSaveVoiceConfig"
>
保存语音配置
</Button>
<Button
v-else
type="primary"

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
/**
* 系统配置 - 满额包邮:订单商品金额达到门槛免运费
* 数据存老库 yii_system_configconfig_type=2, type=2下单运费计算直接读该行
* 走独立接口 free-shipping-config不与新库 xk_system_config 的 key-value 混存
*/
import { onMounted, ref } from 'vue';
import { Button, InputNumber, message } from 'ant-design-vue';
import { getFreeShippingConfig, saveFreeShippingConfig } from '../api';
defineOptions({ name: 'FreeShippingConfigPanel' });
const loading = ref(false);
const saving = ref(false);
/** 门槛金额null 表示尚未加载成功 */
const thresholdValue = ref<null | number>(null);
/** 配置行是否存在(不存在时禁止保存,提示先初始化 SQL */
const configExists = ref(true);
/** 加载当前门槛金额,配置行缺失时后端会报错,这里标记后禁用保存 */
async function load() {
loading.value = true;
try {
const data = await getFreeShippingConfig();
thresholdValue.value = Number(data?.value ?? 0);
configExists.value = true;
} catch {
// 老库无配置行(新环境未跑初始化 SQL时走到这里
configExists.value = false;
} finally {
loading.value = false;
}
}
/** 保存门槛金额name/rule 不传由后端保留库中原值 */
async function handleSave() {
const v = thresholdValue.value;
if (v === null || Number.isNaN(v) || v < 0) {
message.warning('请填写有效的包邮门槛金额');
return;
}
saving.value = true;
try {
await saveFreeShippingConfig({ value: v });
message.success('保存成功');
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">满额包邮门槛</div>
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
订单商品金额达到该门槛时免运费未达到时按收货省份运费地区管理中的快递费计费
单品包邮门店包邮特色方包邮的优先级高于本规则门槛为 0 表示任意金额均包邮
</div>
<InputNumber
v-model:value="thresholdValue"
:min="0"
:precision="2"
:step="1"
:disabled="!configExists"
style="width: 220px"
placeholder="请输入门槛金额"
>
<template #addonAfter> </template>
</InputNumber>
<div v-if="!configExists" class="mt-3 text-sm text-[hsl(var(--warning))]">
未找到包邮配置数据请先在老库执行初始化 SQLsql/20260827/01_yii_system_config_free_shipping_init.sql后刷新
</div>
</div>
<div class="cfg-panel-footer">
<Button type="primary" :loading="saving" :disabled="!configExists" @click="handleSave">
保存本模块配置
</Button>
</div>
</div>
</template>

View File

@@ -17,6 +17,7 @@ import AiModelConfigPanel from './components/ai-model-config-panel.vue';
import DictSearchConfigPanel from './components/dict-search-config-panel.vue';
import DispenseSignConfigPanel from './components/dispense-sign-config-panel.vue';
import DoctorLoginConfigPanel from './components/doctor-login-config-panel.vue';
import FreeShippingConfigPanel from './components/free-shipping-config-panel.vue';
import HomeDisplayConfigPanel from './components/home-display-config-panel.vue';
import InputAuditConfigPanel from './components/input-audit-config-panel.vue';
import InvoiceNoticeConfigPanel from './components/invoice-notice-config-panel.vue';
@@ -83,6 +84,11 @@ const menuItems: MenuItem[] = [
component: PriceAdjustConfigPanel,
},
{ key: 'logistics', title: '物流展示', component: LogisticsConfigPanel },
{
key: 'free_shipping',
title: '满额包邮',
component: FreeShippingConfigPanel,
},
{
key: 'public_account_pay',
title: '公账支付',