Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
443e501da5 | ||
|
|
c2040c2b15 | ||
|
|
0d18089f71 |
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -442,10 +442,49 @@ export async function aiGeneratePrescriptionApi(data: {
|
||||
use_entrusted_process?: 0 | 1 | boolean;
|
||||
/** 中药委托调剂:制剂要求 id(yii_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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音转写(multipart:audio 文件)
|
||||
* 字段名必须为 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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 浏览器录音 → 16k 单声道 WAV(讯飞一句话识别 raw/pcm 要求)
|
||||
* 为什么:MediaRecorder 默认 webm,ASR 不支持,需解码后重编码
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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」选)
|
||||
// 分组 0:Agent 接入(独立开关 + 服务地址 + 鉴权密钥)
|
||||
// aiActiveProviderAdv 不再使用——只保留模型 Tab 里的 activeProvider 作为真实 provider
|
||||
// ===== Agent 配置(走 getSystemConfigList,写入 xk_system_config) =====
|
||||
// 与上方「模型配置」独立:
|
||||
// - 模型配置决定 provider/key/model
|
||||
// - 「Agent 中转」Tab:全局直连/中转 + Agent 服务地址
|
||||
// - 「语音配置」Tab:讯飞 ASR + 口述落方 LLM 路由
|
||||
// - 「Agent 高级」Tab:KB / 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);
|
||||
/** 口述落方 LLM:inherit | 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': {
|
||||
// 钳制 1–5,非法回落默认 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([
|
||||
// 分组 0:Agent 接入
|
||||
// 关键:用 ai_agent_via_agent 独立开关,不再写 ai_active_provider(避免污染模型 Tab 的真实 provider)
|
||||
// 这样后台"模型配置 Tab"选什么 provider/key/model,Go 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|medical(medical 需控制台授权)',
|
||||
},
|
||||
{
|
||||
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: '口述落方 LLM:inherit|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 知识库检索',
|
||||
},
|
||||
// 分组 1:ReAct
|
||||
// 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),
|
||||
},
|
||||
// 分组 2:Token 预算
|
||||
// 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">
|
||||
<!-- 分组 0:Agent 接入配置(独立开关 + 服务地址 + 鉴权密钥) -->
|
||||
<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
|
||||
直连厂商。语音转写与口述落方见「语音配置」Tab;ReAct /
|
||||
知识库等见「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>
|
||||
|
||||
<!-- 分组 1:ReAct 多轮推理循环 -->
|
||||
<!-- ReAct 多轮推理循环 -->
|
||||
<div
|
||||
class="rounded border border-solid border-[hsl(var(--border))] p-4"
|
||||
>
|
||||
@@ -1095,7 +1339,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组 2:Token 预算管理 -->
|
||||
<!-- 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"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 系统配置 - 满额包邮:订单商品金额达到门槛免运费
|
||||
* 数据存老库 yii_system_config(config_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))]">
|
||||
未找到包邮配置数据,请先在老库执行初始化 SQL(sql/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>
|
||||
@@ -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: '公账支付',
|
||||
|
||||
Reference in New Issue
Block a user