Files
xk-admin/apps/web-antd/src/components/public-account-lock/PublicAccountLockOverlay.vue
李琦 879efb8f29 feat: 管理端公账支付与确认、锁店遮罩及表单提交修复
新增公账账单/到账确认页与全局锁店组件;系统配置补公账参数面板;
统一弹窗 onConfirm 校验提交,并完善订单详情与门店相关交互。
2026-08-21 12:51:42 +08:00

721 lines
21 KiB
Vue
Raw Blame History

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