From 50fc55925ffacafb279471ce626ff8da89b5304a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Thu, 15 Oct 2026 10:01:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9C=B0=E5=8C=BA=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8=E6=94=B9=E8=B5=B0=E6=8E=A5=E5=8F=A3=E3=80=81=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=B8=82=E4=BB=B7=E9=87=8D=E7=AE=97=E4=B8=8E=E8=B5=84?= =?UTF-8?q?=E9=87=91=E6=B5=81=E6=B0=B4=E3=80=81=E5=90=8C=E6=AD=A5=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E5=86=85=E5=AE=B9=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit region-address-picker 接 region-picker API;订单支持按进价重算 market_price、追踪页资金流水;MES/ERP/中台同步日志共用内容查看组件;地区管理支持 pid 下钻与拼音检索。 --- apps/desktop/src/main/auto-launch.ts | 12 +- apps/desktop/src/main/printer.ts | 24 +- apps/desktop/src/preload/index.ts | 4 +- apps/web-antd/index.html | 47 ++ apps/web-antd/package.json | 2 + apps/web-antd/src/api/request.ts | 73 +- .../components/search-time-range-picker.vue | 28 +- .../components/modal/ImageCompressModal.vue | 394 ++++++----- .../src/components/modal/QrCodePreview.vue | 294 +++++++- .../src/components/search-time-mode/index.ts | 9 + .../search-time-mode-picker.vue | 352 +++++++++ .../search-time-mode/use-search-time-mode.ts | 93 +++ apps/web-antd/src/main.ts | 17 +- apps/web-antd/src/router/index.ts | 4 + apps/web-antd/src/store/prescription.ts | 52 +- apps/web-antd/src/store/usePrescription.ts | 18 +- apps/web-antd/src/types/desktop.d.ts | 3 + apps/web-antd/src/util/desktop.ts | 4 +- .../web-antd/src/utils/chunk-load-recovery.ts | 119 ++++ .../src/utils/image-compress-config.ts | 73 ++ apps/web-antd/src/utils/image-compress.ts | 95 ++- .../src/utils/open-image-compress-modal.ts | 13 +- .../src/utils/prepare-image-upload.ts | 16 +- .../src/utils/use-paste-upload-listen.ts | 25 +- .../chat/components/PrescriptionModal.vue | 3 + .../order/image-transfer-order/index.vue | 8 +- .../business/order/product-order/api/index.ts | 34 +- .../components/ProductOrderCardList.vue | 7 +- .../components/logistics-modal.vue | 39 +- .../order/product-order/components/modal.vue | 426 ++++++++++- .../order/product-order/config/form.ts | 76 +- .../business/order/product-order/index.vue | 89 ++- .../business/order/register/api/index.ts | 10 + .../components/RegisterDetailModal.vue | 54 +- .../components/RegisterOrderCardList.vue | 10 +- .../components/RegisterReceiptPrintModal.vue | 419 +++++++++++ .../views/business/order/register/index.vue | 33 +- .../order/register/utils/print-receipt.ts | 223 ++++++ .../views/business/store/settings/index.vue | 35 + .../image-transfer-prescription/api/index.ts | 2 + .../doctor/doctor-reception/api/index.ts | 37 + .../components/ChineseMedicineConfig.vue | 665 +++++++++++++++++- .../components/ImageOcrDrawer.vue | 59 +- .../PatientPrescriptionHistoryDrawer.vue | 74 +- .../components/PrescriptionDetail.vue | 27 +- .../views/doctor/doctor-reception/index.vue | 433 +++++++++--- .../components/ConsigneeModal.vue | 82 +++ .../image-transfer-prescription/index.vue | 58 +- .../components/PatientList.vue | 152 +++- .../components/PatientList.vue | 169 ++++- .../doctor/online-consultation/index.vue | 68 +- .../components/apply-modal.vue | 5 + .../views/finance/reconciliation/index.vue | 38 +- .../finance/visualization-chart/index.vue | 8 +- .../components/WithdrawalAudit.vue | 9 +- .../views/finance/withdrawal-audit/index.vue | 38 +- .../components/withdrawal-relation-detail.vue | 9 +- .../utils/resolve-withdraw-subject-name.ts | 26 + .../views/notice/action/NoticeActionHost.vue | 21 + .../web-antd/src/views/notice/action/types.ts | 2 + apps/web-antd/src/views/notice/scenes/meta.ts | 6 + .../notice/views/ExpressTrackFailView.vue | 93 +++ .../src/views/notice/views/registry.ts | 2 + .../views/system/admin/_shared/role-meta.ts | 2 + .../system/admin/transfer-clerk/index.vue | 12 + .../src/views/system/ai/platform/api/index.ts | 11 + .../delivery-warehouse-order/api/index.ts | 34 +- .../components/modal.vue | 386 +++++++++- .../delivery-warehouse-order/config/form.ts | 69 +- .../system/delivery-warehouse-order/index.vue | 8 +- .../src/views/system/pharmacy/index.vue | 15 +- .../src/views/system/region/index.vue | 5 + .../src/views/system/store/api/index.ts | 17 + .../web-antd/src/views/system/store/index.vue | 15 +- .../views/system/system-config/api/index.ts | 23 + .../components/ai-model-config-panel.vue | 76 +- .../components/home-display-config-panel.vue | 13 +- .../image-compress-config-panel.vue | 187 +++++ .../components/mp-short-link-quota-panel.vue | 184 +++++ .../register-voucher-ui-config-panel.vue | 85 +++ .../components/register-wait-config-panel.vue | 148 ++++ .../components/workbench-config-panel.vue | 4 +- .../wx-workbench-card-style-panel.vue | 298 ++++++++ .../wx-workbench-default-fav-panel.vue | 139 +++- .../wx-workbench-tile-icon-panel.vue | 99 +++ .../src/views/system/system-config/index.vue | 61 +- pnpm-lock.yaml | 18 + 87 files changed, 6653 insertions(+), 576 deletions(-) create mode 100644 apps/web-antd/src/components/search-time-mode/index.ts create mode 100644 apps/web-antd/src/components/search-time-mode/search-time-mode-picker.vue create mode 100644 apps/web-antd/src/components/search-time-mode/use-search-time-mode.ts create mode 100644 apps/web-antd/src/utils/chunk-load-recovery.ts create mode 100644 apps/web-antd/src/utils/image-compress-config.ts create mode 100644 apps/web-antd/src/views/business/order/register/components/RegisterReceiptPrintModal.vue create mode 100644 apps/web-antd/src/views/business/order/register/utils/print-receipt.ts create mode 100644 apps/web-antd/src/views/doctor/image-transfer-prescription/components/ConsigneeModal.vue create mode 100644 apps/web-antd/src/views/finance/withdrawal/utils/resolve-withdraw-subject-name.ts create mode 100644 apps/web-antd/src/views/notice/views/ExpressTrackFailView.vue create mode 100644 apps/web-antd/src/views/system/admin/transfer-clerk/index.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/image-compress-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/mp-short-link-quota-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/register-voucher-ui-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/register-wait-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/wx-workbench-card-style-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/wx-workbench-tile-icon-panel.vue diff --git a/apps/desktop/src/main/auto-launch.ts b/apps/desktop/src/main/auto-launch.ts index ddde864c..d8d9886b 100644 --- a/apps/desktop/src/main/auto-launch.ts +++ b/apps/desktop/src/main/auto-launch.ts @@ -4,13 +4,20 @@ import log from 'electron-log/main'; /** * 开机自启:基于系统登录项实现(Windows 写 HKCU\...\Run 注册表、mac 写「系统设置 → 登录项」), * 无需管理员权限;系统登录项本身就是持久化存储,无需额外落盘记录状态。 + * + * 注意(Windows):set 时若带了 args,get 必须传同一组 args,否则 openAtLogin 恒为 false, + * 托盘「开机自启」勾选态刷新后就会丢回显(Electron 官方文档明确要求)。 */ +/** 开机自启启动参数:主进程据此静默进托盘,不弹主窗口 */ +const AUTO_LAUNCH_ARGS = ['--hidden']; + /** 读取当前开机自启状态(直接以系统登录项为准,避免自维护状态与系统不同步) */ export function getAutoLaunchEnabled(): boolean { // 开发模式下登录项指向 electron 开发二进制,无意义,统一视为关闭 if (!app.isPackaged) return false; - return app.getLoginItemSettings().openAtLogin; + // Windows 必须带上与 set 时相同的 args,否则匹配不到注册表项 + return app.getLoginItemSettings({ args: AUTO_LAUNCH_ARGS }).openAtLogin; } /** @@ -24,7 +31,8 @@ export function setAutoLaunchEnabled(enable: boolean): boolean { } app.setLoginItemSettings({ openAtLogin: enable, - args: enable ? ['--hidden'] : [], + // 关闭时清空 args,避免残留无意义的启动参数 + args: enable ? AUTO_LAUNCH_ARGS : [], }); return getAutoLaunchEnabled(); } diff --git a/apps/desktop/src/main/printer.ts b/apps/desktop/src/main/printer.ts index 8dfcd29d..3dd447b6 100644 --- a/apps/desktop/src/main/printer.ts +++ b/apps/desktop/src/main/printer.ts @@ -12,6 +12,12 @@ import log from 'electron-log/main'; * 临时文件 loadFile 对任意大小都稳定,打完即删。 */ +/** 自定义纸张(微米);如热敏 80mm = width: 80000 */ +export interface PrintPageSizeMicrons { + width: number; + height: number; +} + export interface PrintHtmlOptions { /** 完整的打印 HTML(含样式),由渲染层拼好传入 */ html: string; @@ -19,6 +25,13 @@ export interface PrintHtmlOptions { deviceName?: string; /** 是否静默打印(true 不弹系统打印对话框) */ silent?: boolean; + /** + * 纸张尺寸(微米)。挂号小票等场景传 80mm 宽,避免落到打印机默认 A4 + * 不传则跟打印机默认纸张 + */ + pageSize?: PrintPageSizeMicrons | string; + /** 优先采用 CSS @page size(与 pageSize 互补) */ + preferCSSPageSize?: boolean; } export interface PrintResult { @@ -30,7 +43,13 @@ export interface PrintResult { * 执行打印,返回是否成功与失败原因(渲染层据此提示用户) */ export async function printHtml(options: PrintHtmlOptions): Promise { - const { html, deviceName, silent = true } = options; + const { + html, + deviceName, + silent = true, + pageSize, + preferCSSPageSize = false, + } = options; // 临时文件带时间戳防并发打印互相覆盖 const tempFile = join(app.getPath('temp'), `xk-print-${Date.now()}-${Math.random().toString(36).slice(2)}.html`); await writeFile(tempFile, html, 'utf8'); @@ -58,6 +77,9 @@ export async function printHtml(options: PrintHtmlOptions): Promise silent, deviceName: deviceName || undefined, printBackground: true, + // 热敏小票等:显式纸张,避免落到驱动默认 A4 + ...(pageSize ? { pageSize } : {}), + ...(preferCSSPageSize ? { preferCSSPageSize: true } : {}), }, (success, failureReason) => finish(success, success ? undefined : failureReason), ); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b1dd2fad..7d037821 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -67,11 +67,13 @@ const desktopAPI = { /** 获取系统打印机列表(打印设置下拉用) */ getPrinters: (): Promise> => ipcRenderer.invoke('desktop:get-printers'), - /** 静默打印:传入拼好的完整 HTML,主进程隐藏窗口出纸 */ + /** 静默打印:传入拼好的完整 HTML,主进程隐藏窗口出纸;可带 pageSize(微米)指定热敏纸等 */ printHTML: (options: { html: string; deviceName?: string; silent?: boolean; + pageSize?: { width: number; height: number } | string; + preferCSSPageSize?: boolean; }): Promise<{ ok: boolean; message?: string }> => ipcRenderer.invoke('desktop:print-html', options), /** 读取桌面端设置(打印机名称、静默打印开关等) */ diff --git a/apps/web-antd/index.html b/apps/web-antd/index.html index 73f2ce29..ca1d00c8 100644 --- a/apps/web-antd/index.html +++ b/apps/web-antd/index.html @@ -79,6 +79,53 @@ })(); } +
diff --git a/apps/web-antd/package.json b/apps/web-antd/package.json index 3a5965db..2924f0a4 100644 --- a/apps/web-antd/package.json +++ b/apps/web-antd/package.json @@ -50,6 +50,7 @@ "exceljs": "^4.4.0", "html2canvas": "^1.4.1", "js-base64": "^3.7.7", + "jsbarcode": "^3.12.3", "lodash-es": "^4.17.21", "lucide-vue-next": "^0.487.0", "lunar-typescript": "^1.8.6", @@ -63,6 +64,7 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@types/jsbarcode": "^3.11.4", "@types/sortablejs": "catalog:" } } diff --git a/apps/web-antd/src/api/request.ts b/apps/web-antd/src/api/request.ts index 79bfb9a4..4e6402eb 100644 --- a/apps/web-antd/src/api/request.ts +++ b/apps/web-antd/src/api/request.ts @@ -3,6 +3,7 @@ */ import type { RequestClientOptions } from '@vben/request'; +import { LOGIN_PATH } from '@vben/constants'; import { useAppConfig } from '@vben/hooks'; import { preferences } from '@vben/preferences'; import { @@ -32,6 +33,13 @@ import {downloadByData} from "#/util/tool"; const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); +/** 是否已在登录页(兼容 history / hash,避免 request 再引 router 造成循环依赖) */ +function isOnLoginPage(): boolean { + if (typeof window === 'undefined') return false; + const { pathname, hash } = window.location; + return pathname.includes(LOGIN_PATH) || hash.includes(LOGIN_PATH); +} + function createRequestClient(baseURL: string, options?: RequestClientOptions) { const client = new RequestClient({ ...options, @@ -40,36 +48,47 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { /** * 重新认证逻辑 + * 空闲后常会并发多个 401:必须整段流程单飞,否则第二个请求会在弹窗未关时直接 logout, + * Ant Modal 遮罩残留会盖住登录页(桌面端表现为「登录组件不见、无法登录」)。 */ - /** 防重复:并发 401 只弹一次重新登录提示 */ - let authModalShowing = false; + let reAuthPromise: null | Promise = null; async function doReAuthenticate() { - const accessStore = useAccessStore(); - const authStore = useAuthStore(); - accessStore.setAccessToken(null); - if ( - preferences.app.loginExpiredMode === 'modal' && - accessStore.isAccessChecked - ) { - accessStore.setLoginExpired(true); - } else { - // 被踢/登录失效时用模态框提示,确认后再退出 - if (!authModalShowing) { - authModalShowing = true; - await new Promise((resolve) => { - Modal.warning({ - title: '登录失效', - content: '账号需重新登录', - okText: '重新登录', - centered: true, - onOk: () => resolve(), - onCancel: () => resolve(), - }); - }); - authModalShowing = false; - } - await authStore.logout(true); + if (reAuthPromise) { + return reAuthPromise; } + reAuthPromise = (async () => { + const accessStore = useAccessStore(); + const authStore = useAuthStore(); + accessStore.setAccessToken(null); + // 已在登录页则只清 token,避免再弹一次确认框把表单盖住 + if (isOnLoginPage()) { + Modal.destroyAll(); + return; + } + if ( + preferences.app.loginExpiredMode === 'modal' && + accessStore.isAccessChecked + ) { + accessStore.setLoginExpired(true); + return; + } + await new Promise((resolve) => { + Modal.warning({ + title: '登录失效', + content: '账号需重新登录', + okText: '重新登录', + centered: true, + onOk: () => resolve(), + onCancel: () => resolve(), + }); + }); + // 先清掉 Ant Modal 残留遮罩,再跳登录,避免桌面端登录表单被挡住 + Modal.destroyAll(); + await authStore.logout(true); + })().finally(() => { + reAuthPromise = null; + }); + return reAuthPromise; } /** diff --git a/apps/web-antd/src/components/form/components/search-time-range-picker.vue b/apps/web-antd/src/components/form/components/search-time-range-picker.vue index 65b347ae..19849774 100644 --- a/apps/web-antd/src/components/form/components/search-time-range-picker.vue +++ b/apps/web-antd/src/components/form/components/search-time-range-picker.vue @@ -52,9 +52,13 @@ const emit = defineEmits<{ change: [[string, string] | [Dayjs, Dayjs] | null]; }>(); -const mValue = useVModel(props, 'value', emit, { passive: true }); +/** + * 不用 passive:passive 会把 update:value 推迟到 watch 刷新, + * 导致同一次操作里紧随其后的 change 回调仍读到父级旧值(对账单选完日期却带旧区间请求)。 + */ +const mValue = useVModel(props, 'value', emit); -/** RangePicker 内部统一用 Dayjs */ +/** RangePicker 内部统一用 Dayjs;写出时先同步 v-model,再发 change,保证监听方读到新值 */ const innerRange = computed<[Dayjs, Dayjs] | null>({ get() { if (!mValue.value || !Array.isArray(mValue.value) || mValue.value.length < 2) { @@ -125,25 +129,19 @@ function onPickerChange( innerRange.value = [a, b]; } -/** 无值时展示默认本月至今(仅 UI 占位,不写回 v-model 直到用户操作) */ -const pickerValue = computed<[Dayjs, Dayjs] | undefined>({ - get() { - return innerRange.value ?? undefined; - }, - set(val) { - if (!val) { - innerRange.value = null; - return; - } - innerRange.value = val; - }, +/** + * 只读展示值;变更统一走 @change → onPickerChange, + * 避免再绑 v-model 导致 update:value 与 change 各写一次、请求发两遍。 + */ +const pickerValue = computed<[Dayjs, Dayjs] | undefined>(() => { + return innerRange.value ?? undefined; }); -
本包裹尚未发货
+
本包裹尚未发货
@@ -432,4 +455,14 @@ const [Modal, modalApi] = useVbenModal({ .mb-2 { margin-bottom: 0.5rem; } + +.track-empty { + color: hsl(var(--muted-foreground)); +} + +/* 失败提示跟主题,避免亮色写死 */ +.sync-fail-alert { + border: 1px solid hsl(var(--warning, 38 92% 50%) / 30%); + box-shadow: 0 0 6px hsl(var(--warning, 38 92% 50%) / 18%); +} diff --git a/apps/web-antd/src/views/business/order/product-order/components/modal.vue b/apps/web-antd/src/views/business/order/product-order/components/modal.vue index 3c0d2212..7a8359bf 100644 --- a/apps/web-antd/src/views/business/order/product-order/components/modal.vue +++ b/apps/web-antd/src/views/business/order/product-order/components/modal.vue @@ -1,23 +1,175 @@ + + + + diff --git a/apps/web-antd/src/views/business/order/register/index.vue b/apps/web-antd/src/views/business/order/register/index.vue index 0bc361e6..84f68e20 100644 --- a/apps/web-antd/src/views/business/order/register/index.vue +++ b/apps/web-antd/src/views/business/order/register/index.vue @@ -6,6 +6,7 @@ * - 搜索表单从 grid 内嵌拆出为顶部统一 SearchForm,双视图共用同一份条件 * - 新增就诊人/医生关键字、挂号类型、支付/挂号/退款状态筛选 * - 点击卡片或行内「详情」打开挂号详情弹窗(就诊人/用户/诊所/医生/挂号方式/价格) + * - 列表/卡片/详情均可「打印预览」挂号凭证 */ import type { VbenFormProps } from '#/adapter/form'; import type { VxeGridListeners } from '#/adapter/vxe-table'; @@ -15,7 +16,7 @@ import { useRoute } from 'vue-router'; import { Page, useVbenModal } from '@vben/common-ui'; -import { Button, Image, Tag } from 'ant-design-vue'; +import { Button, Image, Tag, message } from 'ant-design-vue'; import { useVbenForm } from '#/adapter/form'; import { useVbenVxeGrid } from '#/adapter/vxe-table'; @@ -35,6 +36,7 @@ import { getRegisterListApi } from './api'; import Refund from './components/refund.vue'; import RegisterDetailModal from './components/RegisterDetailModal.vue'; import RegisterOrderCardList from './components/RegisterOrderCardList.vue'; +import RegisterReceiptPrintModal from './components/RegisterReceiptPrintModal.vue'; import RescheduleModal from './components/reschedule-modal.vue'; import { REGISTER_REFUND_STATUS_MAP, @@ -267,11 +269,30 @@ const [RegisterDetailModalComp, registerDetailModalApi] = useVbenModal({ connectedComponent: RegisterDetailModal, }); +/** 挂号凭证打印预览:列表 / 卡片 / 详情共用 */ +const [RegisterReceiptPrintModalComp, registerReceiptPrintModalApi] = + useVbenModal({ + connectedComponent: RegisterReceiptPrintModal, + zIndex: ORDER_POPUP_Z_INDEX.NESTED, + }); + function openRegisterDetail(row: Record) { registerDetailModalApi.setData({ row }); registerDetailModalApi.open(); } +/** + * 打开挂号凭证打印预览(无订单号时提示) + */ +function openRegisterReceiptPrint(row: Record) { + if (!String(row?.order_no || '').trim()) { + message.warning('缺少订单号,无法打印预览'); + return; + } + registerReceiptPrintModalApi.setData({ row }); + registerReceiptPrintModalApi.open(); +} + function openPrescriptionDetail(id: number) { openOrderPopup( PrescriptionDetailModalApi, @@ -335,7 +356,8 @@ function rowRefundMeta(row: Record) { - + + ) { icon: 'marketeq:eye', onClick: openRegisterDetail.bind(null, row), }, + { + label: '打印预览', + type: 'link', + icon: 'ant-design:printer-outlined', + onClick: openRegisterReceiptPrint.bind(null, row), + }, { label: '改期', type: 'link', @@ -450,6 +478,7 @@ function rowRefundMeta(row: Record) { @reschedule="openRescheduleModal" @open-prescription="openPrescriptionDetail" @open-store="openStoreCard" + @print-receipt="openRegisterReceiptPrint" /> diff --git a/apps/web-antd/src/views/business/order/register/utils/print-receipt.ts b/apps/web-antd/src/views/business/order/register/utils/print-receipt.ts new file mode 100644 index 00000000..4995b6d6 --- /dev/null +++ b/apps/web-antd/src/views/business/order/register/utils/print-receipt.ts @@ -0,0 +1,223 @@ +/** + * 挂号凭证打印工具 + * 限定在传入容器内查找 .print-box,避免与处方笺等其它打印区冲突。 + * 为何自带样式、不用整站 CSS:整站样式 + 宽高为 0 的 iframe 会把 max-width:100% 压成极窄,打印预览显得缩小。 + * Electron 静默打印走 desktop.printHTML;浏览器走隐藏 iframe + window.print。 + */ +import { message } from 'ant-design-vue'; + +import { desktop, isElectron } from '#/util/desktop'; + +/** 小票打印专用样式:固定 80mm,不依赖父级宽度,禁止浏览器「适应纸张」把内容缩得很小 */ +const RECEIPT_PRINT_CSS = ` + @page { + size: 80mm auto; + margin: 0; + } + html, body { + margin: 0; + padding: 0; + background: #fff; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + body { + width: 80mm; + } + .print-box, + .receipt-slip { + width: 80mm !important; + max-width: none !important; + box-sizing: border-box; + margin: 0; + padding: 12px 14px 16px; + background: #fff; + color: #000; + font-family: "Microsoft YaHei", "PingFang SC", sans-serif; + font-size: 13px; + line-height: 1.55; + } + .receipt-slip__center { text-align: center; } + .receipt-slip__clinic { + font-size: 14px; + font-weight: 600; + word-break: break-all; + } + .receipt-slip__title { + margin-top: 4px; + font-size: 16px; + font-weight: 700; + letter-spacing: 0.12em; + } + .receipt-slip__barcode { + display: flex; + justify-content: center; + align-items: center; + margin: 10px 0 6px; + padding: 4px 8px; + background: #fff; + } + .receipt-slip__barcode-img { + display: block; + width: auto !important; + height: auto !important; + max-width: 100%; + image-rendering: crisp-edges; + } + .receipt-slip__body { + display: flex; + align-items: flex-end; + gap: 6px; + margin: 4px 0 2px; + } + .receipt-slip__info { + flex: 1; + min-width: 0; + } + .receipt-slip__qr { + flex-shrink: 0; + width: 20mm; + display: flex; + flex-direction: column; + align-items: center; + align-self: flex-end; + } + .receipt-slip__qr-tip { + margin-top: 10px; + font-size: 10px; + line-height: 1.2; + color: #333; + white-space: nowrap; + } + .receipt-slip__qr-img { + display: block; + width: 18mm !important; + height: 18mm !important; + object-fit: contain; + background: #fff; + } + .receipt-slip__qr-placeholder { + width: 18mm; + min-height: 18mm; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 10px; + color: #666; + } + .receipt-slip__line { + text-align: left; + word-break: break-all; + } + .receipt-slip__dash { + margin: 10px 0 8px; + text-align: center; + letter-spacing: 1px; + color: #333; + } + .receipt-slip__foot { + text-align: left; + font-size: 12px; + word-break: break-all; + } +`; + +/** + * 取容器内 .print-box 的 outerHTML + */ +function getPrintBoxHtml(root: HTMLElement | null | undefined): string | null { + if (!root) return null; + const printBox = root.querySelector('.print-box'); + return printBox ? printBox.outerHTML : null; +} + +/** + * 拼装完整打印 HTML:仅小票内容 + 专用样式(不注入整站 CSS,避免被压扁) + */ +export function buildPrintHtml( + root: HTMLElement | null | undefined, + fileName: string, +): string | null { + const boxHtml = getPrintBoxHtml(root); + if (!boxHtml) return null; + const title = fileName || '挂号凭证'; + return `${title}${boxHtml}`; +} + +/** + * 执行打印:桌面端且开启静默打印则直接出纸,否则弹出系统打印对话框 + */ +export async function printReceipt(options: { + root: HTMLElement | null | undefined; + fileName?: string; + silentPrint?: boolean; + printerName?: string; +}): Promise { + const fileName = options.fileName || '挂号凭证'; + const html = buildPrintHtml(options.root, fileName); + if (!html) { + message.error('找不到打印内容'); + return; + } + const isDesktop = isElectron(); + if (isDesktop && options.silentPrint) { + // Electron 可指定纸张(微米):80mm 宽;高度给足连续纸走纸,避免落到驱动默认 A4 + const result = await desktop.printHTML({ + html, + deviceName: options.printerName || undefined, + silent: true, + pageSize: { width: 80_000, height: 200_000 }, + preferCSSPageSize: true, + }); + if (result.ok) { + message.success('已发送到打印机'); + } else { + message.error(`打印失败:${result.message || '未知错误'}`); + } + return; + } + // iframe 必须有真实宽度:宽高为 0 时 max-width:100% 会把小票压成极窄 + const iframe = document.createElement('iframe'); + iframe.setAttribute('title', fileName); + iframe.style.cssText = + 'position:fixed;left:-9999px;top:0;width:80mm;height:600px;border:0;opacity:0;pointer-events:none;'; + document.body.append(iframe); + const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; + if (!iframeDoc || !iframe.contentWindow) { + iframe.remove(); + message.error('无法创建打印窗口'); + return; + } + iframeDoc.open(); + iframeDoc.write(html); + iframeDoc.close(); + const runPrint = () => { + try { + iframe.contentWindow?.focus(); + iframe.contentWindow?.print(); + } finally { + setTimeout(() => iframe.remove(), 800); + } + }; + // 等图片(条码 PNG)解码后再打,避免空白条码或二次重排显得缩小 + const imgs = Array.from(iframeDoc.images || []); + if (imgs.length === 0) { + setTimeout(runPrint, 50); + return; + } + await Promise.all( + imgs.map( + (img) => + new Promise((resolve) => { + if (img.complete) { + resolve(); + return; + } + img.onload = () => resolve(); + img.onerror = () => resolve(); + }), + ), + ); + setTimeout(runPrint, 50); +} diff --git a/apps/web-antd/src/views/business/store/settings/index.vue b/apps/web-antd/src/views/business/store/settings/index.vue index e0fa342b..09e3b48a 100644 --- a/apps/web-antd/src/views/business/store/settings/index.vue +++ b/apps/web-antd/src/views/business/store/settings/index.vue @@ -25,6 +25,7 @@ import { import html2canvas from 'html2canvas'; import DoctorQrCodePreview from '#/components/modal/DoctorQrCodePreview.vue'; +import QrCodePreview from '#/components/modal/QrCodePreview.vue'; import SensitiveText from '#/components/sensitive-text/SensitiveText.vue'; import StoreVipMemberCard from '#/components/vip/StoreVipMemberCard.vue'; import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue'; @@ -48,6 +49,8 @@ interface TabBarItem { } interface StoreInfo { + /** 门店主键:拼启动参数 STORE_QR_CODE_{id} */ + id: number; name: string; type: number; // 0=诊所, 1=药店 clinic_type?: number; // 1=西医诊所, 2=中医诊所 @@ -70,12 +73,18 @@ interface StoreInfo { nav: { id: number; pic: string }[]; /** 门店 VIP:等级徽标 + 期限丝带 */ vip?: Record | null; + /** 患者端 Short Link 落库数据 */ + mp_short_link?: Record | null; } const [DoctorQrCodePreviewModal, DoctorQrCodePreviewModalApi] = useVbenModal({ connectedComponent: DoctorQrCodePreview, }); +const [StoreQrCodePreviewModal, StoreQrCodePreviewApi] = useVbenModal({ + connectedComponent: QrCodePreview, +}); + const [DoctorCardModals, DoctorCardModalApi] = useVbenModal({ connectedComponent: DoctorCardModal, }); @@ -228,6 +237,28 @@ onMounted(() => { const qrCodeUrl = ref(null); const htmlToImage = ref(null); +/** + * 打开门店二维码弹窗:手动生成/复制微信 Short Link(#小程序://…) + */ +function openStoreShortLinkModal() { + if (!data.value?.id) { + message.warning('缺少门店信息'); + return; + } + const province = data.value.province?.name || ''; + const city = data.value.city?.name || ''; + StoreQrCodePreviewApi.setData({ + values: data.value.qr_code, + title: data.value.name, + address: `${province}${city}${data.value.position || ''}`, + type: data.value.type ?? 0, + id: data.value.id, + mp_short_link: data.value.mp_short_link || {}, + onGenerated: () => getMyInfo(), + }); + StoreQrCodePreviewApi.open(); +} + /** * 下载二维码 * @param name @@ -398,6 +429,7 @@ const handleSwitchClinicType = () => {