Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f10158b454 | ||
|
|
6c6922c400 | ||
|
|
55a71bfbe3 | ||
|
|
443e501da5 | ||
|
|
c2040c2b15 | ||
|
|
0d18089f71 |
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
],
|
],
|
||||||
keepSource: true,
|
keepSource: true,
|
||||||
pagerConfig: {},
|
pagerConfig: {},
|
||||||
|
height: 'auto',
|
||||||
columnConfig: {
|
columnConfig: {
|
||||||
useKey: true,
|
useKey: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' } },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ function dakuanStatusPill(status: number) {
|
|||||||
<template>
|
<template>
|
||||||
<Page
|
<Page
|
||||||
auto-content-height
|
auto-content-height
|
||||||
description="提现前请确认好您的打款账户"
|
description="提现前请确认打款账户,并可查看提现规则"
|
||||||
title="提现管理"
|
title="提现管理"
|
||||||
>
|
>
|
||||||
<OrderDetailModal />
|
<OrderDetailModal />
|
||||||
|
|||||||
@@ -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 访问日志操作用户搜索
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 是否命中业务缓存(简报按天缓存等) */
|
/** 是否命中业务缓存(简报按天缓存等) */
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 系统配置 - 满额包邮:订单商品金额达到门槛免运费
|
||||||
|
* 数据存老库 yii_system_config(config_type=2, type=2),下单运费计算直接读该行,
|
||||||
|
* 走独立接口 free-shipping-config,不与新库 xk_system_config 的 key-value 混存
|
||||||
|
*/
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { Button, InputNumber, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getFreeShippingConfig, saveFreeShippingConfig } from '../api';
|
||||||
|
|
||||||
|
defineOptions({ name: 'FreeShippingConfigPanel' });
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
/** 门槛金额(元),null 表示尚未加载成功 */
|
||||||
|
const thresholdValue = ref<null | number>(null);
|
||||||
|
/** 配置行是否存在(不存在时禁止保存,提示先初始化 SQL) */
|
||||||
|
const configExists = ref(true);
|
||||||
|
|
||||||
|
/** 加载当前门槛金额,配置行缺失时后端会报错,这里标记后禁用保存 */
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await getFreeShippingConfig();
|
||||||
|
thresholdValue.value = Number(data?.value ?? 0);
|
||||||
|
configExists.value = true;
|
||||||
|
} catch {
|
||||||
|
// 老库无配置行(新环境未跑初始化 SQL)时走到这里
|
||||||
|
configExists.value = false;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存门槛金额,name/rule 不传由后端保留库中原值 */
|
||||||
|
async function handleSave() {
|
||||||
|
const v = thresholdValue.value;
|
||||||
|
if (v === null || Number.isNaN(v) || v < 0) {
|
||||||
|
message.warning('请填写有效的包邮门槛金额');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await saveFreeShippingConfig({ value: v });
|
||||||
|
message.success('保存成功');
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
|
||||||
|
<div class="cfg-panel-body">
|
||||||
|
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">满额包邮门槛(元)</div>
|
||||||
|
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
|
||||||
|
订单商品金额达到该门槛时免运费;未达到时按收货省份运费(「地区管理」中的快递费)计费。
|
||||||
|
单品包邮、门店包邮、特色方包邮的优先级高于本规则;门槛为 0 表示任意金额均包邮。
|
||||||
|
</div>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="thresholdValue"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="1"
|
||||||
|
:disabled="!configExists"
|
||||||
|
style="width: 220px"
|
||||||
|
placeholder="请输入门槛金额"
|
||||||
|
>
|
||||||
|
<template #addonAfter> 元 </template>
|
||||||
|
</InputNumber>
|
||||||
|
<div v-if="!configExists" class="mt-3 text-sm text-[hsl(var(--warning))]">
|
||||||
|
未找到包邮配置数据,请先在老库执行初始化 SQL(sql/20260827/01_yii_system_config_free_shipping_init.sql)后刷新
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cfg-panel-footer">
|
||||||
|
<Button type="primary" :loading="saving" :disabled="!configExists" @click="handleSave">
|
||||||
|
保存本模块配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -17,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模型',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user