feat: 语音开方、修复分账问题、识图成方、识图传方、优化订单卡片展示

This commit is contained in:
李琦
2026-09-23 16:22:14 +08:00
parent 6c6922c400
commit f10158b454
37 changed files with 2561 additions and 642 deletions

View File

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

View File

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

View File

@@ -1,5 +1,62 @@
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';
/**

View File

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

View File

@@ -14,7 +14,6 @@ import {
Table,
Tabs,
Tag,
Timeline,
} from 'ant-design-vue';
import {
@@ -45,6 +44,10 @@ const reconciliationLoading = ref(false);
const activeTab = ref('timeline');
const ledgerScope = ref<LedgerScope>('all');
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 ledgerProduct = 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,
);
/** 商品订单分账快照(无快照时为 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';
}
/**
* 把时间线拆成固定四列的折返行。
* 偶数行从左到右占第 14 列;奇数行从右往左,第 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) {
return value?.trim() ? value : '-';
}
@@ -86,6 +144,7 @@ function resetDrawerState() {
activeTab.value = 'timeline';
ledgerScope.value = isPlatformAdmin.value ? 'all' : 'store';
ledgerSubTab.value = 'product';
productLedgerView.value = 'actual';
}
const [Drawer, drawerApi] = useVbenDrawer({
@@ -356,22 +415,44 @@ watch(isPlatformAdmin, (val) => {
<Tabs v-model:active-key="activeTab" @change="(key) => handleMainTabChange(String(key))">
<Tabs.TabPane key="timeline" tab="溯源时间线">
<Timeline v-if="traceData.timeline?.length">
<Timeline.Item
v-for="node in traceData.timeline"
:key="node.key"
<div v-if="timelineRows.length" class="trace-snake">
<div
v-for="(row, rowIndex) in timelineRows"
:key="rowIndex"
class="trace-snake-wrap"
>
<div class="font-medium">{{ node.title }}</div>
<div class="text-sm text-gray-500">{{ node.time }}</div>
<div v-if="node.status" class="text-sm">
<Tag>{{ node.status }}</Tag>
<div class="trace-snake-row">
<div
v-for="node in row.nodes"
: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 v-if="node.extra" class="mt-1 text-sm text-gray-600">
{{ node.extra }}
</div>
</Timeline.Item>
</Timeline>
<div v-else class="py-6 text-center text-gray-500">暂无溯源记录</div>
<div
v-if="row.showTurn"
class="trace-snake-turn"
:style="{ left: `calc((${row.turnColumn} - 0.5) * 25%)` }"
/>
</div>
</div>
<div v-else class="py-6 text-center text-[hsl(var(--muted-foreground))]">暂无溯源记录</div>
</Tabs.TabPane>
<Tabs.TabPane key="ledger" tab="分账明细">
@@ -392,46 +473,76 @@ watch(isPlatformAdmin, (val) => {
<Tabs v-model:active-key="ledgerSubTab" type="card">
<Tabs.TabPane key="product" tab="商品订单分账">
<div class="mb-2 text-sm">
合计分账{{ ledgerProduct?.sum_money ?? '0' }}
<span class="ml-2 text-xs text-[hsl(var(--muted-foreground))]">不含已取消</span>
</div>
<Table
:columns="ledgerColumns"
:data-source="ledgerProduct?.items ?? []"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record, text }">
<template v-if="column.key === 'drug_image'">
<Image
v-if="record.drug_image"
:src="record.drug_image"
:width="48"
:height="48"
class="object-cover"
/>
<span v-else>-</span>
<Tabs v-model:active-key="productLedgerView" class="mb-3" size="small">
<Tabs.TabPane key="actual" tab="真实分账" />
<Tabs.TabPane key="snapshot" tab="快照分账" />
</Tabs>
<template v-if="productLedgerView === 'actual'">
<div class="mb-2 text-sm">
合计分账{{ ledgerProduct?.sum_money ?? '0' }}
<span class="ml-2 text-xs text-[hsl(var(--muted-foreground))]">不含已取消</span>
</div>
<Table
:columns="ledgerColumns"
:data-source="ledgerProduct?.items ?? []"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record, text }">
<template v-if="column.key === 'drug_image'">
<Image
v-if="record.drug_image"
: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
v-else-if="
column.key === 'drug_name' ||
column.key === 'drug_number' ||
column.key === 'specification' ||
column.key === 'party_name'
"
>
{{ formatDrugCell(text) }}
</template>
</template>
</Table>
<div
v-if="!ledgerProduct?.items?.length && !ledgerLoading"
class="py-6 text-center text-gray-500"
>
暂无分账记录
</div>
</Table>
<div
v-if="!ledgerProduct?.items?.length && !ledgerLoading"
class="py-6 text-center text-[hsl(var(--muted-foreground))]"
>
暂无分账记录
</div>
</template>
<template v-else>
<Descriptions
v-if="productLedgerSnapshot"
bordered
size="small"
:column="2"
>
<Descriptions.Item label="分账方式">
{{ productLedgerSnapshot.split_type_txt || '-' }}
</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 key="register" tab="挂号订单分账">
<div class="mb-2 text-sm">
@@ -497,3 +608,147 @@ watch(isPlatformAdmin, (val) => {
<OrderDetailModal />
</Drawer>
</template>
<style scoped>
/* 溯源时间线:固定四列网格,奇数行从右往左折返,列与上一行对齐 */
.trace-snake {
display: flex;
flex-direction: column;
gap: 36px;
padding: 8px 12px 16px;
}
.trace-snake-wrap {
position: relative;
}
.trace-snake-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: auto;
column-gap: 36px;
align-items: stretch;
}
.trace-snake-node {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
min-height: 112px;
padding: 12px;
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 10px;
background: hsl(var(--card, var(--background)));
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.trace-snake-head {
display: flex;
align-items: center;
gap: 8px;
min-height: 22px;
}
.trace-snake-index {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 999px;
background: hsl(var(--primary) / 12%);
color: hsl(var(--primary));
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.trace-snake-title {
min-width: 0;
color: hsl(var(--foreground));
font-size: 14px;
font-weight: 600;
line-height: 1.3;
word-break: break-all;
}
.trace-snake-status {
align-self: flex-start;
max-width: 100%;
padding: 0 8px;
border: 1px solid hsl(var(--border));
border-radius: 999px;
background: hsl(var(--muted) / 35%);
color: hsl(var(--muted-foreground));
font-size: 12px;
line-height: 20px;
}
.trace-snake-status.is-ok {
border-color: hsl(var(--primary) / 28%);
background: hsl(var(--primary) / 12%);
color: hsl(var(--primary));
}
.trace-snake-status.is-warn {
border-color: hsl(var(--warning) / 35%);
background: hsl(var(--warning) / 12%);
color: hsl(var(--warning));
}
.trace-snake-time {
color: hsl(var(--muted-foreground));
font-size: 12px;
font-variant-numeric: tabular-nums;
line-height: 1.4;
}
.trace-snake-extra {
margin-top: auto;
padding: 6px 8px;
overflow: hidden;
border-radius: 6px;
background: hsl(var(--muted) / 35%);
color: hsl(var(--muted-foreground));
font-size: 12px;
line-height: 1.45;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.trace-snake-node.point-right::after,
.trace-snake-node.point-left::after {
content: '';
position: absolute;
top: 23px;
width: 28px;
height: 2px;
background: hsl(var(--primary) / 45%);
}
.trace-snake-node.point-right::after {
right: -32px;
}
.trace-snake-node.point-right::before {
content: '';
position: absolute;
top: 19px;
right: -36px;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: 7px solid hsl(var(--primary) / 70%);
}
.trace-snake-node.point-left::after {
left: -32px;
}
.trace-snake-node.point-left::before {
content: '';
position: absolute;
top: 19px;
left: -36px;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-right: 7px solid hsl(var(--primary) / 70%);
}
.trace-snake-turn {
position: absolute;
bottom: -36px;
width: 2px;
height: 36px;
transform: translateX(-1px);
background: hsl(var(--primary) / 45%);
}
</style>

View File

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

View File

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

View File

@@ -1184,20 +1184,21 @@ function buildRowActions(row: Record<string, any>): {
<PrescriptionDetailModal />
<TraceDrawer />
<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 内嵌表单 -->
<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 />
</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" />
<Button v-if="viewMode === 'card'" type="primary" @click="openExportModal">
导出
</Button>
</div>
<Grid v-if="viewMode === 'list'">
<Grid v-if="viewMode === 'list'" class="h-0 min-h-0 flex-1">
<template #toolbar-actions>
<TableAction
:flex="false"
@@ -1536,7 +1537,7 @@ function buildRowActions(row: Record<string, any>): {
</template>
</Grid>
<!-- 卡片看板视图展示内容与列表一致行操作/弹窗全部复用父级逻辑 -->
<div v-else class="po-card-wrap">
<div v-else class="min-h-0 flex-1 overflow-y-auto">
<ProductOrderCardList
ref="cardListRef"
:form-values="searchValues"
@@ -1558,6 +1559,7 @@ function buildRowActions(row: Record<string, any>): {
@reverse-salesperson="handleReverseSalesperson"
/>
</div>
</div>
<PercentAdjustDrawer />
<!-- 订单改仓按药分别选仓 -->
<AntdModal
@@ -1645,10 +1647,4 @@ function buildRowActions(row: Record<string, any>): {
.tcm-drug-chip {
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
}
/* 卡片看板容器:撑满剩余高度,内部滚动(统计岛 + 收起态搜索一行 + 切换行约 220pxmin-height 兜底) */
.po-card-wrap {
height: calc(100% - 220px);
min-height: 420px;
}
</style>

View File

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

View File

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

View File

@@ -45,6 +45,18 @@ export async function getProductListDoctorReception(data: any) {
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
@@ -670,3 +682,23 @@ export async function getProxyPayQrcodeApi(orderId: number) {
order_id: orderId,
});
}
/** 处方图 OCRDeepSeek */
export async function ocrPrescriptionImageApi(data: {
image_urls: string[];
mode: 'reception' | 'transfer';
register_id?: number;
store_id?: number;
}) {
return requestClient.post<any>(`${prefix}ocr-prescription-image`, data);
}
/** 确认识图传方:建患者/挂号 + 待导入传方单 */
export async function confirmTransferImageOcrApi(data: Record<string, any>) {
return requestClient.post<{
transfer_id: number;
register_id: number;
user_patient_id: number;
patient_action: string;
}>(`${prefix}confirm-transfer-image-ocr`, data);
}

View File

@@ -1,4 +1,10 @@
<script setup lang="ts">
/**
* 中药制剂方式 / 代煎 / 用量配置
* 与小程序对齐:标题带必填星号;父页提交失败可传 errorKeys 标红
*/
import { computed } from 'vue';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { InputNumber } from 'ant-design-vue';
@@ -13,19 +19,30 @@ export type ChineseMedicineConfigChange = {
dayDosage?: number;
};
const props = defineProps<{
ruleType: number;
packageMethodId?: number | null;
processRuleId?: number | null;
childProcessRuleId?: number | null;
processRuleNoteId?: number | null;
dosage: number;
dayDosage: number;
processRuleList?: Array<{ id: number; name: string }>;
childProcessRuleList?: Array<{ id: number; name: string }>;
processRuleNoteList?: Array<{ id: number; note?: string; name?: string }>;
disabled?: boolean;
}>();
const props = withDefaults(
defineProps<{
ruleType: number;
packageMethodId?: number | null;
processRuleId?: number | null;
childProcessRuleId?: number | null;
processRuleNoteId?: number | null;
dosage: number;
dayDosage: number;
processRuleList?: Array<{ id: number; name: string }>;
childProcessRuleList?: Array<{ id: number; name: string }>;
processRuleNoteList?: Array<{ id: number; note?: string; name?: string }>;
disabled?: boolean;
/** 未填字段名;父页提交失败传入,改选后由父清空 */
errorKeys?: string[];
}>(),
{
processRuleList: () => [],
childProcessRuleList: () => [],
processRuleNoteList: () => [],
disabled: false,
errorKeys: () => [],
},
);
const emit = defineEmits<{
change: [payload: ChineseMedicineConfigChange];
@@ -37,6 +54,23 @@ const packageMethodList = [
{ 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>
<div class="chinese-medicine-config">
<!-- 制剂方式 -->
<div class="config-section mb-4">
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">制剂方式</div>
<!-- 制剂方式必填 -->
<div class="config-section mb-4" :class="{ 'sec-error': errorMap.ruleType }">
<div class="section-title mb-2">
<span class="title-text">制剂方式</span>
<span class="req-star">*</span>
</div>
<div class="pill-group">
<span
class="pill"
@@ -123,11 +160,19 @@ function updateDayDosage(val: number) {
@click="handleRuleTypeChange(2)"
>委托调剂</span>
</div>
<div v-if="errorMap.ruleType" class="err-tip">请选择制剂方式</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 class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">包法选择</div>
<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">
<span
v-for="item in packageMethodList"
@@ -137,12 +182,19 @@ function updateDayDosage(val: number) {
@click="handlePackageMethodClick(item.id)"
>{{ item.name }}</span>
</div>
<div v-if="errorMap.packageMethodId" class="err-tip">请选择包法</div>
</div>
<!-- 委托调剂制剂 / 煎法 / 备注 -->
<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 class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">制剂要求</div>
<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">
<span
v-for="item in processRuleList"
@@ -152,9 +204,17 @@ function updateDayDosage(val: number) {
@click="handleProcessRuleClick(item)"
>{{ item.name }}</span>
</div>
<div v-if="errorMap.processRuleId" class="err-tip">请选择制剂要求</div>
</div>
<div v-if="childProcessRuleList?.length" class="config-section mb-4">
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">煎法说明</div>
<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">
<span
v-for="item in childProcessRuleList"
@@ -164,9 +224,17 @@ function updateDayDosage(val: number) {
@click="handleChildProcessRuleClick(item)"
>{{ item.name }}</span>
</div>
<div v-if="errorMap.childProcessRuleId" class="err-tip">请选择煎法说明</div>
</div>
<div v-if="processRuleNoteList?.length" class="config-section mb-4">
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">附加备注</div>
<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">
<span
v-for="item in processRuleNoteList"
@@ -176,15 +244,16 @@ function updateDayDosage(val: number) {
@click="handleProcessRuleNoteClick(item)"
>{{ item.note || item.name }}</span>
</div>
<div v-if="errorMap.processRuleNoteId" class="err-tip">请选择附加备注</div>
</div>
</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="flex items-center gap-2">
<span class="text-sm font-bold text-gray-800 dark:text-gray-200">剂数</span>
<!-- 带加减按钮时需足够宽度避免中间输入区过窄无法输入 -->
<div class="flex items-center gap-2" :class="{ 'field-error': errorMap.dosage }">
<span class="title-text">剂数</span>
<span class="req-star">*</span>
<InputNumber
:value="dosage"
:disabled="disabled"
@@ -200,11 +269,11 @@ function updateDayDosage(val: number) {
<PlusOutlined @click="!disabled && updateDosage(dosage < 100 ? dosage + 1 : 100)" />
</template>
</InputNumber>
<span class="text-sm text-gray-500 dark:text-gray-400">()</span>
<span class="text-sm text-muted-foreground">()</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-gray-800 dark:text-gray-200">用药频次</span>
<!-- 同上加宽以保证可正常手输数字 -->
<div class="flex items-center gap-2" :class="{ 'field-error': errorMap.dayDosage }">
<span class="title-text">用药频次</span>
<span class="req-star">*</span>
<InputNumber
:value="dayDosage"
:disabled="disabled"
@@ -220,14 +289,52 @@ function updateDayDosage(val: number) {
<PlusOutlined @click="!disabled && updateDayDosage(dayDosage < 100 ? dayDosage + 1 : 100)" />
</template>
</InputNumber>
<span class="text-sm text-gray-500 dark:text-gray-400">/</span>
<span class="text-sm text-muted-foreground">/</span>
</div>
</div>
<div v-if="errorMap.dosage" class="err-tip">请填写剂数</div>
<div v-if="errorMap.dayDosage" class="err-tip">请填写用药频次</div>
</div>
</div>
</template>
<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 {
display: flex;
flex-wrap: wrap;
@@ -235,8 +342,8 @@ function updateDayDosage(val: number) {
}
.pill {
padding: 4px 16px;
background-color: #f4f6f8;
color: #6c7380;
background-color: hsl(var(--muted) / 0.55);
color: hsl(var(--muted-foreground));
border-radius: 20px;
font-size: 13px;
cursor: pointer;
@@ -245,45 +352,24 @@ function updateDayDosage(val: number) {
user-select: none;
}
.pill.active {
background-color: #00a88a;
color: #fff;
border-color: #00a88a;
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground, 0 0% 100%));
border-color: hsl(var(--primary));
font-weight: 600;
}
.pill.outline {
background-color: #fff;
border-color: #e2e8f0;
background-color: hsl(var(--card, var(--background)));
border-color: hsl(var(--border));
}
.pill.outline.active {
background-color: #e8f6f4;
color: #00a88a;
border-color: #00a88a;
background-color: hsl(var(--primary) / 0.12);
color: hsl(var(--primary));
border-color: hsl(var(--primary));
}
.pill.disabled {
opacity: 0.5;
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 {
width: 140px;
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,40 @@
<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 { 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 { useFreshSearchTime } from '#/composables/use-fresh-search-time';
import { TableAction } from '#/components/table-action';
import { useFreshSearchTime } from '#/composables/use-fresh-search-time';
import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
import DetailModal from '#/views/business/order/product-order/components/detail.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 { 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 = {
resetSearchTime: async () => {},
markSearchTimeTouched: () => {},
@@ -41,98 +61,226 @@ const freshSearchTime = useFreshSearchTime({
onRefresh: () => gridApi.reload(),
});
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 applyOrdersModalRef = ref<InstanceType<typeof ApplyOrdersModal>>();
const [OrderDetailModal, OrderDetailModalApi] = useVbenModal({
const { OrderDetailModal, RegisterDetailModal, TraceDrawer, openOrderDetail } =
useWithdrawOrderDetailModals();
/** 资金流水行:仅商品订单详情(流水表本身偏产品单) */
const [FundOrderDetailModal, fundOrderDetailModalApi] = useVbenModal({
connectedComponent: DetailModal,
});
const infoModal = (id) => {
OrderDetailModalApi.setData({
// 表单值
id,
});
OrderDetailModalApi.open();
const infoModal = (id: number) => {
if (!id) return;
fundOrderDetailModalApi.setData({ id });
fundOrderDetailModalApi.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>
<template>
<Page auto-content-height title="资金流水记录">
<FundOrderDetailModal />
<OrderDetailModal />
<RegisterDetailModal />
<TraceDrawer />
<ApplyOrdersModal ref="applyOrdersModalRef" @view-order="openOrderDetail" />
<StatisticsReconciliation v-if="statistics" :statistics="statistics" />
<Grid>
<template #toolbar-actions>
<TableAction :actions="[]" :drop-down-actions="[]">
<template #more>
<Button style="margin-left: 16px">
批量操作
<Icon icon="ant-design:down-outlined" />
</Button>
<Tabs
:active-key="activeTab"
class="fund-water-tabs"
type="card"
:destroy-inactive-tab-pane="false"
@change="handleTabChange"
>
<Tabs.TabPane :key="TAB_FUND" tab="资金流水">
<Grid>
<template #toolbar-actions>
<TableAction :actions="[]" :drop-down-actions="[]" />
</template>
</TableAction>
</template>
<template #order-type="{ row }">
<div class="mt-3">
<Tag v-if="row.order_type === 1" color="purple">产品订单</Tag>
<Tag v-else-if="row.order_type === 2" color="green">挂号订单</Tag>
<Tag v-else-if="row.order_type === 3" color="blue">问诊订单</Tag>
</div>
</template>
<template #type="{ row }">
<div class="mt-3">
<Tag v-if="row.type === 'refund'" color="purple">出账</Tag>
<Tag v-else-if="row.type === 'enter'" color="green">入账</Tag>
<Tag v-else-if="row.type === 'withdraw'" color="orange">提现</Tag>
<Tag v-else-if="row.type === 'charge'" color="red">代付手续费</Tag>
</div>
</template>
<template #toolbar-tools></template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '详情',
type: 'link',
icon: 'marketeq:eye',
size: 'small',
// auth: ['order', 'sys:role:detail'],
onClick: infoModal.bind(null, row.order_id),
},
]"
/>
</template>
</Grid>
<!-- 订单号链接与操作列详情同一套 infoModal -->
<template #order_no="{ row }">
<Button
v-if="row.order_no"
type="link"
size="small"
@click="infoModal(row.order_id)"
>
{{ row.order_no }}
</Button>
<span v-else>-</span>
</template>
<template #order-type="{ row }">
<div class="mt-3">
<Tag v-if="row.order_type === 1" color="purple">产品订单</Tag>
<Tag v-else-if="row.order_type === 2" color="green">挂号订单</Tag>
<Tag v-else-if="row.order_type === 3" color="blue">问诊订单</Tag>
</div>
</template>
<template #type="{ row }">
<div class="mt-3">
<Tag v-if="row.type === 'refund'" color="purple">出账</Tag>
<Tag v-else-if="row.type === 'enter'" color="green">入账</Tag>
<Tag v-else-if="row.type === 'withdraw'" color="orange">提现</Tag>
<Tag v-else-if="row.type === 'charge'" color="red">代付手续费</Tag>
</div>
</template>
<template #toolbar-tools></template>
<template #action="{ row }">
<TableAction
:actions="[
{
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>
</template>
<style scoped lang="scss">
.custom-list {
list-style-type: none;
padding-left: 0;
.fund-water-tabs {
:deep(.ant-tabs-content),
:deep(.ant-tabs-tabpane) {
min-height: 480px;
}
}
.custom-list-item {
background-color: rgba(64, 158, 255, 0.04);
border-radius: 4px;
margin-bottom: 8px;
padding: 8px 12px;
font-size: 14px;
transition: background-color 0.3s;
/* 资金变动金额:涨绿跌红走主题变量,适配暗色 */
.amount-cell {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
font-weight: 600;
font-variant-numeric: tabular-nums;
&:hover {
background-color: rgba(64, 158, 255, 0.1);
&.is-up {
color: hsl(var(--success));
}
&::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
background-color: #409eff;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
&.is-down {
color: hsl(var(--destructive));
}
}
.amount-icon {
width: 14px;
height: 14px;
}
.cell-muted {
color: hsl(var(--muted-foreground));
}
</style>

View File

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

View File

@@ -11,6 +11,20 @@ import DailyUploadForm from './daily-upload-form.vue';
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({
fullscreenButton: false,
draggable: true,
@@ -32,10 +46,9 @@ const [Modal, modalApi] = useVbenModal({
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
await nextTick();
await formRef.value?.load(
buildDailyUploadFormData(modalApi.getData<Record<string, any>>() || {}),
);
const form = await waitFormReady();
const row = modalApi.getData<Record<string, any>>() || {};
await form?.load(buildDailyUploadFormData(row));
},
});
</script>

View File

@@ -12,7 +12,13 @@ export interface DailyUploadRowLike {
platform_amount_total?: number | string;
bill_date_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 */
@@ -80,16 +86,60 @@ export function resolveDailyBillDateTxt(
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(
row: DailyUploadRowLike,
extras?: Record<string, any>,
) {
const uploadId = resolveDailyUploadId(row);
const mergeId = resolveDailyMergeId(row);
return {
...row,
...extras,
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),
platform_amount: resolveDailyPlatformAmount(row),
bill_date_txt: resolveDailyBillDateTxt(row),

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,13 @@ export async function getOldApiLogList(data: any) {
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 访问日志操作用户搜索
*/

View File

@@ -3,19 +3,30 @@ import { ref } from 'vue';
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 { getIcon } from '#/util/tool';
import { getApiOpLogDetail } from '../api';
import {
getApiOpLogOperatorLabel,
getApiOpLogPlatformLabel,
} from '../config/platform';
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({
fullscreenButton: false,
@@ -26,13 +37,20 @@ const [Modal, modalApi] = useVbenModal({
onConfirm: async () => {
modalApi.close();
},
onOpenChange(isOpen: boolean) {
async onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values } = modalApi.getData<Record<string, any>>();
if (values) {
data.value = values;
}
if (!isOpen) return;
const { values } = modalApi.getData<Record<string, any>>();
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>
<Modal class="w-[80%] h-[80%]" title="Api访问日志详情">
<Spin :spinning="loading">
<div class="flex flex-col gap-4">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }"
@@ -91,13 +110,14 @@ const [Modal, modalApi] = useVbenModal({
bordered
>
<Descriptions.Item label="参数">
<pre>{{ JSON.parse(data.param) }}</pre>
<pre>{{ formatJson(data.param) }}</pre>
</Descriptions.Item>
<Descriptions.Item label="结果">
<pre>{{ JSON.parse(data.result) }}</pre>
<pre>{{ formatJson(data.result) }}</pre>
</Descriptions.Item>
</Descriptions>
</div>
</Spin>
</Modal>
</template>

View File

@@ -7,11 +7,29 @@ import { computed, ref } from 'vue';
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 GenerationFlowTimeline from './generation-flow-timeline.vue';
const TabPane = Tabs.TabPane;
/** 详情内容区 Tabpreview=结构化预览 / 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<{
/** 打开页面级归档弹窗 */
archive: [row: Record<string, any>];
@@ -22,6 +40,73 @@ const emit = defineEmits<{
const loading = ref(false);
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({
title: '生成记录详情',
class: 'w-[min(1280px,96vw)]',
@@ -345,19 +430,204 @@ function onArchiveClick() {
</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>
<pre class="json-pre">{{ briefPreview }}</pre>
</div>
<div class="mt-3 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>
<!-- 识图开方/传方原图始终展示便于对照下方预览/JSON -->
<div v-if="isOcrScene" class="json-panel mt-3">
<div class="json-title">
处方原图
<span class="ml-2 text-[hsl(var(--muted-foreground))]">
{{ ocrImageUrls.length ? `${ocrImageUrls.length}` : '无图' }}
</span>
</div>
<div class="json-panel">
<div class="json-title">结果 JSON</div>
<pre class="json-pre">{{ prettyJson(detail.result_json) }}</pre>
<div v-if="ocrImageUrls.length" class="ocr-img-row">
<Image.PreviewGroup>
<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 v-else class="ocr-img-empty">
本条记录未写入 image_urls请确认识图时已成功上传
</div>
</div>
<!-- 生成内容详情预览 / JSONlocalStorage 记忆 -->
<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>
</Spin>
@@ -406,6 +676,106 @@ function onArchiveClick() {
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 {
overflow: hidden;
background: hsl(var(--card, var(--background)));

View File

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

View File

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

View File

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

View File

@@ -274,6 +274,15 @@ export async function updateStorePublicAccountLockDays(data: {
return requestClient.post<any>(`${prefix}update-public-account-lock-days`, data);
}
/** 单独更新诊所订单分账类型与平台占比 */
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 }) {
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);
}
/** 门店轮播图列表(平台端传 store_id */
export async function getStoreNavListApi(storeId: number) {
return requestClient.get<any>(`${prefix}nav-list`, {
params: { store_id: storeId },
});
}
/** 全量同步门店轮播图 URL含排序 */
export async function saveStoreNavUrlApi(data: {
store_id: number;
url: string[];
}) {
return requestClient.post<any>(`${prefix}upload-nav`, data);
}

View File

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

View File

@@ -1,5 +1,6 @@
import type { VbenFormProps } from '#/adapter/form';
import { STORE_LEDGER_SPLIT_TYPE_OPTIONS } from './constants';
// 诊所管理表单配置
// 注意已移除类型选择字段固定为type=0诊所类型
@@ -321,6 +322,38 @@ export const modalFormProps: VbenFormProps = {
suffix: () => '%',
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',
// formItemClass: 'col-span-6',

View File

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

View File

@@ -42,7 +42,9 @@ import BindSpecialPrescriptionDoctorModal from './components/BindSpecialPrescrip
import BindPrescriptionTypesModal from './components/BindPrescriptionTypesModal.vue';
import StoreExternalFieldModal from './components/StoreExternalFieldModal.vue';
import SalespersonCommissionDrawer from './components/SalespersonCommissionDrawer.vue';
import LedgerSplitModal from './components/LedgerSplitModal.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 StoreCardModal from '#/components/store-card/StoreCardModal.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 { gridOptions } from './config/table';
import { prescriptionTypeLabel } from './config/prescription-type-constants';
import { storeLedgerSplitTagText } from './config/constants';
import {
canAddStoreDoctor,
canManageStoreBankCard,
@@ -205,6 +208,36 @@ const [BankCardStoreEditModalComponent, bankCardStoreEditModalApi] = useVbenModa
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) => {
bankCardStoreEditModalApi.setData({
storeId: row.id,
@@ -561,6 +594,8 @@ const handleSwitchClinicType = (row: any) => {
<BankCardReportModalComponent />
<BankCardStatusModalComponent />
<BankCardStoreEditModalComponent />
<LedgerSplitModalComponent />
<StoreNavModalComp />
<StoreCardModalComp />
<VipHubModalComp />
<AddDoctorModalComp />
@@ -648,6 +683,7 @@ const handleSwitchClinicType = (row: any) => {
:on-order-price-adjust="updateOrderPricePercentAdjust"
:on-public-account-pay="updatePublicAccountPayEnabled"
:on-public-account-lock-days="handlePublicAccountLockDays"
:on-ledger-split="openLedgerSplitModal"
:on-bank-card-report="handleBankCardReportColumnClick"
/>
</template>
@@ -759,6 +795,13 @@ const handleSwitchClinicType = (row: any) => {
</Tag>
</div>
</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 }">
<Tag color="success">{{ row.start_time }}</Tag>
<br />
@@ -777,6 +820,22 @@ const handleSwitchClinicType = (row: any) => {
auth: ['Super Admin', 'Admin'],
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: '删除',
type: 'link',

View File

@@ -20,6 +20,7 @@ import DoctorLoginConfigPanel from './components/doctor-login-config-panel.vue';
import FreeShippingConfigPanel from './components/free-shipping-config-panel.vue';
import HomeDisplayConfigPanel from './components/home-display-config-panel.vue';
import InputAuditConfigPanel from './components/input-audit-config-panel.vue';
import ImageTransferConfigPanel from './components/image-transfer-config-panel.vue';
import InvoiceNoticeConfigPanel from './components/invoice-notice-config-panel.vue';
import LogisticsConfigPanel from './components/logistics-config-panel.vue';
import MedicalRecordConfigPanel from './components/medical-record-config-panel.vue';
@@ -30,6 +31,7 @@ import PriceAdjustConfigPanel from './components/price-adjust-config-panel.vue';
import PublicAccountPayConfigPanel from './components/public-account-pay-config-panel.vue';
import SalespersonConfigPanel from './components/salesperson-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';
defineOptions({ name: 'SystemConfig' });
@@ -137,6 +139,22 @@ const menuItems: MenuItem[] = [
title: '审核',
component: InputAuditConfigPanel,
},
{
key: 'image_transfer',
title: '传方',
component: ImageTransferConfigPanel,
},
{
key: 'finance',
title: '财务',
tabs: [
{
key: 'withdrawal_rules',
title: '提现规则',
component: WithdrawalRulesConfigPanel,
},
],
},
{
key: 'ai_model',
title: 'AI模型',

View File

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