6 Commits

Author SHA1 Message Date
李琦
f10158b454 feat: 语音开方、修复分账问题、识图成方、识图传方、优化订单卡片展示 2026-09-23 16:22:14 +08:00
李琦
6c6922c400 Merge remote-tracking branch 'origin/main' into vben-dev 2026-09-14 15:31:32 +08:00
李琦
55a71bfbe3 feat: 语音开方 2026-09-14 15:19:31 +08:00
李琦
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
44 changed files with 3630 additions and 758 deletions

View File

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

View File

@@ -2,6 +2,7 @@
/** /**
* 门店详情 - 资金账户面板(余额 + 资金流水 + 账户变动) * 门店详情 - 资金账户面板(余额 + 资金流水 + 账户变动)
* 仅超管/系统管理员在 StoreCardModal 中可见,数据按门店 ID 查询 * 仅超管/系统管理员在 StoreCardModal 中可见,数据按门店 ID 查询
* 口径说明:资金流水按履约店 store_id;账户变动/余额按分账利润归属店 user_id
*/ */
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
@@ -65,6 +66,9 @@ const fundWaterColumns = [
{ title: '金额', dataIndex: 'price', key: 'price', width: 110 }, { title: '金额', dataIndex: 'price', key: 'price', width: 110 },
{ title: '流水类型', dataIndex: 'type', key: 'type', width: 110 }, { title: '流水类型', dataIndex: 'type', key: 'type', width: 110 },
{ title: '订单类型', dataIndex: 'order_type', key: 'order_type', width: 110 }, { title: '订单类型', dataIndex: 'order_type', key: 'order_type', width: 110 },
// store_id 便于核对是否本店履约(委托复诊时流水店与利润店可能不同)
{ title: '门店ID', dataIndex: 'store_id', key: 'store_id', width: 90 },
{ title: '诊所', dataIndex: 'store_name', key: 'store_name', width: 120, ellipsis: true },
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', ellipsis: true }, { title: '订单号', dataIndex: 'order_no', key: 'order_no', ellipsis: true },
{ title: '医生', dataIndex: 'doctor_name', key: 'doctor_name', width: 100 }, { title: '医生', dataIndex: 'doctor_name', key: 'doctor_name', width: 100 },
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 }, { title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
@@ -77,6 +81,7 @@ const changeLogColumns = [
{ title: '变动前', dataIndex: 'before_amount', key: 'before_amount', width: 100 }, { title: '变动前', dataIndex: 'before_amount', key: 'before_amount', width: 100 },
{ title: '变动后', dataIndex: 'after_amount', key: 'after_amount', width: 100 }, { title: '变动后', dataIndex: 'after_amount', key: 'after_amount', width: 100 },
{ title: '变动值', dataIndex: 'change_amount', key: 'change_amount', width: 100 }, { title: '变动值', dataIndex: 'change_amount', key: 'change_amount', width: 100 },
{ title: '主体ID', dataIndex: 'user_id', key: 'user_id', width: 90 },
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', ellipsis: true }, { title: '订单号', dataIndex: 'order_no', key: 'order_no', ellipsis: true },
{ title: '来源表', dataIndex: 'source_table_txt', key: 'source_table_txt', width: 110 }, { title: '来源表', dataIndex: 'source_table_txt', key: 'source_table_txt', width: 110 },
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true }, { title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
@@ -123,6 +128,7 @@ async function loadFundWater(page = fundWaterPage.value) {
fundWaterList.value = (res?.items || []).map((item: any) => ({ fundWaterList.value = (res?.items || []).map((item: any) => ({
...item, ...item,
doctor_name: item?.doctor_info?.name || '-', doctor_name: item?.doctor_info?.name || '-',
store_name: item?.store?.name || '-',
})); }));
fundWaterTotal.value = Number(res?.total || 0); fundWaterTotal.value = Number(res?.total || 0);
} catch (e: any) { } catch (e: any) {
@@ -237,6 +243,9 @@ function orderTypeTag(orderType: number) {
</section> </section>
<section class="finance-section finance-section--list"> <section class="finance-section finance-section--list">
<p class="finance-hint">
资金流水按订单履约店;账户变动/余额按分账利润归属店。委托复诊等场景两边主体可能不同,属业务口径而非串店。
</p>
<Segmented <Segmented
v-model:value="activeListTab" v-model:value="activeListTab"
block block
@@ -266,7 +275,7 @@ function orderTypeTag(orderType: number) {
}" }"
row-key="id" row-key="id"
size="small" size="small"
:scroll="{ x: 800, y: 320 }" :scroll="{ x: 1000, y: 320 }"
@change="(pag: any) => { @change="(pag: any) => {
fundWaterPageSize = pag.pageSize || fundWaterPageSize; fundWaterPageSize = pag.pageSize || fundWaterPageSize;
loadFundWater(pag.current || 1); loadFundWater(pag.current || 1);
@@ -311,12 +320,24 @@ function orderTypeTag(orderType: number) {
}" }"
row-key="id" row-key="id"
size="small" size="small"
:scroll="{ x: 960, y: 320 }" :scroll="{ x: 1080, y: 320 }"
@change="(pag: any) => { @change="(pag: any) => {
changeLogPageSize = pag.pageSize || changeLogPageSize; changeLogPageSize = pag.pageSize || changeLogPageSize;
loadChangeLog(pag.current || 1); loadChangeLog(pag.current || 1);
}" }"
/> >
<template #bodyCell="{ column, record }">
<template
v-if="
column.key === 'before_amount' ||
column.key === 'after_amount' ||
column.key === 'change_amount'
"
>
{{ formatMoney(record[column.key]) }}
</template>
</template>
</Table>
</div> </div>
</section> </section>
</div> </div>
@@ -354,6 +375,13 @@ function orderTypeTag(orderType: number) {
min-width: 0; min-width: 0;
} }
.finance-hint {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.5;
color: hsl(var(--muted-foreground));
}
.finance-list-segment { .finance-list-segment {
margin-bottom: 4px; margin-bottom: 4px;
} }

View File

@@ -19,6 +19,7 @@ export const ENCRYPT_FIELDS = new Set([
'phone', 'phone',
'open_api_key', 'open_api_key',
'api_key', 'api_key',
'app_secret',
]); ]);
/** 需要生成 _tm_text 并默认脱敏展示的字段(encrypt_only) */ /** 需要生成 _tm_text 并默认脱敏展示的字段(encrypt_only) */
@@ -98,7 +99,12 @@ export function maskSensitiveField(field: string, plainText: unknown): string {
return ''; return '';
} }
const str = String(plainText); const str = String(plainText);
if (field === 'password' || field === 'api_key' || field === 'open_api_key') { if (
field === 'password' ||
field === 'api_key' ||
field === 'open_api_key' ||
field === 'app_secret'
) {
return '*'.repeat(Math.max(str.length, 6)); return '*'.repeat(Math.max(str.length, 6));
} }
if (PHONE_FIELDS.has(field)) { if (PHONE_FIELDS.has(field)) {

View File

@@ -1,5 +1,62 @@
export type ProcessRuleListItem = { id?: number | string }; export type ProcessRuleListItem = { id?: number | string };
/** 代煎+用量必填校验入参(对齐小程序 validateChineseConfigRequired) */
export type ChineseConfigRequiredInput = {
ruleType?: number | null;
packageMethodId?: number | null;
processRuleId?: number | null;
childProcessRuleId?: number | null;
processRuleNoteId?: number | null;
dosage?: number | null;
dayDosage?: number | null;
};
/**
* 中药制剂方式 / 代煎 / 用量必填校验
* 返回 keys 供 ChineseMedicineConfig 标红,message 供 toast
*/
export function validateChineseConfigRequired(config: ChineseConfigRequiredInput) {
const keys: string[] = [];
const messages: string[] = [];
const ruleType = Number(config?.ruleType || 0);
if (ruleType !== 1 && ruleType !== 2) {
keys.push('ruleType');
messages.push('请选择制剂方式');
}
if (ruleType === 1) {
if (!Number(config?.packageMethodId)) {
keys.push('packageMethodId');
messages.push('请选择包法');
}
} else if (ruleType === 2) {
if (!Number(config?.processRuleId)) {
keys.push('processRuleId');
messages.push('请选择制剂要求');
}
if (!Number(config?.childProcessRuleId)) {
keys.push('childProcessRuleId');
messages.push('请选择煎法说明');
}
if (!Number(config?.processRuleNoteId)) {
keys.push('processRuleNoteId');
messages.push('请选择附加备注');
}
}
if (!(Number(config?.dosage) >= 1)) {
keys.push('dosage');
messages.push('请填写剂数');
}
if (!(Number(config?.dayDosage) >= 1)) {
keys.push('dayDosage');
messages.push('请填写用药频次');
}
return {
valid: keys.length === 0,
keys,
message: messages[0] || '',
};
}
export type ProcessRuleLoadedLevel = 'root' | 'child' | 'note'; export type ProcessRuleLoadedLevel = 'root' | 'child' | 'note';
/** /**

View File

@@ -9,9 +9,21 @@ export type SearchTimePreset =
| 'prevMonth' | 'prevMonth'
| 'nextMonth'; | 'nextMonth';
/** 界面展示用纯日期;提交给后端时再拼时分秒 */
const YMD = 'YYYY-MM-DD'; 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 { export function todayDayjs(): Dayjs {
return dayjs().startOf('day'); return dayjs().startOf('day');
} }
@@ -24,30 +36,27 @@ export function startOfWeekMonday(base: Dayjs = todayDayjs()): Dayjs {
return d.subtract(diff, 'day'); return d.subtract(diff, 'day');
} }
/** 本月1日 ~ 今天(字符串,供 RangePicker valueFormat) */ /** 本月1日 00:00:00 ~ 今天 23:59:59(字符串,供表单默认值 / 接口) */
export function monthToTodayRangeString(): [string, string] { export function monthToTodayRangeString(): [string, string] {
const today = todayDayjs(); return rangeDayjsToString(monthToTodayRangeDayjs());
return [today.startOf('month').format(YMD), today.format(YMD)];
} }
/** 本月1日 ~ 今天(Dayjs) */ /** 本月1日 ~ 今天(Dayjs,已含起止边界) */
export function monthToTodayRangeDayjs(): [Dayjs, Dayjs] { export function monthToTodayRangeDayjs(): [Dayjs, Dayjs] {
const today = todayDayjs(); 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] { export function lastNDaysRangeString(days = 7): [string, string] {
const today = todayDayjs(); return rangeDayjsToString(lastNDaysRangeDayjs(days));
const start = today.subtract(Math.max(days - 1, 0), 'day');
return [start.format(YMD), today.format(YMD)];
} }
/** 近 N 天(Dayjs) */ /** 近 N 天(Dayjs,已含起止边界) */
export function lastNDaysRangeDayjs(days = 7): [Dayjs, Dayjs] { export function lastNDaysRangeDayjs(days = 7): [Dayjs, Dayjs] {
const today = todayDayjs(); const today = todayDayjs();
const start = today.subtract(Math.max(days - 1, 0), 'day'); const start = today.subtract(Math.max(days - 1, 0), 'day');
return [start, today]; return toSearchDayBounds([start, today]);
} }
/** 将 Dayjs / 字符串 / 时间戳统一为 YYYY-MM-DD */ /** 将 Dayjs / 字符串 / 时间戳统一为 YYYY-MM-DD */
@@ -98,17 +107,17 @@ export function getMonthAnchorDayjs(current: unknown): Dayjs {
return todayDayjs(); return todayDayjs();
} }
/** 自然月整月,结束日不超过今天 */ /** 自然月整月,结束日不超过今天(返回值已含起止边界) */
function fullMonthRangeCapped( function fullMonthRangeCapped(
month: Dayjs, month: Dayjs,
): [Dayjs, Dayjs] { ): [Dayjs, Dayjs] {
const start = month.startOf('month'); const start = month.startOf('month');
const end = month.endOf('month').startOf('day'); const end = month.endOf('month').startOf('day');
const today = todayDayjs(); 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( export function resolveSearchTimePreset(
preset: SearchTimePreset, preset: SearchTimePreset,
current: unknown, current: unknown,
@@ -116,11 +125,11 @@ export function resolveSearchTimePreset(
const today = todayDayjs(); const today = todayDayjs();
switch (preset) { switch (preset) {
case 'today': case 'today':
return [today, today]; return toSearchDayBounds([today, today]);
case 'thisWeek': case 'thisWeek':
return [startOfWeekMonday(today), today]; return toSearchDayBounds([startOfWeekMonday(today), today]);
case 'thisMonth': case 'thisMonth':
return [today.startOf('month'), today]; return toSearchDayBounds([today.startOf('month'), today]);
case 'prevMonth': { case 'prevMonth': {
const anchor = getMonthAnchorDayjs(current); const anchor = getMonthAnchorDayjs(current);
return fullMonthRangeCapped(anchor.subtract(1, 'month')); return fullMonthRangeCapped(anchor.subtract(1, 'month'));
@@ -134,36 +143,44 @@ export function resolveSearchTimePreset(
} }
} }
/** 按预设计算区间(字符串) */ /** 按预设计算区间(字符串,已含时分秒) */
export function resolveSearchTimePresetString( export function resolveSearchTimePresetString(
preset: SearchTimePreset, preset: SearchTimePreset,
current: unknown, current: unknown,
): [string, string] { ): [string, string] {
const [a, b] = resolveSearchTimePreset(preset, current); return rangeDayjsToString(resolveSearchTimePreset(preset, current));
return [a.format(YMD), b.format(YMD)];
} }
/** Dayjs 区间转字符串 */ /**
* Dayjs 区间转接口字符串
* 固定开始 00:00:00、结束 23:59:59,与按天筛选的业务口径一致
*/
export function rangeDayjsToString( export function rangeDayjsToString(
range: [Dayjs, Dayjs], range: [Dayjs, Dayjs],
): [string, string] { ): [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( export function rangeStringToDayjs(
range: [string, string], range: [string, string],
): [Dayjs, Dayjs] { ): [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( export function formatRangeByValueFormat(
range: [Dayjs, Dayjs], range: [Dayjs, Dayjs],
valueFormat?: string, valueFormat?: string,
): [string, string] | [Dayjs, Dayjs] { ): [string, string] | [Dayjs, Dayjs] {
const bounded = toSearchDayBounds(range);
if (valueFormat === YMD) { if (valueFormat === YMD) {
return rangeDayjsToString(range); return rangeDayjsToString(bounded);
} }
return range; return bounded;
} }

View File

@@ -78,6 +78,7 @@ import { ChineseDrugNameBubble } from '#/components/chinese-drug-name-bubble';
import { formatPriceDisplay } from '#/utils/formatPrice'; import { formatPriceDisplay } from '#/utils/formatPrice';
import ChineseMedicineConfig from '#/views/doctor/doctor-reception/components/ChineseMedicineConfig.vue'; import ChineseMedicineConfig from '#/views/doctor/doctor-reception/components/ChineseMedicineConfig.vue';
import { getDeliveryWarehouseOptionsByDrugs, getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api'; import { getDeliveryWarehouseOptionsByDrugs, getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api';
import { alignChineseDrugsToLatestPrice } from '#/views/doctor/doctor-reception/utils/alignChineseDrugPrice';
import WarehouseSelectModal from './WarehouseSelectModal.vue'; import WarehouseSelectModal from './WarehouseSelectModal.vue';
import { detailDoctorOrderToText } from '#/utils/prescriptionDoctorOrder'; import { detailDoctorOrderToText } from '#/utils/prescriptionDoctorOrder';
@@ -1008,20 +1009,25 @@ async function reusePrescriptionFromList(prescriptionId: number) {
message.error('解析中药处方失败'); message.error('解析中药处方失败');
return; return;
} }
prescriptionStore.updateCurrentDrugs( const mappedChinese = (rows || []).map((d: any) => {
(rows || []).map((d: any) => { const drugId = Number(d.drug_id ?? d.id);
const drugId = Number(d.drug_id ?? d.id); return {
return { index_id: d.id ?? drugId,
index_id: d.id ?? drugId, id: drugId,
id: drugId, drug_name: d.name || d.drug_name || '',
drug_name: d.name || d.drug_name || '', number: Number(d.number ?? d.amount ?? 0),
number: Number(d.number ?? d.amount ?? 0), way_id: d.way_id ?? d.use_way?.id ?? d.order,
way_id: d.way_id ?? d.use_way?.id ?? d.order, price: Number(d.price ?? d.sell_price ?? 0),
price: Number(d.price ?? d.sell_price ?? 0), buy_price: d.buy_price ?? null,
unit: d.unit, unit: d.unit,
}; };
}), });
); await alignChineseDrugsToLatestPrice({
registerId: medicalRecordRegisterId.value,
storeId: medicalRecordStoreId.value,
drugs: mappedChinese,
});
prescriptionStore.updateCurrentDrugs(mappedChinese);
if (rep0?.dosage != null) prescriptionStore.dosage = Number(rep0.dosage) || prescriptionStore.dosage; if (rep0?.dosage != null) prescriptionStore.dosage = Number(rep0.dosage) || prescriptionStore.dosage;
if (rep0?.day_dosage != null) { if (rep0?.day_dosage != null) {
prescriptionStore.dayDosage = Number(rep0.day_dosage) || prescriptionStore.dayDosage; prescriptionStore.dayDosage = Number(rep0.day_dosage) || prescriptionStore.dayDosage;
@@ -1297,19 +1303,26 @@ async function handleSelectCommonPrescription(data: any, type: number) {
}); });
} else { } else {
// 中药/颗粒药处方 // 中药/颗粒药处方
recipes.forEach((recipe: any) => { const chineseRows = recipes.map((recipe: any) => ({
const newProduct = { index_id: recipe.drug_id || recipe.id,
index_id: recipe.drug_id || recipe.id, id: recipe.drug_id || recipe.id,
id: recipe.drug_id || recipe.id, drug_name: recipe.drug_name || recipe.name,
drug_name: recipe.drug_name || recipe.name, number: recipe.number || 1,
number: recipe.number || 1, price: recipe.price || 0,
price: recipe.price || 0, buy_price: recipe.buy_price,
buy_price: recipe.buy_price, way_id: recipe.way_id || 0,
way_id: recipe.way_id || 0, specification: recipe.specification || recipe.drug?.specification || '',
specification: recipe.specification || recipe.drug?.specification || '', select_number: 1,
select_number: 1, }));
}; // 颗粒药不走供货比例,只核对中药常用方
// 检查是否已存在 if (type === 2) {
await alignChineseDrugsToLatestPrice({
registerId: medicalRecordRegisterId.value,
storeId: medicalRecordStoreId.value,
drugs: chineseRows,
});
}
chineseRows.forEach((newProduct) => {
const existItem = prescriptionStore.currentDrugs.find( const existItem = prescriptionStore.currentDrugs.find(
(item: any) => item.id === newProduct.id, (item: any) => item.id === newProduct.id,
); );

View File

@@ -14,7 +14,6 @@ import {
Table, Table,
Tabs, Tabs,
Tag, Tag,
Timeline,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { import {
@@ -45,6 +44,10 @@ const reconciliationLoading = ref(false);
const activeTab = ref('timeline'); const activeTab = ref('timeline');
const ledgerScope = ref<LedgerScope>('all'); const ledgerScope = ref<LedgerScope>('all');
const ledgerSubTab = ref<'product' | 'register'>('product'); const ledgerSubTab = ref<'product' | 'register'>('product');
/** 商品订单分账:真实入账行 / 下单时冻结的快照 */
const productLedgerView = ref<'actual' | 'snapshot'>('actual');
/** 时间线一行放几个节点,奇数行从右往左折返 */
const TIMELINE_PER_ROW = 4;
const traceData = ref<Record<string, any> | null>(null); const traceData = ref<Record<string, any> | null>(null);
const ledgerProduct = ref<Record<string, any> | null>(null); const ledgerProduct = ref<Record<string, any> | null>(null);
const ledgerRegister = ref<Record<string, any> | null>(null); const ledgerRegister = ref<Record<string, any> | null>(null);
@@ -73,6 +76,61 @@ const currentLedgerData = computed(() =>
ledgerSubTab.value === 'product' ? ledgerProduct.value : ledgerRegister.value, ledgerSubTab.value === 'product' ? ledgerProduct.value : ledgerRegister.value,
); );
/** 商品订单分账快照(无快照时为 null) */
const productLedgerSnapshot = computed(
() => ledgerProduct.value?.ledger_split ?? null,
);
/**
* 节点状态色:完成类用主题色,驳回/退款用警告色,纯记录用弱色。
* 只根据已有文案归类,不改时间线数据。
*/
function timelineStatusTone(status: string) {
if (/驳回|拒|退|失败|取消/.test(status)) {
return 'warn';
}
if (!status || status === '记录') {
return 'muted';
}
return 'ok';
}
/**
* 把时间线拆成固定四列的折返行。
* 偶数行从左到右占第 1~4 列;奇数行从右往左,第 1 个节点落在最右列,和上一行末尾对齐。
*/
const timelineRows = computed(() => {
const nodes = (traceData.value?.timeline ?? []).map((node: any, index: number) => ({
...node,
seq: index + 1,
tone: timelineStatusTone(String(node.status ?? '')),
}));
const rows: Array<{
nodes: Array<any & { column: number; pointLeft: boolean; hasNext: boolean }>;
rtl: boolean;
showTurn: boolean;
turnColumn: number;
}> = [];
for (let i = 0; i < nodes.length; i += TIMELINE_PER_ROW) {
const chunk = nodes.slice(i, i + TIMELINE_PER_ROW);
const rowIndex = rows.length;
const rtl = rowIndex % 2 === 1;
const placed = chunk.map((node, index) => {
const column = rtl ? TIMELINE_PER_ROW - index : index + 1;
const hasNext = index < chunk.length - 1;
return { ...node, column, hasNext, pointLeft: rtl };
});
const lastIndex = chunk.length - 1;
rows.push({
nodes: placed,
rtl,
showTurn: i + TIMELINE_PER_ROW < nodes.length,
turnColumn: rtl ? TIMELINE_PER_ROW - lastIndex : lastIndex + 1,
});
}
return rows;
});
function formatDrugCell(value?: string) { function formatDrugCell(value?: string) {
return value?.trim() ? value : '-'; return value?.trim() ? value : '-';
} }
@@ -86,6 +144,7 @@ function resetDrawerState() {
activeTab.value = 'timeline'; activeTab.value = 'timeline';
ledgerScope.value = isPlatformAdmin.value ? 'all' : 'store'; ledgerScope.value = isPlatformAdmin.value ? 'all' : 'store';
ledgerSubTab.value = 'product'; ledgerSubTab.value = 'product';
productLedgerView.value = 'actual';
} }
const [Drawer, drawerApi] = useVbenDrawer({ const [Drawer, drawerApi] = useVbenDrawer({
@@ -356,22 +415,44 @@ watch(isPlatformAdmin, (val) => {
<Tabs v-model:active-key="activeTab" @change="(key) => handleMainTabChange(String(key))"> <Tabs v-model:active-key="activeTab" @change="(key) => handleMainTabChange(String(key))">
<Tabs.TabPane key="timeline" tab="溯源时间线"> <Tabs.TabPane key="timeline" tab="溯源时间线">
<Timeline v-if="traceData.timeline?.length"> <div v-if="timelineRows.length" class="trace-snake">
<Timeline.Item <div
v-for="node in traceData.timeline" v-for="(row, rowIndex) in timelineRows"
:key="node.key" :key="rowIndex"
class="trace-snake-wrap"
> >
<div class="font-medium">{{ node.title }}</div> <div class="trace-snake-row">
<div class="text-sm text-gray-500">{{ node.time }}</div> <div
<div v-if="node.status" class="text-sm"> v-for="node in row.nodes"
<Tag>{{ node.status }}</Tag> :key="node.key"
class="trace-snake-node"
:class="{
'point-left': node.pointLeft && node.hasNext,
'point-right': !node.pointLeft && node.hasNext,
}"
:style="{ gridColumn: String(node.column), gridRow: '1' }"
>
<div class="trace-snake-head">
<span class="trace-snake-index">{{ node.seq }}</span>
<span class="trace-snake-title">{{ node.title }}</span>
</div>
<span
v-if="node.status"
class="trace-snake-status"
:class="'is-' + node.tone"
>{{ node.status }}</span>
<div v-if="node.time" class="trace-snake-time">{{ node.time }}</div>
<div v-if="node.extra" class="trace-snake-extra" :title="node.extra">{{ node.extra }}</div>
</div>
</div> </div>
<div v-if="node.extra" class="mt-1 text-sm text-gray-600"> <div
{{ node.extra }} v-if="row.showTurn"
</div> class="trace-snake-turn"
</Timeline.Item> :style="{ left: `calc((${row.turnColumn} - 0.5) * 25%)` }"
</Timeline> />
<div v-else class="py-6 text-center text-gray-500">暂无溯源记录</div> </div>
</div>
<div v-else class="py-6 text-center text-[hsl(var(--muted-foreground))]">暂无溯源记录</div>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane key="ledger" tab="分账明细"> <Tabs.TabPane key="ledger" tab="分账明细">
@@ -392,46 +473,76 @@ watch(isPlatformAdmin, (val) => {
<Tabs v-model:active-key="ledgerSubTab" type="card"> <Tabs v-model:active-key="ledgerSubTab" type="card">
<Tabs.TabPane key="product" tab="商品订单分账"> <Tabs.TabPane key="product" tab="商品订单分账">
<div class="mb-2 text-sm"> <Tabs v-model:active-key="productLedgerView" class="mb-3" size="small">
合计分账:{{ ledgerProduct?.sum_money ?? '0' }} <Tabs.TabPane key="actual" tab="真实分账" />
<span class="ml-2 text-xs text-[hsl(var(--muted-foreground))]">(不含已取消)</span> <Tabs.TabPane key="snapshot" tab="快照分账" />
</div> </Tabs>
<Table <template v-if="productLedgerView === 'actual'">
:columns="ledgerColumns" <div class="mb-2 text-sm">
:data-source="ledgerProduct?.items ?? []" 合计分账:{{ ledgerProduct?.sum_money ?? '0' }}
:pagination="false" <span class="ml-2 text-xs text-[hsl(var(--muted-foreground))]">(不含已取消)</span>
row-key="id" </div>
size="small" <Table
> :columns="ledgerColumns"
<template #bodyCell="{ column, record, text }"> :data-source="ledgerProduct?.items ?? []"
<template v-if="column.key === 'drug_image'"> :pagination="false"
<Image row-key="id"
v-if="record.drug_image" size="small"
:src="record.drug_image" >
:width="48" <template #bodyCell="{ column, record, text }">
:height="48" <template v-if="column.key === 'drug_image'">
class="object-cover" <Image
/> v-if="record.drug_image"
<span v-else>-</span> :src="record.drug_image"
:width="48"
:height="48"
class="object-cover"
/>
<span v-else>-</span>
</template>
<template
v-else-if="
column.key === 'drug_name' ||
column.key === 'drug_number' ||
column.key === 'specification' ||
column.key === 'party_name'
"
>
{{ formatDrugCell(text) }}
</template>
</template> </template>
<template </Table>
v-else-if=" <div
column.key === 'drug_name' || v-if="!ledgerProduct?.items?.length && !ledgerLoading"
column.key === 'drug_number' || class="py-6 text-center text-[hsl(var(--muted-foreground))]"
column.key === 'specification' || >
column.key === 'party_name' 暂无分账记录
" </div>
> </template>
{{ formatDrugCell(text) }} <template v-else>
</template> <Descriptions
</template> v-if="productLedgerSnapshot"
</Table> bordered
<div size="small"
v-if="!ledgerProduct?.items?.length && !ledgerLoading" :column="2"
class="py-6 text-center text-gray-500" >
> <Descriptions.Item label="分账方式">
暂无分账记录 {{ productLedgerSnapshot.split_type_txt || '-' }}
</div> </Descriptions.Item>
<Descriptions.Item label="平台占比">
{{ productLedgerSnapshot.platform_percent }}%
</Descriptions.Item>
<Descriptions.Item label="平台金额">
{{ productLedgerSnapshot.platform_amount }}
</Descriptions.Item>
<Descriptions.Item label="诊所金额">
{{ productLedgerSnapshot.store_amount }}
</Descriptions.Item>
</Descriptions>
<div v-else class="py-6 text-center text-[hsl(var(--muted-foreground))]">
该订单还没有分账快照(上线前的旧单,或尚未结算)
</div>
</template>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane key="register" tab="挂号订单分账"> <Tabs.TabPane key="register" tab="挂号订单分账">
<div class="mb-2 text-sm"> <div class="mb-2 text-sm">
@@ -497,3 +608,147 @@ watch(isPlatformAdmin, (val) => {
<OrderDetailModal /> <OrderDetailModal />
</Drawer> </Drawer>
</template> </template>
<style scoped>
/* 溯源时间线:固定四列网格,奇数行从右往左折返,列与上一行对齐 */
.trace-snake {
display: flex;
flex-direction: column;
gap: 36px;
padding: 8px 12px 16px;
}
.trace-snake-wrap {
position: relative;
}
.trace-snake-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: auto;
column-gap: 36px;
align-items: stretch;
}
.trace-snake-node {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
min-height: 112px;
padding: 12px;
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 10px;
background: hsl(var(--card, var(--background)));
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.trace-snake-head {
display: flex;
align-items: center;
gap: 8px;
min-height: 22px;
}
.trace-snake-index {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 999px;
background: hsl(var(--primary) / 12%);
color: hsl(var(--primary));
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.trace-snake-title {
min-width: 0;
color: hsl(var(--foreground));
font-size: 14px;
font-weight: 600;
line-height: 1.3;
word-break: break-all;
}
.trace-snake-status {
align-self: flex-start;
max-width: 100%;
padding: 0 8px;
border: 1px solid hsl(var(--border));
border-radius: 999px;
background: hsl(var(--muted) / 35%);
color: hsl(var(--muted-foreground));
font-size: 12px;
line-height: 20px;
}
.trace-snake-status.is-ok {
border-color: hsl(var(--primary) / 28%);
background: hsl(var(--primary) / 12%);
color: hsl(var(--primary));
}
.trace-snake-status.is-warn {
border-color: hsl(var(--warning) / 35%);
background: hsl(var(--warning) / 12%);
color: hsl(var(--warning));
}
.trace-snake-time {
color: hsl(var(--muted-foreground));
font-size: 12px;
font-variant-numeric: tabular-nums;
line-height: 1.4;
}
.trace-snake-extra {
margin-top: auto;
padding: 6px 8px;
overflow: hidden;
border-radius: 6px;
background: hsl(var(--muted) / 35%);
color: hsl(var(--muted-foreground));
font-size: 12px;
line-height: 1.45;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.trace-snake-node.point-right::after,
.trace-snake-node.point-left::after {
content: '';
position: absolute;
top: 23px;
width: 28px;
height: 2px;
background: hsl(var(--primary) / 45%);
}
.trace-snake-node.point-right::after {
right: -32px;
}
.trace-snake-node.point-right::before {
content: '';
position: absolute;
top: 19px;
right: -36px;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: 7px solid hsl(var(--primary) / 70%);
}
.trace-snake-node.point-left::after {
left: -32px;
}
.trace-snake-node.point-left::before {
content: '';
position: absolute;
top: 19px;
left: -36px;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-right: 7px solid hsl(var(--primary) / 70%);
}
.trace-snake-turn {
position: absolute;
bottom: -36px;
width: 2px;
height: 36px;
transform: translateX(-1px);
background: hsl(var(--primary) / 45%);
}
</style>

View File

@@ -1,19 +1,17 @@
<script lang="ts" setup> <script lang="ts" setup>
/** /**
* 商品订单卡片看板(C 端紧凑排版 + Apple 毛玻璃):与列表视图展示同一批字段,仅排版不同 * 商品订单卡片看板:展示字段与列表视图一致,只改排版。
* *
* 排版语言参考 C 端 App 订单列表(外卖/电商): * 尺寸对齐配送仓库「我的订单」卡片(宽卡、分区留白、最多三列),
* - 多列自适应网格(minmax 自动 1-3 列),单卡窄而紧凑,减少纵向占用 * 但字段不换成仓库那套(仍保留诊所/人员/金额分项/推广员/包邮等)。
* - 卡片:毛玻璃 + 14px 圆角 + hairline 细边框;状态用「彩点 + 彩字」
* - 商品行右侧直接放合计金额(C 端惯例),人员/收货信息压成内联小字行
* - 语义标签:tinted 小胶囊(同色低透明度底,无边框)
* *
* 数据自取:props.formValues 变化时重置到第 1 页,触底自动加载下一页。 * 滚动:不再用内层无限滚动。内层滚动 + 触底追加会和页面滚动抢滚轮,
* 卡片悬停上移还会在滚动时反复进出命中区,表现为抽搐。这里改成分页,由页面统一滚动。
* 行操作复用父级 buildRowActions(与列表 #action 槽同一份定义)。 * 行操作复用父级 buildRowActions(与列表 #action 槽同一份定义)。
*/ */
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { Button, Empty, Image, message, Spin, Switch } from 'ant-design-vue'; import { Button, Empty, Image, message, Pagination, Spin, Switch } from 'ant-design-vue';
import { CardActions } from '#/components/table-action'; import { CardActions } from '#/components/table-action';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue'; import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
@@ -60,19 +58,15 @@ const emit = defineEmits<{
const items = ref<any[]>([]); const items = ref<any[]>([]);
const page = ref(1); const page = ref(1);
const pageCount = ref(1); const pageSize = ref(12);
const total = ref(0);
const loading = ref(false); const loading = ref(false);
const loadingMore = ref(false);
/** 请求序号:只应用最后一次响应,避免快速改筛选时旧结果覆盖新列表 */ /** 请求序号:只应用最后一次响应,避免快速改筛选时旧结果覆盖新列表 */
let requestSeq = 0; let requestSeq = 0;
/** 已手动展开商品明细的订单 id 集合 */ /** 已手动展开商品明细的订单 id 集合 */
const expandedIds = ref<Set<number>>(new Set()); const expandedIds = ref<Set<number>>(new Set());
const scrollEl = ref<HTMLElement | null>(null); const scrollEl = ref<HTMLElement | null>(null);
const sentinelEl = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const hasMore = computed(() => page.value < pageCount.value);
/** 处方类型元数据(全量 0-7,含特色方),无处方类型时返回 null */ /** 处方类型元数据(全量 0-7,含特色方),无处方类型时返回 null */
function prescriptionTypeMeta(row: Record<string, any>) { function prescriptionTypeMeta(row: Record<string, any>) {
@@ -201,26 +195,22 @@ function toggleExpand(row: Record<string, any>) {
} }
/** /**
* 拉取一页数据;reset 时清空列表并回到第 1 页 * 拉取当前页。分页而不是追加,避免列表高度在滚动中突变。
* 用 requestSeq 丢弃过期响应,防止筛选连点时旧条件结果覆盖新条件 * requestSeq 丢弃过期响应,防止筛选连点时旧条件结果覆盖新列表。
*/ */
async function fetchPage(targetPage: number, reset = false) { async function fetchPage(targetPage: number) {
const seq = ++requestSeq; const seq = ++requestSeq;
if (reset) { loading.value = true;
loading.value = true;
} else {
loadingMore.value = true;
}
try { try {
const res = await getOrderList({ const res = await getOrderList({
page: targetPage, page: targetPage,
pageSize: 12, pageSize: pageSize.value,
...(props.formValues || {}), ...(props.formValues || {}),
}); });
if (seq !== requestSeq) return; if (seq !== requestSeq) return;
items.value = reset ? (res?.items ?? []) : [...items.value, ...(res?.items ?? [])]; items.value = res?.items ?? [];
page.value = res?.page ?? targetPage; page.value = res?.page ?? targetPage;
pageCount.value = res?.page_count ?? 1; total.value = Number(res?.total ?? 0);
} catch (error) { } catch (error) {
if (seq !== requestSeq) return; if (seq !== requestSeq) return;
console.error(error); console.error(error);
@@ -228,7 +218,6 @@ async function fetchPage(targetPage: number, reset = false) {
} finally { } finally {
if (seq === requestSeq) { if (seq === requestSeq) {
loading.value = false; loading.value = false;
loadingMore.value = false;
} }
} }
} }
@@ -237,55 +226,28 @@ async function fetchPage(targetPage: number, reset = false) {
async function reload() { async function reload() {
page.value = 1; page.value = 1;
items.value = []; items.value = [];
await fetchPage(1, true); await fetchPage(1);
} }
/** /**
* 操作成功后的原地刷新:保留当前页码,避免退款/发货后跳回第 1 页 * 操作成功后的原地刷新:留在当前页,避免退款/发货后跳回第 1 页
* 卡片是累积加载的,这里折衷为按已加载条数一次性重拉(页码 1、pageSize=已加载数)
*/ */
async function refreshKeepPage() { async function refreshKeepPage() {
const loaded = Math.max(items.value.length, 12); await fetchPage(page.value);
const seq = ++requestSeq;
loading.value = true;
try {
const res = await getOrderList({
page: 1,
pageSize: loaded,
...(props.formValues || {}),
});
if (seq !== requestSeq) return;
items.value = res?.items ?? [];
// 折算页数:保持触底加载能继续接在后面
page.value = Math.ceil((res?.items?.length ?? 0) / 12) || 1;
pageCount.value = Math.ceil((res?.total ?? 0) / 12) || 1;
} catch (error) {
if (seq === requestSeq) console.error(error);
} finally {
if (seq === requestSeq) loading.value = false;
}
} }
defineExpose({ reload, refreshKeepPage }); defineExpose({ reload, refreshKeepPage });
async function loadMore() { /** 翻页后回到卡片区顶部,避免停在页面最底部看不到新的一页 */
if (!hasMore.value || loading.value || loadingMore.value) return; async function onPageChange(nextPage: number, nextSize: number) {
await fetchPage(page.value + 1, false); if (nextSize !== pageSize.value) {
} pageSize.value = nextSize;
page.value = 1;
/** 触底哨兵:滚动到底部附近自动加载下一页 */ } else {
function setupObserver() { page.value = nextPage;
observer?.disconnect(); }
if (!sentinelEl.value) return; await fetchPage(page.value);
observer = new IntersectionObserver( scrollEl.value?.scrollIntoView({ block: 'start' });
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
void loadMore();
}
},
{ root: scrollEl.value, rootMargin: '120px' },
);
observer.observe(sentinelEl.value);
} }
watch( watch(
@@ -296,15 +258,8 @@ watch(
{ deep: true }, { deep: true },
); );
onMounted(async () => { onMounted(() => {
await reload(); void reload();
await nextTick();
setupObserver();
});
onBeforeUnmount(() => {
observer?.disconnect();
observer = null;
}); });
</script> </script>
@@ -313,8 +268,6 @@ onBeforeUnmount(() => {
<Spin :spinning="loading"> <Spin :spinning="loading">
<div v-if="items.length" class="po-card-grid"> <div v-if="items.length" class="po-card-grid">
<div v-for="row in items" :key="row.id" class="po-card"> <div v-for="row in items" :key="row.id" class="po-card">
<!-- 角落径向荧光(与提现管理统计岛同款质感装饰) -->
<div class="po-card__glow" />
<!-- 行1 头部:诊所名(可点)+ 状态彩点 --> <!-- 行1 头部:诊所名(可点)+ 状态彩点 -->
<div class="po-card__head"> <div class="po-card__head">
<button <button
@@ -583,86 +536,65 @@ onBeforeUnmount(() => {
</div> </div>
<Empty v-else-if="!loading" description="暂无订单" /> <Empty v-else-if="!loading" description="暂无订单" />
</Spin> </Spin>
<div ref="sentinelEl" class="h-8"></div> <div v-if="total > 0" class="po-card-pager">
<div v-if="loadingMore" class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]"> <Pagination
加载中… :current="page"
</div> :page-size="pageSize"
<div :page-size-options="['12', '24', '48']"
v-else-if="items.length && !hasMore" :total="total"
class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]" show-quick-jumper
> show-size-changer
没有更多了 @change="onPageChange"
/>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.po-card-scroll { .po-card-scroll {
height: 100%; padding: 4px 2px 8px;
/* 左右留 6px:卡片 hover 阴影不被容器裁出切边 */
padding: 4px 6px 20px;
overflow: auto;
} }
/* 多列自适应网格:单卡最窄 400px,宽屏自动 2-3 列(C 端紧凑卡片密度); /* 与配送仓库订单相同的列数:窄屏 1 列,中屏 2 列,宽屏最多 3 列,卡片随列宽变大 */
min(400px, 100%) 防止窄容器(分屏)时轨道小于容器导致横向滚动 */
.po-card-grid { .po-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(400px, 100%), 1fr)); grid-template-columns: minmax(0, 1fr);
gap: 10px; gap: 24px;
} }
/* 玻璃质感紧凑卡片(与提现管理统计岛同款):主色渐变玻璃底 + 12px 圆角 + 1px 边框 */ @media (min-width: 768px) {
.po-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1280px) {
.po-card-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
/* 不用位移、不用毛玻璃:滚动经过卡片时 hover 上移会反复触发重绘,看起来像抽搐 */
.po-card { .po-card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; background: hsl(var(--card, var(--background)));
background: linear-gradient(
135deg,
hsl(var(--primary) / 4%),
hsl(var(--card, var(--background)))
);
backdrop-filter: blur(8px);
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 12px; border-radius: 16px;
transition: box-shadow: 0 1px 2px hsl(var(--foreground) / 6%);
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
} }
.po-card:hover { .po-card:hover {
border-color: hsl(var(--primary) / 40%); border-color: hsl(var(--primary) / 35%);
box-shadow: box-shadow: 0 0 6px hsl(var(--primary) / 18%);
0 8px 20px hsl(var(--primary) / 14%),
0 2px 6px rgb(0 0 0 / 6%);
transform: translateY(-3px);
} }
/* 角落径向荧光:右上角淡主色光斑,hover 增强 */
.po-card__glow {
position: absolute;
top: -40px;
right: -40px;
width: 110px;
height: 110px;
pointer-events: none;
background: radial-gradient(circle, hsl(var(--primary) / 18%) 0%, transparent 70%);
opacity: 0.4;
transition: opacity 0.2s ease;
}
.po-card:hover .po-card__glow {
opacity: 1;
}
/* 行1 头部:店铺 + 状态彩点 */
.po-card__head { .po-card__head {
display: flex; display: flex;
gap: 8px; gap: 12px;
align-items: center; align-items: center;
padding: 10px 12px 0; padding: 16px 20px 12px;
border-bottom: 1px solid hsl(var(--border));
} }
.po-card__store { .po-card__store {
@@ -678,9 +610,8 @@ onBeforeUnmount(() => {
.po-card__store-name { .po-card__store-name {
overflow: hidden; overflow: hidden;
font-size: 14px; font-size: 16px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.2px;
color: hsl(var(--foreground)); color: hsl(var(--foreground));
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -692,33 +623,32 @@ onBeforeUnmount(() => {
.po-card__store-arrow { .po-card__store-arrow {
flex-shrink: 0; flex-shrink: 0;
font-size: 14px; font-size: 16px;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
} }
.po-card__status { .po-card__status {
display: inline-flex; display: inline-flex;
flex-shrink: 0; flex-shrink: 0;
gap: 4px; gap: 6px;
align-items: center; align-items: center;
margin-left: auto; margin-left: auto;
font-size: 12px; font-size: 14px;
font-weight: 600; font-weight: 600;
} }
.po-card__status-dot { .po-card__status-dot {
width: 6px; width: 8px;
height: 6px; height: 8px;
border-radius: 50%; border-radius: 50%;
} }
/* 行2 单号/时间小灰字 */
.po-card__meta { .po-card__meta {
display: flex; display: flex;
gap: 4px; gap: 6px;
align-items: center; align-items: center;
padding: 1px 12px 0; padding: 12px 20px 0;
font-size: 11px; font-size: 13px;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
} }
@@ -726,45 +656,41 @@ onBeforeUnmount(() => {
flex-shrink: 0; flex-shrink: 0;
} }
/* tinted 胶囊行(紧凑小号) */
.po-card__pills { .po-card__pills {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 6px;
padding: 6px 12px 0; padding: 10px 20px 0;
} }
.po-pill { .po-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 1px 7px; padding: 2px 10px;
font-size: 10px; font-size: 12px;
font-weight: 500; font-weight: 500;
line-height: 16px; line-height: 20px;
border: none; border: none;
border-radius: 999px; border-radius: 999px;
} }
.po-pill--action { .po-pill--action {
cursor: pointer; cursor: pointer;
transition: filter 0.15s ease;
} }
.po-pill--action:hover { .po-pill--action:hover {
filter: brightness(1.08) saturate(1.2); filter: brightness(1.08);
} }
/* 行3 商品行:左图文右合计(C 端订单卡惯例) */
.po-card__goods { .po-card__goods {
display: flex; display: flex;
gap: 10px; gap: 12px;
align-items: center; align-items: center;
padding: 7px 10px; padding: 12px;
margin: 8px 12px 0; margin: 12px 20px 0;
cursor: pointer; cursor: pointer;
background: hsl(var(--muted) / 30%); background: hsl(var(--muted) / 30%);
border-radius: 10px; border-radius: 12px;
transition: background-color 0.2s ease;
} }
.po-card__goods:hover { .po-card__goods:hover {
@@ -776,12 +702,12 @@ onBeforeUnmount(() => {
flex-shrink: 0; flex-shrink: 0;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px; width: 68px;
height: 40px; height: 68px;
overflow: hidden; overflow: hidden;
background: hsl(var(--card, var(--background))); background: hsl(var(--card, var(--background)));
border-radius: 8px; border: 1px solid hsl(var(--border));
box-shadow: 0 1px 2px rgb(0 0 0 / 6%); border-radius: 12px;
} }
.po-card__goods-thumb :deep(img) { .po-card__goods-thumb :deep(img) {
@@ -789,14 +715,14 @@ onBeforeUnmount(() => {
} }
.po-card__goods-placeholder { .po-card__goods-placeholder {
font-size: 11px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
} }
.po-card__goods-name { .po-card__goods-name {
overflow: hidden; overflow: hidden;
font-size: 13px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: hsl(var(--foreground)); color: hsl(var(--foreground));
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -805,10 +731,10 @@ onBeforeUnmount(() => {
.po-card__goods-count { .po-card__goods-count {
display: flex; display: flex;
gap: 6px; gap: 8px;
align-items: center; align-items: center;
margin-top: 1px; margin-top: 6px;
font-size: 11px; font-size: 13px;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
} }
@@ -817,38 +743,34 @@ onBeforeUnmount(() => {
flex-shrink: 0; flex-shrink: 0;
gap: 1px; gap: 1px;
align-items: center; align-items: center;
font-size: 11px; font-size: 13px;
color: hsl(var(--primary)); color: hsl(var(--primary));
} }
.po-card__goods-toggle i { .po-card__goods-toggle i {
font-style: normal; font-style: normal;
transform: rotate(90deg); transform: rotate(90deg);
transition: transform 0.2s ease;
} }
.po-card__goods-toggle i.up { .po-card__goods-toggle i.up {
transform: rotate(-90deg); transform: rotate(-90deg);
} }
/* 合计金额(商品行右侧) */
.po-card__amount { .po-card__amount {
flex-shrink: 0; flex-shrink: 0;
margin-left: auto; margin-left: auto;
} }
.po-card__amount-value { .po-card__amount-value {
/* DIN 等宽数字:与提现管理统计岛金额同款字体质感 */
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace; font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 16px; font-size: 20px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.3px;
color: hsl(var(--foreground)); color: hsl(var(--foreground));
} }
.po-card__amount-value i { .po-card__amount-value i {
margin-right: 1px; margin-right: 1px;
font-size: 11px; font-size: 13px;
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
} }
@@ -861,40 +783,39 @@ onBeforeUnmount(() => {
.po-card__chips { .po-card__chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 6px;
padding: 6px 12px 0; padding: 10px 20px 0;
} }
.po-card__drug-chip { .po-card__drug-chip {
padding: 1px 8px; padding: 2px 10px;
font-size: 11px; font-size: 13px;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 35%); background: hsl(var(--muted) / 35%);
border-radius: 999px; border-radius: 999px;
} }
/* 行4 人员内联行:label 小灰字 + 可点链接,一行自动换行 */
.po-card__people { .po-card__people {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 2px 14px; gap: 6px 16px;
padding: 7px 12px 0; padding: 12px 20px 0;
font-size: 12px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
} }
.po-kv { .po-kv {
display: inline-flex; display: inline-flex;
gap: 4px; gap: 6px;
align-items: baseline; align-items: baseline;
min-width: 0; min-width: 0;
} }
.po-kv > i { .po-kv > i {
flex-shrink: 0; flex-shrink: 0;
font-size: 11px; font-size: 13px;
font-style: normal; font-style: normal;
color: hsl(var(--muted-foreground) / 75%); color: hsl(var(--muted-foreground));
} }
.po-kv > em { .po-kv > em {
@@ -906,10 +827,10 @@ onBeforeUnmount(() => {
} }
.po-link { .po-link {
max-width: 140px; max-width: 180px;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
font-size: 12px; font-size: 14px;
color: hsl(var(--primary)); color: hsl(var(--primary));
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -922,56 +843,61 @@ onBeforeUnmount(() => {
text-decoration: underline; text-decoration: underline;
} }
/* 行5/6 收货、推广员内联行:label + 单行截断 */
.po-card__express { .po-card__express {
display: flex; display: flex;
gap: 6px; gap: 8px;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
padding: 4px 12px 0; padding: 8px 20px 0;
font-size: 12px; font-size: 14px;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
} }
.po-card__express > i { .po-card__express > i {
flex-shrink: 0; flex-shrink: 0;
font-size: 11px; font-size: 13px;
font-style: normal; font-style: normal;
color: hsl(var(--muted-foreground) / 75%); color: hsl(var(--muted-foreground));
} }
.po-card__salesperson-status { .po-card__salesperson-status {
flex-shrink: 0; flex-shrink: 0;
padding: 0 7px; padding: 0 8px;
font-size: 10px; font-size: 12px;
line-height: 16px; line-height: 20px;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 40%); background: hsl(var(--muted) / 40%);
border-radius: 999px; border-radius: 999px;
} }
/* 弹性占位:吃掉剩余高度让 footer 吸底 */
.po-card__spacer { .po-card__spacer {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 8px; min-height: 12px;
} }
/* 行7 footer:左分项/包邮,右操作按钮;hairline 分隔 */
.po-card__footer { .po-card__footer {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px 10px; gap: 8px 12px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 4px 6px 4px 12px; padding: 12px 12px 12px 20px;
border-top: 1px solid hsl(var(--border) / 50%); margin-top: 12px;
border-top: 1px solid hsl(var(--border));
} }
.po-card__amount-parts { .po-card__amount-parts {
display: flex; display: flex;
gap: 10px; flex-wrap: wrap;
gap: 12px;
align-items: center; align-items: center;
font-size: 11px; font-size: 13px;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
} }
.po-card-pager {
display: flex;
justify-content: flex-end;
padding: 20px 0 8px;
}
</style> </style>

View File

@@ -102,6 +102,7 @@ export const gridOptions: VxeGridProps<RowType> = {
], ],
keepSource: true, keepSource: true,
pagerConfig: {}, pagerConfig: {},
height: 'auto',
columnConfig: { columnConfig: {
useKey: true, useKey: true,
}, },

View File

@@ -1184,20 +1184,21 @@ function buildRowActions(row: Record<string, any>): {
<PrescriptionDetailModal /> <PrescriptionDetailModal />
<TraceDrawer /> <TraceDrawer />
<ChinaErpSyncDrawer @synced="() => refreshCurrentView()" /> <ChinaErpSyncDrawer @synced="() => refreshCurrentView()" />
<!-- 紧凑统计岛:一行 4 格(仅占 2 格),设计对齐提现管理账户卡统计岛 --> <!-- 统计、搜索固定高度,列表/卡片只留一个滚动层,避免页面和表格同时滚动时抢滚轮 -->
<StatIslands class="mb-3" :items="overviewItems" :columns="4" /> <div class="flex h-full min-h-0 flex-col overflow-hidden">
<StatIslands class="mb-3 shrink-0" :items="overviewItems" :columns="4" />
<!-- 顶部统一搜索(双视图共用,替代原 grid 内嵌表单) --> <!-- 顶部统一搜索(双视图共用,替代原 grid 内嵌表单) -->
<div class="mb-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3"> <div class="mb-3 shrink-0 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3">
<SearchForm /> <SearchForm />
</div> </div>
<!-- 视图切换:列表 / 卡片看板;卡片模式下补充导出入口(列表模式导出在表格工具栏) --> <!-- 视图切换:列表 / 卡片看板;卡片模式下补充导出入口(列表模式导出在表格工具栏) -->
<div class="mb-3 flex items-center justify-between gap-3"> <div class="mb-3 flex shrink-0 items-center justify-between gap-3">
<ViewModeSwitch v-model="viewMode" /> <ViewModeSwitch v-model="viewMode" />
<Button v-if="viewMode === 'card'" type="primary" @click="openExportModal"> <Button v-if="viewMode === 'card'" type="primary" @click="openExportModal">
导出 导出
</Button> </Button>
</div> </div>
<Grid v-if="viewMode === 'list'"> <Grid v-if="viewMode === 'list'" class="h-0 min-h-0 flex-1">
<template #toolbar-actions> <template #toolbar-actions>
<TableAction <TableAction
:flex="false" :flex="false"
@@ -1536,7 +1537,7 @@ function buildRowActions(row: Record<string, any>): {
</template> </template>
</Grid> </Grid>
<!-- 卡片看板视图:展示内容与列表一致,行操作/弹窗全部复用父级逻辑 --> <!-- 卡片看板视图:展示内容与列表一致,行操作/弹窗全部复用父级逻辑 -->
<div v-else class="po-card-wrap"> <div v-else class="min-h-0 flex-1 overflow-y-auto">
<ProductOrderCardList <ProductOrderCardList
ref="cardListRef" ref="cardListRef"
:form-values="searchValues" :form-values="searchValues"
@@ -1558,6 +1559,7 @@ function buildRowActions(row: Record<string, any>): {
@reverse-salesperson="handleReverseSalesperson" @reverse-salesperson="handleReverseSalesperson"
/> />
</div> </div>
</div>
<PercentAdjustDrawer /> <PercentAdjustDrawer />
<!-- 订单改仓:按药分别选仓 --> <!-- 订单改仓:按药分别选仓 -->
<AntdModal <AntdModal
@@ -1645,10 +1647,4 @@ function buildRowActions(row: Record<string, any>): {
.tcm-drug-chip { .tcm-drug-chip {
box-shadow: 0 1px 2px rgb(0 0 0 / 4%); box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
} }
/* 卡片看板容器:撑满剩余高度,内部滚动(统计岛 + 收起态搜索一行 + 切换行约 220px,min-height 兜底) */
.po-card-wrap {
height: calc(100% - 220px);
min-height: 420px;
}
</style> </style>

View File

@@ -13,6 +13,7 @@ import { modalFormProps } from '../config/form';
const isUpdate = ref(false); const isUpdate = ref(false);
const gridApi = ref(); const gridApi = ref();
const getMyInfoApi = ref(); const getMyInfoApi = ref();
const existingUrls = ref<string[]>([]);
const [Form, formApi] = useVbenForm(modalFormProps); const [Form, formApi] = useVbenForm(modalFormProps);
@@ -25,30 +26,47 @@ const [Modal, modalApi] = useVbenModal({
onConfirm: async () => { onConfirm: async () => {
const e = await formApi.validate(); const e = await formApi.validate();
if (!e.valid) return; if (!e.valid) return;
const values = await formApi.getValues(); const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true }); modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = saveNavUrlApi; try {
submitApi(values) // 新图拼在旧图后面,避免 uploadNav 全量同步时误删已有轮播
.then(() => { const nextUrls = Array.isArray(values.url) ? values.url : [];
message.success('保存成功'); const merged = [...existingUrls.value];
gridApi.value?.reload(); for (const u of nextUrls) {
getMyInfoApi.value(); const pic = String(u || '').trim();
modalApi.close(); if (pic && !merged.includes(pic)) {
}) merged.push(pic);
.finally(() => { }
modalApi.setState({ loading: false, confirmLoading: false }); }
}); await saveNavUrlApi({ url: merged });
message.success('保存成功');
gridApi.value?.reload?.();
getMyInfoApi.value?.();
modalApi.close();
} catch (err) {
console.error(err);
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
}, },
onOpenChange(isOpen: boolean) { onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null; gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) { if (isOpen) {
getMyInfoApi.value = isOpen ? modalApi.getData()?.getMyInfo : null; getMyInfoApi.value = isOpen ? modalApi.getData()?.getMyInfo : null;
const { values, update } = modalApi.getData<Record<string, any>>(); const payload = modalApi.getData<Record<string, any>>() || {};
existingUrls.value = Array.isArray(payload.existingUrls)
? payload.existingUrls.map(String).filter(Boolean)
: [];
const { values, update } = payload;
if (values) { if (values) {
isUpdate.value = update; isUpdate.value = update;
formApi.setValues(values); formApi.setValues(values);
} else {
isUpdate.value = false;
formApi.reset?.();
} }
} else {
existingUrls.value = [];
} }
}, },
}); });

View File

@@ -85,9 +85,13 @@ const [UploadModals, UploadModalApi] = useVbenModal({
}); });
const openUpload = () => { const openUpload = () => {
// uploadNav 是全量同步:必须把已有图一并带上,否则「只传新图」会把旧轮播软删掉
const existingUrls = (data.value?.nav || [])
.map((item) => String(item?.pic || '').trim())
.filter(Boolean);
UploadModalApi.setData({ UploadModalApi.setData({
// doctorId,
getMyInfo, getMyInfo,
existingUrls,
}); });
UploadModalApi.open(); UploadModalApi.open();
}; };

View File

@@ -45,6 +45,18 @@ export async function getProductListDoctorReception(data: any) {
return requestClient.get<any>(`${prefix}product-list`, { params: data }); return requestClient.get<any>(`${prefix}product-list`, { params: data });
} }
/**
* 复用/常用方填方前对比中药快照价与当前门店价
* 为什么:供货比例变更后关系表已更新,快照价不一致时要让医生确认是否按最新价计算
*/
export async function compareChineseDrugPricesApi(data: {
register_id?: number;
store_id?: number;
drugs: Array<Record<string, any>>;
}) {
return requestClient.post<any>(`${prefix}compare-chinese-drug-prices`, data);
}
/** /**
* 获取中药规格 * 获取中药规格
* @param data * @param data
@@ -442,10 +454,51 @@ export async function aiGeneratePrescriptionApi(data: {
use_entrusted_process?: 0 | 1 | boolean; use_entrusted_process?: 0 | 1 | boolean;
/** 中药委托调剂:制剂要求 id(yii_process_rule pid=0) */ /** 中药委托调剂:制剂要求 id(yii_process_rule pid=0) */
process_rule_id?: number; 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); 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);
// PC 已转成 wav;显式声明避免 MIME 误判
fd.append('audio_format', 'wav');
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 功能门闸(醒目提示 + 是否已签署知情同意) */ /** AI 功能门闸(醒目提示 + 是否已签署知情同意) */
export async function aiFeatureGateApi(data: { feature_code: string }) { export async function aiFeatureGateApi(data: { feature_code: string }) {
return requestClient.get<any>(`${prefix}ai-feature-gate`, { params: data }); return requestClient.get<any>(`${prefix}ai-feature-gate`, { params: data });
@@ -629,3 +682,23 @@ export async function getProxyPayQrcodeApi(orderId: number) {
order_id: orderId, order_id: orderId,
}); });
} }
/** 处方图 OCR(DeepSeek) */
export async function ocrPrescriptionImageApi(data: {
image_urls: string[];
mode: 'reception' | 'transfer';
register_id?: number;
store_id?: number;
}) {
return requestClient.post<any>(`${prefix}ocr-prescription-image`, data);
}
/** 确认识图传方:建患者/挂号 + 待导入传方单 */
export async function confirmTransferImageOcrApi(data: Record<string, any>) {
return requestClient.post<{
transfer_id: number;
register_id: number;
user_patient_id: number;
patient_action: string;
}>(`${prefix}confirm-transfer-image-ocr`, data);
}

View File

@@ -11,12 +11,15 @@ import { Button, Checkbox, Descriptions, Input, Radio, Spin, Switch, Tabs, Tag,
import { import {
aiGeneratePrescriptionApi, aiGeneratePrescriptionApi,
aiGenerateVoicePrescriptionApi,
aiListGenerationsApi, aiListGenerationsApi,
aiMarkGenerationReadApi, aiMarkGenerationReadApi,
aiMatchPrescriptionDrugsApi, aiMatchPrescriptionDrugsApi,
aiSavePrescriptionDrugSelectionApi, aiSavePrescriptionDrugSelectionApi,
aiVoiceAsrApi,
getProcessRuleList, getProcessRuleList,
} from '../api'; } from '../api';
import { encodeBlobToWav16k } from '../utils/encodeWav';
import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue'; import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue';
import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate'; import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate';
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue'; import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
@@ -137,6 +140,19 @@ const editingKey = ref('');
/** 醒目提示文案(系统配置 / 门闸下发) */ /** 醒目提示文案(系统配置 / 门闸下发) */
const disclaimerText = 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 sexLabel = computed(() => {
const s = Number(patientSex.value); const s = Number(patientSex.value);
if (s === 1) return '男'; if (s === 1) return '男';
@@ -193,6 +209,8 @@ const [Drawer, drawerApi] = useVbenDrawer({
patientAge?: number; patientAge?: number;
chiefComplaint?: string; chiefComplaint?: string;
medicalRecord?: Record<string, any>; medicalRecord?: Record<string, any>;
/** assist=携带病历辅助出方;voice=工具栏语音入口,同意后自动开语音弹窗 */
entry?: 'assist' | 'voice';
}>(); }>();
registerId.value = Number(data?.registerId || 0); registerId.value = Number(data?.registerId || 0);
storeId.value = Number(data?.storeId || 0); storeId.value = Number(data?.storeId || 0);
@@ -205,6 +223,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
data?.medicalRecord && typeof data.medicalRecord === 'object' data?.medicalRecord && typeof data.medicalRecord === 'object'
? { ...data.medicalRecord } ? { ...data.medicalRecord }
: {}; : {};
const entryMode = data?.entry === 'voice' ? 'voice' : 'assist';
resetPreview(); resetPreview();
// 首次使用对应 VIP 功能需签署知情同意;拒绝则关闭抽屉 // 首次使用对应 VIP 功能需签署知情同意;拒绝则关闭抽屉
try { try {
@@ -223,6 +242,14 @@ const [Drawer, drawerApi] = useVbenDrawer({
return; return;
} }
void bootstrapHistory(); 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(), 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() { function resetPreview() {
matchedList.value = []; matchedList.value = [];
unmatchedList.value = []; unmatchedList.value = [];
@@ -268,8 +317,12 @@ function resetState() {
disclaimerText.value = ''; disclaimerText.value = '';
genProcessRuleId.value = undefined; genProcessRuleId.value = undefined;
processRuleOptions.value = []; processRuleOptions.value = [];
stopVoiceRecording(true);
voiceTranscript.value = '';
voiceMode.value = 'dictation';
resetPreview(); resetPreview();
genModalApi.close(); genModalApi.close();
voiceModalApi.close();
} }
function formatTime(ts: number) { function formatTime(ts: number) {
@@ -617,6 +670,217 @@ async function confirmGenerateAndClose(): Promise<boolean> {
return true; 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) * 本地把某条历史标为已读(同步 multi / historyList)
*/ */
@@ -682,7 +946,15 @@ function onMultiTabChange(key: string | number) {
applyMultiSlot(slot, idx); 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(); beginGeneratingPlaceholder();
generating.value = true; generating.value = true;
multiPrescriptions.value = []; multiPrescriptions.value = [];
@@ -704,50 +976,16 @@ async function runGenerateInDrawer(chief: string) {
req.process_rule_id = genProcessRuleId.value; req.process_rule_id = genProcessRuleId.value;
req.return_dosage_process_detail = returnDosageProcessDetail.value ? 1 : 0; 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 data = await aiGeneratePrescriptionApi(req);
const durationText = formatDurationMs(data?.duration_ms); await applyGenerateResponse(data, '生成');
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})` : '已生成,请确认导入',
);
} catch (e: any) { } catch (e: any) {
endGeneratingPlaceholder(); endGeneratingPlaceholder();
resetPreview(); 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) { async function onSelectHistory(row: any) {
if (!row?.id || Number(row.id) === PENDING_GEN_ID || row._pending) return; if (!row?.id || Number(row.id) === PENDING_GEN_ID || row._pending) return;
// 点历史时退出「本次多方案」对比态,避免 Tab 与历史选中互相干扰 // 点历史时退出「本次多方案」对比态,避免 Tab 与历史选中互相干扰
@@ -866,6 +1160,10 @@ function onConfirmImport() {
message.warning('请先勾选已对照药品'); message.warning('请先勾选已对照药品');
return; return;
} }
const skipN = unmatchedList.value.length;
if (skipN > 0) {
message.warning(`已跳过 ${skipN} 味未对照药品(标红项不会导入)`);
}
const importPayload: { const importPayload: {
rows: typeof drugs; rows: typeof drugs;
dosage?: number; dosage?: number;
@@ -914,8 +1212,13 @@ defineExpose({
patientAge?: number; patientAge?: number;
chiefComplaint?: string; chiefComplaint?: string;
medicalRecord?: Record<string, any>; medicalRecord?: Record<string, any>;
/** assist 默认;voice=工具栏语音入口 */
entry?: 'assist' | 'voice';
}) { }) {
drawerApi.setData(payload); drawerApi.setData({
...payload,
entry: payload.entry === 'voice' ? 'voice' : 'assist',
});
drawerApi.open(); drawerApi.open();
}, },
}); });
@@ -1175,16 +1478,23 @@ defineExpose({
</div> </div>
<div <div
v-if="unmatchedList.length" 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>
<div <div
v-for="(u, ui) in unmatchedList" v-for="(u, ui) in unmatchedList"
:key="ui" :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 <Button
type="link" type="link"
size="small" size="small"
@@ -1369,6 +1679,106 @@ defineExpose({
</div> </div>
</div> </div>
</GenModal> </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> </template>
<style scoped> <style scoped>

View File

@@ -1,4 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* 中药制剂方式 / 代煎 / 用量配置
* 与小程序对齐:标题带必填星号;父页提交失败可传 errorKeys 标红
*/
import { computed } from 'vue';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons-vue'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { InputNumber } from 'ant-design-vue'; import { InputNumber } from 'ant-design-vue';
@@ -13,19 +19,30 @@ export type ChineseMedicineConfigChange = {
dayDosage?: number; dayDosage?: number;
}; };
const props = defineProps<{ const props = withDefaults(
ruleType: number; defineProps<{
packageMethodId?: number | null; ruleType: number;
processRuleId?: number | null; packageMethodId?: number | null;
childProcessRuleId?: number | null; processRuleId?: number | null;
processRuleNoteId?: number | null; childProcessRuleId?: number | null;
dosage: number; processRuleNoteId?: number | null;
dayDosage: number; dosage: number;
processRuleList?: Array<{ id: number; name: string }>; dayDosage: number;
childProcessRuleList?: Array<{ id: number; name: string }>; processRuleList?: Array<{ id: number; name: string }>;
processRuleNoteList?: Array<{ id: number; note?: string; name?: string }>; childProcessRuleList?: Array<{ id: number; name: string }>;
disabled?: boolean; processRuleNoteList?: Array<{ id: number; note?: string; name?: string }>;
}>(); disabled?: boolean;
/** 未填字段名;父页提交失败传入,改选后由父清空 */
errorKeys?: string[];
}>(),
{
processRuleList: () => [],
childProcessRuleList: () => [],
processRuleNoteList: () => [],
disabled: false,
errorKeys: () => [],
},
);
const emit = defineEmits<{ const emit = defineEmits<{
change: [payload: ChineseMedicineConfigChange]; change: [payload: ChineseMedicineConfigChange];
@@ -37,6 +54,23 @@ const packageMethodList = [
{ id: 2, name: '剂包' }, { id: 2, name: '剂包' },
]; ];
/** 未填字段 → 区块标红 */
const errorMap = computed(() => {
const map: Record<string, boolean> = {
ruleType: false,
packageMethodId: false,
processRuleId: false,
childProcessRuleId: false,
processRuleNoteId: false,
dosage: false,
dayDosage: false,
};
for (const k of props.errorKeys || []) {
if (Object.prototype.hasOwnProperty.call(map, k)) map[k] = true;
}
return map;
});
/** /**
* 统一向父组件抛出配置变更 * 统一向父组件抛出配置变更
* 父组件负责级联加载制剂/煎法/备注列表 * 父组件负责级联加载制剂/煎法/备注列表
@@ -108,9 +142,12 @@ function updateDayDosage(val: number) {
<template> <template>
<div class="chinese-medicine-config"> <div class="chinese-medicine-config">
<!-- 制剂方式 --> <!-- 制剂方式(必填) -->
<div class="config-section mb-4"> <div class="config-section mb-4" :class="{ 'sec-error': errorMap.ruleType }">
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">制剂方式</div> <div class="section-title mb-2">
<span class="title-text">制剂方式</span>
<span class="req-star">*</span>
</div>
<div class="pill-group"> <div class="pill-group">
<span <span
class="pill" class="pill"
@@ -123,11 +160,19 @@ function updateDayDosage(val: number) {
@click="handleRuleTypeChange(2)" @click="handleRuleTypeChange(2)"
>委托调剂</span> >委托调剂</span>
</div> </div>
<div v-if="errorMap.ruleType" class="err-tip">请选择制剂方式</div>
</div> </div>
<!-- 自制剂:包法 --> <!-- 自制剂:包法 -->
<div v-if="ruleType === 1" class="config-section mb-4 border-t border-dashed border-gray-200 dark:border-gray-700 pt-4"> <div
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">包法选择</div> v-if="ruleType === 1"
class="config-section mb-4 border-t border-dashed border-border pt-4"
:class="{ 'sec-error': errorMap.packageMethodId }"
>
<div class="section-title mb-2">
<span class="title-text">包法选择</span>
<span class="req-star">*</span>
</div>
<div class="pill-group"> <div class="pill-group">
<span <span
v-for="item in packageMethodList" v-for="item in packageMethodList"
@@ -137,12 +182,19 @@ function updateDayDosage(val: number) {
@click="handlePackageMethodClick(item.id)" @click="handlePackageMethodClick(item.id)"
>{{ item.name }}</span> >{{ item.name }}</span>
</div> </div>
<div v-if="errorMap.packageMethodId" class="err-tip">请选择包法</div>
</div> </div>
<!-- 委托调剂:制剂 / 煎法 / 备注 --> <!-- 委托调剂:制剂 / 煎法 / 备注 -->
<template v-if="ruleType === 2"> <template v-if="ruleType === 2">
<div class="config-section mb-4 border-t border-dashed border-gray-200 dark:border-gray-700 pt-4"> <div
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">制剂要求</div> class="config-section mb-4 border-t border-dashed border-border pt-4"
:class="{ 'sec-error': errorMap.processRuleId }"
>
<div class="section-title mb-2">
<span class="title-text">制剂要求</span>
<span class="req-star">*</span>
</div>
<div class="pill-group"> <div class="pill-group">
<span <span
v-for="item in processRuleList" v-for="item in processRuleList"
@@ -152,9 +204,17 @@ function updateDayDosage(val: number) {
@click="handleProcessRuleClick(item)" @click="handleProcessRuleClick(item)"
>{{ item.name }}</span> >{{ item.name }}</span>
</div> </div>
<div v-if="errorMap.processRuleId" class="err-tip">请选择制剂要求</div>
</div> </div>
<div v-if="childProcessRuleList?.length" class="config-section mb-4"> <div
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">煎法说明</div> v-if="childProcessRuleList?.length"
class="config-section mb-4"
:class="{ 'sec-error': errorMap.childProcessRuleId }"
>
<div class="section-title mb-2">
<span class="title-text">煎法说明</span>
<span class="req-star">*</span>
</div>
<div class="pill-group"> <div class="pill-group">
<span <span
v-for="item in childProcessRuleList" v-for="item in childProcessRuleList"
@@ -164,9 +224,17 @@ function updateDayDosage(val: number) {
@click="handleChildProcessRuleClick(item)" @click="handleChildProcessRuleClick(item)"
>{{ item.name }}</span> >{{ item.name }}</span>
</div> </div>
<div v-if="errorMap.childProcessRuleId" class="err-tip">请选择煎法说明</div>
</div> </div>
<div v-if="processRuleNoteList?.length" class="config-section mb-4"> <div
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">附加备注</div> v-if="processRuleNoteList?.length"
class="config-section mb-4"
:class="{ 'sec-error': errorMap.processRuleNoteId }"
>
<div class="section-title mb-2">
<span class="title-text">附加备注</span>
<span class="req-star">*</span>
</div>
<div class="pill-group"> <div class="pill-group">
<span <span
v-for="item in processRuleNoteList" v-for="item in processRuleNoteList"
@@ -176,15 +244,16 @@ function updateDayDosage(val: number) {
@click="handleProcessRuleNoteClick(item)" @click="handleProcessRuleNoteClick(item)"
>{{ item.note || item.name }}</span> >{{ item.note || item.name }}</span>
</div> </div>
<div v-if="errorMap.processRuleNoteId" class="err-tip">请选择附加备注</div>
</div> </div>
</template> </template>
<!-- 剂数与频次 --> <!-- 剂数与频次 -->
<div class="config-section border-t border-dashed border-gray-200 dark:border-gray-700 pt-4"> <div class="config-section border-t border-dashed border-border pt-4">
<div class="mt-2 flex flex-wrap items-center gap-4"> <div class="mt-2 flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2" :class="{ 'field-error': errorMap.dosage }">
<span class="text-sm font-bold text-gray-800 dark:text-gray-200">剂数</span> <span class="title-text">剂数</span>
<!-- 带加减按钮时需足够宽度,避免中间输入区过窄无法输入 --> <span class="req-star">*</span>
<InputNumber <InputNumber
:value="dosage" :value="dosage"
:disabled="disabled" :disabled="disabled"
@@ -200,11 +269,11 @@ function updateDayDosage(val: number) {
<PlusOutlined @click="!disabled && updateDosage(dosage < 100 ? dosage + 1 : 100)" /> <PlusOutlined @click="!disabled && updateDosage(dosage < 100 ? dosage + 1 : 100)" />
</template> </template>
</InputNumber> </InputNumber>
<span class="text-sm text-gray-500 dark:text-gray-400">剂(天)</span> <span class="text-sm text-muted-foreground">剂(天)</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2" :class="{ 'field-error': errorMap.dayDosage }">
<span class="text-sm font-bold text-gray-800 dark:text-gray-200">用药频次</span> <span class="title-text">用药频次</span>
<!-- 同上:加宽以保证可正常手输数字 --> <span class="req-star">*</span>
<InputNumber <InputNumber
:value="dayDosage" :value="dayDosage"
:disabled="disabled" :disabled="disabled"
@@ -220,14 +289,52 @@ function updateDayDosage(val: number) {
<PlusOutlined @click="!disabled && updateDayDosage(dayDosage < 100 ? dayDosage + 1 : 100)" /> <PlusOutlined @click="!disabled && updateDayDosage(dayDosage < 100 ? dayDosage + 1 : 100)" />
</template> </template>
</InputNumber> </InputNumber>
<span class="text-sm text-gray-500 dark:text-gray-400">次/天</span> <span class="text-sm text-muted-foreground">次/天</span>
</div> </div>
</div> </div>
<div v-if="errorMap.dosage" class="err-tip">请填写剂数</div>
<div v-if="errorMap.dayDosage" class="err-tip">请填写用药频次</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.section-title {
display: flex;
align-items: center;
gap: 2px;
}
.title-text {
font-size: 14px;
font-weight: 700;
color: hsl(var(--foreground));
}
.req-star {
margin-left: 2px;
color: hsl(var(--destructive, 0 84% 60%));
font-size: 14px;
line-height: 1;
}
.err-tip {
margin-top: 6px;
font-size: 12px;
color: hsl(var(--destructive, 0 84% 60%));
}
.sec-error {
padding: 10px;
margin-left: -10px;
margin-right: -10px;
border-radius: 8px;
border: 1px solid hsl(var(--destructive, 0 84% 60%) / 45%);
box-shadow: 0 0 6px hsl(var(--destructive, 0 84% 60%) / 18%);
background: hsl(var(--destructive, 0 84% 60%) / 6%);
}
.field-error {
padding: 4px 8px;
border-radius: 8px;
border: 1px solid hsl(var(--destructive, 0 84% 60%) / 45%);
box-shadow: 0 0 6px hsl(var(--destructive, 0 84% 60%) / 18%);
}
.pill-group { .pill-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -235,8 +342,8 @@ function updateDayDosage(val: number) {
} }
.pill { .pill {
padding: 4px 16px; padding: 4px 16px;
background-color: #f4f6f8; background-color: hsl(var(--muted) / 0.55);
color: #6c7380; color: hsl(var(--muted-foreground));
border-radius: 20px; border-radius: 20px;
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
@@ -245,45 +352,24 @@ function updateDayDosage(val: number) {
user-select: none; user-select: none;
} }
.pill.active { .pill.active {
background-color: #00a88a; background-color: hsl(var(--primary));
color: #fff; color: hsl(var(--primary-foreground, 0 0% 100%));
border-color: #00a88a; border-color: hsl(var(--primary));
font-weight: 600; font-weight: 600;
} }
.pill.outline { .pill.outline {
background-color: #fff; background-color: hsl(var(--card, var(--background)));
border-color: #e2e8f0; border-color: hsl(var(--border));
} }
.pill.outline.active { .pill.outline.active {
background-color: #e8f6f4; background-color: hsl(var(--primary) / 0.12);
color: #00a88a; color: hsl(var(--primary));
border-color: #00a88a; border-color: hsl(var(--primary));
} }
.pill.disabled { .pill.disabled {
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
} }
/* 暗色模式:标签背景与选中态 */
.dark .pill {
background-color: #2a2a2a;
color: #a3a3a3;
}
.dark .pill.active {
background-color: #00a88a;
color: #fff;
border-color: #00a88a;
}
.dark .pill.outline {
background-color: #1f1f1f;
border-color: #404040;
color: #a3a3a3;
}
.dark .pill.outline.active {
background-color: rgba(0, 168, 138, 0.15);
color: #3dd6b5;
border-color: #00a88a;
}
/* 剂数/频次输入:原 w-24 过窄,加减按钮占位后中间几乎无法输入 */
.dosage-input { .dosage-input {
width: 140px; width: 140px;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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));
}
}

View File

@@ -3,6 +3,7 @@
* 常用快捷标签(诊断 / 医嘱 / 病历词条) * 常用快捷标签(诊断 / 医嘱 / 病历词条)
* - 正文已包含该文案时高亮(is-active) * - 正文已包含该文案时高亮(is-active)
* - 点击:已在正文则移除,否则追加(由父级 onEntryPick 统一 toggle) * - 点击:已在正文则移除,否则追加(由父级 onEntryPick 统一 toggle)
* - collapsible:可收起常用区,避免开方页底部常驻占高
*/ */
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
@@ -25,12 +26,18 @@ const props = withDefaults(
fieldValue?: string; fieldValue?: string;
limit?: number; limit?: number;
disabled?: boolean; disabled?: boolean;
/** 是否可收起(开方页诊断/医嘱建议开) */
collapsible?: boolean;
/** 初始是否收起;仅 collapsible 时生效 */
defaultCollapsed?: boolean;
}>(), }>(),
{ {
fieldCode: '', fieldCode: '',
fieldValue: '', fieldValue: '',
limit: 12, limit: 12,
disabled: false, disabled: false,
collapsible: false,
defaultCollapsed: false,
}, },
); );
@@ -42,6 +49,8 @@ type ChipItem = { id: string | number; text: string };
const chips = ref<ChipItem[]>([]); const chips = ref<ChipItem[]>([]);
const loading = ref(false); const loading = ref(false);
/** 收起态:默认不铺开常用标签,点标题再展开 */
const collapsed = ref(!!props.defaultCollapsed);
/** 文案是否已出现在字段值中(逗号分段或整行/包含) */ /** 文案是否已出现在字段值中(逗号分段或整行/包含) */
function textInField(fieldValue: string, text: string) { function textInField(fieldValue: string, text: string) {
@@ -86,7 +95,6 @@ async function loadChips() {
chips.value = []; chips.value = [];
return; return;
} }
// 全量 my-entry-list 一次,再按 field_code 本地切片
const list = await fetchMyEntriesByField(props.fieldCode, props.limit); const list = await fetchMyEntriesByField(props.fieldCode, props.limit);
chips.value = (list || []) chips.value = (list || [])
.map((item: any) => { .map((item: any) => {
@@ -133,12 +141,22 @@ function onPick(item: ChipItem) {
emit('select', item.text); emit('select', item.text);
} }
function toggleCollapsed() {
if (!props.collapsible) return;
collapsed.value = !collapsed.value;
}
const labelText = computed(() => { const labelText = computed(() => {
if (props.source === 'diagnosis') return '常用诊断'; if (props.source === 'diagnosis') return '常用诊断';
if (props.source === 'doctor_order') return '常用医嘱'; if (props.source === 'doctor_order') return '常用医嘱';
return '常用词条'; return '常用词条';
}); });
const showChipList = computed(() => {
if (!props.collapsible) return true;
return !collapsed.value;
});
watch( watch(
() => [props.source, props.fieldCode] as const, () => [props.source, props.fieldCode] as const,
() => { () => {
@@ -154,8 +172,17 @@ defineExpose({ reload: reloadChips });
</script> </script>
<template> <template>
<div v-if="chips.length > 0" class="dx-order-chips"> <div v-if="chips.length > 0" class="dx-order-chips">
<span class="dx-order-chips__label">{{ labelText }}</span> <button
<div class="dx-order-chips__list"> v-if="collapsible"
type="button"
class="dx-order-chips__toggle"
@click="toggleCollapsed"
>
<span class="dx-order-chips__label">{{ labelText }}</span>
<span class="dx-order-chips__meta">{{ chips.length }} · {{ collapsed ? '展开' : '收起' }}</span>
</button>
<span v-else class="dx-order-chips__label">{{ labelText }}</span>
<div v-if="showChipList" class="dx-order-chips__list">
<Tag <Tag
v-for="item in chips" v-for="item in chips"
:key="`${item.id}_${item.text}`" :key="`${item.id}_${item.text}`"
@@ -174,7 +201,7 @@ defineExpose({ reload: reloadChips });
</template> </template>
<style scoped> <style scoped>
.dx-order-chips { .dx-order-chips {
margin-top: 8px; margin-top: 6px;
} }
.dx-order-chips__label { .dx-order-chips__label {
display: block; display: block;
@@ -182,6 +209,26 @@ defineExpose({ reload: reloadChips });
font-size: 12px; font-size: 12px;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
} }
.dx-order-chips__toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin: 0 0 4px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
}
.dx-order-chips__toggle .dx-order-chips__label {
margin-bottom: 0;
}
.dx-order-chips__meta {
font-size: 12px;
color: hsl(var(--primary));
flex-shrink: 0;
}
.dx-order-chips__list { .dx-order-chips__list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -2,6 +2,10 @@ import type { VbenFormProps } from '#/adapter/form';
import { monthToTodayRangeDayjs } from '#/utils/search-time-range'; import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
/**
* 资金流水搜索表单
* 保留时间范围,并增加诊所 / 订单号筛选,便于按门店或单号快速定位流水
*/
export const formOptions: VbenFormProps = { export const formOptions: VbenFormProps = {
// 默认展开 // 默认展开
collapsed: false, collapsed: false,
@@ -15,6 +19,20 @@ export const formOptions: VbenFormProps = {
fieldName: 'search_time', fieldName: 'search_time',
label: '时间范围', label: '时间范围',
}, },
{
component: 'StorePicker',
fieldName: 'store_id',
label: '诊所',
},
{
component: 'Input',
fieldName: 'order_no',
label: '订单号',
componentProps: {
placeholder: '请输入订单号',
allowClear: true,
},
},
], ],
// 控制表单是否显示折叠按钮 // 控制表单是否显示折叠按钮
showCollapseButton: true, showCollapseButton: true,

View File

@@ -18,6 +18,13 @@ export const gridOptions: VxeGridProps<RowType> = {
columns: [ columns: [
{ field: 'id', align: 'left', title: 'ID' }, { field: 'id', align: 'left', title: 'ID' },
{ field: 'store.name', title: '诊所' }, { field: 'store.name', title: '诊所' },
// 订单号可点开详情,配合搜索筛选项提升「按单号查流水」体验
{
field: 'order_no',
title: '订单号',
minWidth: 160,
slots: { default: 'order_no' },
},
{ field: 'doctor_info.name', title: '医生' }, { field: 'doctor_info.name', title: '医生' },
{ field: 'price', title: '金额' }, { field: 'price', title: '金额' },
{ field: 'order_type', title: '订单类型', slots: { default: 'order-type' } }, { field: 'order_type', title: '订单类型', slots: { default: 'order-type' } },

View File

@@ -1,20 +1,40 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; /**
* 资金流水记录页
* Tab1:支付/提现类资金流水(xk_fund_water)
* Tab2:账户资金变动全量审计(audit=1 + 诊所筛选),与提现页「只看本主体」区分
*/
import { nextTick, ref, watch } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui'; import { Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon as VbenIcon } from '@vben/icons';
import { Button, Tag } from 'ant-design-vue'; import { Button, Tabs, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { useFreshSearchTime } from '#/composables/use-fresh-search-time';
import { TableAction } from '#/components/table-action'; import { TableAction } from '#/components/table-action';
import { useFreshSearchTime } from '#/composables/use-fresh-search-time';
import { monthToTodayRangeDayjs } from '#/utils/search-time-range'; import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
import DetailModal from '#/views/business/order/product-order/components/detail.vue'; import DetailModal from '#/views/business/order/product-order/components/detail.vue';
import StatisticsReconciliation from '#/views/finance/reconciliation/components/statistics.vue'; import StatisticsReconciliation from '#/views/finance/reconciliation/components/statistics.vue';
import ApplyOrdersModal from '#/views/finance/withdrawal/components/apply-orders-modal.vue';
import { useWithdrawOrderDetailModals } from '#/views/finance/withdrawal/utils/use-withdraw-order-detail';
import { accountChangeFormOptions } from './config/account-change-search';
import { accountChangeGridOptions } from './config/account-change-table';
import { formOptions } from './config/search'; import { formOptions } from './config/search';
import { gridOptions } from './config/table'; import { gridOptions } from './config/table';
defineOptions({ name: 'FinanceFundWater' });
/** 记住 Tab,刷新后仍停在上次查看的列表 */
const TAB_STORAGE_KEY = 'finance_fund_water_tab';
const TAB_FUND = 'fundWater';
const TAB_ABLE = 'accountChange';
const allowedTabs = new Set([TAB_FUND, TAB_ABLE]);
const storedTab = localStorage.getItem(TAB_STORAGE_KEY) || TAB_FUND;
const activeTab = ref(allowedTabs.has(storedTab) ? storedTab : TAB_FUND);
const freshSearchTimeBridge = { const freshSearchTimeBridge = {
resetSearchTime: async () => {}, resetSearchTime: async () => {},
markSearchTimeTouched: () => {}, markSearchTimeTouched: () => {},
@@ -41,98 +61,226 @@ const freshSearchTime = useFreshSearchTime({
onRefresh: () => gridApi.reload(), onRefresh: () => gridApi.reload(),
}); });
freshSearchTimeBridge.resetSearchTime = freshSearchTime.resetSearchTime; freshSearchTimeBridge.resetSearchTime = freshSearchTime.resetSearchTime;
freshSearchTimeBridge.markSearchTimeTouched = freshSearchTime.markSearchTimeTouched; freshSearchTimeBridge.markSearchTimeTouched =
freshSearchTime.markSearchTimeTouched;
/** 资金变动列表:平台全量(audit=1),可按诊所筛选 */
const [AccountChangeGrid, accountChangeGridApi] = useVbenVxeGrid({
formOptions: accountChangeFormOptions,
gridOptions: accountChangeGridOptions,
});
const statistics = ref(); const statistics = ref();
const applyOrdersModalRef = ref<InstanceType<typeof ApplyOrdersModal>>();
const [OrderDetailModal, OrderDetailModalApi] = useVbenModal({ const { OrderDetailModal, RegisterDetailModal, TraceDrawer, openOrderDetail } =
useWithdrawOrderDetailModals();
/** 资金流水行:仅商品订单详情(流水表本身偏产品单) */
const [FundOrderDetailModal, fundOrderDetailModalApi] = useVbenModal({
connectedComponent: DetailModal, connectedComponent: DetailModal,
}); });
const infoModal = (id) => { const infoModal = (id: number) => {
OrderDetailModalApi.setData({ if (!id) return;
// 表单值 fundOrderDetailModalApi.setData({ id });
id, fundOrderDetailModalApi.open();
});
OrderDetailModalApi.open();
}; };
/** 提现类变动可能挂多笔订单,先列表再下钻 */
function showApplyOrders(row: Record<string, any>) {
const orders = row.orders || [];
if (orders.length === 1) {
openOrderDetail(orders[0]);
return;
}
applyOrdersModalRef.value?.open({
title: row.order_no ? `关联订单 · ${row.order_no}` : '关联订单',
orders,
});
}
/** 资金变动「订单详情」:单笔直开,提现多笔走关联列表 */
function onAccountChangeOrder(row: Record<string, any>) {
const orders = row.orders || [];
if (
orders.length > 1 ||
(row.source_type === 'withdraw' && orders.length > 0)
) {
showApplyOrders(row);
return;
}
openOrderDetail(row);
}
/** 切换 Tab 并持久化;切回后重算表格高度,避免缩成一行 */
function handleTabChange(key: string | number) {
const next = String(key);
if (!allowedTabs.has(next)) return;
activeTab.value = next;
localStorage.setItem(TAB_STORAGE_KEY, next);
nextTick(() => {
setTimeout(() => {
gridApi.grid?.recalculate?.();
accountChangeGridApi.grid?.recalculate?.();
}, 50);
});
}
watch(activeTab, (key) => {
if (key === TAB_ABLE) {
nextTick(() => accountChangeGridApi.grid?.recalculate?.());
}
});
</script> </script>
<template> <template>
<Page auto-content-height title="资金流水记录"> <Page auto-content-height title="资金流水记录">
<FundOrderDetailModal />
<OrderDetailModal /> <OrderDetailModal />
<RegisterDetailModal />
<TraceDrawer />
<ApplyOrdersModal ref="applyOrdersModalRef" @view-order="openOrderDetail" />
<StatisticsReconciliation v-if="statistics" :statistics="statistics" /> <StatisticsReconciliation v-if="statistics" :statistics="statistics" />
<Grid> <Tabs
<template #toolbar-actions> :active-key="activeTab"
<TableAction :actions="[]" :drop-down-actions="[]"> class="fund-water-tabs"
<template #more> type="card"
<Button style="margin-left: 16px"> :destroy-inactive-tab-pane="false"
批量操作 @change="handleTabChange"
<Icon icon="ant-design:down-outlined" /> >
</Button> <Tabs.TabPane :key="TAB_FUND" tab="资金流水">
<Grid>
<template #toolbar-actions>
<TableAction :actions="[]" :drop-down-actions="[]" />
</template> </template>
</TableAction> <!-- 订单号链接:与操作列「详情」同一套 infoModal -->
</template> <template #order_no="{ row }">
<template #order-type="{ row }"> <Button
<div class="mt-3"> v-if="row.order_no"
<Tag v-if="row.order_type === 1" color="purple">产品订单</Tag> type="link"
<Tag v-else-if="row.order_type === 2" color="green">挂号订单</Tag> size="small"
<Tag v-else-if="row.order_type === 3" color="blue">问诊订单</Tag> @click="infoModal(row.order_id)"
</div> >
</template> {{ row.order_no }}
<template #type="{ row }"> </Button>
<div class="mt-3"> <span v-else>-</span>
<Tag v-if="row.type === 'refund'" color="purple">出账</Tag> </template>
<Tag v-else-if="row.type === 'enter'" color="green">入账</Tag> <template #order-type="{ row }">
<Tag v-else-if="row.type === 'withdraw'" color="orange">提现</Tag> <div class="mt-3">
<Tag v-else-if="row.type === 'charge'" color="red">代付手续费</Tag> <Tag v-if="row.order_type === 1" color="purple">产品订单</Tag>
</div> <Tag v-else-if="row.order_type === 2" color="green">挂号订单</Tag>
</template> <Tag v-else-if="row.order_type === 3" color="blue">问诊订单</Tag>
<template #toolbar-tools></template> </div>
<template #action="{ row }"> </template>
<TableAction <template #type="{ row }">
:actions="[ <div class="mt-3">
{ <Tag v-if="row.type === 'refund'" color="purple">出账</Tag>
label: '详情', <Tag v-else-if="row.type === 'enter'" color="green">入账</Tag>
type: 'link', <Tag v-else-if="row.type === 'withdraw'" color="orange">提现</Tag>
icon: 'marketeq:eye', <Tag v-else-if="row.type === 'charge'" color="red">代付手续费</Tag>
size: 'small', </div>
// auth: ['order', 'sys:role:detail'], </template>
onClick: infoModal.bind(null, row.order_id), <template #toolbar-tools></template>
}, <template #action="{ row }">
]" <TableAction
/> :actions="[
</template> {
</Grid> label: '详情',
type: 'link',
icon: 'marketeq:eye',
size: 'small',
onClick: infoModal.bind(null, row.order_id),
},
]"
/>
</template>
</Grid>
</Tabs.TabPane>
<Tabs.TabPane :key="TAB_ABLE" tab="资金变动记录">
<!-- 全量 able_change:含分账结算、历史补入、退款、提现等;非本主体视角 -->
<AccountChangeGrid>
<template #toolbar-actions></template>
<template #toolbar-tools></template>
<template #store_name="{ row }">
<span v-if="Number(row.user_type) === 1">
{{ row.store_name || `门店#${row.user_id}` }}
</span>
<span v-else class="cell-muted">
{{ row.user_type_txt || '-' }}
<template v-if="row.user_id"> · #{{ row.user_id }}</template>
</span>
</template>
<template #change_amount="{ row }">
<span
v-if="Number(row.change_amount) > 0"
class="amount-cell is-up"
>
<VbenIcon icon="lucide:trending-up" class="amount-icon" />
+{{ Number(row.change_amount).toFixed(2) }}
</span>
<span
v-else-if="Number(row.change_amount) < 0"
class="amount-cell is-down"
>
<VbenIcon icon="lucide:trending-down" class="amount-icon" />
{{ Number(row.change_amount).toFixed(2) }}
</span>
<span v-else class="cell-muted">0.00</span>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '订单详情',
type: 'link',
size: 'small',
ifShow:
!!row.order_id || (row.orders && row.orders.length > 0),
onClick: onAccountChangeOrder.bind(null, row),
},
]"
:drop-down-actions="[]"
/>
</template>
</AccountChangeGrid>
</Tabs.TabPane>
</Tabs>
</Page> </Page>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.custom-list { .fund-water-tabs {
list-style-type: none; :deep(.ant-tabs-content),
padding-left: 0; :deep(.ant-tabs-tabpane) {
min-height: 480px;
}
} }
.custom-list-item { /* 资金变动金额:涨绿跌红走主题变量,适配暗色 */
background-color: rgba(64, 158, 255, 0.04); .amount-cell {
border-radius: 4px; display: inline-flex;
margin-bottom: 8px; align-items: center;
padding: 8px 12px; gap: 4px;
font-size: 14px; font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
transition: background-color 0.3s; font-weight: 600;
font-variant-numeric: tabular-nums;
&:hover { &.is-up {
background-color: rgba(64, 158, 255, 0.1); color: hsl(var(--success));
} }
&::before { &.is-down {
content: ''; color: hsl(var(--destructive));
display: inline-block;
width: 6px;
height: 6px;
background-color: #409eff;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
} }
} }
.amount-icon {
width: 14px;
height: 14px;
}
.cell-muted {
color: hsl(var(--muted-foreground));
}
</style> </style>

View File

@@ -14,13 +14,16 @@ import { clearPublicAccountLock } from '#/utils/publicAccountLock';
import { import {
getPublicAccountDailyBillInfo, getPublicAccountDailyBillInfo,
getPublicAccountDailyMergeDetail,
uploadPublicAccountDailyBill, uploadPublicAccountDailyBill,
} from '../api'; } from '../api';
import { import {
buildDailyUploadFormData, buildDailyUploadFormData,
collectDailyPaymentVouchers,
formatDailyPlatformAmount, formatDailyPlatformAmount,
normalizeDailyVoucherUrls, normalizeDailyVoucherUrls,
resolveDailyBillDateTxt, resolveDailyBillDateTxt,
resolveDailyMergeId,
resolveDailyPlatformAmount, resolveDailyPlatformAmount,
resolveDailyStoreName, resolveDailyStoreName,
resolveDailyUploadId, resolveDailyUploadId,
@@ -100,14 +103,35 @@ async function load(data?: DailyUploadFormData) {
loading.value = true; loading.value = true;
try { try {
const detail = await getPublicAccountDailyBillInfo(dailyId.value); const mergeId = resolveDailyMergeId(normalized);
existVouchers.value = detail?.vouchers || detail?.payment_vouchers || []; // 周期行必须打 merge 详情拿合计金额,单日 daily-detail 可能是 0
storeName.value = resolveDailyStoreName(detail) || storeName.value; if (mergeId > 0) {
if (!presetAmount || presetAmount === '0.00') { const merge = await getPublicAccountDailyMergeDetail(mergeId);
platformAmount.value = resolveDailyPlatformAmount(detail); existVouchers.value = collectDailyPaymentVouchers(merge?.items || []);
} storeName.value =
if (!presetDate) { resolveDailyStoreName(merge?.items?.[0]) || storeName.value;
billDateTxt.value = resolveDailyBillDateTxt(detail); if (!presetAmount || presetAmount === '0.00') {
platformAmount.value = resolveDailyPlatformAmount({
...merge,
is_period: 1,
platform_amount_total: merge?.platform_amount_total,
platform_amount: merge?.platform_amount_total,
items: merge?.items,
});
}
if (!presetDate) {
billDateTxt.value = resolveDailyBillDateTxt(merge);
}
} else {
const detail = await getPublicAccountDailyBillInfo(dailyId.value);
existVouchers.value = detail?.vouchers || detail?.payment_vouchers || [];
storeName.value = resolveDailyStoreName(detail) || storeName.value;
if (!presetAmount || presetAmount === '0.00') {
platformAmount.value = resolveDailyPlatformAmount(detail);
}
if (!presetDate) {
billDateTxt.value = resolveDailyBillDateTxt(detail);
}
} }
} finally { } finally {
loading.value = false; loading.value = false;

View File

@@ -11,6 +11,20 @@ import DailyUploadForm from './daily-upload-form.vue';
const formRef = ref<InstanceType<typeof DailyUploadForm>>(); const formRef = ref<InstanceType<typeof DailyUploadForm>>();
/**
* connectedComponent 首开时子组件可能尚未挂上 ref,一次 nextTick 不够
* 多等几帧再 load,避免应付款停在 0.00 且不打详情接口
*/
async function waitFormReady(maxTries = 12) {
for (let i = 0; i < maxTries; i++) {
if (formRef.value?.load) {
return formRef.value;
}
await nextTick();
}
return formRef.value;
}
const [Modal, modalApi] = useVbenModal({ const [Modal, modalApi] = useVbenModal({
fullscreenButton: false, fullscreenButton: false,
draggable: true, draggable: true,
@@ -32,10 +46,9 @@ const [Modal, modalApi] = useVbenModal({
}, },
async onOpenChange(isOpen: boolean) { async onOpenChange(isOpen: boolean) {
if (!isOpen) return; if (!isOpen) return;
await nextTick(); const form = await waitFormReady();
await formRef.value?.load( const row = modalApi.getData<Record<string, any>>() || {};
buildDailyUploadFormData(modalApi.getData<Record<string, any>>() || {}), await form?.load(buildDailyUploadFormData(row));
);
}, },
}); });
</script> </script>

View File

@@ -12,7 +12,13 @@ export interface DailyUploadRowLike {
platform_amount_total?: number | string; platform_amount_total?: number | string;
bill_date_txt?: string; bill_date_txt?: string;
bill_date_range_txt?: string; bill_date_range_txt?: string;
items?: Array<{ id?: number | string; platform_amount?: number | string }>; items?: Array<{
id?: number | string;
platform_amount?: number | string;
store_name?: string;
vouchers?: Array<{ file_url?: string }>;
payment_vouchers?: Array<{ file_url?: string }>;
}>;
} }
/** 上传接口需要的真实日账单 id(周期行 id 常为 0) */ /** 上传接口需要的真实日账单 id(周期行 id 常为 0) */
@@ -80,16 +86,60 @@ export function resolveDailyBillDateTxt(
return String(row.bill_date_range_txt || row.bill_date_txt || '').trim(); return String(row.bill_date_range_txt || row.bill_date_txt || '').trim();
} }
/** 周期账单主键:列表行 id 常为 0,业务 id 在 merge_id */
export function resolveDailyMergeId(row?: DailyUploadRowLike | null): number {
if (!row) {
return 0;
}
const mergeId = Number(row.merge_id || 0);
if (mergeId > 0) {
return mergeId;
}
if (Number(row.is_period || 0) === 1) {
return Number(row.id || 0);
}
return 0;
}
/** 汇总组内付款凭证(去重),周期详情走 merge 接口时用 */
export function collectDailyPaymentVouchers(
days?: Array<{
vouchers?: Array<{ file_url?: string }>;
payment_vouchers?: Array<{ file_url?: string }>;
}>,
): { file_url: string }[] {
const seen: Record<string, boolean> = {};
const list: { file_url: string }[] = [];
for (const day of days || []) {
const vouchers = day.payment_vouchers || day.vouchers || [];
for (const v of vouchers) {
const url = String(v?.file_url || '').trim();
if (!url || seen[url]) {
continue;
}
seen[url] = true;
list.push({ file_url: url });
}
}
return list;
}
/** 打开上传弹窗/表单时灌入的标准数据结构 */ /** 打开上传弹窗/表单时灌入的标准数据结构 */
export function buildDailyUploadFormData( export function buildDailyUploadFormData(
row: DailyUploadRowLike, row: DailyUploadRowLike,
extras?: Record<string, any>, extras?: Record<string, any>,
) { ) {
const uploadId = resolveDailyUploadId(row); const uploadId = resolveDailyUploadId(row);
const mergeId = resolveDailyMergeId(row);
return { return {
...row, ...row,
...extras, ...extras,
id: uploadId, id: uploadId,
merge_id: mergeId > 0 ? mergeId : Number(row.merge_id || 0),
is_period:
Number(row.is_period || 0) === 1 || mergeId > 0
? 1
: Number(row.is_period || 0),
store_name: resolveDailyStoreName(row), store_name: resolveDailyStoreName(row),
platform_amount: resolveDailyPlatformAmount(row), platform_amount: resolveDailyPlatformAmount(row),
bill_date_txt: resolveDailyBillDateTxt(row), bill_date_txt: resolveDailyBillDateTxt(row),

View File

@@ -64,3 +64,8 @@ export async function resetPassword(id: number) {
export async function getWithdrawableOrders(data: Record<string, any>) { export async function getWithdrawableOrders(data: Record<string, any>) {
return requestClient.get<any>(`${prefix}withdrawable-orders`, { params: data }); return requestClient.get<any>(`${prefix}withdrawable-orders`, { params: data });
} }
/** 提现规则文案 xk-api GET /withdrawal-application/rules(系统配置) */
export async function getWithdrawalRules() {
return requestClient.get<any>(`${prefix}rules`);
}

View File

@@ -47,6 +47,7 @@ const emit = defineEmits<{
apply: []; apply: [];
editCard: []; editCard: [];
refresh: []; refresh: [];
rules: [];
statClick: [key: string]; statClick: [key: string];
viewApply: [id: number]; viewApply: [id: number];
}>(); }>();
@@ -212,6 +213,7 @@ function formatMoney(val: number) {
{{ subjectName }} · 账户信息 {{ subjectName }} · 账户信息
</div> </div>
<div class="header-actions"> <div class="header-actions">
<Button size="small" @click="emit('rules')">提现规则</Button>
<Button type="primary" size="small" @click="emit('apply')"> <Button type="primary" size="small" @click="emit('apply')">
申请提现 申请提现
</Button> </Button>

View File

@@ -15,6 +15,7 @@ import AccountInfoCard from './account-info-card.vue';
import SaveMyCard from './card-modal.vue'; import SaveMyCard from './card-modal.vue';
import GapConfirmModal from './gap-confirm-modal.vue'; import GapConfirmModal from './gap-confirm-modal.vue';
import FormModalDemo from './withdrawal-modal.vue'; import FormModalDemo from './withdrawal-modal.vue';
import WithdrawalRulesModal from './withdrawal-rules-modal.vue';
defineOptions({ defineOptions({
name: 'Statistics', name: 'Statistics',
@@ -97,6 +98,9 @@ const [FormModal, formModalApi] = useVbenModal({
const [saveMyCardModal, saveMyCardApi] = useVbenModal({ const [saveMyCardModal, saveMyCardApi] = useVbenModal({
connectedComponent: SaveMyCard, connectedComponent: SaveMyCard,
}); });
const [RulesModal, rulesModalApi] = useVbenModal({
connectedComponent: WithdrawalRulesModal,
});
/** 差额确认与申请弹窗同级挂载;zIndex 内外都写,避免 connectedComponent 吃掉内部层级 */ /** 差额确认与申请弹窗同级挂载;zIndex 内外都写,避免 connectedComponent 吃掉内部层级 */
const [GapConfirmModalComp, gapConfirmModalApi] = useVbenModal({ const [GapConfirmModalComp, gapConfirmModalApi] = useVbenModal({
connectedComponent: GapConfirmModal, connectedComponent: GapConfirmModal,
@@ -143,6 +147,11 @@ const showCardModal = () => {
}); });
saveMyCardApi.open(); saveMyCardApi.open();
}; };
/** 打开提现规则说明(额度 / 次数 / 到账 / 审核流程) */
function showRulesModal() {
rulesModalApi.open();
}
</script> </script>
<template> <template>
@@ -150,6 +159,7 @@ const showCardModal = () => {
<FormModal /> <FormModal />
<GapConfirmModalComp /> <GapConfirmModalComp />
<saveMyCardModal /> <saveMyCardModal />
<RulesModal />
<AccountInfoCard <AccountInfoCard
:my-card="myCard" :my-card="myCard"
:is-store-user="isStoreUser" :is-store-user="isStoreUser"
@@ -170,6 +180,7 @@ const showCardModal = () => {
@apply="showModal" @apply="showModal"
@edit-card="showCardModal" @edit-card="showCardModal"
@refresh="refresh" @refresh="refresh"
@rules="showRulesModal"
@stat-click="emit('statClick', $event)" @stat-click="emit('statClick', $event)"
@view-apply="emit('viewApply', $event)" @view-apply="emit('viewApply', $event)"
/> />

View File

@@ -157,7 +157,7 @@ function dakuanStatusPill(status: number) {
<template> <template>
<Page <Page
auto-content-height auto-content-height
description="提现前请确认好您的打款账户" description="提现前请确认打款账户,并可查看提现规则"
title="提现管理" title="提现管理"
> >
<OrderDetailModal /> <OrderDetailModal />

View File

@@ -18,6 +18,13 @@ export async function getOldApiLogList(data: any) {
return requestClient.get<any>(`${prefix}api-list`, { params: data }); return requestClient.get<any>(`${prefix}api-list`, { params: data });
} }
/**
* 单条日志详情。列表不返回 param/result,查看时再取,避免大 JSON 拖慢分页
*/
export async function getApiOpLogDetail(id: number) {
return requestClient.get<any>(`${prefix}api-detail`, { params: { id } });
}
/** /**
* Api 访问日志操作用户搜索 * Api 访问日志操作用户搜索
*/ */

View File

@@ -3,19 +3,30 @@ import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui'; import { useVbenModal } from '@vben/common-ui';
import { Descriptions } from 'ant-design-vue'; import { Descriptions, Spin } from 'ant-design-vue';
import { Icon } from '#/components/icon'; import { Icon } from '#/components/icon';
import { getIcon } from '#/util/tool'; import { getIcon } from '#/util/tool';
import { getApiOpLogDetail } from '../api';
import { import {
getApiOpLogOperatorLabel, getApiOpLogOperatorLabel,
getApiOpLogPlatformLabel, getApiOpLogPlatformLabel,
} from '../config/platform'; } from '../config/platform';
const gridApi = ref(); const gridApi = ref();
const data = ref({}); const data = ref<Record<string, any>>({});
const loading = ref(false);
/** 列表行没有 param/result,打开时按 id 拉详情;解析失败则原样展示,避免弹窗白屏 */
function formatJson(raw: unknown) {
if (raw == null || raw === '') return '';
if (typeof raw === 'object') return raw;
try {
return JSON.parse(String(raw));
} catch {
return raw;
}
}
const [Modal, modalApi] = useVbenModal({ const [Modal, modalApi] = useVbenModal({
fullscreenButton: false, fullscreenButton: false,
@@ -26,13 +37,20 @@ const [Modal, modalApi] = useVbenModal({
onConfirm: async () => { onConfirm: async () => {
modalApi.close(); modalApi.close();
}, },
onOpenChange(isOpen: boolean) { async onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null; gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) { if (!isOpen) return;
const { values } = modalApi.getData<Record<string, any>>(); const { values } = modalApi.getData<Record<string, any>>();
if (values) { data.value = values || {};
data.value = values; const id = Number(values?.id || 0);
} if (!id) return;
loading.value = true;
try {
data.value = (await getApiOpLogDetail(id)) || values || {};
} catch (error) {
console.error(error);
} finally {
loading.value = false;
} }
}, },
}); });
@@ -40,6 +58,7 @@ const [Modal, modalApi] = useVbenModal({
<template> <template>
<Modal class="w-[80%] h-[80%]" title="Api访问日志详情"> <Modal class="w-[80%] h-[80%]" title="Api访问日志详情">
<Spin :spinning="loading">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Descriptions <Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }" :column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }"
@@ -91,13 +110,14 @@ const [Modal, modalApi] = useVbenModal({
bordered bordered
> >
<Descriptions.Item label="参数"> <Descriptions.Item label="参数">
<pre>{{ JSON.parse(data.param) }}</pre> <pre>{{ formatJson(data.param) }}</pre>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="结果"> <Descriptions.Item label="结果">
<pre>{{ JSON.parse(data.result) }}</pre> <pre>{{ formatJson(data.result) }}</pre>
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</div> </div>
</Spin>
</Modal> </Modal>
</template> </template>

View File

@@ -7,11 +7,29 @@ import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui'; import { useVbenModal } from '@vben/common-ui';
import { Button, Descriptions, message, Spin, Tag } from 'ant-design-vue'; import { Button, Descriptions, Image, message, Spin, Tabs, Tag } from 'ant-design-vue';
import { getAiGenerationDetail } from '../api'; import { getAiGenerationDetail } from '../api';
import GenerationFlowTimeline from './generation-flow-timeline.vue'; import GenerationFlowTimeline from './generation-flow-timeline.vue';
const TabPane = Tabs.TabPane;
/** 详情内容区 Tab:preview=结构化预览 / json=原始快照;本地记忆 */
const CONTENT_TAB_STORAGE_KEY = 'ai_generation_detail_content_tab';
function readContentTab(): 'preview' | 'json' {
const v = localStorage.getItem(CONTENT_TAB_STORAGE_KEY);
return v === 'json' ? 'json' : 'preview';
}
const contentTab = ref<'preview' | 'json'>(readContentTab());
function onContentTabChange(key: string | number) {
const next = String(key) === 'json' ? 'json' : 'preview';
contentTab.value = next;
localStorage.setItem(CONTENT_TAB_STORAGE_KEY, next);
}
const emit = defineEmits<{ const emit = defineEmits<{
/** 打开页面级归档弹窗 */ /** 打开页面级归档弹窗 */
archive: [row: Record<string, any>]; archive: [row: Record<string, any>];
@@ -22,6 +40,73 @@ const emit = defineEmits<{
const loading = ref(false); const loading = ref(false);
const detail = ref<Record<string, any>>({}); const detail = ref<Record<string, any>>({});
/**
* 识图场景:input_snapshot.image_urls 是上传的处方原图
* 详情里除 JSON 外单独渲染,避免只能看 URL 字符串
*/
const ocrImageUrls = computed(() => {
const snap = detail.value?.input_snapshot;
const urls = snap?.image_urls;
if (!Array.isArray(urls)) return [] as string[];
return urls
.map((u) => String(u || '').trim())
.filter((u) => /^https?:\/\//i.test(u));
});
const isOcrScene = computed(() => {
const scene = String(detail.value?.scene || '');
return (
scene === 'prescription_image_ocr' || scene === 'transfer_image_ocr'
);
});
/** 识图结果结构化预览字段 */
const ocrResult = computed(() => {
const rj = detail.value?.result_json || {};
const ocr = rj.ocr && typeof rj.ocr === 'object' ? rj.ocr : {};
const match = rj.match && typeof rj.match === 'object' ? rj.match : {};
const patientMatch =
rj.patient_match && typeof rj.patient_match === 'object'
? rj.patient_match
: null;
return {
mode: String(rj.mode || ocr.mode || ''),
patient_name: String(ocr.patient_name || ''),
patient_mobile: String(ocr.patient_mobile || ''),
sex: Number(ocr.sex || 0),
age: Number(ocr.age || 0),
patient_address: String(ocr.patient_address || ''),
clinical_diagnose: String(
rj.clinical_diagnose || ocr.clinical_diagnose || '',
),
medical_advice: String(rj.medical_advice || ocr.medical_advice || ''),
dosage: Number(rj.dosage || ocr.dosage || 0),
day_dosage: Number(rj.day_dosage || ocr.day_dosage || 0),
matched: Array.isArray(match.matched) ? match.matched : [],
unmatched: Array.isArray(match.unmatched) ? match.unmatched : [],
patient_match: patientMatch,
};
});
function sexText(sex: number) {
if (sex === 1) return '男';
if (sex === 2) return '女';
return '未知';
}
function candidateDrugName(c: Record<string, any>) {
return String(
c?.drug_name || c?.drug?.drug_name || c?.matched_alias || c?.name || '',
).trim();
}
function selectedCandidateName(row: Record<string, any>) {
const sid = Number(row?.selected_drug_id || 0);
const list = Array.isArray(row?.candidates) ? row.candidates : [];
const hit = list.find((c: any) => Number(c?.drug_id) === sid) || list[0];
return hit ? candidateDrugName(hit) : '';
}
const [Modal, modalApi] = useVbenModal({ const [Modal, modalApi] = useVbenModal({
title: '生成记录详情', title: '生成记录详情',
class: 'w-[min(1280px,96vw)]', class: 'w-[min(1280px,96vw)]',
@@ -345,19 +430,204 @@ function onArchiveClick() {
</div> </div>
</div> </div>
<div v-if="briefPreview" class="json-panel mt-3"> <div v-if="briefPreview && !isOcrScene" class="json-panel mt-3">
<div class="json-title">简报正文</div> <div class="json-title">简报正文</div>
<pre class="json-pre">{{ briefPreview }}</pre> <pre class="json-pre">{{ briefPreview }}</pre>
</div> </div>
<div class="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-2"> <!-- 识图开方/传方:原图始终展示,便于对照下方预览/JSON -->
<div class="json-panel"> <div v-if="isOcrScene" class="json-panel mt-3">
<div class="json-title">输入快照</div> <div class="json-title">
<pre class="json-pre">{{ prettyJson(detail.input_snapshot) }}</pre> 处方原图
<span class="ml-2 text-[hsl(var(--muted-foreground))]">
{{ ocrImageUrls.length ? `${ocrImageUrls.length} 张` : '无图' }}
</span>
</div> </div>
<div class="json-panel"> <div v-if="ocrImageUrls.length" class="ocr-img-row">
<div class="json-title">结果 JSON</div> <Image.PreviewGroup>
<pre class="json-pre">{{ prettyJson(detail.result_json) }}</pre> <Image
v-for="(url, idx) in ocrImageUrls"
:key="`${url}-${idx}`"
:src="url"
:width="120"
:height="120"
class="ocr-thumb"
:preview="{ src: url }"
/>
</Image.PreviewGroup>
</div> </div>
<div v-else class="ocr-img-empty">
本条记录未写入 image_urls(请确认识图时已成功上传)
</div>
</div>
<!-- 生成内容:详情预览 / JSON,localStorage 记忆 -->
<div class="mt-3">
<Tabs
:active-key="contentTab"
size="small"
@change="onContentTabChange"
>
<TabPane key="preview" tab="详情预览">
<div v-if="isOcrScene" class="preview-body">
<Descriptions
size="small"
bordered
:column="2"
class="preview-desc"
>
<Descriptions.Item label="模式">
{{
ocrResult.mode === 'transfer' ? '识图传方' : '识图开方'
}}
</Descriptions.Item>
<Descriptions.Item
v-if="ocrResult.patient_match"
label="患者匹配"
>
<Tag
:color="
ocrResult.patient_match.matched ? 'success' : 'warning'
"
>
{{
ocrResult.patient_match.matched
? `已匹配 #${ocrResult.patient_match.user_patient_id || ''}`
: '未匹配(将新建)'
}}
</Tag>
</Descriptions.Item>
<Descriptions.Item
v-if="ocrResult.patient_name || ocrResult.mode === 'transfer'"
label="姓名"
>
{{ ocrResult.patient_name || '—' }}
</Descriptions.Item>
<Descriptions.Item
v-if="
ocrResult.patient_mobile || ocrResult.mode === 'transfer'
"
label="手机"
>
{{ ocrResult.patient_mobile || '—' }}
</Descriptions.Item>
<Descriptions.Item
v-if="ocrResult.mode === 'transfer'"
label="性别/年龄"
>
{{ sexText(ocrResult.sex) }} /
{{ ocrResult.age > 0 ? `${ocrResult.age} 岁` : '—' }}
</Descriptions.Item>
<Descriptions.Item
v-if="ocrResult.patient_address"
label="地址"
:span="2"
>
{{ ocrResult.patient_address }}
</Descriptions.Item>
<Descriptions.Item label="诊断" :span="2">
{{ ocrResult.clinical_diagnose || '—' }}
</Descriptions.Item>
<Descriptions.Item label="医嘱" :span="2">
{{ ocrResult.medical_advice || '—' }}
</Descriptions.Item>
<Descriptions.Item label="剂数/日次">
{{ ocrResult.dosage || '—' }} 剂 /
{{ ocrResult.day_dosage || '—' }} 次
</Descriptions.Item>
<Descriptions.Item label="药味">
已对照 {{ ocrResult.matched.length }} /
未对照 {{ ocrResult.unmatched.length }}
</Descriptions.Item>
</Descriptions>
<div
v-if="ocrResult.matched.length"
class="preview-section mt-3"
>
<div class="preview-section-title">
已对照({{ ocrResult.matched.length }})
</div>
<!-- 紧凑一行多列:AI名+克数 → 对照药名 -->
<div class="preview-drug-grid">
<div
v-for="(m, mi) in ocrResult.matched"
:key="'m' + mi"
class="preview-drug-chip is-matched"
:title="
selectedCandidateName(m)
? `${m.ai_name || ''} → ${selectedCandidateName(m)}`
: m.ai_name || ''
"
>
<span class="chip-ai">{{ m.ai_name || '—' }}</span>
<span class="chip-dose">
{{ m.dose || '' }}{{ m.unit || '' }}
</span>
<span
v-if="selectedCandidateName(m)"
class="chip-arrow"
>→</span>
<span
v-if="selectedCandidateName(m)"
class="chip-match"
>{{ selectedCandidateName(m) }}</span>
</div>
</div>
</div>
<div
v-if="ocrResult.unmatched.length"
class="preview-section mt-3"
>
<div class="preview-section-title text-warn">
未对照({{ ocrResult.unmatched.length }})
</div>
<div class="preview-drug-grid">
<div
v-for="(u, ui) in ocrResult.unmatched"
:key="'u' + ui"
class="preview-drug-chip is-unmatched"
>
<span class="chip-ai">{{ u.ai_name || '—' }}</span>
<span class="chip-dose">
{{ u.dose || '' }}{{ u.unit || '' }}
</span>
</div>
</div>
</div>
<div
v-if="
!ocrResult.matched.length && !ocrResult.unmatched.length
"
class="ocr-img-empty"
>
暂无药品对照结果
</div>
</div>
<div v-else-if="briefPreview" class="preview-body">
<pre class="json-pre preview-brief">{{ briefPreview }}</pre>
</div>
<div v-else class="ocr-img-empty">
当前场景暂无结构化预览,请切换到「JSON」查看原始内容
</div>
</TabPane>
<TabPane key="json" tab="JSON">
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div class="json-panel">
<div class="json-title">输入快照</div>
<pre class="json-pre">{{
prettyJson(detail.input_snapshot)
}}</pre>
</div>
<div class="json-panel">
<div class="json-title">结果 JSON</div>
<pre class="json-pre">{{
prettyJson(detail.result_json)
}}</pre>
</div>
</div>
</TabPane>
</Tabs>
</div> </div>
</div> </div>
</Spin> </Spin>
@@ -406,6 +676,106 @@ function onArchiveClick() {
white-space: pre-wrap; white-space: pre-wrap;
} }
.ocr-img-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 12px;
}
.ocr-thumb {
overflow: hidden;
object-fit: cover;
cursor: pointer;
border: 1px solid hsl(var(--border));
border-radius: 6px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.ocr-img-empty {
padding: 16px 12px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.preview-body {
padding: 4px 0 8px;
}
.preview-desc :deep(.ant-descriptions-item-label) {
width: 96px;
white-space: nowrap;
}
.preview-section-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.preview-section-title.text-warn {
color: hsl(var(--destructive, var(--warning)));
}
.preview-drug-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preview-drug-chip {
display: inline-flex;
flex-wrap: nowrap;
gap: 4px;
align-items: center;
max-width: 100%;
padding: 3px 8px;
font-size: 12px;
line-height: 1.4;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 6px;
}
.preview-drug-chip.is-matched {
border-color: hsl(var(--primary) / 30%);
box-shadow: 0 0 4px hsl(var(--primary) / 14%);
}
.preview-drug-chip.is-unmatched {
border-color: hsl(0 84% 60% / 40%);
box-shadow: 0 0 4px hsl(0 84% 60% / 14%);
}
.chip-ai {
font-weight: 500;
color: hsl(var(--foreground));
}
.chip-dose {
color: hsl(var(--muted-foreground));
white-space: nowrap;
}
.chip-arrow {
color: hsl(var(--muted-foreground));
}
.chip-match {
color: hsl(var(--primary));
white-space: nowrap;
}
.text-muted {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.preview-brief {
max-height: 360px;
}
.kb-hits-panel { .kb-hits-panel {
overflow: hidden; overflow: hidden;
background: hsl(var(--card, var(--background))); background: hsl(var(--card, var(--background)));

View File

@@ -10,7 +10,10 @@ export const AI_GENERATION_STATUS_OPTIONS = [
export const AI_GENERATION_SCENE_OPTIONS = [ export const AI_GENERATION_SCENE_OPTIONS = [
{ label: '写病历', value: 'medical_record' }, { label: '写病历', value: 'medical_record' },
{ label: '出方', value: 'prescription' }, { label: '出方', value: 'prescription' },
{ label: '语音口述开方', value: 'voice_prescription' },
{ label: '每日简报', value: 'daily_brief' }, { label: '每日简报', value: 'daily_brief' },
{ label: '识图开方', value: 'prescription_image_ocr' },
{ label: '识图传方', value: 'transfer_image_ocr' },
]; ];
/** 是否命中业务缓存(简报按天缓存等) */ /** 是否命中业务缓存(简报按天缓存等) */

View File

@@ -59,15 +59,15 @@ export const reportFormProps: VbenFormProps = {
component: 'RadioGroup', component: 'RadioGroup',
componentProps: { componentProps: {
options: [ options: [
{ label: '对公账户', value: '1' }, // { label: '对公账户', value: '1' },
{ label: '法人账户', value: '2' }, // { label: '法人账户', value: '2' },
{ label: '授权对公账户', value: '3' }, { label: '授权对公账户', value: '3' },
{ label: '授权个人账户', value: '4' }, { label: '授权个人账户', value: '4' },
], ],
}, },
fieldName: 'bank_account_type', fieldName: 'bank_account_type',
label: '账户类型', label: '账户类型',
defaultValue: '2', defaultValue: '4',
}, },
{ {
component: 'VbenInput', component: 'VbenInput',

View File

@@ -103,7 +103,7 @@ export const bankCardReportFormProps: VbenFormProps = {
fieldName: 'account_type', fieldName: 'account_type',
label: '账户类型', label: '账户类型',
rules: 'required', rules: 'required',
defaultValue: '2', defaultValue: 4,
dependencies: { dependencies: {
show: (values) => !values.only_remark, show: (values) => !values.only_remark,
triggerFields: ['only_remark'], triggerFields: ['only_remark'],

View File

@@ -274,6 +274,15 @@ export async function updateStorePublicAccountLockDays(data: {
return requestClient.post<any>(`${prefix}update-public-account-lock-days`, data); return requestClient.post<any>(`${prefix}update-public-account-lock-days`, data);
} }
/** 单独更新诊所订单分账类型与平台占比 */
export async function updateStoreLedgerSplitApi(data: {
id: number;
ledger_split_type: number;
ledger_split_percent: number;
}) {
return requestClient.post<any>(`${prefix}update-ledger-split`, data);
}
export async function toggleSalespersonSeePriceApi(data: { store_id: number }) { export async function toggleSalespersonSeePriceApi(data: { store_id: number }) {
return requestClient.post<any>('salesperson-store-config/toggle-see-price', data); return requestClient.post<any>('salesperson-store-config/toggle-see-price', data);
} }
@@ -302,3 +311,18 @@ export async function updateStoreBankInfoApi(data: {
}) { }) {
return requestClient.post<any>(`${prefix}update-store-bank-info`, data); return requestClient.post<any>(`${prefix}update-store-bank-info`, data);
} }
/** 门店轮播图列表(平台端传 store_id) */
export async function getStoreNavListApi(storeId: number) {
return requestClient.get<any>(`${prefix}nav-list`, {
params: { store_id: storeId },
});
}
/** 全量同步门店轮播图 URL(含排序) */
export async function saveStoreNavUrlApi(data: {
store_id: number;
url: string[];
}) {
return requestClient.post<any>(`${prefix}upload-nav`, data);
}

View File

@@ -5,6 +5,8 @@
*/ */
import { Switch, Tag } from 'ant-design-vue'; import { Switch, Tag } from 'ant-design-vue';
import { storeLedgerSplitTagText } from '../../config/constants';
defineProps<{ defineProps<{
row: Record<string, any>; row: Record<string, any>;
section: 'switches' | 'business' | 'all'; section: 'switches' | 'business' | 'all';
@@ -22,6 +24,8 @@ defineProps<{
onPublicAccountPay?: (id: number) => void; onPublicAccountPay?: (id: number) => void;
/** 点击账期 Tag 打开配置弹窗 */ /** 点击账期 Tag 打开配置弹窗 */
onPublicAccountLockDays?: (row: Record<string, any>) => void; onPublicAccountLockDays?: (row: Record<string, any>) => void;
/** 点击订单分账 Tag 打开分账设置弹窗 */
onLedgerSplit?: (row: Record<string, any>) => void;
onBankCardReport: (row: Record<string, any>) => void; onBankCardReport: (row: Record<string, any>) => void;
onClinicType?: (row: Record<string, any>) => void; onClinicType?: (row: Record<string, any>) => void;
onAllowInsurance?: (id: number) => void; onAllowInsurance?: (id: number) => void;
@@ -121,6 +125,18 @@ function publicAccountLockDaysText(row: Record<string, any>): string {
</Tag> </Tag>
</div> </div>
</div> </div>
<div v-if="onLedgerSplit" class="config-item">
<span class="config-item__label">订单分账</span>
<div class="config-item__control">
<Tag
:color="Number(row.ledger_split_type) === 1 ? 'processing' : 'default'"
class="config-item__tag"
@click="onLedgerSplit(row)"
>
{{ storeLedgerSplitTagText(row) }}
</Tag>
</div>
</div>
</template> </template>
<template v-else-if="section === 'business'"> <template v-else-if="section === 'business'">
@@ -261,6 +277,18 @@ function publicAccountLockDaysText(row: Record<string, any>): string {
</Tag> </Tag>
</div> </div>
</div> </div>
<div v-if="onLedgerSplit" class="config-item">
<span class="config-item__label">订单分账</span>
<div class="config-item__control">
<Tag
:color="Number(row.ledger_split_type) === 1 ? 'processing' : 'default'"
class="config-item__tag"
@click="onLedgerSplit(row)"
>
{{ storeLedgerSplitTagText(row) }}
</Tag>
</div>
</div>
</template> </template>
</div> </div>
</template> </template>

View File

@@ -1,5 +1,6 @@
import type { VbenFormProps } from '#/adapter/form'; import type { VbenFormProps } from '#/adapter/form';
import { STORE_LEDGER_SPLIT_TYPE_OPTIONS } from './constants';
// 诊所管理表单配置 // 诊所管理表单配置
// 注意:已移除类型选择字段,固定为type=0(诊所类型) // 注意:已移除类型选择字段,固定为type=0(诊所类型)
@@ -321,6 +322,38 @@ export const modalFormProps: VbenFormProps = {
suffix: () => '%', suffix: () => '%',
defaultValue: 100, defaultValue: 100,
}, },
{
// 订单分账类型:默认正常;按比例时平台拿实付百分比(仅中药/颗粒)
component: 'RadioGroup',
formItemClass: 'col-span-6',
componentProps: {
options: STORE_LEDGER_SPLIT_TYPE_OPTIONS,
},
fieldName: 'ledger_split_type',
label: '订单分账类型',
defaultValue: 0,
help: '按比例仅对中药/颗粒订单生效;西药等仍走正常分账',
},
{
// 平台占实付 total_pay_price 的百分比,需管理员手动设置
component: 'InputNumber',
formItemClass: 'col-span-6',
componentProps: {
min: 0,
max: 100,
precision: 2,
placeholder: '平台占实付百分比',
},
fieldName: 'ledger_split_percent',
label: '平台分账占比',
suffix: () => '%',
defaultValue: 0,
help: '平台占订单实付总额的百分比,其余归诊所;需手动调整',
dependencies: {
show: (values: Record<string, any>) => Number(values.ledger_split_type) === 1,
triggerFields: ['ledger_split_type'],
},
},
// { // {
// component: 'VbenInput', // component: 'VbenInput',
// formItemClass: 'col-span-6', // formItemClass: 'col-span-6',

View File

@@ -59,6 +59,14 @@ export const gridOptions: VxeGridProps<RowType> = {
width: 200, width: 200,
slots: { default: 'prescription_types_config' }, slots: { default: 'prescription_types_config' },
}, },
{
// 订单分账独立列:点击 Tag 打开设置弹窗(与开关配置内入口一致)
field: 'ledger_split_config',
align: 'left',
title: '订单分账',
width: 140,
slots: { default: 'ledger_split_config' },
},
{ {
field: 'store_config_1', field: 'store_config_1',
align: 'left', align: 'left',

View File

@@ -42,7 +42,9 @@ import BindSpecialPrescriptionDoctorModal from './components/BindSpecialPrescrip
import BindPrescriptionTypesModal from './components/BindPrescriptionTypesModal.vue'; import BindPrescriptionTypesModal from './components/BindPrescriptionTypesModal.vue';
import StoreExternalFieldModal from './components/StoreExternalFieldModal.vue'; import StoreExternalFieldModal from './components/StoreExternalFieldModal.vue';
import SalespersonCommissionDrawer from './components/SalespersonCommissionDrawer.vue'; import SalespersonCommissionDrawer from './components/SalespersonCommissionDrawer.vue';
import LedgerSplitModal from './components/LedgerSplitModal.vue';
import AddDoctorFromStoreModal from './components/AddDoctorFromStoreModal.vue'; import AddDoctorFromStoreModal from './components/AddDoctorFromStoreModal.vue';
import StoreNavModal from './components/StoreNavModal.vue';
import ScheduleCalendarModal from '#/views/doctor/doctor/components/schedule-calendar/ScheduleCalendarModal.vue'; import ScheduleCalendarModal from '#/views/doctor/doctor/components/schedule-calendar/ScheduleCalendarModal.vue';
import StoreCardModal from '#/components/store-card/StoreCardModal.vue'; import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import StoreVipCell from '#/components/vip/StoreVipCell.vue'; import StoreVipCell from '#/components/vip/StoreVipCell.vue';
@@ -54,6 +56,7 @@ import { getStoreBankCardReportDetail } from '#/views/system/store-bank-card/api
import { formOptions } from './config/search'; import { formOptions } from './config/search';
import { gridOptions } from './config/table'; import { gridOptions } from './config/table';
import { prescriptionTypeLabel } from './config/prescription-type-constants'; import { prescriptionTypeLabel } from './config/prescription-type-constants';
import { storeLedgerSplitTagText } from './config/constants';
import { import {
canAddStoreDoctor, canAddStoreDoctor,
canManageStoreBankCard, canManageStoreBankCard,
@@ -205,6 +208,36 @@ const [BankCardStoreEditModalComponent, bankCardStoreEditModalApi] = useVbenModa
connectedComponent: BankCardStoreEditModal, connectedComponent: BankCardStoreEditModal,
}); });
const [LedgerSplitModalComponent, ledgerSplitModalApi] = useVbenModal({
connectedComponent: LedgerSplitModal,
});
const [StoreNavModalComp, storeNavModalApi] = useVbenModal({
connectedComponent: StoreNavModal,
});
/**
* 打开诊所订单分账设置弹窗(列表 Tag / 下拉入口共用)
*/
const openLedgerSplitModal = (row: Record<string, any>) => {
if (!row?.id) return;
ledgerSplitModalApi.setData({
values: row,
gridApi,
});
ledgerSplitModalApi.open();
};
/** 打开门店轮播图配置(上传 / 删除 / 拖拽排序) */
const openStoreNavModal = (row: Record<string, any>) => {
if (!row?.id) return;
storeNavModalApi.setData({
storeId: row.id,
storeName: row.name,
});
storeNavModalApi.open();
};
const openBankCardStoreEdit = (row: any) => { const openBankCardStoreEdit = (row: any) => {
bankCardStoreEditModalApi.setData({ bankCardStoreEditModalApi.setData({
storeId: row.id, storeId: row.id,
@@ -561,6 +594,8 @@ const handleSwitchClinicType = (row: any) => {
<BankCardReportModalComponent /> <BankCardReportModalComponent />
<BankCardStatusModalComponent /> <BankCardStatusModalComponent />
<BankCardStoreEditModalComponent /> <BankCardStoreEditModalComponent />
<LedgerSplitModalComponent />
<StoreNavModalComp />
<StoreCardModalComp /> <StoreCardModalComp />
<VipHubModalComp /> <VipHubModalComp />
<AddDoctorModalComp /> <AddDoctorModalComp />
@@ -648,6 +683,7 @@ const handleSwitchClinicType = (row: any) => {
:on-order-price-adjust="updateOrderPricePercentAdjust" :on-order-price-adjust="updateOrderPricePercentAdjust"
:on-public-account-pay="updatePublicAccountPayEnabled" :on-public-account-pay="updatePublicAccountPayEnabled"
:on-public-account-lock-days="handlePublicAccountLockDays" :on-public-account-lock-days="handlePublicAccountLockDays"
:on-ledger-split="openLedgerSplitModal"
:on-bank-card-report="handleBankCardReportColumnClick" :on-bank-card-report="handleBankCardReportColumnClick"
/> />
</template> </template>
@@ -759,6 +795,13 @@ const handleSwitchClinicType = (row: any) => {
</Tag> </Tag>
</div> </div>
</template> </template>
<template #ledger_split_config="{ row }">
<div class="cursor-pointer" @click="openLedgerSplitModal(row)">
<Tag :color="Number(row.ledger_split_type) === 1 ? 'processing' : 'default'">
{{ storeLedgerSplitTagText(row) }}
</Tag>
</div>
</template>
<template #start-time="{ row }"> <template #start-time="{ row }">
早:<Tag color="success">{{ row.start_time }}</Tag> 早:<Tag color="success">{{ row.start_time }}</Tag>
<br /> <br />
@@ -777,6 +820,22 @@ const handleSwitchClinicType = (row: any) => {
auth: ['Super Admin', 'Admin'], auth: ['Super Admin', 'Admin'],
onClick: showModal.bind(null, row, true), onClick: showModal.bind(null, row, true),
}, },
{
label: '轮播图',
type: 'link',
icon: 'ant-design:picture-outlined',
size: 'small',
auth: ['Super Admin', 'Admin'],
onClick: () => openStoreNavModal(row),
},
{
label: '分账设置',
type: 'link',
icon: 'ant-design:percentage-outlined',
size: 'small',
auth: ['Super Admin', 'Admin'],
onClick: () => openLedgerSplitModal(row),
},
{ {
label: '删除', label: '删除',
type: 'link', type: 'link',

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/'; const wxEntryPrefix = 'wx-workbench-entry/';
/** 某角色已分配的医生端功能入口(含 is_default_fav) */ /** 某角色已分配的医生端功能入口(含 is_default_fav) */

View File

@@ -1,9 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
/** /**
* 系统配置 - AI:二级 Tab * 系统配置 - AI:二级 Tab
* 1) 模型配置:选用平台 / 密钥 / 模型 * 1) 模型配置 2) 病历配置 3) 提醒文案
* 2) 病历配置:勾选 AI 写病历需要生成的字段 * 4) Agent 中转 5) 语音配置 6) Agent 高级
* 3) 提醒文案:VIP 关联的醒目提示与知情同意
*/ */
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
@@ -14,6 +13,7 @@ import {
Input, Input,
InputNumber, InputNumber,
message, message,
Radio,
Spin, Spin,
Switch, Switch,
Tabs, Tabs,
@@ -83,13 +83,12 @@ const mrFieldOptions = ref<{ label: string; value: string }[]>([]);
const mrSelectedFields = ref<string[]>([]); const mrSelectedFields = ref<string[]>([]);
const mrSaving = ref(false); const mrSaving = ref(false);
// ===== Agent 高级配置(走 getSystemConfigList,写入 xk_system_config) ===== // ===== Agent 配置(走 getSystemConfigList,写入 xk_system_config) =====
// 这组配置与上方"模型配置"独立: // 与上方「模型配置」独立:
// - 上方"模型配置"决定用什么 provider/key/model(写入 ai_active_provider/_api_key_id/_model) // - 模型配置决定 provider/key/model
// - 这里只决定"是否走 Go Agent 中转",用独立开关 ai_agent_via_agent 控制 // - 「Agent 中转」Tab:全局直连/中转 + Agent 服务地址
// 这样切换 agent 模式时不会污染模型组合(模型永远在「模型配置 Tab」选) // - 「语音配置」Tab:讯飞 ASR + 口述落方 LLM 路由
// 分组 0:Agent 接入(独立开关 + 服务地址 + 鉴权密钥) // - 「Agent 高级」Tab:KB / ReAct / Token / 流量实验
// aiActiveProviderAdv 不再使用——只保留模型 Tab 里的 activeProvider 作为真实 provider
const aiAgentViaAgent = ref(false); const aiAgentViaAgent = ref(false);
const aiAgentBaseUrl = ref('http://127.0.0.1:18123'); const aiAgentBaseUrl = ref('http://127.0.0.1:18123');
const aiAgentSecret = ref(''); const aiAgentSecret = ref('');
@@ -116,9 +115,24 @@ const aiShadowTrafficEnabled = ref(false);
const aiShadowTrafficRatio = ref(0); const aiShadowTrafficRatio = ref(0);
const aiAbTestEnabled = ref(false); const aiAbTestEnabled = ref(false);
const aiAbTestExperimentRatio = ref(10); const aiAbTestExperimentRatio = ref(10);
// 高级配置保存状态 /** Agent 中转 Tab 保存中 */
const agentViaSaving = ref(false);
/** 语音配置 Tab 保存中 */
const voiceSaving = ref(false);
/** Agent 高级 Tab 保存中 */
const advSaving = ref(false); 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(() => const currentPlatform = computed(() =>
platforms.value.find((p) => p.code === activeProvider.value), platforms.value.find((p) => p.code === activeProvider.value),
); );
@@ -291,6 +305,42 @@ async function loadAdvancedCfg() {
aiAgentViaAgent.value = parseBoolAdv(row.config_value); aiAgentViaAgent.value = parseBoolAdv(row.config_value);
break; 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': { case 'ai_prescription_generate_count': {
// 钳制 1–5,非法回落默认 1 // 钳制 1–5,非法回落默认 1
const n = Number(row.config_value); const n = Number(row.config_value);
@@ -353,14 +403,14 @@ async function loadAdvancedCfg() {
} }
} }
/** 保存 Agent 高级配置(接入 / 知识库 / ReAct / Token / 流量实验)一次性 POST */ /**
async function handleSaveAdvanced() { * 保存「Agent 中转」Tab:直连/中转开关 + 服务地址 + 密钥 + 出方份数
advSaving.value = true; * 为什么单独存:运维高频改中转路径,与 ReAct/流量实验解耦,避免误改高级项
*/
async function handleSaveAgentVia() {
agentViaSaving.value = true;
try { try {
await saveSystemConfig([ 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_key: 'ai_agent_via_agent',
config_value: boolStr(aiAgentViaAgent.value), 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_key: 'ai_agent_kb_enabled_medical_record',
config_value: boolStr(aiAgentKbEnabledMedicalRecord.value), config_value: boolStr(aiAgentKbEnabledMedicalRecord.value),
@@ -394,7 +519,7 @@ async function handleSaveAdvanced() {
config_group: 'ai', config_group: 'ai',
description: '处方场景是否启用 Go Agent 知识库检索', description: '处方场景是否启用 Go Agent 知识库检索',
}, },
// 分组 1:ReAct // ReAct
{ {
config_key: 'ai_react_enabled', config_key: 'ai_react_enabled',
config_value: boolStr(aiReactEnabled.value), config_value: boolStr(aiReactEnabled.value),
@@ -419,7 +544,7 @@ async function handleSaveAdvanced() {
config_key: 'ai_react_json_repair_enabled', config_key: 'ai_react_json_repair_enabled',
config_value: boolStr(aiReactJsonRepairEnabled.value), config_value: boolStr(aiReactJsonRepairEnabled.value),
}, },
// 分组 2:Token 预算 // Token 预算
{ {
config_key: 'ai_token_budget_enabled', config_key: 'ai_token_budget_enabled',
config_value: boolStr(aiTokenBudgetEnabled.value), config_value: boolStr(aiTokenBudgetEnabled.value),
@@ -432,7 +557,7 @@ async function handleSaveAdvanced() {
config_key: 'ai_token_max_per_call', config_key: 'ai_token_max_per_call',
config_value: String(aiTokenMaxPerCall.value), config_value: String(aiTokenMaxPerCall.value),
}, },
// 分组 3:流量实验 // 流量实验
{ {
config_key: 'ai_shadow_traffic_enabled', config_key: 'ai_shadow_traffic_enabled',
config_value: boolStr(aiShadowTrafficEnabled.value), config_value: boolStr(aiShadowTrafficEnabled.value),
@@ -596,17 +721,19 @@ async function handleSaveCopies() {
onMounted(() => { onMounted(() => {
const stored = localStorage.getItem(SUB_TAB_STORAGE_KEY); const stored = localStorage.getItem(SUB_TAB_STORAGE_KEY);
// 新增 advanced 二级 Tab 后,缓存值合法性扩展 // 缓存值合法性:含 Agent 中转 / 语音配置 / Agent 高级
if ( if (
stored === 'model' || stored === 'model' ||
stored === 'copy' || stored === 'copy' ||
stored === 'mr' || stored === 'mr' ||
stored === 'agent' ||
stored === 'voice' ||
stored === 'advanced' stored === 'advanced'
) { ) {
subTab.value = stored; subTab.value = stored;
} }
load(); load();
// 加载 Agent 高级配置(独立接口,失败不阻塞主流程) // 加载 Agent / 语音 / 高级配置(独立接口,失败不阻塞主流程)
loadAdvancedCfg(); loadAdvancedCfg();
}); });
</script> </script>
@@ -862,22 +989,22 @@ onMounted(() => {
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
<!-- Agent 高级:Go Agent 接入 / ReAct / Token 预算 / 流量实验 --> <!-- Agent 中转:直连/中转开关 + 服务地址(独立 Tab,运维高频) -->
<Tabs.TabPane key="advanced" tab="Agent 高级"> <Tabs.TabPane key="agent" tab="Agent 中转">
<div class="py-4 space-y-6"> <div class="py-4 space-y-6">
<!-- 分组 0:Agent 接入配置(独立开关 + 服务地址 + 鉴权密钥) -->
<div <div
class="rounded border border-solid border-blue-200 bg-blue-50/30 p-4 dark:border-blue-900 dark:bg-blue-950/20" 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 class="mb-3 flex items-center justify-between">
<div> <div>
<div class="font-medium text-[hsl(var(--foreground))]"> <div class="font-medium text-[hsl(var(--foreground))]">
Agent 接入配置 直连 / Agent 中转
</div> </div>
<div class="text-xs text-[hsl(var(--muted-foreground))]"> <div class="text-xs text-[hsl(var(--muted-foreground))]">
独立开关:开启后所有 AI 调用走 Go Agent 中转(ReAct 开启后 AI 写病历、AI 辅助辨证出方等经 AiAgentFactory
多轮由下方单独控制;知识库检索也是下方单独开关),模型/密钥/平台始终由上方「模型配置」Tab 的调用走 Go Agent 中转。关闭则为 PHP
决定 直连厂商。语音转写与口述落方见「语音配置」Tab;ReAct /
知识库等见「Agent 高级」。中转开启时须保证下方服务地址可达
</div> </div>
</div> </div>
<Switch <Switch
@@ -974,8 +1101,125 @@ onMounted(() => {
</div> </div>
</div> </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 <div
class="rounded border border-solid border-[hsl(var(--border))] p-4" class="rounded border border-solid border-[hsl(var(--border))] p-4"
> >
@@ -984,9 +1228,9 @@ onMounted(() => {
知识库检索 知识库检索
</div> </div>
<div class="text-xs text-[hsl(var(--muted-foreground))]"> <div class="text-xs text-[hsl(var(--muted-foreground))]">
与上方「Agent 中转」独立:中转只决定走 Go,本开关才决定 Go 与「Agent 中转」Tab 独立:中转只决定走 Go,本开关才决定 Go
是否检索本地知识库 / 是否检索本地知识库 /
MaxKB。需先开启中转才生效;生成记录时间轴会出现「检索知识库」步骤 MaxKB。需先在「Agent 中转」开启中转才生效;生成记录时间轴会出现「检索知识库」步骤
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center gap-6"> <div class="flex flex-wrap items-center gap-6">
@@ -1011,7 +1255,7 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- 分组 1:ReAct 多轮推理循环 --> <!-- ReAct 多轮推理循环 -->
<div <div
class="rounded border border-solid border-[hsl(var(--border))] p-4" class="rounded border border-solid border-[hsl(var(--border))] p-4"
> >
@@ -1095,7 +1339,7 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- 分组 2:Token 预算管理 --> <!-- Token 预算管理 -->
<div <div
class="rounded border border-solid border-[hsl(var(--border))] p-4" class="rounded border border-solid border-[hsl(var(--border))] p-4"
> >
@@ -1151,7 +1395,7 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- 分组 3:流量实验 --> <!-- 流量实验 -->
<div <div
class="rounded border border-solid border-[hsl(var(--border))] p-4" class="rounded border border-solid border-[hsl(var(--border))] p-4"
> >
@@ -1254,6 +1498,22 @@ onMounted(() => {
> >
保存 AI 功能文案 保存 AI 功能文案
</Button> </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 <Button
v-else v-else
type="primary" type="primary"

View File

@@ -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>

View File

@@ -17,8 +17,10 @@ import AiModelConfigPanel from './components/ai-model-config-panel.vue';
import DictSearchConfigPanel from './components/dict-search-config-panel.vue'; import DictSearchConfigPanel from './components/dict-search-config-panel.vue';
import DispenseSignConfigPanel from './components/dispense-sign-config-panel.vue'; import DispenseSignConfigPanel from './components/dispense-sign-config-panel.vue';
import DoctorLoginConfigPanel from './components/doctor-login-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 HomeDisplayConfigPanel from './components/home-display-config-panel.vue';
import InputAuditConfigPanel from './components/input-audit-config-panel.vue'; import InputAuditConfigPanel from './components/input-audit-config-panel.vue';
import ImageTransferConfigPanel from './components/image-transfer-config-panel.vue';
import InvoiceNoticeConfigPanel from './components/invoice-notice-config-panel.vue'; import InvoiceNoticeConfigPanel from './components/invoice-notice-config-panel.vue';
import LogisticsConfigPanel from './components/logistics-config-panel.vue'; import LogisticsConfigPanel from './components/logistics-config-panel.vue';
import MedicalRecordConfigPanel from './components/medical-record-config-panel.vue'; import MedicalRecordConfigPanel from './components/medical-record-config-panel.vue';
@@ -29,6 +31,7 @@ import PriceAdjustConfigPanel from './components/price-adjust-config-panel.vue';
import PublicAccountPayConfigPanel from './components/public-account-pay-config-panel.vue'; import PublicAccountPayConfigPanel from './components/public-account-pay-config-panel.vue';
import SalespersonConfigPanel from './components/salesperson-config-panel.vue'; import SalespersonConfigPanel from './components/salesperson-config-panel.vue';
import WorkbenchConfigPanel from './components/workbench-config-panel.vue'; import WorkbenchConfigPanel from './components/workbench-config-panel.vue';
import WithdrawalRulesConfigPanel from './components/withdrawal-rules-config-panel.vue';
import WxWorkbenchDefaultFavPanel from './components/wx-workbench-default-fav-panel.vue'; import WxWorkbenchDefaultFavPanel from './components/wx-workbench-default-fav-panel.vue';
defineOptions({ name: 'SystemConfig' }); defineOptions({ name: 'SystemConfig' });
@@ -83,6 +86,11 @@ const menuItems: MenuItem[] = [
component: PriceAdjustConfigPanel, component: PriceAdjustConfigPanel,
}, },
{ key: 'logistics', title: '物流展示', component: LogisticsConfigPanel }, { key: 'logistics', title: '物流展示', component: LogisticsConfigPanel },
{
key: 'free_shipping',
title: '满额包邮',
component: FreeShippingConfigPanel,
},
{ {
key: 'public_account_pay', key: 'public_account_pay',
title: '公账支付', title: '公账支付',
@@ -131,6 +139,22 @@ const menuItems: MenuItem[] = [
title: '审核', title: '审核',
component: InputAuditConfigPanel, component: InputAuditConfigPanel,
}, },
{
key: 'image_transfer',
title: '传方',
component: ImageTransferConfigPanel,
},
{
key: 'finance',
title: '财务',
tabs: [
{
key: 'withdrawal_rules',
title: '提现规则',
component: WithdrawalRulesConfigPanel,
},
],
},
{ {
key: 'ai_model', key: 'ai_model',
title: 'AI模型', title: 'AI模型',

View File

@@ -4,6 +4,11 @@ export default defineConfig(async () => {
return { return {
application: {}, application: {},
vite: { vite: {
build: {
modulePreload: {
polyfill: false
}
},
optimizeDeps: { optimizeDeps: {
exclude: [ exclude: [
'@vben/plugins/echarts', '@vben/plugins/echarts',