feat: 管理端公账支付与确认、锁店遮罩及表单提交修复

新增公账账单/到账确认页与全局锁店组件;系统配置补公账参数面板;
统一弹窗 onConfirm 校验提交,并完善订单详情与门店相关交互。
This commit is contained in:
李琦
2026-08-21 12:51:42 +08:00
parent 66e666754a
commit 879efb8f29
145 changed files with 7326 additions and 884 deletions

View File

@@ -68,9 +68,54 @@ alwaysApply: true
## 表单校验
- 必填用字符串规则 `rules: 'required'`(选择项用 `'selectRequired'`),规则定义在 `#/adapter/form.ts` 的 `defineRules`
- 校验调用统一用 `formApi.validate().then(e => { if (e.valid) {...} })`
- 必填用字符串规则 `rules: 'required'`(选择项用 `'selectRequired'`),规则定义在 `#/adapter/form.ts` 的 `rules`
- 必填字段在 schema 中加 `rules: 'required'`,禁止散落 `validator` 写法
- 校验调用统一用 **单次 await**(禁止 `.then` 与 `validateAndSubmitForm` 并用):
```ts
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
```
## 弹窗 onConfirm强制防「点确定无反应」
弹窗内提交必须按下列写法,**禁止**复制旧模板:
```ts
onConfirm: async () => {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
await submitApi(values);
message.success('保存成功');
gridApi.value?.reload?.();
modalApi.close();
} catch (err) {
console.error(err);
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
};
```
- **禁止** `formApi.validate().then(...)` 后再 `await formApi.validateAndSubmitForm()`(并发二次校验,表现为点确定无提交)
- **禁止**无 `handleSubmit` 时调用 `validateAndSubmitForm` / `validateAndSubmit`
- 参考:`product-order/components/refund.vue`、`audit-prescription/components/modal.vue`、`delivery-warehouse-order/components/modal.vue`
## 表单 Divider / 分区标题
- `component: 'Divider'` 仅作分区展示,**禁止**加 `rules: 'required'`(无业务值,必失败)
- **禁止**用占位 `label: 'x'`会渲染成校验文案「请输入x」
- `fieldName` 用唯一占位如 `_section_basic`,不要空字符串
## 敏感字段展示SensitiveText
- 模板使用 `<SensitiveText>` 时,**必须**显式:`import SensitiveText from '#/components/sensitive-text/SensitiveText.vue'`
- 该组件**未**全局注册;表单选择器(`form/components/*-picker.vue`)同样要 import对齐 `doctor-picker` / `store-picker`
- **禁止**只写模板标签不写 import会报 `Failed to resolve component: SensitiveText`
## 字典/枚举管理
@@ -177,10 +222,10 @@ alwaysApply: true
- 表单必须用 `useVbenForm`,禁止直接 `<form>` 或 antd `Form`
- 动态修改 schema 必须用 `formApi.updateSchema([...])`**vben form 没有 `setComponentProps` 这个方法**(这是已踩过的坑)
- 重置表单用 `formApi.resetForm()`**严禁 `formApi.resetFields()`**(那是 antd Form 的 APIVben Form 没有,会报 `resetFields is not a function`;已踩过坑)
- 重置表单用 `formApi.reset()``resetForm` 已弃用);**严禁 `formApi.resetFields()`**(那是 antd Form 的 APIVben Form 没有,会报 `resetFields is not a function`;已踩过坑)
- 取值/赋值用 `formApi.getValues()` / `formApi.setValues(obj)`,禁止自己 `v-model` 收集
- 校验用 `formApi.validate().then(e => { if (e.valid) {...} })` `formApi.validateAndSubmitForm()`
- 必填规则用字符串 `'required'` / `'selectRequired'`,规则在 `#/adapter/form.ts` 的 `defineRules` 注册
- 校验用 `const e = await formApi.validate(); if (!e.valid) return;`**禁止** `validate().then` `validateAndSubmitForm` 并用(见上文「弹窗 onConfirm」
- 必填规则用字符串 `'required'` / `'selectRequired'`,规则在 `#/adapter/form.ts` 的 `rules` 注册
- schema 中字段名(`fieldName`)必须与后端字段 snake_case 对齐,禁止前端 camelCase
- 密码/敏感字段用 `VbenInputPassword` 组件,禁止用 `VbenInput` 配 `type="password"`
- 单选/多选选项来自接口时,必须在弹窗/页面 `onMounted` 或 `onOpenChange` 里拉取后通过 `updateSchema` 注入,禁止写死常量

View File

@@ -5,3 +5,13 @@
```vue
v-access:code="['Admin']
```
# Web + 桌面一起打
pnpm build:antd-desktop
# 只打 Web
node build-antd-desktop.mjs --only=web
# 只打桌面(需已有 apps/web-antd/dist
node build-antd-desktop.mjs --only=desktop

View File

@@ -10,6 +10,9 @@ pnpm dev:desktop
# 打包turbo 先构建 web-antd dist再构建主进程并出安装包产物在 apps/desktop/release/
pnpm build:desktop
# 一键同时打 Web + 桌面(根目录 build-antd-desktop.mjs先 web 再桌面)
pnpm build:antd-desktop
```
- Windows 机器出 NSIS 安装包mac 包必须在 macOS 机器上执行同一命令electron-builder 不支持交叉编译 mac 包)。

View File

@@ -1,19 +1,71 @@
<!doctype html>
<html lang="zh">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="renderer" content="webkit" />
<meta name="description" content="A Modern Back-end Management System" />
<meta name="keywords" content="Vben Admin Vue3 Vite" />
<meta name="author" content="Vben" />
<!-- SEO萧康云医管理后台品牌与说明 -->
<meta
name="description"
content="萧康云医管理后台:诊所药店医联体 SaaS 运营中台,覆盖挂号引流、在线诊疗、订单财务、仓储备药、私域运营与古法煎药中心等业务管理。"
/>
<meta
name="keywords"
content="萧康云医,管理后台,医联体SaaS,诊所管理,药店管理,在线诊疗,挂号引流,电子处方,财务对账"
/>
<meta name="author" content="萧康云医" />
<meta name="application-name" content="萧康云医" />
<meta name="theme-color" content="#4f6ef7" />
<meta name="robots" content="index, follow" />
<meta
name="viewport"
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
/>
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
<title>%VITE_APP_TITLE%</title>
<link rel="icon" href="/favicon.ico" />
<link rel="canonical" href="https://admin.xiaokang88.com/" />
<!-- 站点图标:浏览器标签 + iOS 主屏,与侧栏 logo 同源 -->
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" href="/img/logo.png" />
<link rel="apple-touch-icon" href="/img/logo.png" />
<!-- Open Graph分享卡片展示品牌 Logo 与说明 -->
<meta property="og:type" content="website" />
<meta property="og:locale" content="zh_CN" />
<meta property="og:site_name" content="萧康云医" />
<meta property="og:title" content="萧康云医管理后台" />
<meta
property="og:description"
content="萧康云医管理后台:诊所药店医联体 SaaS 运营中台,覆盖挂号、诊疗、订单、财务与煎药中心等业务管理。"
/>
<meta property="og:url" content="https://admin.xiaokang88.com/" />
<meta property="og:image" content="https://admin.xiaokang88.com/img/logo.png" />
<meta property="og:image:alt" content="萧康云医 Logo" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="萧康云医管理后台" />
<meta
name="twitter:description"
content="萧康云医管理后台:诊所药店医联体 SaaS 运营中台。"
/>
<meta name="twitter:image" content="https://admin.xiaokang88.com/img/logo.png" />
<!-- 结构化数据:搜索引擎识别组织与后台站点 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "萧康云医管理后台",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Web",
"url": "https://admin.xiaokang88.com/",
"description": "萧康云医管理后台:诊所药店医联体 SaaS 运营中台,覆盖挂号引流、在线诊疗、订单财务、仓储备药、私域运营与古法煎药中心等业务管理。",
"image": "https://admin.xiaokang88.com/img/logo.png",
"publisher": {
"@type": "Organization",
"name": "萧康云医",
"url": "https://admin.xiaokang88.com/",
"logo": "https://admin.xiaokang88.com/img/logo.png"
}
}
</script>
<script>
// 生产环境下注入百度统计
if (window._VBEN_ADMIN_PRO_APP_CONF_) {

View File

@@ -152,12 +152,15 @@ const withDefaultPlaceholder = <T extends Component>(
return defineComponent({
name: (component as any).name,
inheritAttrs: false,
setup: (props: any, { attrs, expose, slots }) => {
const placeholder =
props?.placeholder ||
attrs?.placeholder ||
$t(`ui.placeholder.${type}`);
// 透传组件暴露的方法
// 必须声明 valueinheritAttrs:false 时若只靠 attrs 转发,异步 Input/Select
// 收不到 v-model表现是能打字但表单仍是 defaultValue查询参数一直为空
props: {
placeholder: { type: String, default: undefined },
value: { default: undefined },
},
emits: ['update:value'],
setup: (props, { attrs, emit, expose, slots }) => {
// 透传内部组件暴露的方法(如 focus给表单 autofocus 等使用
const innerRef = ref();
expose(
new Proxy(
@@ -168,12 +171,24 @@ const withDefaultPlaceholder = <T extends Component>(
},
),
);
return () =>
h(
return () => {
const placeholder =
props.placeholder ||
(attrs.placeholder as string | undefined) ||
$t(`ui.placeholder.${type}`);
return h(
component,
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
{
...componentProps,
...attrs,
placeholder,
value: props.value,
'onUpdate:value': (val: unknown) => emit('update:value', val),
ref: innerRef,
},
slots,
);
};
},
});
};

View File

@@ -9,13 +9,34 @@ import type { ComponentPropsMap, ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
/**
* 判断表单值是否「空」(必填校验共用)
* 为什么不写 value.length===0InputNumber 是 number.length 为 undefined会误判/漏判
* 数字 0 视为有值(比例、金额等合法)
*/
function isEmptyFormValue(value: unknown): boolean {
if (value === undefined || value === null) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
return false;
}
/** 校验文案用的字段名:优先 label避免 i18n {0} 裸露成「请输入x」 */
function ruleLabel(ctx: { label?: string; name?: string }): string {
return (ctx.label || ctx.name || '该项').trim() || '该项';
}
async function initSetupVbenForm() {
setupVbenForm<ComponentType>({
config: {
// ant design vue组件库默认都是 v-model:value
baseModelPropName: 'value',
// antd Input 的 onChange 带的是 event打开回退后表单能从 event.target.value 取值
// (与 v-model:value 双通道写入,避免包装层漏绑时查询条件一直是空)
changeEventFallback: true,
// 一些组件是 v-model:checked 或者 v-model:fileList
// Vben* 走 modelValue避免被 baseModelPropName=value 误绑后把 Event 写进字段
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
@@ -23,20 +44,23 @@ async function initSetupVbenForm() {
Upload: 'fileList',
UploadImage: 'modelValue',
UploadOssFile: 'modelValue',
VbenInput: 'modelValue',
VbenInputPassword: 'modelValue',
VbenPinInput: 'modelValue',
},
},
rules: {
// 输入项目必填国际化适配
// 输入项目必填国际化适配(兼容 InputNumber number
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
if (isEmptyFormValue(value)) {
return $t('ui.formRules.required', [ruleLabel(ctx)]);
}
return true;
},
// 选择项目必填国际化适配
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
if (isEmptyFormValue(value)) {
return $t('ui.formRules.selectRequired', [ruleLabel(ctx)]);
}
return true;
},
@@ -46,11 +70,11 @@ async function initSetupVbenForm() {
*/
mobile: (value, _params, ctx) => {
const phone = typeof value === 'string' ? value.trim() : value;
if (phone === undefined || phone === null || phone === '') {
return $t('ui.formRules.required', [ctx.label]);
if (isEmptyFormValue(phone)) {
return $t('ui.formRules.required', [ruleLabel(ctx)]);
}
if (!/^1[3456789]\d{9}$/.test(String(phone))) {
return $t('ui.formRules.mobile', [ctx.label]);
return $t('ui.formRules.mobile', [ruleLabel(ctx)]);
}
return true;
},

View File

@@ -25,6 +25,8 @@ import {
} from '#/constants/sensitive-fields';
import { useAuthStore } from '#/store';
import { showPublicAccountLock } from '#/utils/publicAccountLock';
import { refreshTokenApi } from './core';
import {downloadByData} from "#/util/tool";
@@ -135,6 +137,12 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
});
break;
}
case 4101: {
showPublicAccountLock(result || {});
const lockError = new Error(responseData?.message || '公账日账单已逾期');
Object.assign(lockError, { silent: true, code: 4101, response });
throw lockError;
}
case 403:
case 404:
case 500: {
@@ -164,11 +172,11 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
client.addResponseInterceptor(
errorMessageResponseInterceptor((msg: string, error) => {
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
// 当前mock接口返回的错误字段是 error 或者 message
if (error?.silent || Number(error?.code) === 4101 || Number(error?.response?.data?.code) === 4101) {
return;
}
const responseData = error?.response?.data ?? {};
const errorMessage = responseData?.error ?? responseData?.message ?? '';
// 如果没有错误信息,则会根据状态码进行提示
message.error(errorMessage || msg);
}),
);

View File

@@ -5,6 +5,7 @@ import { useVModel } from '@vueuse/core';
import { Avatar, Empty, Input, Popover, Spin, Tag } from 'ant-design-vue';
import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { getPromoterOptions } from '#/views/system/store-input/api';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';

View File

@@ -96,6 +96,7 @@ function currentUserId(): number | string {
}
const mValue = useVModel(props, 'value', emits, {
// 本地立即更新才能连续多选;同时 commitIds 会同步 emit避免回车搜索读到旧值
passive: true,
defaultValue: [],
});
@@ -257,13 +258,22 @@ function handleFocus() {
}
}
/**
* 把已选 ID 同步回表单 v-model
* 显式 emit避免只改本地 ref、父级搜索条件仍是空数组
*/
function commitIds(ids: number[]) {
emits('update:value', ids);
mValue.value = ids;
}
/**
* 多选:追加 ID保留关键词与面板不关闭气泡
*/
function selectOption(item: StoreSearchItem) {
if (selectedIds.value.includes(item.id)) return;
selectedMap.value = { ...selectedMap.value, [item.id]: item };
mValue.value = [...selectedIds.value, item.id];
commitIds([...selectedIds.value, item.id]);
// 高亮下一项(列表已剔除刚选的)
nextTick(() => {
if (visibleOptions.value.length === 0) {
@@ -280,7 +290,7 @@ function selectOption(item: StoreSearchItem) {
}
function removeSelected(id: number) {
mValue.value = selectedIds.value.filter((x) => x !== id);
commitIds(selectedIds.value.filter((x) => x !== id));
const next = { ...selectedMap.value };
delete next[id];
selectedMap.value = next;
@@ -313,6 +323,11 @@ function scrollHighlightIntoView() {
}
function handleKeydown(e: KeyboardEvent) {
// 气泡打开时回车是选用,不能冒泡给外层搜索表单(否则会带着选店前的旧值提交)
if (e.key === 'Enter' && showDropdown.value) {
e.preventDefault();
e.stopPropagation();
}
if (!showDropdown.value || visibleOptions.value.length === 0) {
if (e.key === 'Escape') closePanel();
return;
@@ -334,8 +349,10 @@ function handleKeydown(e: KeyboardEvent) {
scrollHighlightIntoView();
break;
case 'Enter':
e.preventDefault();
{
// 面板打开时回车只选用,必须 stop 否则冒泡到搜索表单会带着旧 store_ids 提交
if (showDropdown.value) {
e.preventDefault();
e.stopPropagation();
const idx = highlightIndex.value >= 0 ? highlightIndex.value : 0;
const item = visibleOptions.value[idx];
if (item) selectOption(item);

View File

@@ -353,6 +353,7 @@ onBeforeUnmount(() => {
:title="previewTitle"
:footer="null"
width="80%"
:z-index="4000"
destroy-on-close
@cancel="onPreviewClose"
>

View File

@@ -0,0 +1,440 @@
<script lang="ts" setup>
/**
* 锁店页只读:日账单 + 下属商品订单
* 从合计应付款点开,不提供任何写操作
*/
import { computed, onMounted, ref, watch } from 'vue';
import { Button, Spin } from 'ant-design-vue';
import { getPublicAccountDailyLockOrders } from '#/views/finance/public-account-pay/api';
const props = defineProps<{
mergeId?: number;
dailyId?: number;
overdueDailyIds?: number[];
/** 锁店内页要返回;弹窗内隐藏 */
showBack?: boolean;
/** 弹窗内铺满宽度 */
embedded?: boolean;
}>();
const emit = defineEmits<{
back: [];
}>();
const showBackBtn = computed(() => props.showBack !== false);
const loading = ref(false);
const loadError = ref('');
const bundle = ref<Record<string, any> | null>(null);
/** 展开的日账单 id */
const openDayIds = ref<Record<number, boolean>>({});
const days = computed(() => {
const list = bundle.value?.items;
return Array.isArray(list) ? list : [];
});
const summaryTxt = computed(() => {
const b = bundle.value;
if (!b) {
return '';
}
const range = b.bill_date_range_txt || '';
const billCount = Number(b.bill_count || 0);
const orderCount = Number(b.order_count || 0);
const amount = b.platform_amount_total || '0.00';
return `${range} · ${billCount} 张日账单 · ${orderCount} 笔商品订单 · 合计 ¥${amount}`;
});
function dayDateTxt(day: Record<string, any>) {
return day.bill_date_txt || day.bill_date || '—';
}
function isDayOpen(id: number) {
return !!openDayIds.value[id];
}
/** 默认全部展开,方便一眼看完 */
function toggleDay(id: number) {
openDayIds.value = {
...openDayIds.value,
[id]: !openDayIds.value[id],
};
}
async function loadOrders() {
loading.value = true;
loadError.value = '';
try {
const data = await getPublicAccountDailyLockOrders({
merge_id: Number(props.mergeId || 0) || undefined,
daily_id: Number(props.dailyId || 0) || undefined,
overdue_daily_ids: (props.overdueDailyIds || []).join(',') || undefined,
});
bundle.value = data && typeof data === 'object' ? data : null;
const next: Record<number, boolean> = {};
for (const day of days.value) {
const id = Number(day.id || 0);
if (id > 0) {
next[id] = true;
}
}
openDayIds.value = next;
} catch (err: any) {
loadError.value = err?.message || '加载失败';
bundle.value = null;
} finally {
loading.value = false;
}
}
onMounted(() => {
void loadOrders();
});
watch(
() => [
Number(props.mergeId || 0),
Number(props.dailyId || 0),
(props.overdueDailyIds || []).join(','),
],
() => {
void loadOrders();
},
);
</script>
<template>
<div class="pap-orders" :class="{ 'is-embedded': embedded }">
<div class="pap-orders-head">
<div>
<div class="pap-orders-badge">只读明细</div>
<h3 class="pap-orders-title">日账单与商品订单</h3>
<p v-if="summaryTxt" class="pap-orders-summary">{{ summaryTxt }}</p>
</div>
<Button
v-if="showBackBtn"
size="large"
class="pap-orders-back"
@click="emit('back')"
>
返回
</Button>
</div>
<Spin :spinning="loading">
<p v-if="loadError" class="pap-orders-empty">{{ loadError }}</p>
<p v-else-if="!loading && !days.length" class="pap-orders-empty">
暂无日账单明细
</p>
<div v-else class="pap-orders-list">
<div v-for="day in days" :key="day.id" class="pap-day">
<button
type="button"
class="pap-day-head"
@click="toggleDay(Number(day.id))"
>
<div class="pap-day-meta">
<strong>日账单 {{ dayDateTxt(day) }}</strong>
<span>
¥{{ day.platform_amount || '0.00' }} ·
{{ (day.items || []).length }} 笔商品订单 ·
{{ day.status_txt || '' }}
</span>
</div>
<span class="pap-day-toggle">
{{ isDayOpen(Number(day.id)) ? '收起' : '展开' }}
</span>
</button>
<div v-if="isDayOpen(Number(day.id))" class="pap-day-body">
<div
v-if="!(day.items || []).length"
class="pap-order-empty"
>
该日暂无商品订单
</div>
<div
v-for="order in day.items || []"
:key="order.id"
class="pap-order-row"
>
<div class="pap-order-top">
<div class="pap-order-main">
<span class="pap-order-tag">商品订单</span>
<strong>{{ order.order_no || `#${order.order_id}` }}</strong>
</div>
<div class="pap-order-side">
<span>订单 ¥{{ order.order_amount || '0.00' }}</span>
<strong>平台 ¥{{ order.platform_amount || '0.00' }}</strong>
<span>{{ order.status_txt || '' }}</span>
</div>
</div>
<div
v-if="(order.products || []).length"
class="pap-order-products"
>
<div
v-for="product in order.products"
:key="product.id"
class="pap-product"
:class="{ 'is-chinese': Number(product.is_chinese) === 1 }"
>
<template v-if="Number(product.is_chinese) === 1">
<span class="pap-product-chip">
{{ product.drug_name || '—' }}×{{ product.number ?? 0 }}
</span>
</template>
<template v-else>
<img
v-if="product.drug_image"
class="pap-product-img"
:src="product.drug_image"
:alt="product.drug_name || '药品'"
/>
<div v-else class="pap-product-img is-empty"></div>
<div class="pap-product-meta">
<strong>{{ product.drug_name || '—' }}</strong>
<span>×{{ product.number ?? 0 }}</span>
</div>
</template>
</div>
</div>
<div v-else class="pap-order-empty-products">暂无商品明细</div>
</div>
</div>
</div>
</div>
</Spin>
</div>
</template>
<style scoped>
.pap-orders {
width: min(640px, 100%);
max-width: 100%;
text-align: left;
}
.pap-orders.is-embedded {
width: 100%;
}
.pap-orders-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.pap-orders-badge {
display: inline-block;
padding: 2px 10px;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: hsl(var(--pap-accent));
border: 1px solid hsl(var(--pap-accent) / 40%);
border-radius: 999px;
background: hsl(var(--pap-accent) / 12%);
}
.pap-orders-title {
margin: 0 0 6px;
font-size: var(--pap-fs-title, 22px);
font-weight: 700;
color: hsl(var(--foreground));
}
.pap-orders-summary {
margin: 0;
font-size: var(--pap-fs-flow, 13px);
line-height: 1.5;
color: hsl(var(--muted-foreground));
}
.pap-orders-back {
flex-shrink: 0;
}
.pap-orders-empty {
margin: 24px 0;
font-size: 14px;
color: hsl(var(--muted-foreground));
text-align: center;
}
.pap-orders-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.pap-day {
border: 1px solid hsl(var(--pap-accent) / 28%);
border-radius: 12px;
background: hsl(var(--card, var(--background)));
box-shadow: 0 0 6px hsl(var(--pap-accent) / 14%);
overflow: hidden;
}
.pap-day-head {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
text-align: left;
cursor: pointer;
border: 0;
background: transparent;
color: inherit;
}
.pap-day-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.pap-day-meta strong {
font-size: 15px;
color: hsl(var(--foreground));
}
.pap-day-meta span {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.pap-day-toggle {
flex-shrink: 0;
font-size: 12px;
color: hsl(var(--pap-accent));
}
.pap-day-body {
padding: 0 12px 12px;
border-top: 1px solid hsl(var(--border));
}
.pap-order-empty {
padding: 12px 4px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.pap-order-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 8px;
margin-top: 8px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--muted) / 0.2);
}
.pap-order-top {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.pap-order-main {
display: flex;
align-items: center;
gap: 8px;
}
.pap-order-tag {
padding: 1px 8px;
font-size: 11px;
color: hsl(var(--pap-accent));
border: 1px solid hsl(var(--pap-accent) / 30%);
border-radius: 999px;
background: hsl(var(--pap-accent) / 10%);
}
.pap-order-main strong {
font-size: 13px;
color: hsl(var(--foreground));
}
.pap-order-side {
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.pap-order-side strong {
color: hsl(var(--pap-accent));
}
.pap-order-products {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pap-product {
display: flex;
align-items: center;
gap: 6px;
width: calc(33.333% - 4px);
min-width: 0;
padding: 4px 6px;
border-radius: 6px;
background: hsl(var(--background) / 0.55);
border: 1px solid hsl(var(--border) / 70%);
}
.pap-product.is-chinese {
width: auto;
max-width: 100%;
padding: 2px 8px;
background: hsl(var(--muted) / 0.28);
}
.pap-product-chip {
font-size: 12px;
line-height: 1.3;
color: hsl(var(--foreground));
white-space: nowrap;
}
.pap-product-img {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: 6px;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.35);
flex-shrink: 0;
}
.pap-product-img.is-empty {
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: hsl(var(--muted-foreground));
}
.pap-product-meta {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
gap: 4px;
min-width: 0;
}
.pap-product-meta strong {
font-size: 12px;
font-weight: 600;
line-height: 1.2;
color: hsl(var(--foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pap-product-meta span {
flex-shrink: 0;
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.pap-order-empty-products {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
@media (max-width: 560px) {
.pap-orders-head {
flex-direction: column;
}
.pap-orders-back {
width: 100%;
}
.pap-orders-title {
font-size: 18px;
}
.pap-product:not(.is-chinese) {
width: calc(50% - 3px);
}
}
</style>

View File

@@ -0,0 +1,152 @@
<script lang="ts" setup>
/**
* 锁框内上传凭证:与锁定提示同一块面板切换,不用弹窗/抽屉/整页
*/
import { onMounted, ref } from 'vue';
import { Button } from 'ant-design-vue';
import DailyUploadForm from '#/views/finance/public-account-pay/components/daily-upload-form.vue';
const props = defineProps<{
dailyId: number;
storeName?: string;
platformAmount?: string;
billDateTxt?: string;
unlockMode?: string;
}>();
const emit = defineEmits<{
back: [];
waiting: [payload?: {
bill_status?: number;
contact_phones?: { name?: string; phone: string }[];
}];
}>();
const formRef = ref<InstanceType<typeof DailyUploadForm>>();
const submitting = ref(false);
const unlockHint = props.unlockMode === 'voucher'
? '提交成功后立刻解锁,接诊和开方可继续。'
: '提交后仍锁定,需平台管理员确认到账后才会解锁。';
onMounted(() => {
formRef.value?.load({
id: props.dailyId,
store_name: props.storeName,
platform_amount: props.platformAmount,
bill_date_txt: props.billDateTxt,
});
});
/** 可无附件直接声明已支付;若已选文件一并提交 */
async function onDeclarePaid() {
submitting.value = true;
try {
await formRef.value?.submit({ allowEmpty: true });
} finally {
submitting.value = false;
}
}
function onFormSuccess(payload: {
unlocked: boolean;
bill_status?: number;
contact_phones?: { name?: string; phone: string }[];
}) {
if (!payload.unlocked) {
emit('waiting', payload);
}
}
</script>
<template>
<div class="pap-lock-pay">
<div class="pap-lock-badge">上传付款凭证</div>
<h2 class="pap-lock-title">处理逾期日账单</h2>
<p class="pap-lock-desc">{{ unlockHint }}</p>
<DailyUploadForm
ref="formRef"
:preview-z-index="4000"
allow-empty-voucher
@success="onFormSuccess"
/>
<div class="pap-lock-pay-actions">
<Button size="large" class="pap-lock-btn is-ghost" @click="emit('back')">返回说明</Button>
<Button
type="primary"
size="large"
class="pap-lock-btn"
:loading="submitting"
@click="onDeclarePaid"
>
我已支付
</Button>
</div>
<p class="pap-lock-pay-tip">
已选凭证会一并提交未上传也可直接点我已支付
</p>
</div>
</template>
<style scoped>
.pap-lock-pay {
width: min(520px, 100%);
max-width: 100%;
text-align: left;
}
.pap-lock-badge {
display: inline-block;
padding: 3px 10px;
margin-bottom: var(--pap-gap-sm, 14px);
font-size: var(--pap-fs-badge, 12px);
font-weight: 600;
letter-spacing: 0.08em;
color: hsl(var(--pap-accent));
border: 1px solid hsl(var(--pap-accent) / 40%);
border-radius: 999px;
background: hsl(var(--pap-accent) / 12%);
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
}
.pap-lock-title {
margin: 0 0 var(--pap-gap-sm, 12px);
font-size: var(--pap-fs-title, 28px);
font-weight: 700;
line-height: 1.25;
color: hsl(var(--foreground));
}
.pap-lock-desc {
margin: 0 0 var(--pap-gap-md, 20px);
font-size: var(--pap-fs-desc, 15px);
line-height: 1.55;
color: hsl(var(--muted-foreground));
}
.pap-lock-pay-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--pap-gap-sm, 12px);
margin-top: var(--pap-gap-md, 20px);
}
.pap-lock-btn {
width: 100%;
height: var(--pap-btn-h, 48px);
font-size: var(--pap-fs-btn, 16px);
}
.pap-lock-btn.is-ghost {
color: hsl(var(--foreground));
border-color: hsl(var(--pap-accent) / 40%);
}
.pap-lock-pay-tip {
margin: var(--pap-gap-sm, 12px) 0 0;
font-size: var(--pap-fs-label, 12px);
line-height: 1.5;
color: hsl(var(--muted-foreground));
text-align: center;
}
@media (max-width: 560px) {
.pap-lock-pay-actions {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,237 @@
<script lang="ts" setup>
/**
* 锁框内等待审核:催办发 OA展示配置里允许拨打的管理员手机号
*/
import { computed, ref } from 'vue';
import { Button, message } from 'ant-design-vue';
import { urgePublicAccountDailyBill } from '#/views/finance/public-account-pay/api';
import type { PublicAccountLockContact } from '#/utils/publicAccountLock';
const props = defineProps<{
dailyId: number;
platformAmount?: string;
billDateTxt?: string;
contacts?: PublicAccountLockContact[];
canPay?: boolean;
}>();
const emit = defineEmits<{
reupload: [];
}>();
const urging = ref(false);
const phoneList = computed(() =>
(props.contacts || [])
.map((item) => ({
...item,
dial: String(item.phone_txt || item.tel || item.phone || '').replace(/[^\d]/g, ''),
}))
.filter((item) => /^1\d{10}$/.test(item.dial)),
);
/** 催办:后端限流 5 分钟,失败文案直接展示 */
async function onUrge() {
if (props.dailyId < 1) {
message.warning('暂无待审核日账单');
return;
}
if (urging.value) {
return;
}
urging.value = true;
try {
await urgePublicAccountDailyBill({ id: props.dailyId });
message.success('已向管理员发送催办');
} finally {
urging.value = false;
}
}
function callPhone(phone: string) {
window.open(`tel:${phone}`);
}
</script>
<template>
<div class="pap-lock-wait">
<div class="pap-lock-badge">等待管理员审核</div>
<h2 class="pap-lock-title">凭证已提交</h2>
<p class="pap-lock-desc">
平台管理员确认到账后自动解锁可催办或拨打下方号码联系管理员
</p>
<div class="pap-lock-stat">
<div class="pap-lock-stat-item">
<span class="pap-lock-stat-label">账单区间</span>
<strong>{{ billDateTxt || '—' }}</strong>
</div>
<div class="pap-lock-stat-item">
<span class="pap-lock-stat-label">合计应付款</span>
<strong class="is-amount">¥{{ platformAmount || '0.00' }}</strong>
</div>
</div>
<div v-if="phoneList.length" class="pap-lock-phones">
<div class="pap-lock-phones-title">联系管理员</div>
<button
v-for="item in phoneList"
:key="item.dial"
type="button"
class="pap-lock-phone"
@click="callPhone(item.dial)"
>
<span>{{ item.name || '管理员' }}</span>
<strong>{{ item.dial }}</strong>
</button>
</div>
<p v-else class="pap-lock-empty">暂未配置可拨打的管理员号码请使用催办</p>
<div class="pap-lock-pay-actions" :class="{ 'is-single': !canPay }">
<Button
v-if="canPay"
size="large"
class="pap-lock-btn is-ghost"
@click="emit('reupload')"
>
重新上传
</Button>
<Button
type="primary"
size="large"
class="pap-lock-btn"
:loading="urging"
@click="onUrge"
>
催办
</Button>
</div>
</div>
</template>
<style scoped>
.pap-lock-wait {
width: min(520px, 100%);
max-width: 100%;
text-align: left;
}
.pap-lock-badge {
display: inline-block;
padding: 3px 10px;
margin-bottom: var(--pap-gap-sm, 14px);
font-size: var(--pap-fs-badge, 12px);
font-weight: 600;
letter-spacing: 0.08em;
color: hsl(var(--pap-accent));
border: 1px solid hsl(var(--pap-accent) / 40%);
border-radius: 999px;
background: hsl(var(--pap-accent) / 12%);
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
}
.pap-lock-title {
margin: 0 0 var(--pap-gap-sm, 12px);
font-size: var(--pap-fs-title, 28px);
font-weight: 700;
line-height: 1.25;
color: hsl(var(--foreground));
}
.pap-lock-desc {
margin: 0 0 var(--pap-gap-md, 20px);
font-size: var(--pap-fs-desc, 15px);
line-height: 1.55;
color: hsl(var(--muted-foreground));
}
.pap-lock-stat {
display: grid;
grid-template-columns: var(--pap-stat-cols, 1fr 1fr);
gap: var(--pap-gap-sm, 16px);
margin-bottom: var(--pap-gap-md, 16px);
}
.pap-lock-stat-item {
min-width: 0;
padding: var(--pap-block-pad, 16px) calc(var(--pap-block-pad, 16px) + 2px);
border: 1px solid hsl(var(--pap-accent) / 30%);
border-radius: 12px;
background: hsl(var(--card, var(--background)));
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
}
.pap-lock-stat-label {
display: block;
margin-bottom: var(--pap-gap-xs, 6px);
font-size: var(--pap-fs-label, 12px);
color: hsl(var(--muted-foreground));
}
.pap-lock-stat-item strong {
display: block;
font-size: var(--pap-fs-stat, 22px);
font-weight: 700;
line-height: 1.3;
color: hsl(var(--foreground));
word-break: break-word;
}
.pap-lock-stat-item strong.is-amount {
color: hsl(var(--pap-accent));
}
.pap-lock-phones {
padding: var(--pap-block-pad, 16px) calc(var(--pap-block-pad, 16px) + 2px);
margin-bottom: var(--pap-gap-md, 20px);
border: 1px solid hsl(var(--pap-accent) / 30%);
border-radius: 12px;
background: hsl(var(--card, var(--background)));
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
}
.pap-lock-phones-title {
margin-bottom: var(--pap-gap-xs, 10px);
font-size: var(--pap-fs-flow, 13px);
font-weight: 600;
color: hsl(var(--pap-accent));
}
.pap-lock-phone {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 10px 0;
font-size: var(--pap-fs-desc, 14px);
color: hsl(var(--foreground));
background: transparent;
border: 0;
border-bottom: 1px solid hsl(var(--border));
cursor: pointer;
text-align: left;
}
.pap-lock-phone:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.pap-lock-phone strong {
color: hsl(var(--primary));
}
.pap-lock-empty {
margin: 0 0 var(--pap-gap-md, 20px);
font-size: var(--pap-fs-flow, 13px);
color: hsl(var(--muted-foreground));
}
.pap-lock-pay-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--pap-gap-sm, 12px);
}
.pap-lock-pay-actions.is-single {
grid-template-columns: 1fr;
}
.pap-lock-btn {
width: 100%;
height: var(--pap-btn-h, 48px);
font-size: var(--pap-fs-btn, 16px);
}
.pap-lock-btn.is-ghost {
color: hsl(var(--foreground));
border-color: hsl(var(--pap-accent) / 40%);
}
@media (max-width: 560px) {
.pap-lock-pay-actions {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,306 @@
<script lang="ts" setup>
/**
* 公账逾期弹窗背景:警戒条、脉冲环、余烬粒子
* 跟随亮/暗主题,弹窗关闭即停 RAF避免后台空转
*/
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps<{
active?: boolean;
/** 后台配置blue / yellow / red */
color?: 'blue' | 'yellow' | 'red';
/** 上传/等待页只保留上下警戒条,避免三角和表单叠在一起 */
tapesOnly?: boolean;
}>();
const containerRef = ref<HTMLDivElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
interface Ember {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
size: number;
}
let animationId = 0;
let resizeObserver: ResizeObserver | null = null;
let themeObserver: MutationObserver | null = null;
let running = false;
function isDarkTheme() {
return (
document.documentElement.classList.contains('dark') ||
document.documentElement.getAttribute('data-theme') === 'dark'
);
}
function startLoop() {
if (running || !containerRef.value || !canvasRef.value) return;
const canvas = canvasRef.value;
const ctx = canvas.getContext('2d');
const container = containerRef.value;
if (!ctx) return;
running = true;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let cssW = 0;
let cssH = 0;
let last = 0;
let elapsed = 0;
let embers: Ember[] = [];
const MAX_EMBERS = 22;
function updateSize() {
const rect = container.getBoundingClientRect();
cssW = rect.width || 760;
cssH = rect.height || 460;
const pw = Math.round(cssW * dpr);
const ph = Math.round(cssH * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
}
function theme() {
const dark = isDarkTheme();
const color = props.color === 'blue' || props.color === 'red' ? props.color : 'yellow';
if (color === 'blue') {
return {
dark,
warn: dark ? { r: 96, g: 165, b: 250 } : { r: 37, g: 99, b: 235 },
tape: dark ? { r: 59, g: 130, b: 246 } : { r: 37, g: 99, b: 235 },
ink: dark ? { r: 15, g: 23, b: 42 } : { r: 15, g: 23, b: 42 },
bg: dark ? 'rgba(10, 18, 32, 0.55)' : 'rgba(239, 246, 255, 0.42)',
};
}
if (color === 'red') {
return {
dark,
warn: dark ? { r: 248, g: 113, b: 113 } : { r: 220, g: 38, b: 38 },
tape: dark ? { r: 239, g: 68, b: 68 } : { r: 220, g: 38, b: 38 },
ink: dark ? { r: 23, g: 15, b: 15 } : { r: 28, g: 25, b: 23 },
bg: dark ? 'rgba(32, 10, 10, 0.55)' : 'rgba(254, 242, 242, 0.42)',
};
}
return {
dark,
warn: dark ? { r: 251, g: 191, b: 36 } : { r: 217, g: 119, b: 6 },
tape: dark ? { r: 245, g: 158, b: 11 } : { r: 217, g: 119, b: 6 },
ink: dark ? { r: 15, g: 23, b: 42 } : { r: 28, g: 25, b: 23 },
bg: dark ? 'rgba(23, 18, 10, 0.55)' : 'rgba(255, 251, 235, 0.35)',
};
}
function spawnEmber(): Ember {
return {
x: Math.random() * cssW,
y: cssH * (0.35 + Math.random() * 0.5),
vx: (Math.random() - 0.5) * 18,
vy: -12 - Math.random() * 22,
life: 1,
maxLife: 1.6 + Math.random() * 2.2,
size: 1.2 + Math.random() * 2.4,
};
}
function drawTape(
c: CanvasRenderingContext2D,
y: number,
h: number,
offset: number,
t: ReturnType<typeof theme>,
) {
c.save();
c.beginPath();
c.rect(0, y, cssW, h);
c.clip();
c.fillStyle = `rgb(${t.tape.r},${t.tape.g},${t.tape.b})`;
c.fillRect(0, y, cssW, h);
c.fillStyle = `rgba(${t.ink.r},${t.ink.g},${t.ink.b},0.82)`;
const stripe = 16;
const shift = offset % (stripe * 2);
for (let x = -h - stripe * 2 + shift; x < cssW + h; x += stripe * 2) {
c.beginPath();
c.moveTo(x, y);
c.lineTo(x + stripe, y);
c.lineTo(x + stripe + h, y + h);
c.lineTo(x + h, y + h);
c.closePath();
c.fill();
}
c.restore();
}
function drawTriangle(
c: CanvasRenderingContext2D,
cx: number,
cy: number,
size: number,
pulse: number,
t: ReturnType<typeof theme>,
) {
const s = size * pulse;
c.save();
c.shadowColor = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.55)`;
c.shadowBlur = 22;
c.beginPath();
c.moveTo(cx, cy - s);
c.lineTo(cx + s * 0.92, cy + s * 0.78);
c.lineTo(cx - s * 0.92, cy + s * 0.78);
c.closePath();
c.fillStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.16)`;
c.fill();
c.lineWidth = 3;
c.strokeStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.95)`;
c.stroke();
c.shadowBlur = 0;
c.fillStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.95)`;
c.beginPath();
c.roundRect(cx - 3, cy - s * 0.28, 6, s * 0.52, 2);
c.fill();
c.beginPath();
c.arc(cx, cy + s * 0.42, 4, 0, Math.PI * 2);
c.fill();
c.restore();
}
function draw(ts: number) {
if (!running) return;
updateSize();
if (cssW <= 0 || cssH <= 0) {
animationId = requestAnimationFrame(draw);
return;
}
if (!last) last = ts;
let dt = (ts - last) / 1000;
if (dt <= 0) dt = 0.016;
if (dt > 0.2) dt = 0.2;
last = ts;
elapsed += dt;
const t = theme();
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.scale(dpr, dpr);
ctx.fillStyle = t.bg;
ctx.fillRect(0, 0, cssW, cssH);
if (!props.tapesOnly) {
// 三角/光环随面板高度缩放,避免矮屏占满上半区把正文挤乱
const tapeReserve = Math.round(Math.min(18, Math.max(10, cssH * 0.028)));
const cy = Math.round(
Math.min(Math.max(48, tapeReserve + 28 + cssH * 0.04), cssH * 0.16),
);
const triSize = Math.round(Math.min(36, Math.max(16, cssH * 0.045)));
const ringSpan = Math.min(150, Math.max(60, cssH * 0.18));
const cx = cssW / 2;
const glow = ctx.createRadialGradient(cx, cy, 8, cx, cy, Math.max(cssW, cssH) * 0.55);
glow.addColorStop(0, `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.22)`);
glow.addColorStop(0.45, `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.06)`);
glow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, cssW, cssH);
for (let i = 0; i < 3; i++) {
const phase = (elapsed * 0.55 + i / 3) % 1;
const radius = triSize * 0.85 + phase * ringSpan;
const alpha = (1 - phase) * 0.35;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},${alpha})`;
ctx.lineWidth = Math.max(1.5, triSize / 28);
ctx.stroke();
}
const pulse = 1 + Math.sin(elapsed * 3.2) * 0.06;
drawTriangle(ctx, cx, cy, triSize, pulse, t);
while (embers.length < MAX_EMBERS) embers.push(spawnEmber());
for (let i = embers.length - 1; i >= 0; i--) {
const p = embers[i]!;
p.life -= dt / p.maxLife;
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.life <= 0) {
embers.splice(i, 1);
continue;
}
ctx.save();
ctx.globalAlpha = p.life * 0.75;
ctx.shadowColor = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.8)`;
ctx.shadowBlur = 8;
ctx.fillStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},${p.life})`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * Math.min(1, cssH / 520), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
const tapeH = Math.round(Math.min(18, Math.max(12, cssH * 0.028)));
const shift = elapsed * 36;
drawTape(ctx, 0, tapeH, shift, t);
drawTape(ctx, cssH - tapeH, tapeH, -shift, t);
ctx.restore();
animationId = requestAnimationFrame(draw);
}
updateSize();
animationId = requestAnimationFrame(draw);
if (window.ResizeObserver) {
resizeObserver = new ResizeObserver(() => updateSize());
resizeObserver.observe(container);
}
themeObserver = new MutationObserver(() => undefined);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme'],
});
}
function stopLoop() {
running = false;
cancelAnimationFrame(animationId);
animationId = 0;
resizeObserver?.disconnect();
resizeObserver = null;
themeObserver?.disconnect();
themeObserver = null;
}
onMounted(async () => {
await nextTick();
if (props.active !== false) startLoop();
});
watch(
() => props.active,
(active) => {
if (active === false) stopLoop();
else nextTick(() => startLoop());
},
);
onUnmounted(() => {
stopLoop();
});
</script>
<template>
<div ref="containerRef" class="lock-canvas-wrap">
<canvas ref="canvasRef" class="lock-canvas" />
</div>
</template>
<style scoped>
.lock-canvas-wrap,
.lock-canvas {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
}
.lock-canvas {
display: block;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,270 @@
<script lang="ts" setup>
/**
* PC 公账逾期提醒:先展示友好大条幅数秒,再收起到顶栏「传方」旁胶囊
* 「去处理」打开软处理层(上传/进度),避免已在账单页时路由跳转无感
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
applyPublicAccountLockSession,
dockPublicAccountBanner,
expandPublicAccountBanner,
getPublicAccountLockState,
normalizeLockAlertColor,
openPublicAccountLockHandle,
} from '#/utils/publicAccountLock';
import { getPublicAccountLockStatus } from '#/views/finance/public-account-pay/api';
const DOCK_AFTER_MS = 4500;
const lockState = getPublicAccountLockState();
const dockTimer = ref<ReturnType<typeof setTimeout> | null>(null);
const alertColor = computed(() =>
normalizeLockAlertColor(lockState.payload.alert_color),
);
const amountTxt = computed(
() =>
lockState.payload.platform_amount_total ||
lockState.payload.platform_amount ||
'0.00',
);
const billCount = computed(() => {
const n = Number(lockState.payload.bill_count || 0);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
});
const rangeTxt = computed(() =>
String(lockState.payload.bill_date_range_txt || '').trim(),
);
const titleTxt = computed(() =>
billCount.value > 1 ? '有多张日账单待结清' : '有日账单待结清',
);
const descTxt = computed(() => {
if (billCount.value > 1) {
return `${rangeTxt.value || '账单区间'} · 共 ${billCount.value} 张 · 合计 ¥${amountTxt.value}`;
}
return `应付款 ¥${amountTxt.value},请尽快汇款以免影响接诊开方`;
});
const showExpanded = computed(
() => lockState.bannerVisible && !lockState.bannerDocked,
);
function clearDockTimer() {
if (dockTimer.value) {
clearTimeout(dockTimer.value);
dockTimer.value = null;
}
}
/** 大条幅展示数秒后收到顶栏 */
function scheduleDock() {
clearDockTimer();
if (!lockState.bannerVisible || lockState.bannerDocked) {
return;
}
dockTimer.value = setTimeout(() => {
dockPublicAccountBanner();
}, DOCK_AFTER_MS);
}
async function refreshBanner() {
try {
const live = await getPublicAccountLockStatus();
if (live && typeof live === 'object') {
applyPublicAccountLockSession(live);
}
} catch {
// 条幅刷新失败不挡布局
}
}
/** 去处理:打开锁框上传/进度(软层) */
function goHandle(e?: Event) {
e?.stopPropagation?.();
openPublicAccountLockHandle();
}
function onExpandChip() {
expandPublicAccountBanner();
scheduleDock();
}
watch(
() => [lockState.bannerVisible, lockState.bannerDocked] as const,
([visible, docked]) => {
if (visible && !docked) {
scheduleDock();
} else {
clearDockTimer();
}
},
);
onMounted(() => {
void refreshBanner().then(() => scheduleDock());
});
onBeforeUnmount(() => {
clearDockTimer();
});
</script>
<template>
<!-- 展开大条幅 -->
<Teleport to="body">
<Transition name="pap-banner-fade">
<div
v-if="showExpanded"
class="pap-banner-host"
role="status"
>
<div class="pap-banner" :class="'is-' + alertColor">
<div class="pap-banner-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none">
<path
d="M12 3.5L21 20H3L12 3.5Z"
stroke="currentColor"
stroke-width="1.6"
stroke-linejoin="round"
/>
<path
d="M12 9v5.5M12 16.8v.2"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>
</div>
<div class="pap-banner-main">
<div class="pap-banner-title">{{ titleTxt }}</div>
<div class="pap-banner-desc">{{ descTxt }}</div>
</div>
<button
type="button"
class="pap-banner-action"
@click="goHandle"
>
去处理
</button>
<button
type="button"
class="pap-banner-min"
title="收到顶栏"
@click="dockPublicAccountBanner"
>
收起
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.pap-banner-host {
position: fixed;
top: 64px;
right: 16px;
left: 16px;
z-index: 1090;
display: flex;
justify-content: center;
pointer-events: none;
}
@media (min-width: 768px) {
.pap-banner-host {
left: calc(var(--vben-sider-width, 224px) + 16px);
}
}
.pap-banner {
display: flex;
gap: 12px;
align-items: center;
width: min(720px, 100%);
padding: 12px 14px;
pointer-events: auto;
--pap-banner-accent: var(--warning);
border: 1px solid hsl(var(--pap-banner-accent) / 32%);
border-radius: 12px;
background: hsl(var(--card, var(--background)));
box-shadow:
0 0 6px hsl(var(--pap-banner-accent) / 16%),
0 10px 28px hsl(var(--foreground) / 8%);
}
.pap-banner.is-blue {
--pap-banner-accent: 217 91% 53%;
}
.pap-banner.is-yellow {
--pap-banner-accent: var(--warning);
}
.pap-banner.is-red {
--pap-banner-accent: 0 72% 51%;
}
.pap-banner-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
color: hsl(var(--pap-banner-accent));
background: hsl(var(--pap-banner-accent) / 12%);
border: 1px solid hsl(var(--pap-banner-accent) / 28%);
border-radius: 10px;
}
.pap-banner-main {
min-width: 0;
flex: 1;
}
.pap-banner-title {
margin-bottom: 2px;
font-size: 14px;
font-weight: 650;
color: hsl(var(--foreground));
}
.pap-banner-desc {
font-size: 12px;
line-height: 1.45;
color: hsl(var(--muted-foreground));
}
.pap-banner-action {
flex-shrink: 0;
height: 32px;
padding: 0 14px;
font-size: 13px;
font-weight: 600;
color: #fff;
cursor: pointer;
background: hsl(var(--pap-banner-accent));
border: none;
border-radius: 8px;
}
.pap-banner-action:hover {
filter: brightness(1.05);
}
.pap-banner-min {
flex-shrink: 0;
height: 32px;
padding: 0 10px;
font-size: 12px;
color: hsl(var(--muted-foreground));
cursor: pointer;
background: transparent;
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.pap-banner-min:hover {
color: hsl(var(--foreground));
border-color: hsl(var(--pap-banner-accent) / 40%);
}
.pap-banner-fade-enter-active,
.pap-banner-fade-leave-active {
transition:
opacity 0.28s ease,
transform 0.28s ease;
}
.pap-banner-fade-enter-from,
.pap-banner-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,112 @@
<script lang="ts" setup>
/**
* 顶栏公账逾期胶囊:大条幅收起后出现在「传方」旁,点击可展开或去处理
*/
import { computed } from 'vue';
import {
expandPublicAccountBanner,
getPublicAccountLockState,
normalizeLockAlertColor,
openPublicAccountLockHandle,
} from '#/utils/publicAccountLock';
const lockState = getPublicAccountLockState();
const visible = computed(
() => lockState.bannerVisible && lockState.bannerDocked,
);
const alertColor = computed(() =>
normalizeLockAlertColor(lockState.payload.alert_color),
);
const amountTxt = computed(
() =>
lockState.payload.platform_amount_total ||
lockState.payload.platform_amount ||
'0.00',
);
const tipTxt = computed(() => `公账待结 ¥${amountTxt.value}`);
function onChipClick() {
openPublicAccountLockHandle();
}
function onExpand(e: Event) {
e.stopPropagation();
expandPublicAccountBanner();
}
</script>
<template>
<button
v-if="visible"
type="button"
class="pap-dock-chip"
:class="'is-' + alertColor"
:title="tipTxt"
@click="onChipClick"
>
<span class="pap-dock-dot" />
<span class="pap-dock-text">公账待结</span>
<strong class="pap-dock-amt">¥{{ amountTxt }}</strong>
<span class="pap-dock-expand" title="展开提醒" @click="onExpand">展开</span>
</button>
</template>
<style scoped>
.pap-dock-chip {
display: inline-flex;
gap: 6px;
align-items: center;
max-width: 220px;
height: 28px;
padding: 0 8px 0 10px;
margin-right: 4px;
font-size: 12px;
line-height: 1;
cursor: pointer;
--pap-dock-accent: var(--warning);
color: hsl(var(--foreground));
background: hsl(var(--pap-dock-accent) / 10%);
border: 1px solid hsl(var(--pap-dock-accent) / 32%);
border-radius: 999px;
box-shadow: 0 0 6px hsl(var(--pap-dock-accent) / 14%);
}
.pap-dock-chip.is-blue {
--pap-dock-accent: 217 91% 53%;
}
.pap-dock-chip.is-yellow {
--pap-dock-accent: var(--warning);
}
.pap-dock-chip.is-red {
--pap-dock-accent: 0 72% 51%;
}
.pap-dock-chip:hover {
background: hsl(var(--pap-dock-accent) / 16%);
}
.pap-dock-dot {
width: 7px;
height: 7px;
background: hsl(var(--pap-dock-accent));
border-radius: 50%;
box-shadow: 0 0 0 3px hsl(var(--pap-dock-accent) / 22%);
}
.pap-dock-text {
color: hsl(var(--pap-dock-accent));
font-weight: 600;
}
.pap-dock-amt {
font-weight: 700;
color: hsl(var(--foreground));
}
.pap-dock-expand {
padding: 2px 6px;
margin-left: 2px;
color: hsl(var(--muted-foreground));
background: hsl(var(--background) / 60%);
border-radius: 999px;
}
.pap-dock-expand:hover {
color: hsl(var(--foreground));
}
</style>

View File

@@ -0,0 +1,720 @@
<script lang="ts" setup>
/**
* 公账日账单逾期全屏警示:不可点遮罩/Esc/X 关闭
* 上传在同一锁框内切换展示,不用弹窗、抽屉、整页
*/
import { computed, ref, watch } from 'vue';
import { useUserStore } from '@vben/stores';
import { Button, Modal, message } from 'ant-design-vue';
import { useAuthStore } from '#/store';
import {
PUBLIC_ACCOUNT_LOCK_PROMPT_Z_INDEX,
clearPublicAccountLock,
closePublicAccountLockHandle,
getPublicAccountLockState,
normalizeLockAlertColor,
} from '#/utils/publicAccountLock';
import {
generatePublicAccountDailyBill,
getPublicAccountDailyBillInfo,
getPublicAccountLockStatus,
} from '#/views/finance/public-account-pay/api';
import LockPayPanel from './LockPayPanel.vue';
import LockWaitPanel from './LockWaitPanel.vue';
import LockOrdersPanel from './LockOrdersPanel.vue';
import LockWarningCanvas from './LockWarningCanvas.vue';
const userStore = useUserStore();
const authStore = useAuthStore();
const lockState = getPublicAccountLockState();
const panelMode = ref<'hint' | 'pay' | 'wait' | 'orders'>('hint');
const payDailyId = ref(0);
const openingPay = ref(false);
const loggingOut = ref(false);
const roleId = computed(() =>
Number(userStore.userInfo?.role_id ?? userStore.userInfo?.roles?.id ?? 0),
);
/** 超管/系统管理员/诊所管理员可在锁框内上传 */
const canPay = computed(() => [1, 2, 8].includes(roleId.value));
/** 硬锁或条幅「去处理」软层 */
const overlayOpen = computed(
() => lockState.visible || lockState.handleVisible,
);
/** 软层:可返回,不是身份硬锁 */
const softHandle = computed(
() => lockState.handleVisible && !lockState.visible,
);
const billDateTxt = computed(() => {
const date = String(lockState.payload.bill_date || '');
if (date.length === 8) {
return `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`;
}
return date;
});
/** 多张逾期展示几号到几号;单张回落单日 */
const billDateRangeTxt = computed(() => {
const range = String(lockState.payload.bill_date_range_txt || '').trim();
if (range) {
return range;
}
return billDateTxt.value;
});
const billDateLabel = computed(() =>
billCount.value > 1 ? '账单区间' : '账单日',
);
const amountTxt = computed(
() =>
lockState.payload.platform_amount_total ||
lockState.payload.platform_amount ||
'0.00',
);
const billCount = computed(() => {
const n = Number(lockState.payload.bill_count || 0);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
});
const mergeHint = computed(() => {
if (billCount.value > 1) {
return `合并处理 ${billCount.value} 张(${billDateRangeTxt.value}),一次上传覆盖整段。`;
}
return '';
});
const alertColor = computed(() =>
normalizeLockAlertColor(lockState.payload.alert_color),
);
const unlockMode = computed(() =>
String(lockState.payload.unlock_mode || 'confirm'),
);
const lockTime = computed(() => lockState.payload.lock_time || '09:00');
const lockDays = computed(() => {
const days = Number(lockState.payload.lock_days || 1);
return Number.isFinite(days) && days >= 1 ? Math.floor(days) : 1;
});
/** 锁店说明里的宽限文案1 天写「次日」,多天写「后第 N 天」 */
const lockDaysLabel = computed(() =>
lockDays.value === 1 ? '次日' : `后第 ${lockDays.value}`,
);
const unlockByVoucher = computed(() => unlockMode.value === 'voucher');
const contactPhones = computed(() => lockState.payload.contact_phones || []);
/** 尊敬的{诊所名} */
const storeGreeting = computed(() => {
const name = String(lockState.payload.store_name || '').trim();
return name ? `尊敬的${name}` : '尊敬的诊所管理员';
});
/** 锁定原因:不重复账单区间/金额,只点明周期已到 */
const lockReasonTxt = '账单支付周期已到';
/** 日汇总已传凭证才露出进度0 不能盖掉 3 */
const canViewProgress = computed(() => {
const payload = lockState.payload || {};
return Number(payload.status) === 3 || Number(payload.bill_status) === 3;
});
function applyBillStatus(status: number) {
if (![0, 1, 2, 3].includes(status)) {
return;
}
lockState.payload.status = status;
lockState.payload.bill_status = status;
}
/** 锁缓存可能停在 0打开锁框后用现算状态 + 日汇总详情纠正 */
async function refreshLockStatus() {
try {
const live = await getPublicAccountLockStatus();
if (live && typeof live === 'object') {
Object.assign(lockState.payload, live);
if (Number(live.status) === 3 || Number(live.bill_status) === 3) {
applyBillStatus(3);
}
}
} catch {
// 刷新失败仍用当前 payload
}
const dailyId = Number(lockState.payload.daily_id || 0);
if (dailyId < 1) {
return;
}
try {
const detail = await getPublicAccountDailyBillInfo(dailyId);
// 周期锁店:合计用 platform_amount_total勿用单日金额覆盖
const billCount = Number(lockState.payload.bill_count || 0);
const hasPeriod =
billCount > 1 ||
Number(lockState.payload.merge_id || 0) > 0 ||
!!String(lockState.payload.platform_amount_total || '').trim();
if (detail?.platform_amount && !hasPeriod) {
lockState.payload.platform_amount = detail.platform_amount;
}
if (detail?.status != null) {
applyBillStatus(Number(detail.status));
}
} catch {
// 详情失败不挡锁框
}
}
async function resolveDailyId(): Promise<number> {
const existId = Number(lockState.payload.daily_id || 0);
if (existId > 0) {
return existId;
}
const storeId = Number(lockState.payload.store_id || 0);
if (storeId < 1 || !billDateTxt.value) {
return 0;
}
const res = await generatePublicAccountDailyBill({
store_id: storeId,
bill_date: billDateTxt.value,
});
const createdId = Number(res?.id || 0);
if (createdId > 0) {
lockState.payload.daily_id = createdId;
}
return createdId;
}
async function goPay() {
openingPay.value = true;
try {
const dailyId = await resolveDailyId();
if (dailyId < 1) {
message.warning('暂无待支付日账单');
return;
}
payDailyId.value = dailyId;
panelMode.value = 'pay';
} finally {
openingPay.value = false;
}
}
function backToHint() {
panelMode.value = 'hint';
}
/** 只有这次上传成功且未解锁,才切到等待审核 */
function onWaiting(payload?: {
status?: number;
bill_status?: number;
contact_phones?: { name?: string; phone: string }[];
}) {
if (payload?.contact_phones?.length) {
lockState.payload.contact_phones = payload.contact_phones;
}
applyBillStatus(3);
panelMode.value = 'wait';
}
function goWaitReupload() {
panelMode.value = 'pay';
}
function goWait() {
if (!canViewProgress.value) {
message.warning('暂无待审核进度');
return;
}
payDailyId.value = Number(lockState.payload.daily_id || 0);
panelMode.value = 'wait';
}
/** 点击应付款:只读拉日账单 + 商品订单 */
function goOrders() {
panelMode.value = 'orders';
}
const ordersMergeId = computed(() => Number(lockState.payload.merge_id || 0));
const ordersDailyId = computed(() => Number(lockState.payload.daily_id || 0));
const ordersOverdueIds = computed(() => {
const raw = lockState.payload.overdue_daily_ids;
if (!Array.isArray(raw)) {
return [] as number[];
}
return raw.map((id) => Number(id)).filter((id) => id > 0);
});
/**
* 锁框不可关闭,提供退出登录以免账号卡在锁店态
* 确认框抬高 zIndex避免被锁层2100挡住
*/
function logout() {
Modal.confirm({
title: '提示',
content: '确定退出登录?',
okText: '退出登录',
cancelText: '取消',
zIndex: PUBLIC_ACCOUNT_LOCK_PROMPT_Z_INDEX,
onOk: async () => {
if (loggingOut.value) {
return;
}
loggingOut.value = true;
try {
clearPublicAccountLock();
await authStore.logout(false);
} finally {
loggingOut.value = false;
}
},
});
}
watch(
overlayOpen,
(open) => {
document.body.classList.toggle('pap-lock-open', !!open);
if (!open) {
panelMode.value = 'hint';
return;
}
panelMode.value = 'hint';
void refreshLockStatus();
},
{ immediate: true },
);
</script>
<template>
<Teleport to="body">
<div v-if="overlayOpen" class="pap-lock-mask">
<div
class="pap-lock-panel"
:class="['is-' + alertColor, { 'is-sub': panelMode !== 'hint' }]"
role="alertdialog"
aria-modal="true"
>
<LockWarningCanvas
:active="overlayOpen"
:tapes-only="panelMode !== 'hint'"
:color="alertColor"
/>
<div class="pap-lock-shell">
<div class="pap-lock-scroll">
<LockPayPanel
v-if="panelMode === 'pay'"
:daily-id="payDailyId"
:platform-amount="amountTxt"
:bill-date-txt="billDateRangeTxt"
:unlock-mode="unlockMode"
@back="backToHint"
@waiting="onWaiting"
/>
<LockWaitPanel
v-else-if="panelMode === 'wait'"
:daily-id="payDailyId || Number(lockState.payload.daily_id || 0)"
:platform-amount="amountTxt"
:bill-date-txt="billDateRangeTxt"
:contacts="contactPhones"
:can-pay="canPay"
@reupload="goWaitReupload"
/>
<LockOrdersPanel
v-else-if="panelMode === 'orders'"
:merge-id="ordersMergeId"
:daily-id="ordersDailyId"
:overdue-daily-ids="ordersOverdueIds"
@back="backToHint"
/>
<template v-else>
<div class="pap-lock-badge">
{{ softHandle ? '待处理提醒' : '功能已锁定' }}
</div>
<p class="pap-lock-hello">{{ storeGreeting }}</p>
<h2 class="pap-lock-title">公账日账单已逾期</h2>
<p class="pap-lock-reason">{{ lockReasonTxt }}</p>
<p class="pap-lock-desc">
<template v-if="softHandle">
请尽快处理完成后可返回继续工作
</template>
<template v-else>
接诊开方已暂停本提示不可关闭
</template>
<template v-if="mergeHint">
{{ mergeHint }}
</template>
</p>
<div class="pap-lock-stat">
<div class="pap-lock-stat-item">
<span class="pap-lock-stat-label">{{ billDateLabel }}</span>
<strong>{{ billDateRangeTxt || '—' }}</strong>
</div>
<button
type="button"
class="pap-lock-stat-item is-clickable"
@click="goOrders"
>
<span class="pap-lock-stat-label">合计应付款点此查看明细</span>
<strong class="is-amount">¥{{ amountTxt }}</strong>
</button>
</div>
<div class="pap-lock-flow">
<div class="pap-lock-flow-title">解锁流程</div>
<ol class="pap-lock-flow-list">
<li>
账单日{{ lockDaysLabel }} {{ lockTime }}
后未处理即锁定更早的账单全天锁定
</li>
<li>诊所管理员在本提示中上传付款凭证转账截图</li>
<li v-if="unlockByVoucher">提交成功后立刻解锁接诊和开方可继续</li>
<li v-else>提交后仍锁定等平台管理员确认到账后自动解锁</li>
<li v-if="!softHandle">未达解锁条件前无法关闭本提示也无法继续业务</li>
</ol>
</div>
</template>
</div>
<!-- 底部操作固定在可视区内不随正文滚走 -->
<div class="pap-lock-foot">
<template v-if="panelMode === 'hint'">
<div v-if="canPay" class="pap-lock-actions">
<Button
type="primary"
size="large"
class="pap-lock-btn"
:loading="openingPay"
@click="goPay"
>
去上传凭证
</Button>
<Button
v-if="canViewProgress"
size="large"
class="pap-lock-btn is-ghost"
@click="goWait"
>
查看审核进度
</Button>
</div>
<p v-else class="pap-lock-hint">
请联系诊所管理员按上方流程上传付款凭证
</p>
</template>
<button
v-if="softHandle"
type="button"
class="pap-lock-logout"
@click="closePublicAccountLockHandle"
>
返回工作台
</button>
<button
v-else
type="button"
class="pap-lock-logout"
:disabled="loggingOut"
@click="logout"
>
退出登录
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
/* 遮罩占满视口,子面板用 max-height:100% 才能真正不溢出 */
.pap-lock-mask {
position: fixed;
inset: 0;
z-index: 2100;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: max(10px, env(safe-area-inset-top, 0px))
max(10px, env(safe-area-inset-right, 0px))
max(10px, env(safe-area-inset-bottom, 0px))
max(10px, env(safe-area-inset-left, 0px));
overflow: hidden;
background: hsl(var(--background) / 62%);
backdrop-filter: blur(6px);
}
.pap-lock-panel {
position: relative;
display: flex;
flex-direction: column;
box-sizing: border-box;
width: min(820px, 100%);
max-width: 100%;
/* 相对遮罩内容盒,保证整框含警戒条都在视口内 */
max-height: 100%;
min-height: 0;
overflow: hidden;
--pap-accent: var(--warning);
/* 顶留白跟警示三角对齐:矮屏大幅压缩 */
--pap-pad-top-hint: clamp(52px, 9dvh + 20px, 150px);
--pap-pad-x: clamp(14px, 3vw, 40px);
--pap-pad-y: clamp(8px, 1.6dvh, 20px);
--pap-gap-xs: clamp(2px, 0.5dvh, 6px);
--pap-gap-sm: clamp(6px, 1dvh, 12px);
--pap-gap-md: clamp(8px, 1.4dvh, 16px);
--pap-gap-lg: clamp(8px, 1.8dvh, 20px);
--pap-block-pad: clamp(8px, 1.2dvh, 14px);
--pap-fs-badge: clamp(11px, 1dvh + 5px, 12px);
--pap-fs-hello: clamp(12px, 1dvh + 5px, 15px);
--pap-fs-title: clamp(17px, 1.5dvh + 8px, 26px);
--pap-fs-reason: clamp(12px, 0.9dvh + 5px, 13px);
--pap-fs-desc: clamp(12px, 1dvh + 5px, 14px);
--pap-fs-label: clamp(11px, 0.8dvh + 5px, 12px);
--pap-fs-stat: clamp(13px, 1.2dvh + 5px, 18px);
--pap-fs-flow: clamp(11px, 0.9dvh + 4px, 12px);
--pap-fs-btn: clamp(14px, 1.1dvh + 5px, 16px);
--pap-btn-h: clamp(36px, 4.2dvh, 44px);
--pap-stat-cols: 1fr 1fr;
--pap-content-w: min(480px, 100%);
/* 给 canvas 上下警戒条留出露边,避免被底栏背景盖住 */
--pap-tape-h: 16px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--pap-accent) / 38%);
border-radius: clamp(10px, 1.2vw, 16px);
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
}
.pap-lock-panel.is-blue {
--pap-accent: 217 91% 53%;
}
.pap-lock-panel.is-yellow {
--pap-accent: var(--warning);
}
.pap-lock-panel.is-red {
--pap-accent: 0 72% 51%;
}
.pap-lock-panel.is-sub {
--pap-pad-top-hint: clamp(14px, 2dvh, 28px);
}
/* shell上滚下钉上下内边距露出警戒条动画 */
.pap-lock-shell {
position: relative;
z-index: 1;
display: flex;
flex: 1 1 auto;
flex-direction: column;
box-sizing: border-box;
width: 100%;
min-height: 0;
max-height: 100%;
padding-top: var(--pap-tape-h);
padding-bottom: var(--pap-tape-h);
}
.pap-lock-scroll {
display: flex;
flex: 1 1 auto;
flex-direction: column;
align-items: center;
box-sizing: border-box;
width: 100%;
min-height: 0;
padding: var(--pap-pad-top-hint) var(--pap-pad-x) var(--pap-gap-sm);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
text-align: center;
}
.pap-lock-foot {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: center;
box-sizing: border-box;
width: 100%;
padding: var(--pap-gap-sm) var(--pap-pad-x) var(--pap-pad-y);
border-top: 1px solid hsl(var(--pap-accent) / 22%);
/* 半透明,滚动区边缘仍能透出粒子;底边警戒条靠 shell padding 露白 */
background: hsl(var(--card, var(--background)) / 88%);
backdrop-filter: blur(4px);
}
.pap-lock-badge {
flex-shrink: 0;
padding: 3px 10px;
margin-bottom: var(--pap-gap-sm);
font-size: var(--pap-fs-badge);
font-weight: 600;
letter-spacing: 0.08em;
color: hsl(var(--pap-accent));
border: 1px solid hsl(var(--pap-accent) / 40%);
border-radius: 999px;
background: hsl(var(--pap-accent) / 12%);
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
}
.pap-lock-hello {
max-width: min(560px, 100%);
margin: 0 0 var(--pap-gap-xs);
padding: 0 4px;
font-size: var(--pap-fs-hello);
font-weight: 600;
line-height: 1.35;
color: hsl(var(--foreground));
word-break: break-word;
overflow-wrap: anywhere;
}
.pap-lock-title {
margin: 0 0 var(--pap-gap-xs);
font-size: var(--pap-fs-title);
font-weight: 700;
line-height: 1.25;
color: hsl(var(--foreground));
}
.pap-lock-reason {
max-width: min(520px, 100%);
margin: 0 0 var(--pap-gap-xs);
font-size: var(--pap-fs-reason);
line-height: 1.4;
color: hsl(var(--muted-foreground));
}
.pap-lock-desc {
max-width: min(520px, 100%);
margin: 0 0 var(--pap-gap-md);
font-size: var(--pap-fs-desc);
line-height: 1.45;
color: hsl(var(--muted-foreground));
}
.pap-lock-stat {
display: grid;
grid-template-columns: var(--pap-stat-cols);
gap: var(--pap-gap-sm);
width: var(--pap-content-w);
margin-bottom: var(--pap-gap-md);
}
.pap-lock-stat-item {
min-width: 0;
padding: var(--pap-block-pad) calc(var(--pap-block-pad) + 2px);
text-align: left;
border: 1px solid hsl(var(--pap-accent) / 30%);
border-radius: 10px;
background: hsl(var(--card, var(--background)));
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
}
.pap-lock-stat-item.is-clickable {
cursor: pointer;
color: inherit;
font: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.pap-lock-stat-item.is-clickable:hover {
border-color: hsl(var(--pap-accent) / 55%);
box-shadow: 0 0 10px hsl(var(--pap-accent) / 28%);
}
.pap-lock-stat-label {
display: block;
margin-bottom: var(--pap-gap-xs);
font-size: var(--pap-fs-label);
color: hsl(var(--muted-foreground));
}
.pap-lock-stat-item strong {
display: block;
font-size: var(--pap-fs-stat);
font-weight: 700;
line-height: 1.25;
color: hsl(var(--foreground));
word-break: break-word;
overflow-wrap: anywhere;
}
.pap-lock-stat-item strong.is-amount {
color: hsl(var(--pap-accent));
}
.pap-lock-flow {
width: var(--pap-content-w);
padding: var(--pap-block-pad) calc(var(--pap-block-pad) + 2px);
margin-bottom: 0;
text-align: left;
border: 1px solid hsl(var(--pap-accent) / 30%);
border-radius: 10px;
background: hsl(var(--card, var(--background)));
box-shadow: 0 0 6px hsl(var(--pap-accent) / 18%);
}
.pap-lock-flow-title {
margin-bottom: var(--pap-gap-xs);
font-size: var(--pap-fs-flow);
font-weight: 600;
color: hsl(var(--pap-accent));
}
.pap-lock-flow-list {
margin: 0;
padding-left: 16px;
font-size: var(--pap-fs-flow);
line-height: 1.45;
color: hsl(var(--muted-foreground));
}
.pap-lock-actions {
display: grid;
grid-template-columns: 1fr;
gap: var(--pap-gap-sm);
width: var(--pap-content-w);
}
.pap-lock-btn {
width: 100%;
height: var(--pap-btn-h);
font-size: var(--pap-fs-btn);
}
.pap-lock-btn.is-ghost {
color: hsl(var(--foreground));
border-color: hsl(var(--pap-accent) / 40%);
}
.pap-lock-hint {
max-width: var(--pap-content-w);
margin: 0;
font-size: var(--pap-fs-desc);
line-height: 1.45;
color: hsl(var(--pap-accent));
}
.pap-lock-logout {
margin-top: var(--pap-gap-sm);
padding: 0;
font-size: var(--pap-fs-desc);
line-height: 1.4;
color: hsl(var(--muted-foreground));
text-decoration: underline;
cursor: pointer;
background: transparent;
border: 0;
}
.pap-lock-logout:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-height: 720px) {
.pap-lock-mask {
align-items: flex-start;
}
.pap-lock-panel {
/* 矮屏仍双列,省纵向高度;过窄才单列 */
--pap-pad-top-hint: clamp(44px, 7dvh + 16px, 96px);
}
}
@media (max-width: 560px) {
.pap-lock-mask {
padding: 8px;
}
.pap-lock-panel {
--pap-stat-cols: 1fr;
--pap-pad-x: 12px;
}
}
</style>
<style>
/* 锁层 z-index 保持 2100antd message 默认 1010锁屏后抬到 5000 才能看见提示 */
body.pap-lock-open .ant-message,
body.pap-lock-open .ant-notification {
z-index: 5000 !important;
}
/* 退出确认等 Modal 也要压过锁层 */
body.pap-lock-open .ant-modal-wrap,
body.pap-lock-open .ant-modal-mask {
z-index: 5000 !important;
}
</style>

View File

@@ -23,6 +23,9 @@ import { notification } from 'ant-design-vue';
import { Bell, Users } from 'lucide-vue-next';
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
import PublicAccountLockBanner from '#/components/public-account-lock/PublicAccountLockBanner.vue';
import PublicAccountLockHeaderChip from '#/components/public-account-lock/PublicAccountLockHeaderChip.vue';
import PublicAccountLockOverlay from '#/components/public-account-lock/PublicAccountLockOverlay.vue';
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
import { useAuthStore } from '#/store';
@@ -294,6 +297,7 @@ watch(
@logout="handleLogout"
>
<template #header-right-55>
<PublicAccountLockHeaderChip />
<DoctorTransferFloat v-if="showDoctorTransfer" />
</template>
<template #user-dropdown>
@@ -376,4 +380,6 @@ watch(
<LockScreen :avatar @to-login="handleLogout" />
</template>
</BasicLayout>
<PublicAccountLockBanner />
<PublicAccountLockOverlay />
</template>

View File

@@ -15,20 +15,19 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
modalApi.setState({loading: true, confirmLoading: true});
updatePassword(values).then(() => {
modalApi.close();
message.success('修改成功,请重新登录');
})
.finally(() => {
modalApi.setState({loading: false, confirmLoading: false});
});
}
});
await formApi.validateAndSubmitForm();
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
await updatePassword(values);
modalApi.close();
message.success('修改成功,请重新登录');
} catch (err) {
console.error(err);
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
});
</script>

View File

@@ -1,38 +1,38 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:layout-dashboard',
order: -1,
title: $t('page.dashboard.title'),
},
name: 'Dashboard',
path: '/dashboard',
children: [
{
name: 'Analytics',
path: 'analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: true,
icon: 'lucide:area-chart',
title: $t('page.dashboard.analytics'),
},
},
{
name: 'Workspace',
path: 'workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',
title: $t('page.dashboard.workspace'),
},
},
],
},
];
export default routes;
// import type { RouteRecordRaw } from 'vue-router';
//
// import { $t } from '#/locales';
//
// const routes: RouteRecordRaw[] = [
// {
// meta: {
// icon: 'lucide:layout-dashboard',
// order: -1,
// title: $t('page.dashboard.title'),
// },
// name: 'Dashboard',
// path: '/dashboard',
// children: [
// {
// name: 'Analytics',
// path: 'analytics',
// component: () => import('#/views/dashboard/analytics/index.vue'),
// meta: {
// affixTab: true,
// icon: 'lucide:area-chart',
// title: $t('page.dashboard.analytics'),
// },
// },
// {
// name: 'Workspace',
// path: 'workspace',
// component: () => import('#/views/dashboard/workspace/index.vue'),
// meta: {
// icon: 'carbon:workspace',
// title: $t('page.dashboard.workspace'),
// },
// },
// ],
// },
// ];
//
// export default routes;

View File

@@ -13,6 +13,7 @@ import { defineStore } from 'pinia';
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
import { $t } from '#/locales';
import { applyPublicAccountLockSession } from '#/utils/publicAccountLock';
const { updateWatermark } = useWatermark();
@@ -143,6 +144,10 @@ export const useAuthStore = defineStore('auth', () => {
let userInfo: null | UserInfo = null;
userInfo = await getUserInfoApi();
userStore.setUserInfo(userInfo);
const lock = (userInfo as any)?.public_account_lock;
if (lock) {
applyPublicAccountLockSession(lock);
}
return userInfo;
}

View File

@@ -0,0 +1,179 @@
/**
* 公账逾期锁店弹窗 + 工作台条幅状态
* locked/visible=身份硬锁全屏bannerVisible=门店逾期且未硬锁时的顶部条幅
*/
import { reactive } from 'vue';
export type PublicAccountLockAlertColor = 'blue' | 'yellow' | 'red';
export interface PublicAccountLockContact {
name?: string;
phone: string;
/** 明文拨号号码(拦截器/后端写出,不走 phone 加密脱敏) */
phone_txt?: string;
/** 兼容旧字段,同 phone_txt */
tel?: string;
}
export interface PublicAccountLockPayload {
action?: string;
daily_id?: number;
store_id?: number;
/** 诊所名称,锁框问候用 */
store_name?: string;
platform_amount?: string;
/** 多天逾期合计应付款 */
platform_amount_total?: string;
/** 逾期日账单张数 */
bill_count?: number;
bill_date?: number;
status?: number;
bill_status?: number;
alert_color?: PublicAccountLockAlertColor | string;
unlock_mode?: 'voucher' | 'confirm' | string;
lock_time?: string;
/** 账单日起算宽限天数,默认 1 */
lock_days?: number;
bill_date_from?: number;
bill_date_to?: number;
bill_date_range_txt?: string;
overdue_daily_ids?: number[];
/** 合并批次 id预览=最早日账单 id */
merge_id?: number;
/** 当前身份是否硬锁(全屏) */
locked?: boolean;
/** 门店是否逾期(条幅) */
store_locked?: boolean;
contact_phones?: PublicAccountLockContact[];
}
/** 后台配置的提醒框颜色,非法值回落黄色 */
export function normalizeLockAlertColor(
raw: unknown,
): PublicAccountLockAlertColor {
const color = String(raw || 'yellow');
if (color === 'blue' || color === 'red') {
return color;
}
return 'yellow';
}
const state = reactive({
/** 身份硬锁全屏 */
visible: false,
/** 门店逾期条幅(未硬锁时) */
bannerVisible: false,
/** 条幅已收起到顶栏小胶囊 */
bannerDocked: false,
/** 条幅「去处理」打开的软处理层(可返回,非硬锁) */
handleVisible: false,
payload: {} as PublicAccountLockPayload,
});
/** 锁层是 2100antd message 默认 1010锁屏后提示会被挡住 */
export const PUBLIC_ACCOUNT_LOCK_PROMPT_Z_INDEX = 5000;
const LOCK_BODY_CLASS = 'pap-lock-open';
export function getPublicAccountLockState() {
return state;
}
/** 锁屏时抬高 message/notification不改锁层自己的 z-index */
function syncLockPromptLayer(visible: boolean) {
if (typeof document === 'undefined') {
return;
}
document.body.classList.toggle(LOCK_BODY_CLASS, visible);
}
function isTruthyFlag(v: unknown): boolean {
return v === true || v === 1 || v === '1';
}
/** 是否门店逾期(条幅) */
export function isStoreLockedPayload(payload?: PublicAccountLockPayload | null) {
if (!payload || typeof payload !== 'object') {
return false;
}
if (isTruthyFlag(payload.store_locked) || isTruthyFlag(payload.locked)) {
return true;
}
return !!(payload.daily_id || payload.bill_date);
}
/**
* 登录 my-info / lock-status按 locked / store_locked 分别驱动全屏与条幅
*/
export function applyPublicAccountLockSession(payload?: PublicAccountLockPayload | null) {
const data = (payload && typeof payload === 'object' ? payload : {}) as PublicAccountLockPayload;
state.payload = data;
const hard = isTruthyFlag(data.locked);
const storeLocked = isStoreLockedPayload(data);
state.visible = hard;
const showBanner = storeLocked && !hard;
// 新出现条幅时重新展开几秒,避免一直贴顶无人注意
if (showBanner && !state.bannerVisible) {
state.bannerDocked = false;
}
state.bannerVisible = showBanner;
if (!showBanner) {
state.bannerDocked = false;
}
if (!hard) {
// 硬锁解除时,处理层由业务自行关
}
syncLockPromptLayer(hard || state.handleVisible);
}
/** 4101强制全屏硬锁 */
export function showPublicAccountLock(payload: PublicAccountLockPayload) {
const data = {
...(payload || {}),
locked: true,
store_locked: true,
} as PublicAccountLockPayload;
state.payload = data;
state.visible = true;
state.bannerVisible = false;
state.bannerDocked = false;
state.handleVisible = false;
syncLockPromptLayer(true);
}
/** 条幅收起到顶栏 */
export function dockPublicAccountBanner() {
if (state.bannerVisible) {
state.bannerDocked = true;
}
}
/** 顶栏胶囊再展开大条幅 */
export function expandPublicAccountBanner() {
state.bannerDocked = false;
}
/**
* 条幅「去处理」:打开软处理层(上传/看进度),避免已在账单页时 router.push 无感
*/
export function openPublicAccountLockHandle() {
if (!isStoreLockedPayload(state.payload) && !state.bannerVisible && !state.visible) {
return;
}
state.handleVisible = true;
syncLockPromptLayer(true);
}
/** 关闭软处理层(硬锁时不可用) */
export function closePublicAccountLockHandle() {
state.handleVisible = false;
syncLockPromptLayer(state.visible);
}
export function clearPublicAccountLock() {
state.visible = false;
state.bannerVisible = false;
state.bannerDocked = false;
state.handleVisible = false;
state.payload = {};
syncLockPromptLayer(false);
}

View File

@@ -97,23 +97,22 @@ const saving = ref(false);
* 保存资料:校验 → 提交白名单字段 → 重拉全局登录态 → 通知父组件刷新
* 为什么要 fetchUserInfo后端已刷新 Redis 会话,前端重拉 my-info 才能让顶栏/工作台立即显示新头像昵称
*/
function handleSave() {
formApi.validate().then(async (e: any) => {
if (!e.valid) return;
const values = await formApi.getValues();
saving.value = true;
try {
await updateMyProfile({
nick_name: values.nick_name,
avatar: values.avatar || '',
});
message.success('保存成功');
await authStore.fetchUserInfo();
emits('saved');
} finally {
saving.value = false;
}
});
async function handleSave() {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
saving.value = true;
try {
await updateMyProfile({
nick_name: values.nick_name,
avatar: values.avatar || '',
});
message.success('保存成功');
await authStore.fetchUserInfo();
emits('saved');
} finally {
saving.value = false;
}
}
</script>
<template>

View File

@@ -20,19 +20,18 @@ const submitting = ref(false);
* 提交改密:校验 → 调 admin/update-password → 清空表单
* 成功提示与顶栏弹窗一致token 仍有效,下次登录使用新密码)
*/
function handleSubmit() {
formApi.validate().then(async (e: any) => {
if (!e.valid) return;
const values = await formApi.getValues();
submitting.value = true;
try {
await updatePassword(values);
message.success('修改成功,请重新登录');
formApi.resetForm();
} finally {
submitting.value = false;
}
});
async function handleSubmit() {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
submitting.value = true;
try {
await updatePassword(values);
message.success('修改成功,请重新登录');
formApi.resetForm();
} finally {
submitting.value = false;
}
}
</script>
<template>

View File

@@ -24,8 +24,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
@@ -40,9 +40,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -50,6 +50,7 @@ const markupQuickOptions = computed(() =>
const [DrawerComponent, drawerApi] = useVbenDrawer({
class: 'w-[520px]',
zIndex: 2000,
onConfirm: async () => {
const d = Number(priceDiscount.value);
if (!d || d <= 0) {

View File

@@ -24,6 +24,10 @@ import {
} from '#/views/business/order/api/order-ops';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import {
ORDER_POPUP_Z_INDEX,
openOrderPopup,
} from '#/views/business/order/utils/popup-z-index';
import DetailModal from '../product-order/components/detail.vue';
import ReconciliationDetailTable from './reconciliation-detail-table.vue';
@@ -90,6 +94,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
showConfirmButton: false,
showCancelButton: false,
destroyOnClose: true,
zIndex: ORDER_POPUP_Z_INDEX.NESTED,
onOpenChange(isOpen) {
if (isOpen) {
ledgerScope.value = isPlatformAdmin.value ? 'all' : 'store';
@@ -102,10 +107,12 @@ const [Drawer, drawerApi] = useVbenDrawer({
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
connectedComponent: PrescriptionDetail,
zIndex: ORDER_POPUP_Z_INDEX.NESTED2,
});
const [OrderDetailModal, OrderDetailModalApi] = useVbenModal({
connectedComponent: DetailModal,
zIndex: ORDER_POPUP_Z_INDEX.NESTED2,
});
async function loadTrace() {
@@ -229,8 +236,11 @@ function openPrescriptionDetail() {
if (!id) {
return;
}
PrescriptionDetailModalApi.setData({ values: id });
PrescriptionDetailModalApi.open();
openOrderPopup(
PrescriptionDetailModalApi,
{ values: id },
ORDER_POPUP_Z_INDEX.NESTED2,
);
}
function openOrderDetail() {
@@ -238,8 +248,22 @@ function openOrderDetail() {
if (!id) {
return;
}
OrderDetailModalApi.setData({ id });
OrderDetailModalApi.open();
// fromTrace详情不再叠一层溯源打印走本抽屉已挂载的处方弹窗高于详情 3000
openOrderPopup(
OrderDetailModalApi,
{
id,
fromTrace: true,
onOpenPrescription: (pId: number) => {
openOrderPopup(
PrescriptionDetailModalApi,
{ values: pId },
ORDER_POPUP_Z_INDEX.NESTED3,
);
},
},
ORDER_POPUP_Z_INDEX.NESTED2,
);
}
function goAuditPrescription() {

View File

@@ -6,7 +6,10 @@ const prefix = 'prescription/';
* @param data
*/
export async function getPrescriptionListApi(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
return requestClient.get<any>(`${prefix}list`, {
params: data,
paramsSerializer: 'indices',
});
}
/**
* 处方溯源

View File

@@ -9,7 +9,7 @@
*/
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { computed, ref, watch } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
@@ -69,6 +69,20 @@ function collectDefaultSearchValues(): Record<string, any> {
/** 列表与卡片共用的搜索条件(不含 status TabTab 单独合并) */
const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
/**
* 读搜索表单现值(不要只用 handleSubmit 快照)
* 抽出独立 SearchForm 后,列表 query 必须自己取表单值,否则点查询/翻页会丢掉刚填的条件
*/
async function readLiveSearchValues(): Promise<Record<string, any>> {
try {
await nextTick();
const live = await searchFormApi.getValues();
return { ...(live || {}) };
} catch {
return { ...searchValues.value };
}
}
/** 卡片视图最终条件:搜索条件 + 状态 Tabcomputed 保证 Tab 切换时卡片自动重拉) */
const cardFormValues = computed(() => ({
...searchValues.value,
@@ -81,8 +95,8 @@ const cardFormValues = computed(() => ({
*/
const [SearchForm, searchFormApi] = useVbenForm({
...formOptions,
handleSubmit: async (values) => {
searchValues.value = { ...(values || {}) };
handleSubmit: async () => {
searchValues.value = await readLiveSearchValues();
if (viewMode.value === 'list') {
gridApi.reload();
}
@@ -116,6 +130,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
ajax: {
/** 始终合并顶部统一搜索条件 + 状态 Tab */
query: async ({ page }: { page: { currentPage: number; pageSize: number } }) => {
searchValues.value = await readLiveSearchValues();
return await getPrescriptionListApi({
page: page.currentPage,
pageSize: page.pageSize,

View File

@@ -6,7 +6,11 @@ const prefix = 'order/';
* @param data
*/
export async function getOrderList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
return requestClient.get<any>(`${prefix}list`, {
params: data,
// 数组参数search_time / store_ids必须带下标Laravel request()->get 才能拿到数组
paramsSerializer: 'indices',
});
}
/**
* 订单状态下拉
@@ -30,7 +34,10 @@ export async function getOrderPrescriptionTypeOption(data?: any) {
* @param data
*/
export async function saleAmountApi(data: any) {
return requestClient.get<any>(`${prefix}sale-amount`, { params: data });
return requestClient.get<any>(`${prefix}sale-amount`, {
params: data,
paramsSerializer: 'indices',
});
}
/**
@@ -154,7 +161,10 @@ export interface OrderExportDataResult {
* 导出订单 JSON 数据(供前端 ExcelJS
*/
export async function getOrderExportDataApi(params: OrderExportDataParams) {
return requestClient.get<OrderExportDataResult>(`${prefix}export-data`, { params });
return requestClient.get<OrderExportDataResult>(`${prefix}export-data`, {
params,
paramsSerializer: 'indices',
});
}
export interface OrderExportSchemePayload {

View File

@@ -147,10 +147,9 @@ function patientNameOf(row: Record<string, any>) {
return row.user_patient?.name || row.userPatient?.name || row.patient || '—';
}
/** 是否有代付信息(有付款人 ID 或至少有付款人类型 */
/** 是否展示代付行:仅医生代支付(自付/就诊人侧不标代付 */
function hasPayUser(row: Record<string, any>) {
const payUserId = Number(row.pay_user_id || row.pay_user?.id || 0);
return payUserId > 0 || Number(row.pay_user_type || 0) > 0;
return !!(row.is_proxy_pay || Number(row.pay_user_type || 0) === 2);
}
/** 代付展示文案:[类型]名字(医生代付回落医生名,就诊人代付回落就诊人名) */

View File

@@ -110,9 +110,9 @@ function resolvePayUserId(row: Record<string, any>) {
return Number(row.pay_user_id || row.pay_user?.id || 0);
}
/** 是否展示代付行:有付款人 ID或至少有类型医生代付可回落医生名 */
/** 是否展示代付行:仅医生代支付(自付也会写 pay_user不能据此标代付 */
function hasPayUserInfo(row: Record<string, any>) {
return resolvePayUserId(row) > 0 || Number(row.pay_user_type || 0) > 0;
return !!(row.is_proxy_pay || Number(row.pay_user_type || 0) === 2);
}
/** 代付展示名:优先 pay_user 昵称;医生代付可回落医生名 */
@@ -221,53 +221,35 @@ function handleOpenPayUser() {
</div>
</div>
</div>
<div class="order-user-info__row">
<!-- 仅医生代支付展示代付行自付/就诊人侧不写代付避免误标 -->
<div v-if="hasPayUserInfo(row)" class="order-user-info__row">
<Avatar :size="24" :src="getPayUserAvatarSrc(row)" class="shrink-0" />
<div class="order-user-info__text min-w-0">
<span class="text-[11px] leading-tight text-gray-400 dark:text-slate-500">
代付
</span>
<!-- 医生代支付醒目 Tag避免只显示代付被忽略 -->
<!-- <Tag-->
<!-- v-if="row.is_proxy_pay || Number(row.pay_user_type) === 2"-->
<!-- color="magenta"-->
<!-- class="!mr-1 !leading-none"-->
<!-- >-->
<!-- 代支付-->
<!-- </Tag>-->
<template v-if="hasPayUserInfo(row)">
<span class="mr-1 text-[11px] text-gray-500 dark:text-slate-400">
[{{ payUserTypeLabel(row) }}]
</span>
<Button
v-if="Number(row.pay_user_type) === 2 && row.doctor?.name"
class="order-user-info__link-btn !h-auto !px-0 !py-0"
type="link"
@click.stop="emit('openDoctor', row)"
>
{{ payUserDisplayName(row) }}
</Button>
<Button
v-else-if="Number(row.pay_user_type) === 3 && resolveUpId(row)"
class="order-user-info__link-btn !h-auto !px-0 !py-0"
type="link"
@click.stop="handleOpenPatient"
>
{{ payUserDisplayName(row) }}
</Button>
<Button
v-else-if="resolvePayUserId(row)"
class="order-user-info__link-btn !h-auto !px-0 !py-0"
type="link"
@click.stop="handleOpenPayUser"
>
{{ payUserDisplayName(row) }}
</Button>
<span v-else class="truncate text-xs text-gray-700 dark:text-slate-200">
{{ payUserDisplayName(row) }}
</span>
</template>
<span v-else class="truncate text-xs text-gray-700 dark:text-slate-200"></span>
<span class="mr-1 text-[11px] text-gray-500 dark:text-slate-400">
[{{ payUserTypeLabel(row) }}]
</span>
<Button
v-if="Number(row.pay_user_type) === 2 && row.doctor?.name"
class="order-user-info__link-btn !h-auto !px-0 !py-0"
type="link"
@click.stop="emit('openDoctor', row)"
>
{{ payUserDisplayName(row) }}
</Button>
<Button
v-else-if="resolvePayUserId(row)"
class="order-user-info__link-btn !h-auto !px-0 !py-0"
type="link"
@click.stop="handleOpenPayUser"
>
{{ payUserDisplayName(row) }}
</Button>
<span v-else class="truncate text-xs text-gray-700 dark:text-slate-200">
{{ payUserDisplayName(row) }}
</span>
</div>
</div>
</div>

View File

@@ -6,8 +6,8 @@ import { useUserStore } from '@vben/stores';
import { Button, Card, Descriptions, Image, Modal as AntdModal, Space, Tabs, Tag, message } from 'ant-design-vue';
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
import { retrySettleApi } from '#/views/business/order/api/order-ops';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import OrderPricePercentAdjustDrawer from '#/views/business/order/components/OrderPricePercentAdjustDrawer.vue';
import { getOrderPriceAdjustConfig, adjustOrderPercent } from '#/api/order/priceAdjust';
@@ -16,6 +16,10 @@ import { formatPriceDiscountLabel, normalizeQuickOptions } from '#/utils/pricePe
import { expressDetailByOrderId, getOrderInfo, syncLegacyShipmentApi } from '../api';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
import {
ORDER_POPUP_Z_INDEX,
openOrderPopup,
} from '#/views/business/order/utils/popup-z-index';
import LogisticsModal from './logistics-modal.vue';
defineOptions({
@@ -33,6 +37,14 @@ const deliveryMethod = ref(-1);
const syncingLegacy = ref(false);
/** 重新结算请求中,防止重复点击 */
const retrySettling = ref(false);
/** 从溯源抽屉打开:底下已有溯源,底栏不再重复露出「查看溯源」 */
const fromTrace = ref(false);
/** 页面级打开溯源(对齐就诊人:父层不关,子层叠上去) */
const onOpenTraceFn = ref<null | ((payload: Record<string, any>) => void)>(
null,
);
/** 页面级打开处方详情,供底部「打印处方」复用已有打印页 */
const onOpenPrescriptionFn = ref<null | ((pId: number) => void)>(null);
/**
* 超管可见:已支付且未取消/未退款时可重新结算(补齐残缺分账)
@@ -65,10 +77,6 @@ const canSyncLegacyShipment = computed(() => {
return Number(data.value?.express_no_id) > 0;
});
const [TraceDrawer, traceDrawerApi] = useVbenDrawer({
connectedComponent: OrderTraceDrawer,
});
const [PercentAdjustDrawer, percentAdjustDrawerApi] = useVbenDrawer({
connectedComponent: OrderPricePercentAdjustDrawer,
});
@@ -78,6 +86,11 @@ const [LogisticsModalComp, logisticsModalApi] = useVbenModal({
connectedComponent: LogisticsModal,
});
/** 无页面级处方弹窗时的兜底(提现/流水等入口) */
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
connectedComponent: PrescriptionDetail,
});
const priceAdjustMeta = ref({
scope: 'sale_only' as 'both' | 'sale_only',
quickOptions: [] as QuickDiscountOption[],
@@ -102,15 +115,18 @@ async function reloadOrder() {
async function openOrderPercentAdjust() {
await reloadOrder();
const discount = Number(data.value?.price_discount ?? 100);
percentAdjustDrawerApi.setData({
stage: 'post_order',
priceDiscount: discount,
price_discount: discount,
productOrderId: data.value.id,
quickOptions: normalizeQuickOptions(priceAdjustMeta.value.quickOptions),
onSuccess: reloadOrder,
});
percentAdjustDrawerApi.open();
openOrderPopup(
percentAdjustDrawerApi,
{
stage: 'post_order',
priceDiscount: discount,
price_discount: discount,
productOrderId: data.value.id,
quickOptions: normalizeQuickOptions(priceAdjustMeta.value.quickOptions),
onSuccess: reloadOrder,
},
ORDER_POPUP_Z_INDEX.NESTED4,
);
}
async function clearOrderPriceDiscount() {
@@ -132,16 +148,44 @@ async function loadPriceAdjustMeta() {
loadPriceAdjustMeta();
/**
* 查看溯源:走页面级抽屉叠在当前详情上,详情不关(同就诊人列表→详情)
*/
function openOrderTrace() {
if (!data.value?.id) {
return;
}
traceDrawerApi.setData({
const openTrace = onOpenTraceFn.value;
if (typeof openTrace !== 'function') {
message.warning('当前页暂不支持查看溯源');
return;
}
openTrace({
scene: 'product',
order_id: data.value.id,
order_no: data.value.order_no,
});
traceDrawerApi.open();
}
/**
* 底部打印:打开处方详情(打印按钮已在处方弹窗底栏)
*/
function openPrintPrescription() {
const pId = Number(data.value?.p_id || 0);
if (pId < 1) {
message.warning('该订单没有关联处方');
return;
}
const openRx = onOpenPrescriptionFn.value;
if (typeof openRx === 'function') {
openRx(pId);
return;
}
openOrderPopup(
PrescriptionDetailModalApi,
{ values: pId },
ORDER_POPUP_Z_INDEX.NESTED4,
);
}
/**
@@ -156,6 +200,7 @@ function handleRetrySettle() {
content: `确认为订单 ${data.value.order_no} 重新结算分账?将作废旧分账后按当前规则重建,已完整的订单不会重复加账。`,
okText: '确认',
cancelText: '取消',
zIndex: ORDER_POPUP_Z_INDEX.NESTED4,
onOk: async () => {
retrySettling.value = true;
try {
@@ -183,6 +228,15 @@ const [Modal, modalApi] = useVbenModal({
if (isOpen) {
const modalData = modalApi.getData<Record<string, any>>() || {};
const { values, id, onShipPackage: shipFn } = modalData;
fromTrace.value = !!modalData.fromTrace;
onOpenTraceFn.value =
typeof modalData.onOpenTrace === 'function'
? modalData.onOpenTrace
: null;
onOpenPrescriptionFn.value =
typeof modalData.onOpenPrescription === 'function'
? modalData.onOpenPrescription
: null;
// 列表传入:按 warehouse_id 打开发货弹窗(平台包=0
onShipPackage.value = typeof shipFn === 'function' ? shipFn : null;
if (id) {
@@ -199,6 +253,9 @@ const [Modal, modalApi] = useVbenModal({
}
} else {
onShipPackage.value = null;
fromTrace.value = false;
onOpenTraceFn.value = null;
onOpenPrescriptionFn.value = null;
}
},
});
@@ -253,8 +310,11 @@ function maskExpressName(name: unknown): string {
*/
function openLogisticsModal() {
if (!data.value?.id) return;
logisticsModalApi.setData({ order_id: data.value.id });
logisticsModalApi.open();
openOrderPopup(
logisticsModalApi,
{ order_id: data.value.id },
ORDER_POPUP_Z_INDEX.NESTED4,
);
}
/**
@@ -295,6 +355,14 @@ const showPrescriptionStatus = computed(() => {
return row.prescription?.status != null;
});
/** 有关联处方即可从底栏打开打印页 */
const canPrintPrescription = computed(() => Number(data.value?.p_id) > 0);
/** 仅列表等页面传入了回调时显示;从溯源点进来时底下已有抽屉 */
const canShowTrace = computed(
() => !fromTrace.value && typeof onOpenTraceFn.value === 'function',
);
function prescriptionStatusText() {
const status = data.value?.prescription?.status;
return prescriptionStatusMap[Number(status)] ?? '未知';
@@ -311,20 +379,7 @@ function prescriptionStatusColor() {
<template>
<Modal class="h-[80%] w-[80%]" title="订单详情">
<div v-if="data" class="flex flex-col gap-4">
<Space>
<Button type="primary" size="small" @click="openOrderTrace">
查看溯源
</Button>
<Button
v-if="canRetrySettle"
size="small"
:loading="retrySettling"
@click="handleRetrySettle"
>
重新结算
</Button>
</Space>
<h3 class="mt-4">订单信息</h3>
<h3>订单信息</h3>
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
@@ -397,6 +452,15 @@ function prescriptionStatusColor() {
>
医生代支付
</Descriptions.Item>
<Descriptions.Item v-if="Number(data.type) > 0" label="支付类型">
{{
Number(data.type) === 3
? '公账支付'
: Number(data.type) === 1
? '微信支付'
: '易票联支付'
}}
</Descriptions.Item>
<Descriptions.Item label="配送方式">
{{ deliveryMethod === 0 ? '快递到家' : '药店自提' }}
</Descriptions.Item>
@@ -666,10 +730,30 @@ function prescriptionStatusColor() {
</Descriptions>
</div>
</div>
<template #prepend-footer>
<Space>
<Button v-if="canShowTrace" type="primary" @click="openOrderTrace">
查看溯源
</Button>
<Button
v-if="canPrintPrescription"
@click="openPrintPrescription"
>
打印处方
</Button>
<Button
v-if="canRetrySettle"
:loading="retrySettling"
@click="handleRetrySettle"
>
重新结算
</Button>
</Space>
</template>
</Modal>
<TraceDrawer />
<PercentAdjustDrawer />
<LogisticsModalComp />
<PrescriptionDetailModal />
</template>
<style scoped>

View File

@@ -183,6 +183,7 @@ const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
showConfirmButton: false,
zIndex: 2000,
cancelText: '关闭',
onCancel() {
modalApi.close();

View File

@@ -1,4 +1,8 @@
<script lang="ts" setup>
/**
* 商品订单发货弹窗
* 确认时自行校验并调发货接口;不再走已弃用的 validateAndSubmitForm会表现为点确定无反应
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -9,8 +13,6 @@ import { useVbenForm } from '#/adapter/form';
import { sendOrder } from '#/views/business/order/product-order/api';
import { modalFormProps } from '#/views/business/order/product-order/config/form';
const isUpdate = ref(false);
const gridApi = ref();
const orderNo = ref('');
@@ -24,30 +26,32 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
// 仅提交合法非负整数 warehouse_id脏值不传由后端按角色默认平台包 0
const wid = Number(values.warehouse_id);
const payload: Record<string, any> = { ...values };
if (Number.isFinite(wid) && wid >= 0 && String(values.warehouse_id) !== '[object Object]') {
payload.warehouse_id = wid;
} else {
delete payload.warehouse_id;
}
modalApi.setState({ loading: true, confirmLoading: true });
sendOrder(payload)
.then(() => {
message.success('发货成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
// 仅提交合法非负整数 warehouse_id脏值不传由后端按角色默认平台包 0
const wid = Number(values.warehouse_id);
const payload: Record<string, any> = { ...values };
if (
Number.isFinite(wid) &&
wid >= 0 &&
String(values.warehouse_id) !== '[object Object]'
) {
payload.warehouse_id = wid;
} else {
delete payload.warehouse_id;
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
await sendOrder(payload);
message.success('发货成功');
gridApi.value?.reload?.();
modalApi.close();
} catch (err) {
console.error(err);
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
@@ -65,7 +69,10 @@ const [Modal, modalApi] = useVbenModal({
});
</script>
<template>
<Modal :title="`订单:【 ${orderNo} 】发货`" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
<Modal
:title="`订单:【 ${orderNo} 】发货`"
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
>
<Form />
</Modal>
</template>

View File

@@ -56,3 +56,10 @@ export const IS_ONLINE_MAP: Record<number, TagMeta> = {
/** 就诊来源兜底(线下) */
export const IS_ONLINE_FALLBACK: TagMeta = { label: '线下就诊', color: 'default' };
/** 支付方式yii_product_order.type1微信 2易票联 3公账 */
export const PAY_TYPE_MAP: Record<number, TagMeta> = {
1: { label: '微信支付', color: 'green' },
2: { label: '易票联支付', color: 'blue' },
3: { label: '公账支付', color: 'orange' },
};

View File

@@ -54,6 +54,8 @@ export const formOptions: VbenFormProps = {
},
defaultValue: [],
fieldName: 'store_ids',
// 与 antd 系一致走 v-model:value避免自定义组件绑到 modelValue 导致选了店却搜不到
modelPropName: 'value',
label: '门店',
},
{

View File

@@ -2,7 +2,7 @@
import type { VbenFormProps } from '#/adapter/form';
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { computed, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
@@ -66,6 +66,12 @@ import FormModalDemo from './components/modal.vue';
import ProductOrderCardList from './components/ProductOrderCardList.vue';
import ProductOrderExportModal from './components/ProductOrderExportModal.vue';
import Refund from './components/refund.vue';
import PublicAccountConfirmModal from '#/views/finance/public-account-pay/components/confirm-modal.vue';
import { getPublicAccountPayConfig } from '#/views/finance/public-account-pay/api';
import {
ORDER_POPUP_Z_INDEX,
openOrderPopup,
} from '#/views/business/order/utils/popup-z-index';
import {
ORDER_STATUS_MAP,
ORDER_TYPE_MAP,
@@ -86,6 +92,8 @@ const canViewInviterCommission = isPlatformSuperAdmin(
);
const hasTopTableDropDownActions = ref(false);
/** 公账出账模式:未拉到配置前不当成 order避免按天误露出逐单确认 */
const publicAccountBillMode = ref('');
function findRegisterOrderListPath(): null | string {
const routes = router.getRoutes();
@@ -198,14 +206,29 @@ function collectDefaultSearchValues(): Record<string, any> {
/** 列表与卡片共用的搜索条件(提交/重置时整体替换,卡片靠 watch 自动拉数) */
const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
/**
* 读搜索表单现值(不要只用 handleSubmit 快照)
* 抽出独立 SearchForm 后,列表 query 必须自己取表单值,否则点查询/翻页会丢掉刚选的门店等条件
*/
async function readLiveSearchValues(): Promise<Record<string, any>> {
try {
// 等自定义组件同步 emit 写入表单后再取值,避免点查询仍是选店前的旧条件
await nextTick();
const live = await searchFormApi.getValues();
return { ...(live || {}) };
} catch {
return { ...searchValues.value };
}
}
/**
* 顶部统一搜索表单(从 grid 内嵌表单抽出,双视图共用)
* 列表模式提交后 reload 回第 1 页;卡片模式改 searchValues 由 CardList watch 拉数
*/
const [SearchForm, searchFormApi] = useVbenForm({
...effectiveFormOptions,
handleSubmit: async (values) => {
searchValues.value = { ...(values || {}) };
handleSubmit: async () => {
searchValues.value = await readLiveSearchValues();
if (viewMode.value === 'list') {
gridApi.reload();
} else {
@@ -234,6 +257,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
ajax: {
/** 始终合并顶部统一搜索条件;同时刷新顶部销售金额统计 */
query: async ({ page }: { page: { currentPage: number; pageSize: number } }) => {
searchValues.value = await readLiveSearchValues();
saleAmount(searchValues.value);
return await getOrderList({
page: page.currentPage,
@@ -285,6 +309,15 @@ onMounted(() => {
if (viewMode.value === 'card') {
saleAmount(searchValues.value);
}
// 按天先用后付禁止走 confirm-by-order行操作「公账支付」只在按订单模式显示
getPublicAccountPayConfig()
.then((cfg) => {
publicAccountBillMode.value =
cfg?.bill_mode === 'daily' ? 'daily' : 'order';
})
.catch(() => {
publicAccountBillMode.value = '';
});
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: FormModalDemo,
@@ -299,6 +332,9 @@ const [LogisticsModalComp, logisticsModalApi] = useVbenModal({
const [RefundModal, RefundModalApi] = useVbenModal({
connectedComponent: Refund,
});
const [PublicAccountConfirmModals, publicAccountConfirmModalApi] = useVbenModal({
connectedComponent: PublicAccountConfirmModal,
});
const [ExportModal, exportModalApi] = useVbenModal({
connectedComponent: ProductOrderExportModal,
@@ -315,6 +351,17 @@ const infoModal = (data: Record<string, any> = {}) => {
modalApi.close();
wareSend(data, warehouseId);
},
// 二级弹窗必须页面级挂载,详情里再嵌溯源会被盖住
onOpenTrace: (payload: Record<string, any>) => {
openOrderPopup(
traceDrawerApi,
payload,
ORDER_POPUP_Z_INDEX.NESTED,
);
},
onOpenPrescription: (pId: number) => {
openPrescriptionDetail(pId);
},
});
modalApi.open();
};
@@ -363,6 +410,10 @@ const collapseAll = () => {
const overviewItems = ref<StatIslandItem[]>([]);
const income = ref(0);
const total = ref(0);
/** 平台管理员顶部展示「平台收益」,其余角色仍为「我的收益」 */
const isPlatformAdmin = computed(
() => Number(userStore?.userInfo?.roles?.user_type) === 2,
);
/** 按当前筛选条件刷新顶部销售金额/收益(支持门店多选 store_ids双视图共用 */
const saleAmount = (formValues?: Record<string, any>) => {
const values = formValues ?? searchValues.value ?? {};
@@ -385,7 +436,7 @@ const saleAmount = (formValues?: Record<string, any>) => {
},
{
key: 'income',
label: '我的收益',
label: isPlatformAdmin.value ? '平台收益' : '我的收益',
value: income.value,
icon: 'lucide:wallet',
emphasize: true,
@@ -396,20 +447,26 @@ const saleAmount = (formValues?: Record<string, any>) => {
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
connectedComponent: PrescriptionDetail,
zIndex: ORDER_POPUP_Z_INDEX.NESTED,
});
const [DoctorCardModals, DoctorCardModalApi] = useVbenModal({
connectedComponent: DoctorCardModal,
zIndex: ORDER_POPUP_Z_INDEX.NESTED,
});
const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
connectedComponent: StoreCardModal,
zIndex: ORDER_POPUP_Z_INDEX.NESTED,
});
function openStoreCard(storeId?: number) {
if (!storeId) return;
StoreCardModalApi.setData({ storeId: Number(storeId) });
StoreCardModalApi.open();
openOrderPopup(
StoreCardModalApi,
{ storeId: Number(storeId) },
ORDER_POPUP_Z_INDEX.NESTED,
);
}
const [PatientsModal, PatientsModalApi] = useVbenModal({
@@ -418,6 +475,7 @@ const [PatientsModal, PatientsModalApi] = useVbenModal({
const [PatientDetailModal, PatientDetailModalApi] = useVbenModal({
connectedComponent: WxUserPatientDetailModal,
zIndex: ORDER_POPUP_Z_INDEX.NESTED,
});
/** 从订单列表以只读模式打开医生档案 */
@@ -427,12 +485,15 @@ function showOrderDoctorCard(row: Record<string, any>) {
message.warning('该订单无关联医生');
return;
}
DoctorCardModalApi.setData({
id: doctor?.id,
su_id: row.su_id ?? doctor?.su_id,
readonly: true,
});
DoctorCardModalApi.open();
openOrderPopup(
DoctorCardModalApi,
{
id: doctor?.id,
su_id: row.su_id ?? doctor?.su_id,
readonly: true,
},
ORDER_POPUP_Z_INDEX.NESTED,
);
}
/** 点下单用户 → 该用户下就诊人列表 Modal */
@@ -459,11 +520,14 @@ function openOrderPatient(payload: { upId: number; patientName: string }) {
message.warning('缺少就诊人信息');
return;
}
PatientDetailModalApi.setData({
upId: payload.upId,
patientName: payload.patientName,
});
PatientDetailModalApi.open();
openOrderPopup(
PatientDetailModalApi,
{
upId: payload.upId,
patientName: payload.patientName,
},
ORDER_POPUP_Z_INDEX.NESTED,
);
}
const [TraceDrawer, traceDrawerApi] = useVbenDrawer({
@@ -507,17 +571,20 @@ async function openOrderPercentAdjust(row: Record<string, any>) {
} catch {
// 列表字段缺失时沿用行内值
}
percentAdjustDrawerApi.setData({
stage: 'post_order',
priceDiscount: discount,
price_discount: discount,
productOrderId: row.id,
quickOptions: normalizeQuickOptions(priceAdjustMeta.value.quickOptions),
onSuccess: () => {
refreshCurrentViewFromPageOne();
openOrderPopup(
percentAdjustDrawerApi,
{
stage: 'post_order',
priceDiscount: discount,
price_discount: discount,
productOrderId: row.id,
quickOptions: normalizeQuickOptions(priceAdjustMeta.value.quickOptions),
onSuccess: () => {
refreshCurrentViewFromPageOne();
},
},
});
percentAdjustDrawerApi.open();
ORDER_POPUP_Z_INDEX.NESTED,
);
}
function openChinaErpSyncLog(row: Record<string, any>) {
@@ -530,12 +597,25 @@ function openChinaErpSyncLog(row: Record<string, any>) {
}
function openOrderTrace(row: Record<string, any>) {
traceDrawerApi.setData({
scene: 'product',
openOrderPopup(
traceDrawerApi,
{
scene: 'product',
order_id: row.id,
order_no: row.order_no,
},
ORDER_POPUP_Z_INDEX.NESTED,
);
}
/** 超管/管理员在待支付订单上直接确认公账到账 */
function openPublicAccountPay(row: Record<string, any>) {
publicAccountConfirmModalApi.setData({
order_id: row.id,
order_no: row.order_no,
gridApi: gridApiProxy,
});
traceDrawerApi.open();
publicAccountConfirmModalApi.open();
}
function handleSimulatePay(row: Record<string, any>) {
@@ -648,11 +728,11 @@ function formatCommissionRecordDetail(record: Record<string, any>) {
}
const openPrescriptionDetail = (values: number) => {
// 打开西药处方模态框逻辑
PrescriptionDetailModalApi.setData({
values,
});
PrescriptionDetailModalApi.open();
openOrderPopup(
PrescriptionDetailModalApi,
{ values },
ORDER_POPUP_Z_INDEX.NESTED,
);
};
/**
@@ -969,6 +1049,17 @@ function buildRowActions(row: Record<string, any>): {
ifShow: row.is_pay === 0,
onClick: handleSimulatePay.bind(null, row),
},
{
label: '公账支付',
type: 'link',
icon: 'mdi:bank',
auth: ['Super Admin', 'Admin'],
ifShow:
publicAccountBillMode.value === 'order' &&
row.is_pay === 0 &&
Number(row.store?.public_account_pay_enabled ?? 0) === 1,
onClick: openPublicAccountPay.bind(null, row),
},
{
// 仅超管:待收货确认收货 / 已确认收货重试解冻
label: Number(row.status) === 2 ? '确认收货' : '重试解冻',
@@ -1019,6 +1110,7 @@ function buildRowActions(row: Record<string, any>): {
<template>
<Page auto-content-height title="订单管理">
<FormModal />
<PublicAccountConfirmModals />
<Modal />
<LogisticsModalComp />
<AntdModal

View File

@@ -31,7 +31,10 @@ export interface RegisterOrderItem {
export async function getRegisterListApi(data: Record<string, unknown>) {
return requestClient.get<{ items: RegisterOrderItem[]; total: number }>(
`${prefix}list`,
{ params: data },
{
params: data,
paramsSerializer: 'indices',
},
);
}

View File

@@ -28,6 +28,8 @@ const props = defineProps<{
formValues?: Record<string, any>;
/** 是否可退款(与列表行操作同一份判定,父级传入保证口径一致) */
canRefund: (row: Record<string, any>) => boolean;
/** 是否可改期(与列表行操作同一份判定,父级传入保证口径一致) */
canReschedule: (row: Record<string, any>) => boolean;
}>();
const emit = defineEmits<{
@@ -35,6 +37,8 @@ const emit = defineEmits<{
openDetail: [row: Record<string, any>];
/** 退款(复用父级退款弹窗) */
refund: [registerId: number];
/** 改期(复用父级改期弹窗) */
reschedule: [row: Record<string, any>];
/** 点处方号打开处方详情 */
openPrescription: [prescriptionId: number];
/** 点诊所名打开诊所卡片 */
@@ -77,7 +81,7 @@ function money(value: unknown) {
return Number.isFinite(n) ? n.toFixed(2) : '—';
}
/** 行操作:退款(显隐与列表一致),通过 emit 复用父级弹窗 */
/** 行操作:详情 / 改期 / 退款(显隐与列表一致),通过 emit 复用父级弹窗 */
function buildActions(row: Record<string, any>): ActionItem[] {
return [
{
@@ -86,6 +90,13 @@ function buildActions(row: Record<string, any>): ActionItem[] {
icon: 'marketeq:eye',
onClick: () => emit('openDetail', row),
},
{
label: '改期',
type: 'link',
icon: 'ant-design:calendar-outlined',
ifShow: props.canReschedule(row),
onClick: () => emit('reschedule', row),
},
{
label: '退款',
type: 'link',

View File

@@ -22,22 +22,20 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
refundRegisterApi(values)
.then(() => {
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
await refundRegisterApi(values);
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
} catch (err) {
console.error(err);
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -10,7 +10,7 @@
import type { VbenFormProps } from '#/adapter/form';
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref, watch } from 'vue';
import { nextTick, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
@@ -23,6 +23,10 @@ import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import { TableAction } from '#/components/table-action';
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import {
ORDER_POPUP_Z_INDEX,
openOrderPopup,
} from '#/views/business/order/utils/popup-z-index';
import { getRegisterListApi } from './api';
import Refund from './components/refund.vue';
@@ -100,14 +104,28 @@ function collectDefaultSearchValues(): Record<string, any> {
/** 列表与卡片共用的搜索条件(提交/重置时整体替换,卡片靠 watch 自动拉数) */
const searchValues = ref<Record<string, any>>(collectDefaultSearchValues());
/**
* 读搜索表单现值(不要只用 handleSubmit 快照)
* 抽出独立 SearchForm 后,列表 query 必须自己取表单值,否则点查询/翻页会丢掉刚填的条件
*/
async function readLiveSearchValues(): Promise<Record<string, any>> {
try {
await nextTick();
const live = await searchFormApi.getValues();
return { ...(live || {}) };
} catch {
return { ...searchValues.value };
}
}
/**
* 顶部统一搜索表单(从 grid 内嵌表单抽出,双视图共用)
* 列表模式提交后 reload 回第 1 页;卡片模式由 formValues watch 自动拉数
*/
const [SearchForm, searchFormApi] = useVbenForm({
...effectiveFormOptions,
handleSubmit: async (values) => {
searchValues.value = { ...values };
handleSubmit: async () => {
searchValues.value = await readLiveSearchValues();
if (viewMode.value === 'list') {
gridApi.reload();
}
@@ -135,6 +153,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
}: {
page: { currentPage: number; pageSize: number };
}) => {
searchValues.value = await readLiveSearchValues();
return await getRegisterListApi({
page: page.currentPage,
pageSize: page.pageSize,
@@ -181,16 +200,21 @@ const gridApiProxy = {
const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
connectedComponent: StoreCardModal,
zIndex: ORDER_POPUP_Z_INDEX.NESTED,
});
function openStoreCard(storeId?: number) {
if (!storeId) return;
StoreCardModalApi.setData({ storeId: Number(storeId) });
StoreCardModalApi.open();
openOrderPopup(
StoreCardModalApi,
{ storeId: Number(storeId) },
ORDER_POPUP_Z_INDEX.NESTED,
);
}
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
connectedComponent: PrescriptionDetail,
zIndex: ORDER_POPUP_Z_INDEX.NESTED,
});
const [RefundModal, RefundModalApi] = useVbenModal({
@@ -213,10 +237,11 @@ function openRegisterDetail(row: Record<string, any>) {
}
function openPrescriptionDetail(id: number) {
PrescriptionDetailModalApi.setData({
values: id,
});
PrescriptionDetailModalApi.open();
openOrderPopup(
PrescriptionDetailModalApi,
{ values: id },
ORDER_POPUP_Z_INDEX.NESTED,
);
}
function openRefundModal(id: number) {
@@ -388,8 +413,10 @@ function rowRefundMeta(row: Record<string, any>) {
ref="cardListRef"
:form-values="searchValues"
:can-refund="canRefund"
:can-reschedule="canReschedule"
@open-detail="openRegisterDetail"
@refund="openRefundModal"
@reschedule="openRescheduleModal"
@open-prescription="openPrescriptionDetail"
@open-store="openStoreCard"
/>

View File

@@ -0,0 +1,32 @@
/**
* 订单弹层层级Vben 默认是 CSS --popup-z-index=2000不是 1000
* 二级必须高于 2000否则和订单详情同层看起来像没打开
* 每次 open 都要 setState 传入,只写在构造上不可靠
*/
export const ORDER_POPUP_Z_INDEX = {
/** 对齐 Vben 默认弹窗 */
BASE: 2000,
/** 详情上再开溯源/处方,必须比 2000 高 */
NESTED: 3000,
NESTED2: 4000,
NESTED3: 5000,
/** 详情内物流/改价/兜底处方,高于提现详情 */
NESTED4: 6000,
} as const;
type OrderPopupApi = {
setState: (state: Record<string, any>) => void;
setData: (data: any) => void;
open: () => void;
};
/** 打开弹层并显式带上 zIndex避免被上一层 1000 挡住 */
export function openOrderPopup(
api: OrderPopupApi,
data: Record<string, any>,
zIndex: number,
) {
api.setState({ zIndex });
api.setData(data);
api.open();
}

View File

@@ -25,8 +25,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
@@ -41,9 +41,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -55,36 +55,23 @@ const [Modal, modalApi] = useVbenModal({
onCancel() {
modalApi.close();
},
// 确认按钮回调:提交表单数据
// 确认按钮回调:单次 await validate 后调接口(勿并用 validateAndSubmitForm
onConfirm: async () => {
// 验证表单
formApi.validate().then(async (e: any) => {
if (e.valid) {
// 获取表单值
const values = await formApi.getValues();
// 设置弹窗加载状态
modalApi.setState({ loading: true, confirmLoading: true });
// 根据是否为编辑模式选择不同的API
const submitApi = isUpdate.value
? updateHealthFood
: createHealthFood;
// 提交数据
submitApi(values)
.then(() => {
message.success('保存成功');
// 刷新表格数据
gridApi.value?.reload();
// 关闭弹窗
modalApi.close();
})
.finally(() => {
// 取消加载状态
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
// 验证并提交表单
await formApi.validateAndSubmitForm();
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value ? updateHealthFood : createHealthFood;
try {
await submitApi(values);
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
} catch (err) {
console.error(err);
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
// 弹窗打开/关闭状态变化回调
onOpenChange(isOpen: boolean) {

View File

@@ -34,8 +34,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
@@ -50,9 +50,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -34,8 +34,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
@@ -50,9 +50,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -26,8 +26,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
@@ -42,9 +42,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -58,8 +58,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
@@ -74,9 +74,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -25,8 +25,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
@@ -41,9 +41,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -23,8 +23,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = saveNavUrlApi;
@@ -38,9 +38,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -75,8 +75,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (!e.valid) return;
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
if (!isUpdate.value) {
await submitPlain(values);
@@ -119,8 +119,7 @@ const [Modal, modalApi] = useVbenModal({
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -70,8 +70,8 @@ const [Modal, modalApi] = useVbenModal({
message.warning('该商品不可改价');
return;
}
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
@@ -86,9 +86,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -70,8 +70,8 @@ const [Modal, modalApi] = useVbenModal({
message.warning('该商品不可改价');
return;
}
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
@@ -86,9 +86,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -24,8 +24,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value ? updateDoctorArticle : createDoctorArticle;
@@ -38,9 +38,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -713,6 +713,9 @@ async function runGenerateInDrawer(chief: string) {
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();

View File

@@ -13,6 +13,7 @@ import { Button, message, Popover, Select, Spin, Switch } from 'ant-design-vue';
import { desktop, isElectron } from '#/util/desktop';
import { formatStoreNameWithHu } from '#/utils/formatStoreNameWithHu';
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
import { ORDER_POPUP_Z_INDEX } from '#/views/business/order/utils/popup-z-index';
import { getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api';
import { passApi } from '#/views/pharmacist/audit-prescription/api';
import RejectionReason from '#/views/pharmacist/audit-prescription/components/modal.vue';
@@ -40,6 +41,7 @@ const showAuditActions = computed(
const [RejectionReasonModal, RejectionReasonModalApi] = useVbenModal({
connectedComponent: RejectionReason,
zIndex: ORDER_POPUP_Z_INDEX.NESTED3,
});
const [Modal, modalApi] = useVbenModal({
@@ -269,51 +271,6 @@ function parseRecipeContent(content: Record<string, unknown> | string) {
<RejectionReasonModal />
<Spin :spinning="loading">
<Page>
<Button type="primary" @click="handleWindowPrint('#demo', '处方')">
打印处方
</Button>
<!-- 打印设置仅桌面端可见配置静默打印开关与目标打印机 -->
<Popover
v-if="isDesktop"
trigger="click"
placement="bottomLeft"
@open-change="handlePrintSettingOpen"
>
<template #content>
<div class="flex w-64 flex-col gap-3">
<div class="flex items-center justify-between">
<span>静默打印</span>
<Switch
:checked="printSettings.silentPrint"
@change="
(checked: any) =>
updatePrintSettings({ silentPrint: !!checked })
"
/>
</div>
<div>
<div class="mb-1">打印机</div>
<Select
class="w-full"
:value="printSettings.printerName || undefined"
:options="printerOptions"
placeholder="系统默认打印机"
allow-clear
@change="
(value: any) =>
updatePrintSettings({
printerName: (value as string) || '',
})
"
/>
</div>
<div class="text-xs text-muted-foreground">
开启后打印处方不再弹出打印对话框直接从所选打印机出纸
</div>
</div>
</template>
<Button class="ml-2">打印设置</Button>
</Popover>
<div class="prescription-container print-box">
<div class="prescription-header">
<div class="header-top">
@@ -512,14 +469,57 @@ function parseRecipeContent(content: Record<string, unknown> | string) {
</div>
</Page>
</Spin>
<template v-if="showAuditActions" #footer>
<div class="flex w-full justify-end gap-2">
<Button @click="modalApi.close()">取消</Button>
<Button danger :disabled="passing" @click="handleReject">拒绝</Button>
<Button type="primary" :loading="passing" @click="handlePass">
通过审方
</Button>
</div>
<template #prepend-footer>
<Button type="primary" @click="handleWindowPrint('#demo', '处方')">
打印处方
</Button>
<Popover
v-if="isDesktop"
trigger="click"
placement="topLeft"
@open-change="handlePrintSettingOpen"
>
<template #content>
<div class="flex w-64 flex-col gap-3">
<div class="flex items-center justify-between">
<span>静默打印</span>
<Switch
:checked="printSettings.silentPrint"
@change="
(checked: any) =>
updatePrintSettings({ silentPrint: !!checked })
"
/>
</div>
<div>
<div class="mb-1">打印机</div>
<Select
class="w-full"
:value="printSettings.printerName || undefined"
:options="printerOptions"
placeholder="系统默认打印机"
allow-clear
@change="
(value: any) =>
updatePrintSettings({
printerName: (value as string) || '',
})
"
/>
</div>
<div class="text-xs text-muted-foreground">
开启后打印处方不再弹出打印对话框,直接从所选打印机出纸
</div>
</div>
</template>
<Button class="ml-2">打印设置</Button>
</Popover>
</template>
<template v-if="showAuditActions" #append-footer>
<Button danger :disabled="passing" @click="handleReject">拒绝</Button>
<Button type="primary" :loading="passing" @click="handlePass">
通过审方
</Button>
</template>
</Modal>
</template>

View File

@@ -16,6 +16,7 @@ import {
MenuUnfoldOutlined,
PlusOutlined,
QrcodeOutlined,
BankOutlined,
ReloadOutlined,
RobotOutlined,
UserAddOutlined,
@@ -95,6 +96,7 @@ import RefusalOfTreatmentModal
import CreateUserPatientModal from './components/CreateUserPatientModal.vue';
import ClaimQrcodeModal from './components/ClaimQrcodeModal.vue';
import ProxyPayQrcodeModal from './components/ProxyPayQrcodeModal.vue';
import ApplyPublicAccountPayModal from '#/views/finance/public-account-pay/components/apply-modal.vue';
// 常用方选择弹窗组件
import CommonPrescriptionModal from './components/CommonPrescriptionModal.vue';
import GoldenFormulaModal from './components/GoldenFormulaModal.vue';
@@ -333,6 +335,8 @@ const seeRate = ref(0);
const allowInsuranceCategory = ref(0);
/** 是否开启订单百分比调价 */
const priceAdjustEnabled = ref(false);
const publicAccountPayEnabled = ref(false);
const publicAccountDailyMode = ref(false);
/** 开方购物车整单价格比例100=原价 */
const priceDiscount = ref(100);
const { config: priceAdjustConfig, loadByStoreId, applyRatioToDrugs: applyRatioDrugs } = useOrderPriceAdjust();
@@ -406,6 +410,8 @@ async function fetchStoreSeeRate() {
seeRate.value = Number(res?.see_rate ?? 0);
allowInsuranceCategory.value = Number(res?.allow_insurance_category ?? 0);
priceAdjustEnabled.value = Number(res?.enable_order_price_percent_adjust ?? 0) === 1;
publicAccountPayEnabled.value = Number(res?.public_account_pay_enabled ?? 0) === 1;
publicAccountDailyMode.value = String(res?.public_account_bill_mode || '') === 'daily';
// 有挂号时 vip 已按履约诊所返回,供病历/AI/金方门控
receptionVip.value = res?.vip ?? null;
await loadByStoreId(myStoreId.value);
@@ -1232,6 +1238,7 @@ const sendPrescription = () => {
order_id: Number(res?.order_id || 0),
order_no: res?.order_no || '',
total_pay_price: res?.total_pay_price || '',
is_pay: Number(res?.is_pay || 0),
patient_name: activePatient.value?.name || '',
diagnosis: diagnosis.value,
};
@@ -1384,6 +1391,9 @@ const [ClaimQrcodeModals, ClaimQrcodeModalApi] = useVbenModal({
const [ProxyPayQrcodeModals, ProxyPayQrcodeModalApi] = useVbenModal({
connectedComponent: ProxyPayQrcodeModal,
});
const [ApplyPublicAccountPayModals, ApplyPublicAccountPayModalApi] = useVbenModal({
connectedComponent: ApplyPublicAccountPayModal,
});
// ==================== 常用方弹窗 ====================
const [CommonPrescriptionModals, CommonPrescriptionModalApi] = useVbenModal({
@@ -1488,6 +1498,22 @@ function openProxyPayQrcode(orderId: number) {
ProxyPayQrcodeModalApi.open();
}
/** 开方成功页申请公账支付(付款凭证选填) */
function openApplyPublicAccountPay() {
const info = sentPrescriptionInfo.value || {};
const oid = Number(info.order_id || 0);
if (!oid) {
message.error('订单ID无效');
return;
}
ApplyPublicAccountPayModalApi.setData({
order_id: oid,
order_no: info.order_no,
total_pay_price: info.total_pay_price,
});
ApplyPublicAccountPayModalApi.open();
}
/**
* 打开金方导入弹窗VIPgolden_formula仅中药处方可用
*/
@@ -3288,6 +3314,7 @@ function onStoreSelectOpenChange(open: boolean) {
<CreateUserPatientModals />
<ClaimQrcodeModals />
<ProxyPayQrcodeModals />
<ApplyPublicAccountPayModals />
<InfoModalComponent />
<!-- 左侧患者列表悬停展开移出收起收起态仅箭头+背景提示 -->
<aside
@@ -4389,7 +4416,12 @@ function onStoreSelectOpenChange(open: boolean) {
</div>
<div class="rx-sent-row">
<span>订单状态</span>
<Tag color="orange" class="rx-sent-tag">待患者支付</Tag>
<Tag
:color="Number(sentPrescriptionInfo?.is_pay) === 1 ? 'success' : 'orange'"
class="rx-sent-tag"
>
{{ Number(sentPrescriptionInfo?.is_pay) === 1 ? '已支付(公账先用后付)' : '待患者支付' }}
</Tag>
</div>
<div class="rx-sent-total">
<span>总金额</span>
@@ -4400,7 +4432,7 @@ function onStoreSelectOpenChange(open: boolean) {
<div class="rx-sent-ops">
<!-- 代付入口患者不便操作手机时医生代其发起收款复用代付码弹窗 -->
<Button
v-if="sentPrescriptionInfo?.order_id"
v-if="sentPrescriptionInfo?.order_id && Number(sentPrescriptionInfo?.is_pay) !== 1"
type="primary"
shape="round"
size="large"
@@ -4410,6 +4442,21 @@ function onStoreSelectOpenChange(open: boolean) {
<QrcodeOutlined />
代付收款
</Button>
<Button
v-if="
sentPrescriptionInfo?.order_id &&
publicAccountPayEnabled &&
!publicAccountDailyMode &&
Number(sentPrescriptionInfo?.is_pay) !== 1
"
shape="round"
size="large"
class="rx-sent-next"
@click="openApplyPublicAccountPay"
>
<BankOutlined />
申请公账支付
</Button>
<Button shape="round" size="large" class="rx-sent-next" @click="tabType = 2">继续开方</Button>
</div>
</div>

View File

@@ -25,8 +25,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value ? updateDoctor : createDoctor;
@@ -39,9 +39,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -74,8 +74,9 @@ export const infoModalFormProps: VbenFormProps = {
},
},
{
fieldName: 'otherinfo',
label: '基础信息',
// 分区标题:仅展示,禁止与业务字段同名 / 重复 fieldName
fieldName: '_section_basic',
label: '',
component: 'Divider',
formItemClass: 'col-span-12',
componentProps: {},
@@ -278,8 +279,9 @@ export const infoModalFormProps: VbenFormProps = {
rules: 'required',
},
{
fieldName: 'otherinfo',
label: '执业&身份信息',
// 分区标题:仅展示,与基础信息分区 fieldName 必须唯一
fieldName: '_section_practice',
label: '',
component: 'Divider',
formItemClass: 'col-span-12',
componentProps: {},

View File

@@ -24,8 +24,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value ? updatePharmacist : createPharmacist;
@@ -38,9 +38,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -73,8 +73,9 @@ export const infoModalFormProps: VbenFormProps = {
},
},
{
fieldName: 'otherinfo',
label: '基础信息',
// 分区标题:仅展示,禁止与业务字段同名 / 重复 fieldName
fieldName: '_section_basic',
label: '',
component: 'Divider',
formItemClass: 'col-span-12',
componentProps: {},
@@ -223,8 +224,9 @@ export const infoModalFormProps: VbenFormProps = {
rules: 'required',
},
{
fieldName: 'otherinfo',
label: '执业&身份信息',
// 分区标题:仅展示,与基础信息分区 fieldName 必须唯一
fieldName: '_section_practice',
label: '',
component: 'Divider',
formItemClass: 'col-span-12',
componentProps: {},

View File

@@ -25,7 +25,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
const statistics = ref();
const { OrderDetailModal, RegisterDetailModal, openOrderDetail } =
const { OrderDetailModal, RegisterDetailModal, TraceDrawer, openOrderDetail } =
useWithdrawOrderDetailModals();
const [RelationDetailModal, relationDetailApi] = useVbenModal({
@@ -72,6 +72,7 @@ const openDetail = (id: number) => {
<Page auto-content-height title="代支付手续费记录">
<OrderDetailModal />
<RegisterDetailModal />
<TraceDrawer />
<RelationDetailModal />
<StatisticsReconciliation
v-if="statistics"

View File

@@ -1,18 +1,24 @@
<script lang="ts" setup>
/**
* 月付结算模块弹窗:作废 / 结算
* 对齐 monthly-payment 弹窗:按类型渲染表单,调用作废/结算接口,禁止误调提现接口
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createWithdrawalApplicationWithdrawal } from '#/views/finance/withdrawal/api';
import {
invalidatedApi,
settlementApi,
} from '#/views/finance/monthly-payment/api';
import { invalidatedForm, settlementForm } from '../config/form';
const gridApi = ref();
const type = ref(1);
const id = ref(0);
const [InvalidatedForm, InvalidatedFormApi] = useVbenForm(invalidatedForm);
const [SettlementForm, SettlementFormApi] = useVbenForm(settlementForm);
@@ -24,44 +30,42 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
let formApi = type.value === 1 ? InvalidatedFormApi : SettlementFormApi;
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = createWithdrawalApplicationWithdrawal;
submitApi(values)
.then(() => {
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
const formApi = type.value === 1 ? InvalidatedFormApi : SettlementFormApi;
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = type.value === 1 ? invalidatedApi : settlementApi;
try {
await submitApi(values);
message.success('保存成功');
gridApi.value?.reload?.();
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) {
const { values, modalType } = modalApi.getData<Record<string, any>>();
if (values) {
id.value = values;
}
if (type) {
type.value = modalType;
}
if (!isOpen) return;
const { modalType, id } = modalApi.getData<Record<string, any>>() || {};
if (modalType) {
type.value = modalType;
}
if (id && type.value === 1) {
InvalidatedFormApi.setValues({ id });
}
},
});
</script>
<template>
<Modal class="w-[80%] h-[80%]" title="申请提现">
<InvalidatedForm />
<SettlementForm />
<Modal
:title="type === 1 ? '作废订单' : '结算订单'"
class="h-[60%] w-[80%] md:w-[50%] lg:w-[30%]"
>
<InvalidatedForm v-if="type === 1" />
<SettlementForm v-else />
</Modal>
</template>

View File

@@ -1,54 +1,70 @@
import type { VbenFormProps } from '#/adapter/form';
import { getStoreOption } from '#/views/system/store/api';
/**
* 作废表单
* 月付作废表单:字段与 MonthlyPaymentController::invalidated 对齐id + invalidated_reason
*/
export const invalidatedForm: VbenFormProps = {
wrapperClass: 'grid-cols-12', // 24栅格,
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
// 所有表单项
componentProps: {
class: 'w-full',
},
},
// handleSubmit: onSubmit,
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
dependencies: {
show: false,
triggerFields: ['id'],
},
rules: 'required',
},
{
component: 'Textarea',
componentProps: {
placeholder: '请输入作废原因',
},
fieldName: 'amount',
fieldName: 'invalidated_reason',
label: '作废原因',
rules: 'required',
},
],
showDefaultActions: false,
};
/**
* 结算表单
* 月付结算表单:字段与 MonthlyPaymentController::settlement 对齐store_id + url
*/
export const settlementForm: VbenFormProps = {
wrapperClass: 'grid-cols-12', // 24栅格,
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
// 所有表单项
componentProps: {
class: 'w-full',
},
},
// handleSubmit: onSubmit,
layout: 'horizontal',
schema: [
{
component: 'Textarea',
component: 'ApiSelect',
componentProps: {
placeholder: '请输入作废原因',
placeholder: '请选择',
afterFetch: (data: { id: number; name: string }[]) => {
return data.map((item: any) => ({
label: item.name,
value: item.id,
}));
},
api: getStoreOption,
},
fieldName: 'amount',
label: '作废原因',
fieldName: 'store_id',
label: '诊所',
rules: 'required',
},
{
@@ -56,7 +72,7 @@ export const settlementForm: VbenFormProps = {
componentProps: {
placeholder: '请上传转账截图',
},
fieldName: 'amount',
fieldName: 'url',
label: '转账截图',
rules: 'required',
},

View File

@@ -25,10 +25,12 @@ const [MonthlyPaymentModal, MonthlyPaymentModalApi] = useVbenModal({
connectedComponent: MonthPayment,
});
const infoModal = (id) => {
const infoModal = (id: number) => {
// 结算列表「作废」modalType=1回填账单 id
MonthlyPaymentModalApi.setData({
// 表单值
id,
modalType: 1,
gridApi,
});
MonthlyPaymentModalApi.open();
};

View File

@@ -28,8 +28,8 @@ const [Modal, modalApi] = useVbenModal({
},
onConfirm: async () => {
const formApi = type.value === 1 ? InvalidatedFormApi : SettlementFormApi;
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = type.value === 1 ? invalidatedApi : settlementApi;
@@ -42,9 +42,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -0,0 +1,13 @@
/**
* 公账到账确认:复用账单模块接口,不另开后端
*/
export {
confirmPublicAccountDailyBill,
confirmPublicAccountPay,
getPublicAccountDailyBillInfo,
getPublicAccountDailyBillList,
getPublicAccountDailyMergeDetail,
getPublicAccountPayConfig,
getPublicAccountPayInfo,
getPublicAccountPayList,
} from '#/views/finance/public-account-pay/api';

View File

@@ -0,0 +1,41 @@
import type { VbenFormProps } from '#/adapter/form';
import { PUBLIC_ACCOUNT_DAILY_STATUS_OPTIONS } from '#/views/finance/public-account-pay/config/constants';
/** 按天确认页默认只看已传凭证待确认 */
export const dailyFormOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'StoreMultiSearch',
componentProps: {
placeholder: '输入名称或拼音首拼',
},
defaultValue: [],
fieldName: 'store_ids',
label: '诊所',
},
{
component: 'Input',
fieldName: 'bill_date',
label: '账单日',
componentProps: {
placeholder: '如 20260818',
},
},
{
component: 'Select',
fieldName: 'status',
label: '状态',
defaultValue: 3,
componentProps: {
allowClear: true,
options: PUBLIC_ACCOUNT_DAILY_STATUS_OPTIONS,
placeholder: '全部状态',
},
},
],
showCollapseButton: false,
submitOnChange: true,
submitOnEnter: true,
};

View File

@@ -0,0 +1,87 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
interface RowType {
id: number;
bill_date: number;
status: number;
}
export const dailyGridOptions: VxeGridProps<RowType> = {
columns: [
{ field: 'id', title: 'ID', width: 90, formatter: ({ row }) =>
Number(row.is_period || 0) === 1
? `合#${row.merge_id || '—'}`
: String(row.id || '—'),
},
{
field: 'row_type_txt',
title: '类型',
width: 90,
formatter: ({ row }) =>
Number(row.is_period || 0) === 1 ? '周期账单' : '日账单',
},
{
field: 'merge_id',
title: '合并ID',
width: 110,
formatter: ({ row }) => {
const mid = Number(row.merge_id || 0);
if (mid < 1) {
return '—';
}
if (Number(row.is_period || 0) === 1) {
return String(mid);
}
return row.merge_parent_txt || `周期#${mid}`;
},
},
{ field: 'store_name', title: '诊所', minWidth: 160 },
{
field: 'bill_date_range_txt',
title: '账单日',
width: 180,
formatter: ({ row }) =>
row.bill_date_range_txt || row.bill_date_txt || row.bill_date || '—',
},
{ field: 'order_count', title: '订单数', width: 90 },
{ field: 'order_amount', title: '订单合计', width: 110 },
{ field: 'platform_amount', title: '应付款', width: 110 },
{ field: 'status', title: '状态', width: 160, slots: { default: 'status' } },
{ field: 'vouchers', title: '付款凭证', width: 150, slots: { default: 'vouchers' } },
{ field: 'arrival_vouchers', title: '到账凭证', width: 150, slots: { default: 'arrival_vouchers' } },
{ field: 'upload_admin_name', title: '上传人', width: 100 },
{ field: 'confirm_admin_name', title: '确认人', width: 100 },
{ field: 'created_at', title: '生成时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 220,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
keyField: 'row_uid',
},
proxyConfig: {
ajax: {
query: async () => ({ items: [], total: 0 }),
},
},
height: 'auto',
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
},
showOverflow: false,
};

View File

@@ -0,0 +1,65 @@
import type { VbenFormProps } from '#/adapter/form';
import {
PUBLIC_ACCOUNT_PAY_STATUS_OPTIONS,
PUBLIC_ACCOUNT_SPLIT_MODE_OPTIONS,
} from '#/views/finance/public-account-pay/config/constants';
/** 按订单确认页默认只看待到账 */
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'StoreMultiSearch',
componentProps: {
placeholder: '输入名称或拼音首拼',
},
defaultValue: [],
fieldName: 'store_ids',
label: '诊所',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '订单号',
allowClear: true,
},
fieldName: 'order_no',
label: '订单号',
},
{
component: 'VbenSelect',
componentProps: {
placeholder: '请选择',
allowClear: true,
options: PUBLIC_ACCOUNT_PAY_STATUS_OPTIONS,
},
defaultValue: 0,
fieldName: 'status',
label: '状态',
},
{
component: 'VbenSelect',
componentProps: {
placeholder: '请选择',
allowClear: true,
options: PUBLIC_ACCOUNT_SPLIT_MODE_OPTIONS,
},
fieldName: 'split_mode',
label: '分账方式',
},
{
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD',
},
fieldName: 'search_time',
label: '申请时间',
},
],
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
submitOnEnter: true,
};

View File

@@ -0,0 +1,55 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
interface RowType {
id: number;
order_no: string;
status: number;
}
export const gridOptions: VxeGridProps<RowType> = {
columns: [
{ field: 'id', title: 'ID', width: 80 },
{ field: 'store_name', title: '诊所', minWidth: 140 },
{ field: 'order_no', title: '订单号', minWidth: 180 },
{ field: 'order_amount', title: '订单金额', width: 110 },
{ field: 'platform_amount', title: '平台份额', width: 110 },
{ field: 'store_amount', title: '门店份额', width: 110 },
{ field: 'paid_amount', title: '入账金额', width: 110 },
{ field: 'split_mode_txt', title: '分账方式', width: 110 },
{ field: 'status', title: '状态', width: 100, slots: { default: 'status' } },
{ field: 'vouchers', title: '凭证', width: 150, slots: { default: 'vouchers' } },
{ field: 'apply_admin_name', title: '申请人', width: 100 },
{ field: 'confirm_admin_name', title: '确认人', width: 100 },
{ field: 'created_at', title: '申请时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 160,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
proxyConfig: {
ajax: {
query: async () => ({ items: [], total: 0 }),
},
},
height: 'auto',
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
},
showOverflow: false,
};

View File

@@ -0,0 +1,279 @@
<script lang="ts" setup>
/**
* 财务中心 · 公账到账确认(超管/系统管理员)
* 按天:确认已传凭证的日汇总;按订单:确认待到账单
*/
import { nextTick, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Tag, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import ConfirmModal from '#/views/finance/public-account-pay/components/confirm-modal.vue';
import DailyConfirmModal from '#/views/finance/public-account-pay/components/daily-confirm-modal.vue';
import MergeOrdersModal from '#/views/finance/public-account-pay/components/merge-orders-modal.vue';
import VoucherThumbs from '#/views/finance/public-account-pay/components/voucher-thumbs.vue';
import {
publicAccountDailyStatusMeta,
publicAccountPayStatusMeta,
} from '#/views/finance/public-account-pay/config/constants';
import {
getPublicAccountDailyBillInfo,
getPublicAccountDailyBillList,
getPublicAccountPayConfig,
getPublicAccountPayInfo,
getPublicAccountPayList,
} from './api';
import { dailyFormOptions } from './config/daily-search';
import { dailyGridOptions } from './config/daily-table';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
defineOptions({ name: 'FinancePublicAccountConfirm' });
const route = useRoute();
const billMode = ref<'order' | 'daily'>('order');
const unlockMode = ref<'voucher' | 'confirm'>('confirm');
const canConfirm = ref(false);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [DailyGrid, dailyGridApi] = useVbenVxeGrid({
formOptions: dailyFormOptions,
gridOptions: dailyGridOptions,
});
const [ConfirmPayModal, confirmPayModalApi] = useVbenModal({
connectedComponent: ConfirmModal,
});
const [DailyConfirmPayModal, dailyConfirmModalApi] = useVbenModal({
connectedComponent: DailyConfirmModal,
});
const [MergeOrdersPayModal, mergeOrdersModalApi] = useVbenModal({
connectedComponent: MergeOrdersModal,
});
/** 按订单确认到账 */
function openConfirm(row: Record<string, any>) {
confirmPayModalApi.setData({
id: row.id,
order_id: row.order_id,
order_no: row.order_no,
gridApi,
});
confirmPayModalApi.open();
}
/** 按天确认日汇总到账(周期行用 merge_id勿用虚构 id */
function openDailyConfirm(row: Record<string, any>) {
const isPeriod = Number(row.is_period || 0) === 1;
const mergeId = Number(row.merge_id || 0);
// 确认接口需要真实日账单 id周期行取 merge_id=最早日账单 id
const confirmId = isPeriod ? mergeId : Number(row.id || 0);
dailyConfirmModalApi.setData({
id: confirmId,
merge_id: mergeId,
is_period: isPeriod ? 1 : 0,
platform_amount: row.platform_amount,
bill_date_range_txt: row.bill_date_range_txt || row.bill_date_txt,
store_name: row.store_name,
gridApi: dailyGridApi,
});
dailyConfirmModalApi.open();
}
/** 查看合并:弹窗展示日账单 + 商品订单 */
function openMergeDetail(mergeId: number) {
if (mergeId < 1) {
return;
}
mergeOrdersModalApi.setData({ merge_id: mergeId });
mergeOrdersModalApi.open();
}
function tryOpenFromQuery() {
const dailyId = Number(route.query.daily_id || 0);
const id = Number(route.query.id || 0);
if (dailyId > 0) {
getPublicAccountDailyBillInfo(dailyId)
.then((row) => {
if (Number(row?.status) === 3) {
openDailyConfirm(row);
} else {
message.info('该日汇总当前不是待确认状态');
}
})
.catch(() => {});
return;
}
if (id < 1) return;
getPublicAccountPayInfo(id)
.then((row) => {
if (Number(row?.status) === 0) {
openConfirm(row);
} else {
message.info('该账单当前不是待到账状态');
}
})
.catch(() => {});
}
const initTableAjax = () => {
gridApi.setGridOptions({
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPublicAccountPayList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
});
dailyGridApi.setGridOptions({
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPublicAccountDailyBillList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
});
};
initTableAjax();
/** 表格按父级高度重算,避免 auto-content-height 下缩成一行 */
function resizeGrid() {
nextTick(() => {
setTimeout(() => {
dailyGridApi.grid?.recalculate?.();
gridApi.grid?.recalculate?.();
}, 50);
});
}
onMounted(async () => {
const cfg = await getPublicAccountPayConfig().catch(() => null);
billMode.value = cfg?.bill_mode === 'daily' ? 'daily' : 'order';
unlockMode.value = cfg?.unlock_mode === 'voucher' ? 'voucher' : 'confirm';
canConfirm.value = Number(cfg?.can_confirm) === 1 || cfg?.can_confirm === true;
resizeGrid();
tryOpenFromQuery();
});
watch(
() => [route.query.id, route.query.daily_id],
() => tryOpenFromQuery(),
);
</script>
<template>
<Page auto-content-height title="公账到账确认">
<div class="pac-page">
<ConfirmPayModal />
<DailyConfirmPayModal />
<MergeOrdersPayModal />
<div
v-if="billMode === 'daily' && unlockMode === 'voucher'"
class="hint-card"
>
当前解锁条件为上传凭证即解锁无需在本页确认到账
</div>
<DailyGrid v-else-if="billMode === 'daily'">
<template #status="{ row }">
<Tag :color="publicAccountDailyStatusMeta(row.status).color">
{{
row.status_txt || publicAccountDailyStatusMeta(row.status).label
}}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #arrival_vouchers="{ row }">
<VoucherThumbs :items="row.arrival_vouchers" />
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '确认到账',
type: 'link',
ifShow: canConfirm && row.status === 3,
onClick: openDailyConfirm.bind(null, row),
},
{
label: '查看合并',
type: 'link',
ifShow: Number(row.merge_id || 0) > 0,
onClick: openMergeDetail.bind(null, Number(row.merge_id)),
},
]"
/>
</template>
</DailyGrid>
<Grid v-else>
<template #status="{ row }">
<Tag :color="publicAccountPayStatusMeta(row.status).color">
{{
row.status_txt || publicAccountPayStatusMeta(row.status).label
}}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '确认到账',
type: 'link',
ifShow: canConfirm && row.status === 0,
onClick: openConfirm.bind(null, row),
},
]"
/>
</template>
</Grid>
</div>
</Page>
</template>
<style scoped>
.pac-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.pac-page > :deep(.h-full) {
flex: 1;
min-height: 0;
}
.pac-page :deep(.vxe-grid) {
height: 100% !important;
}
.hint-card {
padding: 16px 18px;
color: hsl(var(--muted-foreground));
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--warning) / 30%);
border-radius: 10px;
box-shadow: 0 0 6px hsl(var(--warning) / 18%);
}
</style>

View File

@@ -0,0 +1,101 @@
import { requestClient } from '#/api/request';
const prefix = 'public-account-pay/';
/** 公账支付账单分页列表 */
export async function getPublicAccountPayList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/** 账单详情(含付款/到账凭证) */
export async function getPublicAccountPayInfo(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/** 预览平台/门店分账金额 */
export async function previewPublicAccountPay(orderId: number) {
return requestClient.get<any>(`${prefix}preview`, { params: { order_id: orderId } });
}
/** 医生申请公账支付(付款凭证可选) */
export async function applyPublicAccountPay(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}apply`, data);
}
/** 账单页确认已到账(到账凭证必传) */
export async function confirmPublicAccountPay(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}confirm`, data);
}
/** 待支付订单上直接确认公账到账 */
export async function confirmPublicAccountPayByOrder(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}confirm-by-order`, data);
}
/** 取消待到账单 */
export async function cancelPublicAccountPay(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}cancel`, data);
}
/** 出账模式与解锁条件 */
export async function getPublicAccountPayConfig() {
return requestClient.get<any>(`${prefix}config`);
}
/** 手动生成日汇总(管理员生成会站内信通知诊所) */
export async function generatePublicAccountDailyBill(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}daily-generate`, data);
}
/** 日汇总分页 */
export async function getPublicAccountDailyBillList(data: any) {
return requestClient.get<any>(`${prefix}daily-list`, { params: data });
}
/** 日汇总详情(子单 + 凭证) */
export async function getPublicAccountDailyBillInfo(id: number) {
return requestClient.get<any>(`${prefix}daily-detail`, { params: { id } });
}
/** 按 merge_id 拉出合并组内全部日账单 */
export async function getPublicAccountDailyMergeDetail(mergeId: number) {
return requestClient.get<any>(`${prefix}daily-merge-detail`, {
params: { merge_id: mergeId },
});
}
/** 锁店页只读:日账单 + 商品订单 */
export async function getPublicAccountDailyLockOrders(data: {
merge_id?: number;
daily_id?: number;
overdue_daily_ids?: number[] | string;
}) {
return requestClient.get<any>(`${prefix}daily-lock-orders`, {
params: data,
});
}
/** 上传日汇总付款凭证 */
export async function uploadPublicAccountDailyBill(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}daily-upload`, data);
}
/** 催办确认到账 */
export async function urgePublicAccountDailyBill(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}daily-urge`, data);
}
/** 锁店现算状态(配色/日汇总状态/联系人) */
export async function getPublicAccountLockStatus() {
return requestClient.get<any>(`${prefix}lock-status`);
}
/** 确认日汇总到账 */
export async function confirmPublicAccountDailyBill(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}daily-confirm`, data);
}
/** 取消未确认日汇总 */
export async function cancelPublicAccountDailyBill(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}daily-cancel`, data);
}

View File

@@ -0,0 +1,125 @@
<script lang="ts" setup>
/**
* 医生申请公账支付:付款凭证选填,不选分账方式
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, message } from 'ant-design-vue';
import UploadDraggerPaste from '#/components/form/components/upload-dragger-paste.vue';
import { applyPublicAccountPay } from '../api';
const TextArea = Input.TextArea;
const orderId = ref(0);
const orderNo = ref('');
const amount = ref('');
const voucherUrls = ref<string[]>([]);
const applyRemark = ref('');
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
if (orderId.value < 1) {
message.warning('订单无效');
return;
}
modalApi.lock();
modalApi.setState({ confirmLoading: true });
try {
const res = await applyPublicAccountPay({
order_id: orderId.value,
voucher_urls: voucherUrls.value,
apply_remark: applyRemark.value,
});
message.success(
res?.bill_mode === 'daily'
? '已申请公账支付,订单已标记已付,将计入当日日汇总'
: '已提交公账支付申请,请等待管理员确认到账',
);
modalApi.close();
} finally {
modalApi.unlock();
modalApi.setState({ confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
orderId.value = Number(data.order_id || 0);
orderNo.value = String(data.order_no || '');
amount.value = String(data.total_pay_price || data.order_amount || '');
voucherUrls.value = [];
applyRemark.value = '';
},
});
</script>
<template>
<Modal title="申请公账支付" class="w-[560px]">
<div class="space-y-4 py-2">
<div class="apply-card">
<div class="apply-row">
<span>订单号</span>
<b>{{ orderNo || '—' }}</b>
</div>
<div class="apply-row">
<span>订单金额</span>
<b>¥{{ amount || '0.00' }}</b>
</div>
</div>
<p class="text-sm text-muted-foreground">
按天模式提交后订单立即变为已支付公账金额等日汇总确认再入账按订单模式提交后等管理员确认到账确认前患者无法再微信/易票联支付
</p>
<div>
<div class="mb-1 font-medium text-foreground">付款凭证选填</div>
<UploadDraggerPaste
v-model="voucherUrls"
accept=".jpg,.jpeg,.png,.webp,.pdf,image/*,application/pdf"
tip="可上传转账截图,也可不传"
:max-count="9"
:max-size-mb="20"
/>
</div>
<div>
<div class="mb-1 font-medium text-foreground">备注</div>
<TextArea
v-model:value="applyRemark"
:rows="3"
placeholder="可选,如公账户名/转账时间"
:maxlength="200"
/>
</div>
</div>
</Modal>
</template>
<style scoped>
.apply-card {
padding: 12px 14px;
background: hsl(var(--muted) / 0.25);
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 10px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.apply-row {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.apply-row:last-child {
margin-bottom: 0;
}
.apply-row b {
color: hsl(var(--foreground));
}
</style>

View File

@@ -0,0 +1,236 @@
<script lang="ts" setup>
/**
* 公账支付确认到账弹窗
* 到账凭证必传;分账方式必选;只读展示医生已传付款凭证
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Image, Radio, RadioGroup, message } from 'ant-design-vue';
import UploadDraggerPaste from '#/components/form/components/upload-dragger-paste.vue';
import {
confirmPublicAccountPay,
confirmPublicAccountPayByOrder,
getPublicAccountPayInfo,
previewPublicAccountPay,
} from '../api';
import { PUBLIC_ACCOUNT_SPLIT_MODE_OPTIONS } from '../config/constants';
const payId = ref(0);
const orderId = ref(0);
const orderNo = ref('');
const splitMode = ref<number>(1);
const voucherUrls = ref<string[]>([]);
const confirmRemark = ref('');
const paymentVouchers = ref<{ file_url: string }[]>([]);
const preview = ref({
platform_amount: '0.00',
store_amount: '0.00',
order_amount: '0.00',
});
const gridApi = ref<any>();
const loadingPreview = ref(false);
const paidAmount = computed(() => {
if (Number(splitMode.value) === 1) {
return preview.value.platform_amount;
}
const a = Number(preview.value.platform_amount || 0);
const b = Number(preview.value.store_amount || 0);
return (a + b).toFixed(2);
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
if (!voucherUrls.value.length) {
message.warning('请上传到账凭证');
return;
}
if (![1, 2].includes(Number(splitMode.value))) {
message.warning('请选择分账方式');
return;
}
modalApi.lock();
modalApi.setState({ confirmLoading: true });
try {
if (payId.value > 0) {
await confirmPublicAccountPay({
id: payId.value,
split_mode: splitMode.value,
voucher_urls: voucherUrls.value,
confirm_remark: confirmRemark.value,
});
} else {
await confirmPublicAccountPayByOrder({
order_id: orderId.value,
split_mode: splitMode.value,
voucher_urls: voucherUrls.value,
confirm_remark: confirmRemark.value,
});
}
message.success('已确认到账');
// reload() 常返回 undefined用 || 会再打一次 query列表会刷两遍
if (typeof gridApi.value?.reload === 'function') {
gridApi.value.reload();
} else if (typeof gridApi.value?.query === 'function') {
gridApi.value.query();
}
modalApi.close();
} finally {
modalApi.unlock();
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
payId.value = Number(data.id || 0);
orderId.value = Number(data.order_id || 0);
orderNo.value = String(data.order_no || '');
splitMode.value = 1;
voucherUrls.value = [];
confirmRemark.value = '';
paymentVouchers.value = [];
gridApi.value = data.gridApi;
if (payId.value > 0) {
const detail = await getPublicAccountPayInfo(payId.value);
orderId.value = Number(detail?.order_id || orderId.value);
orderNo.value = String(detail?.order_no || orderNo.value);
paymentVouchers.value = detail?.payment_vouchers || [];
}
if (orderId.value > 0) {
loadingPreview.value = true;
try {
preview.value = await previewPublicAccountPay(orderId.value);
} catch {
preview.value = {
platform_amount: '0.00',
store_amount: '0.00',
order_amount: '0.00',
};
} finally {
loadingPreview.value = false;
}
}
},
});
</script>
<template>
<Modal title="确认公账到账" class="w-[640px]">
<div class="space-y-4 py-2">
<div class="amount-card">
<div v-if="loadingPreview" class="mb-2 text-xs text-muted-foreground">正在预览分账金额</div>
<div class="amount-row">
<span>订单号</span>
<b>{{ orderNo || '—' }}</b>
</div>
<div class="amount-row">
<span>订单金额</span>
<b>¥{{ preview.order_amount || '0.00' }}</b>
</div>
<div class="amount-row">
<span>平台份额</span>
<b>¥{{ preview.platform_amount || '0.00' }}</b>
</div>
<div class="amount-row">
<span>门店份额</span>
<b>¥{{ preview.store_amount || '0.00' }}</b>
</div>
<div class="amount-row amount-row--total">
<span>本次入账</span>
<b>¥{{ paidAmount }}</b>
</div>
</div>
<div>
<div class="mb-2 font-medium text-foreground">分账方式</div>
<RadioGroup v-model:value="splitMode">
<Radio
v-for="opt in PUBLIC_ACCOUNT_SPLIT_MODE_OPTIONS"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</Radio>
</RadioGroup>
</div>
<div v-if="paymentVouchers.length">
<div class="mb-2 font-medium text-foreground">医生付款凭证</div>
<div class="flex flex-wrap gap-2">
<Image
v-for="(item, idx) in paymentVouchers"
:key="idx"
:src="item.file_url"
:width="72"
:height="72"
/>
</div>
</div>
<div>
<div class="mb-1 font-medium text-foreground">到账凭证必传</div>
<UploadDraggerPaste
v-model="voucherUrls"
accept=".jpg,.jpeg,.png,.webp,.pdf,image/*,application/pdf"
tip="点击或拖拽到账凭证到此处"
:max-count="9"
:max-size-mb="20"
/>
</div>
<div>
<div class="mb-1 font-medium text-foreground">确认备注</div>
<textarea
v-model="confirmRemark"
class="confirm-remark"
rows="2"
maxlength="200"
placeholder="可选"
/>
</div>
</div>
</Modal>
</template>
<style scoped>
.amount-card {
padding: 12px 14px;
background: hsl(var(--muted) / 0.25);
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 10px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.amount-row {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.amount-row b {
color: hsl(var(--foreground));
}
.amount-row--total {
margin-top: 8px;
margin-bottom: 0;
font-size: 14px;
color: hsl(var(--foreground));
}
.amount-row--total b {
color: hsl(var(--primary));
}
.confirm-remark {
width: 100%;
padding: 8px 10px;
color: hsl(var(--foreground));
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,211 @@
<script lang="ts" setup>
/**
* 超管确认日汇总到账unlock_mode=confirm
* 诊所可「我已支付」不附付款凭证;管理员只需传到账凭证即可确认
* 周期账单按 merge 拉合计与组内付款凭证
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Image, message } from 'ant-design-vue';
import UploadDraggerPaste from '#/components/form/components/upload-dragger-paste.vue';
import { clearPublicAccountLock } from '#/utils/publicAccountLock';
import {
confirmPublicAccountDailyBill,
getPublicAccountDailyBillInfo,
getPublicAccountDailyMergeDetail,
} from '../api';
const dailyId = ref(0);
const mergeId = ref(0);
const storeName = ref('');
const platformAmount = ref('0.00');
const billDateTxt = ref('');
const paymentVouchers = ref<{ file_url: string }[]>([]);
const arrivalUrls = ref<string[]>([]);
const payRemark = ref('');
const gridApi = ref<any>();
function isImageUrl(url: string) {
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
}
/** 汇总组内付款凭证(去重) */
function collectPaymentVouchers(days: any[]): { 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;
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
// 诊所可仅声明已支付;财务只需到账凭证
if (!arrivalUrls.value.length) {
message.warning('请上传到账凭证');
return;
}
modalApi.lock();
modalApi.setState({ confirmLoading: true });
try {
await confirmPublicAccountDailyBill({
id: dailyId.value,
voucher_urls: arrivalUrls.value,
pay_remark: payRemark.value,
});
message.success('已确认到账');
clearPublicAccountLock();
gridApi.value?.reload?.() || gridApi.value?.query?.();
modalApi.close();
} finally {
modalApi.unlock();
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<{
id: number;
merge_id?: number;
is_period?: number;
platform_amount?: string;
bill_date_range_txt?: string;
bill_date_txt?: string;
store_name?: string;
gridApi?: any;
}>();
dailyId.value = Number(data?.id || 0);
mergeId.value = Number(data?.merge_id || 0);
// 周期行 id 恒为 0业务主键是 merge_id
if (Number(data?.is_period || 0) === 1) {
if (mergeId.value < 1) {
mergeId.value = dailyId.value;
}
if (dailyId.value < 1 && mergeId.value > 0) {
dailyId.value = mergeId.value;
}
}
gridApi.value = data?.gridApi;
payRemark.value = '';
arrivalUrls.value = [];
paymentVouchers.value = [];
storeName.value = data?.store_name || '';
platformAmount.value = String(data?.platform_amount || '0.00');
billDateTxt.value =
data?.bill_date_range_txt || data?.bill_date_txt || '';
if (dailyId.value < 1) {
return;
}
try {
if (mergeId.value > 0) {
const merge = await getPublicAccountDailyMergeDetail(mergeId.value);
storeName.value =
merge?.items?.[0]?.store_name || storeName.value;
platformAmount.value = String(
merge?.platform_amount_total || platformAmount.value,
);
billDateTxt.value =
merge?.bill_date_range_txt || billDateTxt.value;
paymentVouchers.value = collectPaymentVouchers(merge?.items || []);
dailyId.value = mergeId.value;
} else {
const detail = await getPublicAccountDailyBillInfo(dailyId.value);
storeName.value = detail?.store_name || storeName.value;
platformAmount.value = String(
detail?.platform_amount || platformAmount.value,
);
billDateTxt.value =
detail?.bill_date_range_txt ||
detail?.bill_date_txt ||
billDateTxt.value;
paymentVouchers.value =
detail?.payment_vouchers || detail?.vouchers || [];
const mid = Number(detail?.merge_id || 0);
if (mid > 0) {
mergeId.value = mid;
const merge = await getPublicAccountDailyMergeDetail(mid);
platformAmount.value = String(
merge?.platform_amount_total || platformAmount.value,
);
billDateTxt.value =
merge?.bill_date_range_txt || billDateTxt.value;
paymentVouchers.value = collectPaymentVouchers(merge?.items || []);
dailyId.value = mid;
}
}
} catch (err) {
console.error(err);
}
},
});
</script>
<template>
<Modal title="确认日汇总到账" class="w-[560px]">
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
{{ storeName }} · {{ billDateTxt }} · 应付款 ¥{{ platformAmount }}
</div>
<div v-if="paymentVouchers.length" class="mb-3">
<div class="mb-1 text-sm">诊所付款凭证</div>
<div class="flex flex-wrap gap-2">
<template v-for="(item, idx) in paymentVouchers" :key="idx">
<Image
v-if="isImageUrl(item.file_url)"
:src="item.file_url"
:width="72"
:height="72"
/>
<a
v-else
class="inline-flex h-[72px] min-w-[72px] items-center justify-center rounded border border-[hsl(var(--border))] px-2 text-xs text-primary"
:href="item.file_url"
target="_blank"
rel="noopener"
>
PDF
</a>
</template>
</div>
</div>
<div
v-else
class="mb-3 rounded border border-[hsl(var(--warning)/30%)] bg-[hsl(var(--warning)/0.12)] px-3 py-2 text-sm text-[hsl(var(--warning))] shadow-[0_0_6px_hsl(var(--warning)/18%)]"
>
诊所未附付款凭证已声明我已支付确认时上传到账凭证即可
</div>
<div class="mb-3">
<div class="mb-1 font-medium text-foreground">到账凭证必传</div>
<UploadDraggerPaste
v-model="arrivalUrls"
accept=".jpg,.jpeg,.png,.webp,.pdf,image/*,application/pdf"
tip="点击或拖拽到账凭证到此处"
:max-count="9"
:max-size-mb="20"
/>
</div>
<textarea
v-model="payRemark"
class="w-full rounded border border-[hsl(var(--border))] bg-[hsl(var(--background))] p-2 text-sm"
rows="2"
placeholder="确认备注(选填)"
/>
</Modal>
</template>

View File

@@ -0,0 +1,182 @@
<script lang="ts" setup>
/**
* 日汇总上传凭证表单:财务弹窗和锁店二级页共用
* 锁店页可无附件直接「我已支付」;财务弹窗默认仍建议传凭证
*/
import { computed, ref } from 'vue';
import { Image, message } from 'ant-design-vue';
import UploadDraggerPaste from '#/components/form/components/upload-dragger-paste.vue';
import { clearPublicAccountLock } from '#/utils/publicAccountLock';
import { getPublicAccountDailyBillInfo, uploadPublicAccountDailyBill } from '../api';
export interface DailyUploadFormData {
id: number;
store_name?: string;
platform_amount?: string;
bill_date_txt?: string;
gridApi?: any;
}
const props = withDefaults(
defineProps<{
/** 预览层必须高于锁店遮罩 2100否则图会沉到底下 */
previewZIndex?: number;
/** 锁店页:允许不传文件直接声明已支付 */
allowEmptyVoucher?: boolean;
}>(),
{ previewZIndex: 2500, allowEmptyVoucher: false },
);
const emit = defineEmits<{
success: [payload: {
unlocked: boolean;
bill_status?: number;
contact_phones?: { name?: string; phone: string }[];
}];
}>();
const dailyId = ref(0);
const storeName = ref('');
const platformAmount = ref('0.00');
const billDateTxt = ref('');
const voucherUrls = ref<string[]>([]);
const existVouchers = ref<{ file_url: string }[]>([]);
const payRemark = ref('');
const gridApi = ref<any>();
const submitting = ref(false);
const loading = ref(false);
const voucherTip = '点击或拖拽上传付款凭证';
const voucherHint = computed(() =>
props.allowEmptyVoucher
? '支持转账截图JPG/PNG/WEBP与 PDF 回单,可多张;也可不传文件,直接点「我已支付」。'
: '支持转账截图JPG/PNG/WEBP与 PDF 回单,请至少上传 1 个文件。',
);
function isImageUrl(url: string) {
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
}
/** 打开时灌入日汇总:锁店传入的周期金额/区间优先,详情只补凭证与店名 */
async function load(data?: DailyUploadFormData) {
dailyId.value = Number(data?.id || 0);
storeName.value = data?.store_name || '';
// 锁店页已算好周期合计,勿被单日详情盖掉
const lockAmount = String(data?.platform_amount || '').trim();
const lockDate = String(data?.bill_date_txt || '').trim();
platformAmount.value = lockAmount || '0.00';
billDateTxt.value = lockDate;
gridApi.value = data?.gridApi;
voucherUrls.value = [];
payRemark.value = '';
existVouchers.value = [];
if (dailyId.value < 1) {
return;
}
loading.value = true;
try {
const detail = await getPublicAccountDailyBillInfo(dailyId.value);
existVouchers.value = detail?.vouchers || [];
storeName.value = detail?.store_name || storeName.value;
// 仅财务弹窗未带金额/日期时,才回落单日字段
if (!lockAmount || lockAmount === '0.00') {
platformAmount.value = String(detail?.platform_amount || platformAmount.value);
}
if (!lockDate) {
billDateTxt.value = detail?.bill_date_txt || billDateTxt.value;
}
} finally {
loading.value = false;
}
}
/**
* 提交allowEmpty=true 时可无附件(我已支付)
*/
async function submit(options?: { allowEmpty?: boolean }): Promise<boolean> {
const allowEmpty = Boolean(options?.allowEmpty || props.allowEmptyVoucher);
if (!voucherUrls.value.length && !allowEmpty) {
message.warning('请至少上传 1 张付款凭证(截图或 PDF');
return false;
}
submitting.value = true;
try {
const res = await uploadPublicAccountDailyBill({
id: dailyId.value,
voucher_urls: voucherUrls.value,
pay_remark: payRemark.value,
declare_paid: allowEmpty && !voucherUrls.value.length ? 1 : 0,
});
message.success(voucherUrls.value.length ? '已上传' : '已声明支付');
if (res?.unlocked) {
clearPublicAccountLock();
}
gridApi.value?.reload?.() || gridApi.value?.query?.();
emit('success', {
unlocked: Boolean(res?.unlocked),
status: Number(res?.status ?? res?.bill_status ?? 0),
bill_status: Number(res?.status ?? res?.bill_status ?? 0),
contact_phones: res?.contact_phones || [],
});
return true;
} finally {
submitting.value = false;
}
}
defineExpose({ load, submit, submitting, loading, voucherUrls });
</script>
<template>
<div class="daily-upload-form">
<div v-if="loading" class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
正在加载日汇总...
</div>
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
{{ storeName || '本店' }} · {{ billDateTxt || '—' }} · 应付款 ¥{{ platformAmount }}
</div>
<div v-if="existVouchers.length" class="mb-3">
<div class="mb-1 text-sm text-[hsl(var(--foreground))]">已传凭证</div>
<div class="flex flex-wrap gap-2">
<template v-for="(item, idx) in existVouchers" :key="idx">
<Image
v-if="isImageUrl(item.file_url)"
:src="item.file_url"
:width="72"
:height="72"
:preview="{ zIndex: props.previewZIndex }"
/>
<a
v-else
class="inline-flex h-[72px] min-w-[72px] items-center justify-center rounded border border-[hsl(var(--border))] px-2 text-xs text-primary"
:href="item.file_url"
target="_blank"
rel="noopener"
>
PDF
</a>
</template>
</div>
</div>
<div class="mb-2 text-sm font-medium text-[hsl(var(--foreground))]">
付款凭证{{ allowEmptyVoucher ? '(选填)' : '(必传)' }}
</div>
<p class="mb-2 text-xs leading-5 text-[hsl(var(--muted-foreground))]">
{{ voucherHint }}
</p>
<UploadDraggerPaste
v-model="voucherUrls"
:tip="voucherTip"
accept=".jpg,.jpeg,.png,.webp,.pdf,image/*,application/pdf"
/>
<textarea
v-model="payRemark"
class="mt-3 w-full rounded border border-[hsl(var(--border))] bg-[hsl(var(--background))] p-2 text-sm text-[hsl(var(--foreground))]"
rows="2"
placeholder="备注(选填)"
/>
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script lang="ts" setup>
/**
* 财务列表用的日汇总上传弹窗;锁店态走锁框内 LockPayPanel不走这里
*/
import { nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import DailyUploadForm from './daily-upload-form.vue';
const formRef = ref<InstanceType<typeof DailyUploadForm>>();
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
modalApi.lock();
modalApi.setState({ confirmLoading: true });
try {
const ok = await formRef.value?.submit();
if (ok) {
modalApi.close();
}
} finally {
modalApi.unlock();
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
await nextTick();
await formRef.value?.load(
modalApi.getData<{
id: number;
store_name?: string;
platform_amount?: string;
bill_date_txt?: string;
gridApi?: any;
}>(),
);
},
});
</script>
<template>
<Modal title="上传日汇总付款凭证" class="w-[560px]">
<DailyUploadForm ref="formRef" />
</Modal>
</template>

View File

@@ -0,0 +1,116 @@
<script lang="ts" setup>
/**
* 手动生成日汇总弹窗
* 诊所管理员只生成本店、不推站内信;超管/系统管理员选店后生成并站内信通知诊所
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import { generatePublicAccountDailyBill } from '../api';
const isPlatformAdmin = ref(false);
const gridApi = ref<any>();
const hint = computed(() =>
isPlatformAdmin.value
? '生成后将站内信通知该诊所管理员上传凭证'
: '将本店该日待出账订单收成一张日汇总',
);
const [Form, formApi] = useVbenForm({
layout: 'vertical',
commonConfig: {
labelWidth: 80,
},
schema: [
{
component: 'StorePicker',
fieldName: 'store_id',
label: '诊所',
rules: 'selectRequired',
},
{
component: 'DatePicker',
fieldName: 'bill_date',
label: '账单日',
rules: 'required',
defaultValue: dayjs().format('YYYY-MM-DD'),
componentProps: {
class: 'w-full',
allowClear: false,
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
},
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.lock();
modalApi.setState({ confirmLoading: true });
try {
await generatePublicAccountDailyBill({
store_id: values.store_id || 0,
bill_date: values.bill_date,
});
message.success('日汇总已生成');
if (gridApi.value?.reload) {
gridApi.value.reload();
} else {
gridApi.value?.query?.();
}
modalApi.close();
} finally {
modalApi.unlock();
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<{
isPlatformAdmin?: boolean;
gridApi?: any;
}>();
isPlatformAdmin.value = Boolean(data?.isPlatformAdmin);
gridApi.value = data?.gridApi;
await formApi.resetForm();
await formApi.updateSchema([
{
fieldName: 'store_id',
rules: isPlatformAdmin.value ? 'selectRequired' : '',
dependencies: {
show: isPlatformAdmin.value,
triggerFields: ['store_id'],
},
},
]);
await formApi.setValues({
store_id: undefined,
bill_date: dayjs().format('YYYY-MM-DD'),
});
},
});
</script>
<template>
<Modal title="生成日账单" class="w-[480px]">
<p class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">{{ hint }}</p>
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,62 @@
<script lang="ts" setup>
/**
* 合并账单明细弹窗:日账单 + 下属商品订单(只读)
* 确认到账 / 公账账单页「查看合并」共用,避免页脚内联卡片
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import LockOrdersPanel from '#/components/public-account-lock/LockOrdersPanel.vue';
const mergeId = ref(0);
const panelKey = ref(0);
const [Modal, modalApi] = useVbenModal({
title: '合并账单明细',
class: 'w-[min(860px,96vw)]',
contentClass: 'pap-merge-orders-modal-body',
showConfirmButton: false,
cancelText: '关闭',
draggable: true,
onOpenChange(isOpen: boolean) {
if (!isOpen) {
mergeId.value = 0;
return;
}
const data = modalApi.getData<{ merge_id?: number }>();
const mid = Number(data?.merge_id || 0);
mergeId.value = mid;
// 每次打开强制重挂载,避免上次缓存干扰
panelKey.value += 1;
},
});
</script>
<template>
<Modal>
<div class="pap-merge-orders-wrap">
<LockOrdersPanel
v-if="mergeId > 0"
:key="panelKey"
:merge-id="mergeId"
:show-back="false"
embedded
/>
<p v-else class="pap-merge-orders-empty">暂无合并账单</p>
</div>
</Modal>
</template>
<style scoped>
.pap-merge-orders-wrap {
max-height: min(72vh, 720px);
overflow: auto;
padding-right: 4px;
}
.pap-merge-orders-empty {
margin: 24px 0;
text-align: center;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,72 @@
<script lang="ts" setup>
/**
* 公账凭证缩略图列表格内预览图片PDF 等非图片新开页
*/
import { computed } from 'vue';
import { Image } from 'ant-design-vue';
defineOptions({ name: 'PublicAccountVoucherThumbs' });
const props = defineProps<{
items?: { id?: number; file_url?: string }[];
}>();
function isImageUrl(url: string) {
if (!url) return false;
return !/\.pdf(\?|#|$)/i.test(url);
}
const imageItems = computed(() =>
(props.items || []).filter((item) => isImageUrl(String(item.file_url || ''))),
);
const fileItems = computed(() =>
(props.items || []).filter((item) => !isImageUrl(String(item.file_url || ''))),
);
</script>
<template>
<div v-if="(items || []).length" class="voucher-thumbs">
<Image.PreviewGroup>
<Image
v-for="item in imageItems"
:key="item.id || item.file_url"
:src="item.file_url"
:width="40"
:height="40"
/>
</Image.PreviewGroup>
<a
v-for="item in fileItems"
:key="item.id || item.file_url"
class="file-link"
:href="item.file_url"
target="_blank"
rel="noreferrer"
>
文件
</a>
</div>
<span v-else class="empty"></span>
</template>
<style scoped>
.voucher-thumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.voucher-thumbs :deep(.ant-image) {
border: 1px solid hsl(var(--border));
border-radius: 6px;
overflow: hidden;
}
.file-link {
color: hsl(var(--primary));
font-size: 12px;
}
.empty {
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,35 @@
/**
* 公账支付账单常量(与后端枚举对齐)
*/
export const PUBLIC_ACCOUNT_PAY_STATUS_OPTIONS = [
{ label: '待到账', value: 0 },
{ label: '已到账', value: 1 },
{ label: '已取消', value: 2 },
];
export const PUBLIC_ACCOUNT_SPLIT_MODE_OPTIONS = [
{ label: '只付平台费', value: 1 },
{ label: '全额支付', value: 2 },
];
export function publicAccountPayStatusMeta(status: number) {
if (status === 1) return { label: '已到账', color: 'green' };
if (status === 2) return { label: '已取消', color: 'default' };
return { label: '待到账', color: 'orange' };
}
export const PUBLIC_ACCOUNT_DAILY_STATUS_OPTIONS = [
{ label: '未付', value: 0 },
{ label: '已确认到账', value: 1 },
{ label: '已取消', value: 2 },
{ label: '已传凭证待确认', value: 3 },
{ label: '已并入账单', value: 4 },
];
export function publicAccountDailyStatusMeta(status: number) {
if (status === 1) return { label: '已确认到账', color: 'green' };
if (status === 2) return { label: '已取消', color: 'default' };
if (status === 3) return { label: '已传凭证待确认', color: 'blue' };
if (status === 4) return { label: '已并入账单', color: 'purple' };
return { label: '未付', color: 'orange' };
}

View File

@@ -0,0 +1,39 @@
import type { VbenFormProps } from '#/adapter/form';
import { PUBLIC_ACCOUNT_DAILY_STATUS_OPTIONS } from './constants';
export const dailyFormOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'StoreMultiSearch',
componentProps: {
placeholder: '输入名称或拼音首拼',
},
defaultValue: [],
fieldName: 'store_ids',
label: '诊所',
},
{
component: 'Input',
fieldName: 'bill_date',
label: '账单日',
componentProps: {
placeholder: '如 20260818',
},
},
{
component: 'Select',
fieldName: 'status',
label: '状态',
componentProps: {
allowClear: true,
options: PUBLIC_ACCOUNT_DAILY_STATUS_OPTIONS,
placeholder: '全部状态',
},
},
],
showCollapseButton: false,
submitOnChange: true,
submitOnEnter: true,
};

View File

@@ -0,0 +1,91 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
interface RowType {
id: number;
bill_date: number;
status: number;
}
export const dailyGridOptions: VxeGridProps<RowType> = {
columns: [
{ type: 'expand', width: 50, slots: { content: 'expand' } },
{ field: 'id', title: 'ID', width: 90, formatter: ({ row }) =>
Number(row.is_period || 0) === 1
? `合#${row.merge_id || '—'}`
: String(row.id || '—'),
},
{
field: 'row_type_txt',
title: '类型',
width: 90,
formatter: ({ row }) =>
Number(row.is_period || 0) === 1 ? '周期账单' : '日账单',
},
{
field: 'merge_id',
title: '合并ID',
width: 100,
formatter: ({ row }) => {
const mid = Number(row.merge_id || 0);
if (mid < 1) {
return '—';
}
if (Number(row.is_period || 0) === 1) {
return String(mid);
}
return row.merge_parent_txt || `周期#${mid}`;
},
},
{ field: 'store_name', title: '诊所', minWidth: 140 },
{
field: 'bill_date_range_txt',
title: '账单日',
width: 180,
formatter: ({ row }) =>
row.bill_date_range_txt || row.bill_date_txt || row.bill_date || '—',
},
{ field: 'order_count', title: '订单数', width: 90 },
{ field: 'order_amount', title: '订单合计', width: 110 },
{ field: 'platform_amount', title: '应付款', width: 110 },
{ field: 'status', title: '状态', width: 160, slots: { default: 'status' } },
{ field: 'vouchers', title: '付款凭证', width: 150, slots: { default: 'vouchers' } },
{ field: 'arrival_vouchers', title: '到账凭证', width: 150, slots: { default: 'arrival_vouchers' } },
{ field: 'upload_admin_name', title: '上传人', width: 100 },
{ field: 'confirm_admin_name', title: '确认人', width: 100 },
{ field: 'created_at', title: '生成时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 280,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
keyField: 'row_uid',
},
proxyConfig: {
ajax: {
query: async () => ({ items: [], total: 0 }),
},
},
height: 'auto',
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-actions',
},
},
showOverflow: false,
};

View File

@@ -0,0 +1,63 @@
import type { VbenFormProps } from '#/adapter/form';
import {
PUBLIC_ACCOUNT_PAY_STATUS_OPTIONS,
PUBLIC_ACCOUNT_SPLIT_MODE_OPTIONS,
} from './constants';
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'StoreMultiSearch',
componentProps: {
placeholder: '输入名称或拼音首拼',
},
defaultValue: [],
fieldName: 'store_ids',
label: '诊所',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '订单号',
allowClear: true,
},
fieldName: 'order_no',
label: '订单号',
},
{
component: 'VbenSelect',
componentProps: {
placeholder: '请选择',
allowClear: true,
options: PUBLIC_ACCOUNT_PAY_STATUS_OPTIONS,
},
fieldName: 'status',
label: '状态',
},
{
component: 'VbenSelect',
componentProps: {
placeholder: '请选择',
allowClear: true,
options: PUBLIC_ACCOUNT_SPLIT_MODE_OPTIONS,
},
fieldName: 'split_mode',
label: '分账方式',
},
{
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD',
},
fieldName: 'search_time',
label: '申请时间',
},
],
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
submitOnEnter: true,
};

View File

@@ -0,0 +1,58 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
interface RowType {
id: number;
order_no: string;
status: number;
}
export const gridOptions: VxeGridProps<RowType> = {
columns: [
{ field: 'id', title: 'ID', width: 80 },
{ field: 'store_name', title: '诊所', minWidth: 140 },
{ field: 'order_no', title: '订单号', minWidth: 180 },
{ field: 'order_amount', title: '订单金额', width: 110 },
{ field: 'platform_amount', title: '平台份额', width: 110 },
{ field: 'store_amount', title: '门店份额', width: 110 },
{ field: 'paid_amount', title: '入账金额', width: 110 },
{ field: 'split_mode_txt', title: '分账方式', width: 110 },
{ field: 'status', title: '状态', width: 100, slots: { default: 'status' } },
{ field: 'vouchers', title: '凭证', width: 150, slots: { default: 'vouchers' } },
{ field: 'apply_admin_name', title: '申请人', width: 100 },
{ field: 'confirm_admin_name', title: '确认人', width: 100 },
{ field: 'created_at', title: '申请时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 200,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
proxyConfig: {
ajax: {
query: async () => ({ items: [], total: 0 }),
},
},
height: 'auto',
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-actions',
},
},
showOverflow: false,
};

View File

@@ -0,0 +1,547 @@
<script lang="ts" setup>
/**
* 财务中心 · 公账支付账单
* 按订单:查看/取消;按天:上传凭证、生成日账单。到账确认在「公账到账确认」页
*/
import { nextTick, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Image, Tabs, Tag, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import {
cancelPublicAccountDailyBill,
cancelPublicAccountPay,
getPublicAccountDailyBillInfo,
getPublicAccountDailyBillList,
getPublicAccountPayConfig,
getPublicAccountPayInfo,
getPublicAccountPayList,
} from './api';
import DailyUploadModal from './components/daily-upload-modal.vue';
import GenerateDailyModal from './components/generate-daily-modal.vue';
import MergeOrdersModal from './components/merge-orders-modal.vue';
import VoucherThumbs from './components/voucher-thumbs.vue';
import {
publicAccountDailyStatusMeta,
publicAccountPayStatusMeta,
} from './config/constants';
import { dailyFormOptions } from './config/daily-search';
import { dailyGridOptions } from './config/daily-table';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
defineOptions({ name: 'FinancePublicAccountPay' });
const route = useRoute();
const TAB_STORAGE_KEY = 'finance_public_account_pay_tab';
const activeTab = ref(localStorage.getItem(TAB_STORAGE_KEY) || 'daily');
const billMode = ref<'order' | 'daily'>('order');
const canConfirm = ref(false);
const canUpload = ref(false);
const canGenerate = ref(false);
const detail = ref<Record<string, any> | null>(null);
const dailyDetail = ref<Record<string, any> | null>(null);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [DailyGrid, dailyGridApi] = useVbenVxeGrid({
formOptions: dailyFormOptions,
gridOptions: dailyGridOptions,
});
const [DailyUploadPayModal, dailyUploadModalApi] = useVbenModal({
connectedComponent: DailyUploadModal,
});
const [GenerateDailyPayModal, generateDailyModalApi] = useVbenModal({
connectedComponent: GenerateDailyModal,
});
const [MergeOrdersPayModal, mergeOrdersModalApi] = useVbenModal({
connectedComponent: MergeOrdersModal,
});
/** 记住当前 Tab并让刚显示的表格按父级高度重算避免切 Tab 后仍缩成一行 */
function persistTab(key: string) {
activeTab.value = key;
localStorage.setItem(TAB_STORAGE_KEY, key);
nextTick(() => {
setTimeout(() => {
dailyGridApi.grid?.recalculate?.();
gridApi.grid?.recalculate?.();
}, 50);
});
}
/** 门店生成本店;管理员选店后生成并站内信通知诊所 */
function openGenerateDaily() {
generateDailyModalApi.setData({
isPlatformAdmin: canConfirm.value,
gridApi: {
reload: () => {
persistTab('daily');
dailyGridApi.reload();
gridApi.reload();
},
},
});
generateDailyModalApi.open();
}
function openDailyUpload(row: Record<string, any>) {
const isPeriod = Number(row.is_period || 0) === 1;
const mergeId = Number(row.merge_id || 0);
// 上传接口需要真实日账单 id周期行取 merge_id=最早日账单 id
const uploadId = isPeriod ? mergeId : Number(row.id || 0);
dailyUploadModalApi.setData({
id: uploadId,
merge_id: mergeId,
is_period: isPeriod ? 1 : 0,
store_name: row.store_name,
platform_amount: row.platform_amount,
bill_date_txt: row.bill_date_range_txt || row.bill_date_txt,
gridApi: dailyGridApi,
});
dailyUploadModalApi.open();
}
async function openDetail(id: number) {
detail.value = await getPublicAccountPayInfo(id);
}
async function openDailyDetail(id: number) {
dailyDetail.value = await getPublicAccountDailyBillInfo(id);
}
/** 查看合并:弹窗展示日账单 + 商品订单 */
function openMergeDetail(mergeId: number) {
if (mergeId < 1) {
return;
}
mergeOrdersModalApi.setData({ merge_id: mergeId });
mergeOrdersModalApi.open();
}
async function handleCancel(row: Record<string, any>) {
await cancelPublicAccountPay({ id: row.id });
message.success('已取消');
gridApi.reload();
}
async function handleDailyCancel(row: Record<string, any>) {
await cancelPublicAccountDailyBill({ id: row.id });
message.success('已取消');
dailyGridApi.reload();
}
function tryOpenFromQuery() {
const dailyId = Number(route.query.daily_id || 0);
const id = Number(route.query.id || 0);
if (dailyId > 0) {
persistTab('daily');
getPublicAccountDailyBillInfo(dailyId)
.then((row) => {
dailyDetail.value = row;
if (Number(row?.status) !== 1 && Number(row?.status) !== 2 && canUpload.value) {
openDailyUpload(row);
}
})
.catch(() => {});
return;
}
if (id < 1) return;
persistTab('order');
getPublicAccountPayInfo(id)
.then((row) => {
detail.value = row;
})
.catch(() => {});
}
const initTableAjax = () => {
gridApi.setGridOptions({
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPublicAccountPayList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
});
dailyGridApi.setGridOptions({
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPublicAccountDailyBillList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
});
};
initTableAjax();
onMounted(async () => {
const cfg = await getPublicAccountPayConfig().catch(() => null);
billMode.value = cfg?.bill_mode === 'daily' ? 'daily' : 'order';
canConfirm.value = Number(cfg?.can_confirm) === 1 || cfg?.can_confirm === true;
canUpload.value = Number(cfg?.can_upload) === 1 || cfg?.can_upload === true;
canGenerate.value = Number(cfg?.can_generate) === 1 || cfg?.can_generate === true;
if (billMode.value === 'order') {
persistTab('order');
}
tryOpenFromQuery();
});
watch(
() => [route.query.id, route.query.daily_id],
() => tryOpenFromQuery(),
);
</script>
<template>
<Page auto-content-height title="公账支付账单">
<div class="pap-page">
<DailyUploadPayModal />
<GenerateDailyPayModal />
<MergeOrdersPayModal />
<Tabs
v-if="billMode === 'daily'"
class="pap-tabs"
:active-key="activeTab"
@change="persistTab"
>
<Tabs.TabPane key="daily" tab="日汇总">
<DailyGrid>
<template #toolbar-actions>
<TableAction
:actions="[
{
label: '生成日账单',
type: 'primary',
ifShow: canGenerate,
onClick: openGenerateDaily,
},
]"
/>
</template>
<template #status="{ row }">
<Tag :color="publicAccountDailyStatusMeta(row.status).color">
{{ row.status_txt || publicAccountDailyStatusMeta(row.status).label }}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #arrival_vouchers="{ row }">
<VoucherThumbs :items="row.arrival_vouchers" />
</template>
<template #expand="{ row }">
<div class="expand-box">
<template v-if="Number(row.is_period || 0) === 1">
<div
v-for="item in row.items || []"
:key="'m-' + item.id"
class="expand-row"
>
日账单 #{{ item.id }} · {{ item.bill_date_txt || item.bill_date }} · ¥{{
item.platform_amount
}}
· {{ item.status_txt }}
</div>
</template>
<template v-else>
<div
v-for="item in row.items || []"
:key="item.id"
class="expand-row"
>
{{ item.order_no }} · ¥{{ item.platform_amount }} · {{ item.status_txt }}
</div>
<a
v-if="!(row.items || []).length"
class="text-sm text-primary"
@click="openDailyDetail(row.id)"
>
查看子单
</a>
</template>
</div>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '上传凭证',
type: 'link',
ifShow:
canUpload &&
row.status !== 1 &&
row.status !== 2 &&
(Number(row.is_period || 0) === 1 ||
(Number(row.is_period || 0) !== 1 &&
Number(row.status) !== 4)),
onClick: openDailyUpload.bind(null, row),
},
{
label: '查看合并',
type: 'link',
ifShow: Number(row.merge_id || 0) > 0,
onClick: openMergeDetail.bind(null, Number(row.merge_id)),
},
{
label: '详情',
type: 'link',
ifShow: Number(row.is_period || 0) !== 1,
onClick: openDailyDetail.bind(null, row.id),
},
{
label: '取消',
type: 'link',
danger: true,
ifShow:
canConfirm &&
Number(row.is_period || 0) !== 1 &&
row.status !== 1 &&
row.status !== 2 &&
row.status !== 4,
popConfirm: {
title: '确认取消该日汇总?子单将退回待出账',
confirm: handleDailyCancel.bind(null, row),
},
},
]"
/>
</template>
</DailyGrid>
</Tabs.TabPane>
<Tabs.TabPane key="order" tab="订单明细">
<Grid>
<template #toolbar-actions>
<TableAction
:actions="[
{
label: '生成日账单',
type: 'primary',
ifShow: canGenerate,
onClick: openGenerateDaily,
},
]"
/>
</template>
<template #status="{ row }">
<Tag :color="publicAccountPayStatusMeta(row.status).color">
{{ row.status_txt || publicAccountPayStatusMeta(row.status).label }}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '详情',
type: 'link',
onClick: openDetail.bind(null, row.id),
},
]"
/>
</template>
</Grid>
</Tabs.TabPane>
</Tabs>
<Grid v-else>
<template #status="{ row }">
<Tag :color="publicAccountPayStatusMeta(row.status).color">
{{ row.status_txt || publicAccountPayStatusMeta(row.status).label }}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '详情',
type: 'link',
onClick: openDetail.bind(null, row.id),
},
{
label: '取消',
type: 'link',
danger: true,
ifShow: row.status === 0,
popConfirm: {
title: '确认取消该待到账单?',
confirm: handleCancel.bind(null, row),
},
},
]"
/>
</template>
</Grid>
<div v-if="dailyDetail" class="detail-card mt-4">
<div class="mb-2 flex items-center justify-between">
<b>
日汇总 #{{ dailyDetail.id }} ·
{{ dailyDetail.bill_date_range_txt || dailyDetail.bill_date_txt }}
</b>
<a class="text-sm text-primary" @click="dailyDetail = null">关闭</a>
</div>
<div class="text-sm text-[hsl(var(--muted-foreground))]">
{{ dailyDetail.store_name }} · {{ dailyDetail.status_txt }} · 应付款
¥{{ dailyDetail.platform_amount || '0.00' }} · {{ dailyDetail.order_count }}
<template v-if="Number(dailyDetail.merge_id || 0) > 0">
· 合并ID
<a
class="text-primary"
@click="openMergeDetail(Number(dailyDetail.merge_id))"
>
#{{ dailyDetail.merge_id }}
</a>
</template>
</div>
<div v-if="(dailyDetail.items || []).length" class="mt-3 text-sm">
<div
v-for="item in dailyDetail.items"
:key="item.id"
class="expand-row"
>
{{ item.order_no }} · 平台 ¥{{ item.platform_amount }} · {{ item.status_txt }}
</div>
</div>
<div v-if="(dailyDetail.vouchers || []).length" class="mt-3">
<div class="mb-1 text-sm">付款凭证</div>
<div class="flex flex-wrap gap-2">
<Image
v-for="item in dailyDetail.vouchers"
:key="item.id"
:src="item.file_url"
:width="80"
:height="80"
/>
</div>
</div>
<div v-if="(dailyDetail.arrival_vouchers || []).length" class="mt-3">
<div class="mb-1 text-sm">到账凭证</div>
<div class="flex flex-wrap gap-2">
<Image
v-for="item in dailyDetail.arrival_vouchers"
:key="item.id"
:src="item.file_url"
:width="80"
:height="80"
/>
</div>
</div>
</div>
<div v-if="detail" class="detail-card mt-4">
<div class="mb-2 flex items-center justify-between">
<b>账单 #{{ detail.id }} · {{ detail.order_no }}</b>
<a class="text-sm text-primary" @click="detail = null">关闭</a>
</div>
<div class="text-sm text-[hsl(var(--muted-foreground))]">
状态 {{ detail.status_txt }} · 分账 {{ detail.split_mode_txt || '未选' }} ·
入账 ¥{{ detail.paid_amount || '0.00' }}
</div>
<div v-if="(detail.payment_vouchers || []).length" class="mt-3">
<div class="mb-1 text-sm">付款凭证</div>
<div class="flex flex-wrap gap-2">
<Image
v-for="item in detail.payment_vouchers"
:key="item.id"
:src="item.file_url"
:width="80"
:height="80"
/>
</div>
</div>
<div v-if="(detail.arrival_vouchers || []).length" class="mt-3">
<div class="mb-1 text-sm">到账凭证</div>
<div class="flex flex-wrap gap-2">
<Image
v-for="item in detail.arrival_vouchers"
:key="item.id"
:src="item.file_url"
:width="80"
:height="80"
/>
</div>
</div>
</div>
</div>
</Page>
</template>
<style scoped>
.pap-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.pap-page > :deep(.h-full) {
flex: 1;
min-height: 0;
}
.pap-tabs {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.pap-tabs :deep(.ant-tabs-nav) {
flex-shrink: 0;
margin-bottom: 8px;
}
.pap-tabs :deep(.ant-tabs-content-holder) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.pap-tabs :deep(.ant-tabs-content) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.pap-tabs :deep(.ant-tabs-tabpane),
.pap-tabs :deep(.ant-tabs-tabpane-active) {
flex: 1;
height: 100%;
min-height: 0;
}
.pap-page :deep(.vxe-grid) {
height: 100% !important;
}
.detail-card {
padding: 14px 16px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 10px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.expand-box {
padding: 8px 12px;
}
.expand-row {
padding: 4px 0;
color: hsl(var(--muted-foreground));
font-size: 13px;
}
</style>

View File

@@ -25,7 +25,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
const statistics = ref();
const { OrderDetailModal, RegisterDetailModal, openOrderDetail } =
const { OrderDetailModal, RegisterDetailModal, TraceDrawer, openOrderDetail } =
useWithdrawOrderDetailModals();
const [WithdrawalAuditModal, WithdrawalAuditModalApi] = useVbenModal({
@@ -63,6 +63,7 @@ const openDetail = (id: number) => {
<WithdrawalAuditModal />
<OrderDetailModal />
<RegisterDetailModal />
<TraceDrawer />
<RelationDetailModal />
<StatisticsReconciliation v-if="statistics" :statistics="statistics" />
<Grid>

View File

@@ -37,6 +37,8 @@ const props = defineProps<{
customGap?: number;
/** 造成差额的历史自定义提现单 */
customGapApplies?: Record<string, any>[];
/** 公账支付累计金额(只记累计,不可提现) */
publicAccountAmount?: number;
/** 点击统计岛 */
onStatClick?: (key: string) => void;
}>();
@@ -49,7 +51,28 @@ const emit = defineEmits<{
viewApply: [id: number];
}>();
/** 父级 onStatClick 实际会下钻的统计岛 / 余额拆分项 */
const CLICKABLE_STAT_KEYS = new Set([
'balance',
'tcm',
'western',
'other',
'reviewing',
'withdrawn',
'pending',
]);
/** 可提现余额拆分:中药/西药/其他是合计构成,不单独成岛以免被加成三笔 */
const balanceBreakdown = computed(() => [
{ key: 'tcm', label: '中药', value: Number(props.tcmBalance || 0) },
{ key: 'western', label: '西药', value: Number(props.westernBalance || 0) },
{ key: 'other', label: '其他', value: Number(props.otherBalance || 0) },
]);
function handleStatClick(key: string) {
if (!CLICKABLE_STAT_KEYS.has(key)) {
return;
}
props.onStatClick?.(key);
emit('statClick', key);
}
@@ -105,6 +128,7 @@ const maskedCard = computed(() => {
*/
const statItems = computed(() => {
const items: {
clickable?: boolean;
emphasize?: boolean;
icon: string;
key: string;
@@ -123,24 +147,6 @@ const statItems = computed(() => {
value: Number(props.balance || 0),
icon: 'lucide:scale',
},
{
key: 'tcm',
label: '中药可提',
value: Number(props.tcmBalance || 0),
icon: 'lucide:leaf',
},
{
key: 'western',
label: '西药可提',
value: Number(props.westernBalance || 0),
icon: 'lucide:pill',
},
{
key: 'other',
label: '其他可提',
value: Number(props.otherBalance || 0),
icon: 'lucide:layers',
},
];
if (!props.isStoreUser) {
items.push({
@@ -151,6 +157,12 @@ const statItems = computed(() => {
});
}
items.push(
{
key: 'public_account',
label: '公账支付金额',
value: Number(props.publicAccountAmount || 0),
icon: 'lucide:landmark',
},
{
key: 'pending',
label: '待结算收益',
@@ -179,7 +191,10 @@ const statItems = computed(() => {
emphasize: true,
});
}
return items;
return items.map((item) => ({
...item,
clickable: CLICKABLE_STAT_KEYS.has(item.key),
}));
});
/** 金额展示为两位小数 */
@@ -280,10 +295,14 @@ function formatMoney(val: number) {
<article
v-for="item in statItems"
:key="item.key"
class="stat-island stat-island--clickable"
:class="{ 'stat-island--fee': item.emphasize }"
class="stat-island"
:class="{
'stat-island--fee': item.emphasize,
'stat-island--clickable': item.clickable,
'stat-island--balance': item.key === 'balance',
}"
:aria-label="item.label"
@click="handleStatClick(item.key)"
@click="item.clickable ? handleStatClick(item.key) : undefined"
>
<div class="stat-island-glow"></div>
<!-- 右上角图标规范SVG 图标固定尺寸 -->
@@ -292,6 +311,22 @@ function formatMoney(val: number) {
<div class="stat-val">
<span class="stat-val-prefix">¥</span>{{ formatMoney(item.value) }}
</div>
<div
v-if="item.key === 'balance'"
class="stat-break"
@click.stop
>
<button
v-for="part in balanceBreakdown"
:key="part.key"
type="button"
class="stat-chip"
@click.stop="handleStatClick(part.key)"
>
<span>{{ part.label }}</span>
<b>¥{{ formatMoney(part.value) }}</b>
</button>
</div>
</article>
</div>
</div>
@@ -692,7 +727,6 @@ function formatMoney(val: number) {
position: relative;
padding: 14px;
overflow: hidden;
cursor: pointer;
background: linear-gradient(
135deg,
hsl(var(--primary) / 4%),
@@ -707,7 +741,15 @@ function formatMoney(val: number) {
border-color 0.2s ease;
}
.stat-island:hover {
.stat-island--balance {
grid-column: span 2;
}
.stat-island--clickable {
cursor: pointer;
}
.stat-island--clickable:hover {
border-color: hsl(var(--primary) / 40%);
box-shadow:
0 8px 20px hsl(var(--primary) / 14%),
@@ -732,7 +774,7 @@ function formatMoney(val: number) {
transition: opacity 0.2s ease;
}
.stat-island:hover .stat-island-glow {
.stat-island--clickable:hover .stat-island-glow {
opacity: 1;
}
@@ -761,7 +803,7 @@ function formatMoney(val: number) {
transition: color 0.2s ease;
}
.stat-island:hover .stat-icon {
.stat-island--clickable:hover .stat-icon {
color: hsl(var(--primary));
}
@@ -799,4 +841,37 @@ function formatMoney(val: number) {
.stat-island--fee .stat-val {
color: hsl(var(--primary));
}
.stat-break {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.stat-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 11px;
line-height: 1.4;
color: hsl(var(--muted-foreground));
cursor: pointer;
background: hsl(var(--muted) / 0.35);
border: 1px solid hsl(var(--border));
border-radius: 999px;
}
.stat-chip b {
font-weight: 600;
color: hsl(var(--foreground));
}
.stat-chip:hover {
border-color: hsl(var(--primary) / 40%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
</style>

View File

@@ -23,8 +23,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = saveCard;
@@ -38,9 +38,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -53,6 +53,7 @@ const westernBalance = ref(0);
const otherBalance = ref(0);
const customGap = ref(0);
const customGapApplies = ref<Record<string, any>[]>([]);
const publicAccountAmount = ref(0);
const myCard = ref<Record<string, any> | null>(null);
/**
@@ -75,6 +76,7 @@ function showCard() {
otherBalance.value = Number(res.other_balance || 0);
customGap.value = Number(res.custom_gap || 0);
customGapApplies.value = res.custom_gap_applies || [];
publicAccountAmount.value = Number(res.public_account_amount || 0);
});
}
@@ -164,6 +166,7 @@ const showCardModal = () => {
:other-balance="otherBalance"
:custom-gap="customGap"
:custom-gap-applies="customGapApplies"
:public-account-amount="publicAccountAmount"
@apply="showModal"
@edit-card="showCardModal"
@refresh="refresh"

View File

@@ -28,7 +28,7 @@ import { gridOptions2, gridOptions3 } from './config/settlement-table';
import { gridOptions } from './config/table';
import { useWithdrawOrderDetailModals } from './utils/use-withdraw-order-detail';
const { OrderDetailModal, RegisterDetailModal, openOrderDetail } =
const { OrderDetailModal, RegisterDetailModal, TraceDrawer, openOrderDetail } =
useWithdrawOrderDetailModals();
const [Grid, GridApi] = useVbenVxeGrid({
@@ -162,6 +162,7 @@ function dakuanStatusPill(status: number) {
>
<OrderDetailModal />
<RegisterDetailModal />
<TraceDrawer />
<RelationDetailModal />
<WithdrawableOrderDrawer
ref="withdrawableOrderDrawerRef"

View File

@@ -2,13 +2,18 @@
* 提现相关页共用:页面级挂载商品订单 / 挂号订单详情弹窗
* 二级弹窗不嵌套在提现详情里zIndex 4000 盖住提现进度(3000)
*/
import { useVbenModal } from '@vben/common-ui';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
import ProductOrderDetail from '#/views/business/order/product-order/components/detail.vue';
import { getRegisterListApi } from '#/views/business/order/register/api';
import RegisterOrderDetail from '#/views/business/order/register/components/RegisterDetailModal.vue';
import ProductOrderDetail from '#/views/business/order/product-order/components/detail.vue';
import {
ORDER_POPUP_Z_INDEX,
openOrderPopup,
} from '#/views/business/order/utils/popup-z-index';
/** 叠在提现详情(3000)之上,避免详情被挡住 */
const ORDER_DETAIL_Z_INDEX = 4000;
@@ -22,6 +27,7 @@ export async function openWithdrawRelatedOrderDetail(
apis: {
productModalApi: { setData: (data: Record<string, any>) => void; open: () => void };
registerModalApi: { setData: (data: Record<string, any>) => void; open: () => void };
onOpenTrace?: (payload: Record<string, any>) => void;
},
) {
const orderType = Number(row.order_type || 0);
@@ -29,7 +35,10 @@ export async function openWithdrawRelatedOrderDetail(
const orderNo = String(row.order_no || '');
if (orderType === 1 && orderId > 0) {
apis.productModalApi.setData({ id: orderId });
apis.productModalApi.setData({
id: orderId,
onOpenTrace: apis.onOpenTrace,
});
apis.productModalApi.open();
return;
}
@@ -63,7 +72,7 @@ export async function openWithdrawRelatedOrderDetail(
message.info('暂不支持查看该类型订单详情');
}
/** 提现 / 审核 / 代付页挂载订单详情弹窗,并把打开方法传给提现详情 */
/** 提现 / 审核 / 代付页挂载订单详情 + 溯源抽屉,并把打开方法传给提现详情 */
export function useWithdrawOrderDetailModals() {
const [OrderDetailModal, productModalApi] = useVbenModal({
connectedComponent: ProductOrderDetail,
@@ -73,17 +82,27 @@ export function useWithdrawOrderDetailModals() {
connectedComponent: RegisterOrderDetail,
zIndex: ORDER_DETAIL_Z_INDEX,
});
const [TraceDrawer, traceDrawerApi] = useVbenDrawer({
connectedComponent: OrderTraceDrawer,
});
/** 详情叠在 4000溯源再用 5000 盖住详情 */
function onOpenTrace(payload: Record<string, any>) {
openOrderPopup(traceDrawerApi, payload, ORDER_POPUP_Z_INDEX.NESTED3);
}
async function openOrderDetail(row: Record<string, any>) {
await openWithdrawRelatedOrderDetail(row, {
productModalApi,
registerModalApi,
onOpenTrace,
});
}
return {
OrderDetailModal,
RegisterDetailModal,
TraceDrawer,
openOrderDetail,
};
}

View File

@@ -76,6 +76,24 @@ async function handleAction(item: NoticeItemLike) {
const typeCode = String(item.type_code || '');
const payload = (item.action_payload || {}) as Record<string, any>;
if (action === NOTICE_ACTION.OPEN_PUBLIC_ACCOUNT_PAY) {
const id = Number(payload.biz_id || payload.id || 0);
router.push({
path: '/finance/public-account-pay',
query: id ? { id: String(id) } : {},
});
return;
}
if (action === NOTICE_ACTION.OPEN_PUBLIC_ACCOUNT_DAILY_BILL) {
const dailyId = Number(payload.daily_id || payload.biz_id || payload.id || 0);
router.push({
path: '/finance/public-account-pay',
query: dailyId ? { daily_id: String(dailyId) } : {},
});
return;
}
if (
action === NOTICE_ACTION.OPEN_ROUTE &&
typeCode !== 'queue_failure' &&

View File

@@ -11,6 +11,8 @@ export const NOTICE_ACTION = {
OPEN_VIP_CARD: 'open_vip_card',
OPEN_ROUTE: 'open_route',
OPEN_QUEUE_JOB_DETAIL: 'open_queue_job_detail',
OPEN_PUBLIC_ACCOUNT_PAY: 'open_public_account_pay',
OPEN_PUBLIC_ACCOUNT_DAILY_BILL: 'open_public_account_daily_bill',
} as const;
export type NoticeActionType =

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
/**
* 拒绝审核原因弹窗
* 确认时自行校验并调 reject 接口;不再走已弃用的 validateAndSubmitForm会表现为点确定无反应
* 支持 gridApi.reload/query 或自定义 onSuccess 回调(详情内审方场景)
*/
import { ref } from 'vue';
@@ -11,9 +12,10 @@ import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { modalFormProps } from '../config/form';
import { rejectApi } from '#/views/pharmacist/audit-prescription/api';
import { modalFormProps } from '../config/form';
const isUpdate = ref(false);
const gridApi = ref();
const onSuccess = ref<(() => void) | null>(null);
@@ -27,24 +29,22 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
rejectApi(values)
.then(() => {
message.success('拒绝成功');
gridApi.value?.reload?.();
gridApi.value?.query?.();
onSuccess.value?.();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
await rejectApi(values);
message.success('拒绝成功');
gridApi.value?.reload?.();
gridApi.value?.query?.();
onSuccess.value?.();
modalApi.close();
} catch (err) {
console.error(err);
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
@@ -59,8 +59,10 @@ const [Modal, modalApi] = useVbenModal({
const { values, update } = data;
if (values) {
isUpdate.value = update;
// 写入当前处方 id并清空上次填写的拒绝原因
formApi.setValues({
id: values,
reject_reason: '',
});
}
},

View File

@@ -23,8 +23,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value ? updateAdmin : createAdmin;
@@ -37,9 +37,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -23,8 +23,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value ? updateBaseConfig : createBaseConfig;
@@ -37,9 +37,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

View File

@@ -121,8 +121,8 @@ const [Modal, modalApi] = useVbenModal({
);
return;
}
formApi.validate().then(async (e: any) => {
if (e.valid) {
const e = await formApi.validate();
if (!e.valid) return;
const payload = { ...(await formApi.getValues()) };
// sale_price 只作封顶校验,不用费用合计覆盖
if (payload.sale_price === undefined || payload.sale_price === '') {
@@ -146,9 +146,7 @@ const [Modal, modalApi] = useVbenModal({
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
async onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;

Some files were not shown because too many files have changed in this diff Show More