feat: 管理端公账支付与确认、锁店遮罩及表单提交修复
新增公账账单/到账确认页与全局锁店组件;系统配置补公账参数面板; 统一弹窗 onConfirm 校验提交,并完善订单详情与门店相关交互。
This commit is contained in:
@@ -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 的 API,Vben Form 没有,会报 `resetFields is not a function`;已踩过坑)
|
||||
- 重置表单用 `formApi.reset()`(`resetForm` 已弃用);**严禁 `formApi.resetFields()`**(那是 antd Form 的 API,Vben 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` 注入,禁止写死常量
|
||||
|
||||
10
README.md
10
README.md
@@ -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
|
||||
|
||||
@@ -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 包)。
|
||||
|
||||
@@ -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_) {
|
||||
|
||||
@@ -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}`);
|
||||
// 透传组件暴露的方法
|
||||
// 必须声明 value:inheritAttrs: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,
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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===0:InputNumber 是 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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -353,6 +353,7 @@ onBeforeUnmount(() => {
|
||||
:title="previewTitle"
|
||||
:footer="null"
|
||||
width="80%"
|
||||
:z-index="4000"
|
||||
destroy-on-close
|
||||
@cancel="onPreviewClose"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 保持 2100;antd 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
179
apps/web-antd/src/utils/publicAccountLock.ts
Normal file
179
apps/web-antd/src/utils/publicAccountLock.ts
Normal 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,
|
||||
});
|
||||
|
||||
/** 锁层是 2100,antd 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 处方溯源
|
||||
|
||||
@@ -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 Tab,Tab 单独合并) */
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
/** 卡片视图最终条件:搜索条件 + 状态 Tab(computed 保证 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/** 代付展示文案:[类型]名字(医生代付回落医生名,就诊人代付回落就诊人名) */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -183,6 +183,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
showConfirmButton: false,
|
||||
zIndex: 2000,
|
||||
cancelText: '关闭',
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -56,3 +56,10 @@ export const IS_ONLINE_MAP: Record<number, TagMeta> = {
|
||||
|
||||
/** 就诊来源兜底(线下) */
|
||||
export const IS_ONLINE_FALLBACK: TagMeta = { label: '线下就诊', color: 'default' };
|
||||
|
||||
/** 支付方式(yii_product_order.type:1微信 2易票联 3公账) */
|
||||
export const PAY_TYPE_MAP: Record<number, TagMeta> = {
|
||||
1: { label: '微信支付', color: 'green' },
|
||||
2: { label: '易票联支付', color: 'blue' },
|
||||
3: { label: '公账支付', color: 'orange' },
|
||||
};
|
||||
|
||||
@@ -54,6 +54,8 @@ export const formOptions: VbenFormProps = {
|
||||
},
|
||||
defaultValue: [],
|
||||
fieldName: 'store_ids',
|
||||
// 与 antd 系一致走 v-model:value,避免自定义组件绑到 modelValue 导致选了店却搜不到
|
||||
modelPropName: 'value',
|
||||
label: '门店',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开金方导入弹窗(VIP:golden_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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 公账到账确认:复用账单模块接口,不另开后端
|
||||
*/
|
||||
export {
|
||||
confirmPublicAccountDailyBill,
|
||||
confirmPublicAccountPay,
|
||||
getPublicAccountDailyBillInfo,
|
||||
getPublicAccountDailyBillList,
|
||||
getPublicAccountDailyMergeDetail,
|
||||
getPublicAccountPayConfig,
|
||||
getPublicAccountPayInfo,
|
||||
getPublicAccountPayList,
|
||||
} from '#/views/finance/public-account-pay/api';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
279
apps/web-antd/src/views/finance/public-account-confirm/index.vue
Normal file
279
apps/web-antd/src/views/finance/public-account-confirm/index.vue
Normal 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>
|
||||
101
apps/web-antd/src/views/finance/public-account-pay/api/index.ts
Normal file
101
apps/web-antd/src/views/finance/public-account-pay/api/index.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
547
apps/web-antd/src/views/finance/public-account-pay/index.vue
Normal file
547
apps/web-antd/src/views/finance/public-account-pay/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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: '',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user