feat: 地区选择器改走接口、订单市价重算与资金流水、同步日志内容复用

region-address-picker 接 region-picker API;订单支持按进价重算 market_price、追踪页资金流水;MES/ERP/中台同步日志共用内容查看组件;地区管理支持 pid 下钻与拼音检索。
This commit is contained in:
李琦
2026-10-15 10:01:20 +08:00
parent 8df1ffb80a
commit 50fc55925f
87 changed files with 6653 additions and 576 deletions

View File

@@ -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();
}

View File

@@ -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<PrintResult> {
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<PrintResult>
silent,
deviceName: deviceName || undefined,
printBackground: true,
// 热敏小票等:显式纸张,避免落到驱动默认 A4
...(pageSize ? { pageSize } : {}),
...(preferCSSPageSize ? { preferCSSPageSize: true } : {}),
},
(success, failureReason) => finish(success, success ? undefined : failureReason),
);

View File

@@ -67,11 +67,13 @@ const desktopAPI = {
/** 获取系统打印机列表(打印设置下拉用) */
getPrinters: (): Promise<Array<{ name: string; displayName: string; isDefault: boolean }>> =>
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),
/** 读取桌面端设置(打印机名称、静默打印开关等) */

View File

@@ -79,6 +79,53 @@
})();
}
</script>
<script>
/**
* 发版后旧 hash JS 失效时的兜底:在 main 还未跑起来前也能收到 vite:preloadError / unhandledrejection,
* 自动硬刷新一次,避免全局 loading 一直转且控制台只有 Failed to fetch dynamically imported module。
*/
(function () {
var KEY = 'xk_admin_chunk_reload_at';
var COOLDOWN = 15000;
function canReload() {
try {
var last = Number(sessionStorage.getItem(KEY) || 0);
if (last && Date.now() - last < COOLDOWN) return false;
sessionStorage.setItem(KEY, String(Date.now()));
return true;
} catch (e) {
return true;
}
}
function isChunkErr(msg) {
msg = String(msg || '');
return (
msg.indexOf('Failed to fetch dynamically imported module') !== -1 ||
msg.indexOf('Importing a module script failed') !== -1 ||
msg.indexOf('error loading dynamically imported module') !== -1
);
}
function reload(source) {
if (!canReload()) return;
console.info(
'[chunk-load-recovery] chunk 加载失败,自动刷新',
source,
);
location.reload();
}
window.addEventListener('vite:preloadError', function (e) {
try {
e.preventDefault();
} catch (err) {}
reload('html:vite:preloadError');
});
window.addEventListener('unhandledrejection', function (e) {
var reason = e && e.reason;
var msg = (reason && reason.message) || reason;
if (isChunkErr(msg)) reload('html:unhandledrejection');
});
})();
</script>
</head>
<body>
<div id="app"></div>

View File

@@ -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:"
}
}

View File

@@ -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<void> = 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<void>((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<void>((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;
}
/**

View File

@@ -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;
});
</script>
<template>
<div class="search-time-range-picker" :class="props.class">
<DatePicker.RangePicker
v-model:value="pickerValue"
:value="pickerValue"
v-bind="$attrs"
:disabled="disabled"
:format="format"

View File

@@ -1,7 +1,19 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
<script lang="ts" setup>
/**
* PC 图片压缩弹窗(C 端设计语言)
*
* 触发时机:上传的图片超过后台配置的阈值(系统配置-上传-图片压缩阈值)时弹出。
* 结构参考 C 端付款成功页/收银台的视觉规范:浅灰蓝底 + 白色圆角卡 + 胶囊按钮,
* 颜色全部走主题变量,保证暗色模式下同样可读(不写死 #fff / #f8fafc)。
*
* 弹窗基于 useVbenModal 渲染;由 utils/open-image-compress-modal.ts 命令式挂载,
* 打开即自动开始压缩,用户可选择「使用压缩图 / 使用原图 / 取消」。
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Button, Image, Modal, Spin } from 'ant-design-vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Image, Spin } from 'ant-design-vue';
import {
compressImageFile,
@@ -9,29 +21,31 @@ import {
formatSavingsPercent,
} from '#/utils/image-compress';
export type ImageCompressChoice = 'compressed' | 'original' | 'cancel';
export type ImageCompressChoice = 'cancel' | 'compressed' | 'original';
export interface ImageCompressModalResult {
choice: ImageCompressChoice;
file: File | null;
meta?: {
originalSize: number;
compressedSize?: number;
originalSize: number;
usedCompress: boolean;
};
}
const props = defineProps<{
file: File;
/** 目标体积上限(字节),来自系统配置阈值;不传则用工具内默认值 */
maxBytes?: number;
}>();
const emit = defineEmits<{
resolve: [result: ImageCompressModalResult];
}>();
type ModalStatus = 'compressing' | 'ready' | 'gifSkipped' | 'error';
/** 弹窗状态:compressing 压缩中 / ready 可对比 / gif 动图不压 / noGain 压完没变小 / error 失败 */
type ModalStatus = 'compressing' | 'error' | 'gif' | 'noGain' | 'ready';
const visible = ref(true);
const status = ref<ModalStatus>('compressing');
const progressText = ref('正在压缩,请稍候…');
const errorMessage = ref('');
@@ -41,31 +55,71 @@ const compressedFile = ref<File | null>(null);
const originalSize = ref(0);
const compressedSize = ref(0);
const sizeText = computed(() => {
const [Modal, modalApi] = useVbenModal({
class: 'ic-modal',
footerClass: 'ic-footer',
cancelText: '取消',
// 压缩未完成前不允许 X / 遮罩 / Esc 关闭,避免用户误以为已取消上传
closable: false,
closeOnClickModal: false,
closeOnPressEscape: false,
fullscreenButton: false,
onCancel: () => finish('cancel'),
onConfirm: () => finish('compressed'),
});
/** 是否直接沿用原图(动图不压 / 压了没变小) */
const isSkipped = computed(
() => status.value === 'gif' || status.value === 'noGain',
);
/** 是否显示「使用原图」按钮 */
const showOriginalAction = computed(
() =>
status.value === 'ready' ||
status.value === 'error' ||
isSkipped.value,
);
/** 沿用原图时该按钮就是主操作,用实心胶囊突出 */
const originalActionPrimary = computed(
() => isSkipped.value || status.value === 'error',
);
/** 体积说明文案:原图 → 压缩后(约节省 x%) */
const summaryText = computed(() => {
if (!originalSize.value) return '';
const originalText = formatFileSize(originalSize.value);
if (status.value === 'gifSkipped') {
if (isSkipped.value) {
return `原图 ${originalText}`;
}
if (compressedSize.value && status.value === 'ready') {
if (status.value === 'ready' && compressedSize.value) {
const savings = formatSavingsPercent(
originalSize.value,
compressedSize.value,
);
return `原图 ${originalText} → 压缩后 ${formatFileSize(compressedSize.value)}${savings}`;
}
if (compressedSize.value) {
return `原图 ${originalText} → 压缩后 ${formatFileSize(compressedSize.value)}`;
}
return `原图 ${originalText}`;
});
/** 跳过/失败时的提示文案 */
const hintText = computed(() => {
if (status.value === 'gif') {
return 'GIF 动图压缩会丢帧,将上传原图。';
}
if (status.value === 'noGain') {
return '压缩后体积没有变小,将上传原图。';
}
return errorMessage.value;
});
const originalButtonLabel = computed(() => {
if (!originalSize.value) return '使用原图';
return `使用原图 (${formatFileSize(originalSize.value)})`;
});
const compressedButtonLabel = computed(() => {
const confirmButtonLabel = computed(() => {
if (!compressedSize.value) return '使用压缩图';
return `使用压缩图 (${formatFileSize(compressedSize.value)})`;
});
@@ -81,6 +135,7 @@ function revokePreviewUrls() {
}
}
/** 收敛选择结果给命令式调用方;取消时 file 传 null,调用方终止上传 */
function finish(choice: ImageCompressChoice) {
const useCompressed = choice === 'compressed' && status.value === 'ready';
const selectedFile =
@@ -102,17 +157,13 @@ function finish(choice: ImageCompressChoice) {
usedCompress: useCompressed,
},
});
visible.value = false;
}
async function startCompress() {
originalSize.value = props.file.size;
revokePreviewUrls();
originalPreviewUrl.value = URL.createObjectURL(props.file);
if (props.file.type === 'image/gif') {
status.value = 'gifSkipped';
progressText.value = '';
status.value = 'gif';
return;
}
@@ -121,19 +172,20 @@ async function startCompress() {
try {
const result = await compressImageFile(props.file, {
maxBytes: props.maxBytes,
onProgress: (message) => {
progressText.value = message;
},
});
compressedFile.value = result.file;
compressedSize.value = result.file.size;
if (result.skipped) {
status.value = 'gifSkipped';
// 压完没变小:沿用了原图,提示用户只能使用原图
status.value = 'noGain';
return;
}
compressedFile.value = result.file;
compressedSize.value = result.file.size;
compressedPreviewUrl.value = URL.createObjectURL(result.file);
status.value = 'ready';
progressText.value = '';
@@ -145,8 +197,22 @@ async function startCompress() {
}
}
/** 按钮显隐跟随状态:只有压缩完成才有「使用压缩图」,压缩中只留「取消」 */
watch(
status,
() => {
modalApi.setState({
showConfirmButton: status.value === 'ready',
confirmDisabled: status.value !== 'ready',
});
},
{ immediate: true },
);
onMounted(() => {
startCompress();
originalPreviewUrl.value = URL.createObjectURL(props.file);
modalApi.open();
void startCompress();
});
onBeforeUnmount(() => {
@@ -155,200 +221,208 @@ onBeforeUnmount(() => {
</script>
<template>
<Modal
v-model:visible="visible"
title="图片较大"
:width="640"
:mask-closable="false"
:closable="false"
:footer="null"
@cancel="finish('cancel')"
>
<div class="image-compress-modal">
<p v-if="sizeText" class="size-text">{{ sizeText }}</p>
<Modal title="图片较大">
<template #confirmText>{{ confirmButtonLabel }}</template>
<template #center-footer>
<Button
v-if="showOriginalAction"
shape="round"
:type="originalActionPrimary ? 'primary' : 'default'"
@click="finish('original')"
>
{{ originalButtonLabel }}
</Button>
</template>
<div v-if="status === 'compressing'" class="compressing-panel">
<div class="preview-single">
<div class="ic-body">
<p v-if="summaryText" class="ic-summary">{{ summaryText }}</p>
<!-- 压缩中:只展示原图预览 + 进度 -->
<div v-if="status === 'compressing'" class="ic-stack">
<div class="ic-card">
<span class="ic-card-label">原图</span>
<Image
v-if="originalPreviewUrl"
:src="originalPreviewUrl"
class="ic-thumb"
alt="原图预览"
class="preview-image"
/>
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
<span class="ic-card-size">{{ formatFileSize(originalSize) }}</span>
</div>
<div class="compressing-status">
<div class="ic-progress">
<Spin />
<span class="progress-text">{{ progressText }}</span>
<span class="ic-progress-text">{{ progressText }}</span>
</div>
</div>
<div v-else-if="status === 'ready'" class="compare-panel">
<div class="preview-column">
<div class="preview-label">原图</div>
<!-- 压缩完成:原图 / 压缩后 并列对比 -->
<div v-else-if="status === 'ready'" class="ic-compare">
<div class="ic-card">
<span class="ic-card-label">原图</span>
<Image
:src="originalPreviewUrl"
class="ic-thumb"
alt="原图预览"
class="preview-image"
/>
<div class="preview-size">{{ formatFileSize(originalSize) }}</div>
<span class="preview-hint">点击预览</span>
<span class="ic-card-size">{{ formatFileSize(originalSize) }}</span>
</div>
<div class="preview-column">
<div class="preview-label">压缩后</div>
<span class="ic-arrow">→</span>
<div class="ic-card ic-card--target">
<span class="ic-card-label">压缩后</span>
<Image
:src="compressedPreviewUrl"
class="ic-thumb"
alt="压缩图预览"
class="preview-image"
/>
<div class="preview-size">{{ formatFileSize(compressedSize) }}</div>
<span class="preview-hint">点击预览</span>
<span class="ic-card-size ic-card-size--target">
{{ formatFileSize(compressedSize) }}
</span>
</div>
</div>
<div v-else-if="status === 'gifSkipped'" class="gif-panel">
<div class="preview-single">
<!-- 跳过 / 失败:单图 + 提示 -->
<div v-else class="ic-stack">
<div class="ic-card">
<span class="ic-card-label">原图</span>
<Image
v-if="originalPreviewUrl"
:src="originalPreviewUrl"
class="ic-thumb"
alt="原图预览"
class="preview-image"
/>
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
<span class="ic-card-size">{{ formatFileSize(originalSize) }}</span>
</div>
<p class="hint-text">GIF 不支持压缩,将上传原图。</p>
<p class="ic-hint" :class="{ 'ic-hint--error': status === 'error' }">
{{ hintText }}
</p>
</div>
<div v-else class="error-panel">
<div class="preview-single">
<Image
v-if="originalPreviewUrl"
:src="originalPreviewUrl"
alt="原图预览"
class="preview-image"
/>
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
</div>
<p class="error-text">{{ errorMessage }}</p>
</div>
<div class="footer-actions">
<Button @click="finish('cancel')">取消</Button>
<Button
v-if="status === 'ready' || status === 'error'"
@click="finish('original')"
>
{{ originalButtonLabel }}
</Button>
<Button
v-if="status === 'ready'"
type="primary"
@click="finish('compressed')"
>
{{ compressedButtonLabel }}
</Button>
<Button
v-if="status === 'gifSkipped'"
type="primary"
@click="finish('original')"
>
{{ originalButtonLabel }}
</Button>
</div>
<p class="ic-tip">点击图片可放大查看</p>
</div>
</Modal>
</template>
<style lang="less" scoped>
.image-compress-modal {
.size-text {
margin-bottom: 16px;
color: rgba(0, 0, 0, 0.65);
<!-- 说明:footer 由 VbenModal 内部控制,无法用 scoped 命中,故用唯一前缀 .ic-modal 全局限定 -->
<style lang="less">
.ic-modal {
/* C 端页面底色(对齐 C 端 #F8FAFC):用 muted 低透明度自动适配暗色 */
.ic-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 4px 2px;
}
.ic-summary {
margin: 0;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
text-align: center;
}
.preview-single,
.preview-column {
display: flex;
flex-direction: column;
.ic-compare {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 10px;
align-items: center;
}
.preview-image {
max-width: 100%;
max-height: 220px;
cursor: pointer;
:deep(.ant-image-img) {
max-width: 100%;
max-height: 220px;
object-fit: contain;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
}
.ic-arrow {
font-size: 16px;
color: hsl(var(--muted-foreground));
}
.preview-label {
margin-bottom: 8px;
font-weight: 500;
}
.preview-size {
margin-top: 8px;
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
.preview-hint {
margin-top: 4px;
color: rgba(0, 0, 0, 0.35);
font-size: 12px;
}
.compressing-panel,
.gif-panel,
.error-panel {
.ic-stack {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
}
.compressing-status {
display: flex;
gap: 12px;
align-items: center;
}
/* 白色圆角卡:C 端卡片形态,暗色下用 card 变量 */
.ic-card {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
width: 100%;
padding: 10px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border) / 55%);
border-radius: 10px;
box-shadow: 0 1px 6px hsl(var(--foreground) / 4%);
}
.ic-card--target {
border-color: hsl(var(--primary) / 30%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.ic-card-label {
font-size: 12px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
.ic-thumb {
width: 100%;
text-align: center;
}
/* antd Image 的 class 落在包裹层上,图片本身要用后代选择器命中 */
.ic-thumb .ant-image-img {
max-width: 100%;
max-height: 190px;
object-fit: contain;
border-radius: 8px;
background: hsl(var(--muted) / 40%);
}
.ic-card-size {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.ic-card-size--target {
font-weight: 600;
color: hsl(var(--primary));
}
.ic-progress {
display: flex;
gap: 10px;
align-items: center;
justify-content: center;
}
.progress-text {
color: rgba(0, 0, 0, 0.65);
.ic-progress-text {
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.compare-panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.hint-text,
.error-text {
.ic-hint {
margin: 0;
color: rgba(0, 0, 0, 0.65);
font-size: 13px;
color: hsl(var(--muted-foreground));
text-align: center;
}
.error-text {
color: #ff4d4f;
.ic-hint--error {
color: hsl(var(--destructive));
}
.footer-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
.ic-tip {
margin: 0;
font-size: 12px;
color: hsl(var(--muted-foreground) / 75%);
text-align: center;
}
/* 胶囊按钮:对齐 C 端底部通栏 / 收银台按钮形态 */
.ic-footer button {
border-radius: 999px;
padding-inline: 16px;
}
}
</style>

View File

@@ -1,28 +1,134 @@
<script lang="ts" setup>
/**
* 诊所/药店二维码预览弹窗
* 按门店 type(0 诊所 / 1 药店)切换「诊所|药店」文案,列表页与门店卡片共用
* 按门店 type(0 诊所 / 1 药店)切换文案;支持手动生成并复制微信 Short Link(#小程序://…)
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useClipboard } from '@vueuse/core';
import { Button, Image } from 'ant-design-vue';
import { Button, Checkbox, Image, Modal, Radio, message } from 'ant-design-vue';
import html2canvas from 'html2canvas';
import { generateStoreMpShortLinkApi } from '#/views/system/store/api';
export interface MpShortLinkPayload {
page_url?: string;
permanent_at?: number;
permanent_link?: string;
temporary_at?: number;
temporary_expire_at?: number;
temporary_link?: string;
temporary_valid?: number;
}
/** 患者端首页:tabBar「首页」(pages/index/index 是「消息」勿用) */
const PATIENT_HOME_PATH = 'pages/home/home';
const qrCodeUrl = ref('');
const htmlToImage = ref('');
const url = ref('');
const previewTitle = ref('二维码');
const previewAddress = ref('');
/** 门店类型:0=诊所 1=药店,未传时按诊所示 */
/** 门店类型:0=诊所 1=药店 */
const storeType = ref(0);
const storeId = ref(0);
/** 已落库短链 */
const mpShortLink = ref<MpShortLinkPayload>({});
const generating = ref(false);
/** 生成弹层 */
const genOpen = ref(false);
const genType = ref<'permanent' | 'temporary'>('temporary');
const genForce = ref(false);
/** 生成成功后通知列表刷新短链字段 */
const onGenerated = ref<null | (() => void)>(null);
const isPharmacy = computed(() => Number(storeType.value) === 1);
const kindLabel = computed(() => (isPharmacy.value ? '药店' : '诊所'));
const addressLabel = computed(() => `${kindLabel.value}地址:`);
const [Modal, modalApi] = useVbenModal({
const pageUrl = computed(() => {
const fromRow = String(mpShortLink.value?.page_url || '').trim();
if (fromRow) return fromRow;
const id = Number(storeId.value) || 0;
return id > 0 ? `${PATIENT_HOME_PATH}?scene=STORE_QR_CODE_${id}` : '';
});
const permanentLink = computed(() =>
String(mpShortLink.value?.permanent_link || '').trim(),
);
const temporaryLink = computed(() =>
String(mpShortLink.value?.temporary_link || '').trim(),
);
const temporaryValid = computed(() => {
if (Number(mpShortLink.value?.temporary_valid) === 1) return true;
const expire = Number(mpShortLink.value?.temporary_expire_at || 0);
return temporaryLink.value !== '' && expire * 1000 > Date.now();
});
const temporaryExpireText = computed(() => {
const expire = Number(mpShortLink.value?.temporary_expire_at || 0);
if (expire <= 0) return '';
const d = new Date(expire * 1000);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
});
const { copy } = useClipboard({ legacy: true });
async function copyText(text: string, tip: string) {
if (!text) {
message.warning('暂无可复制内容');
return;
}
await copy(text);
message.success(tip);
}
function openGenerate() {
if (!storeId.value) {
message.warning('缺少门店 ID');
return;
}
genType.value = 'temporary';
genForce.value = false;
genOpen.value = true;
}
/**
* 调用后端手动生成短链并写回本地展示
*/
async function confirmGenerate() {
const id = Number(storeId.value) || 0;
if (id <= 0) return;
if (
genType.value === 'permanent' &&
permanentLink.value &&
!genForce.value
) {
message.warning('已有长期短链,请勾选「强制重新生成」');
return;
}
generating.value = true;
try {
const res = await generateStoreMpShortLinkApi({
id,
type: genType.value,
force: genType.value === 'permanent' && genForce.value ? 1 : 0,
});
const data = ((res as any)?.data ?? res ?? {}) as MpShortLinkPayload;
mpShortLink.value = { ...data };
genOpen.value = false;
message.success('短链生成成功');
onGenerated.value?.();
} catch (err) {
console.error(err);
} finally {
generating.value = false;
}
}
const [ModalShell, modalApi] = useVbenModal({
fullscreenButton: false,
footer: false,
header: false,
@@ -39,15 +145,27 @@ const [Modal, modalApi] = useVbenModal({
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const { values, title, address, type, store_type } =
modalApi.getData<Record<string, any>>() || {};
const data = modalApi.getData<Record<string, any>>() || {};
const {
values,
title,
address,
type,
store_type,
id,
store_id,
mp_short_link,
onGenerated: cb,
} = data;
if (values) {
url.value = values;
qrCodeUrl.value = values;
previewTitle.value = title;
previewAddress.value = address;
// 兼容 type / store_type 两种传参
storeType.value = Number(type ?? store_type ?? 0);
storeId.value = Number(id ?? store_id ?? 0);
mpShortLink.value = (mp_short_link || {}) as MpShortLinkPayload;
onGenerated.value = typeof cb === 'function' ? cb : null;
modalApi.setState({
confirmText: `保存${kindLabel.value}卡片图片`,
cancelText: `保存${kindLabel.value}二维码图片`,
@@ -59,8 +177,6 @@ const [Modal, modalApi] = useVbenModal({
/**
* 下载二维码卡片或纯二维码图片
* @param name 文件名后缀
* @param isQr true=只截二维码区域,false=截整张卡片
*/
const downloadQRCode = async (name: string, isQr = false) => {
const element = isQr ? qrCodeUrl.value : htmlToImage.value;
@@ -79,7 +195,7 @@ const downloadQRCode = async (name: string, isQr = false) => {
};
</script>
<template>
<Modal :title="`${previewTitle}二维码`">
<ModalShell :title="`${previewTitle}二维码`">
<div class="qrBox" style="text-align: center">
<div ref="htmlToImage" class="qrModal">
<div id="qrcode" ref="qrCodeUrl" class="qrcode">
@@ -91,6 +207,58 @@ const downloadQRCode = async (name: string, isQr = false) => {
</div>
</div>
<div class="launch-link">
<div class="launch-link__label">微信 Short Link(粘贴到微信可打开)</div>
<div v-if="permanentLink" class="launch-link__block">
<div class="launch-link__tag">长期</div>
<div class="launch-link__row">
<code class="launch-link__text">{{ permanentLink }}</code>
<Button
type="primary"
size="small"
@click="copyText(permanentLink, '长期短链已复制')"
>
复制
</Button>
</div>
</div>
<div v-if="temporaryLink" class="launch-link__block">
<div class="launch-link__tag">
短期
<span v-if="temporaryExpireText" class="launch-link__expire">
({{ temporaryValid ? '有效至' : '已过期' }}
{{ temporaryExpireText }})
</span>
</div>
<div class="launch-link__row">
<code class="launch-link__text">{{ temporaryLink }}</code>
<Button
type="primary"
size="small"
:disabled="!temporaryValid"
@click="copyText(temporaryLink, '短期短链已复制')"
>
复制
</Button>
</div>
</div>
<div
v-if="!permanentLink && !temporaryLink"
class="launch-link__empty"
>
尚未生成短链,请点击下方「生成短链」(勿手拼 #小程序://路径)
</div>
<div class="launch-link__actions">
<Button type="primary" :loading="generating" @click="openGenerate">
生成短链
</Button>
</div>
<div v-if="pageUrl" class="launch-link__page">
<span class="launch-link__label">启动参数 page_url</span>
<code class="launch-link__text">{{ pageUrl }}</code>
</div>
</div>
<Button
style="margin: 20px auto"
type="primary"
@@ -106,11 +274,32 @@ const downloadQRCode = async (name: string, isQr = false) => {
二维码下载
</Button>
</div>
</Modal>
<Modal
v-model:open="genOpen"
title="生成微信 Short Link"
:confirm-loading="generating"
ok-text="确认生成"
@ok="confirmGenerate"
>
<div class="gen-tip">
将调用微信接口生成可用的 #小程序://… 链接并保存到本门店;长期计入 10
万配额,短期约 30 天有效。
</div>
<Radio.Group v-model:value="genType" class="gen-radio">
<Radio value="temporary">短期(约 30 天)</Radio>
<Radio value="permanent">长期(永久)</Radio>
</Radio.Group>
<div v-if="genType === 'permanent' && permanentLink" class="gen-force">
<Checkbox v-model:checked="genForce">
强制重新生成(再消耗 1 次长期配额)
</Checkbox>
</div>
</Modal>
</ModalShell>
</template>
<style lang="scss" scoped>
.qrModal {
// 宽度适应文本长度
margin: 0 auto;
text-align: center;
background: radial-gradient(
@@ -141,7 +330,6 @@ const downloadQRCode = async (name: string, isQr = false) => {
.qrcode {
width: fit-content;
//width: 50%;
margin: 0 auto;
text-align: center;
padding: 20px 30px;
@@ -149,4 +337,84 @@ const downloadQRCode = async (name: string, isQr = false) => {
border-radius: 20px;
}
}
.launch-link {
margin: 16px auto 0;
max-width: 560px;
text-align: left;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.25);
}
.launch-link__label {
font-size: 12px;
color: hsl(var(--muted-foreground));
margin-bottom: 8px;
}
.launch-link__block {
margin-bottom: 12px;
}
.launch-link__tag {
font-size: 12px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 6px;
}
.launch-link__expire {
font-weight: 400;
color: hsl(var(--muted-foreground));
}
.launch-link__row {
display: flex;
align-items: center;
gap: 10px;
}
.launch-link__text {
flex: 1;
min-width: 0;
font-size: 12px;
word-break: break-all;
color: hsl(var(--foreground));
line-height: 1.5;
}
.launch-link__empty {
font-size: 12px;
color: hsl(var(--muted-foreground));
margin-bottom: 10px;
}
.launch-link__actions {
margin-top: 4px;
}
.launch-link__page {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed hsl(var(--border));
}
.gen-tip {
font-size: 13px;
color: hsl(var(--muted-foreground));
margin-bottom: 16px;
line-height: 1.5;
}
.gen-radio {
display: flex;
flex-direction: column;
gap: 10px;
}
.gen-force {
margin-top: 14px;
}
</style>

View File

@@ -0,0 +1,9 @@
/**
* 带 tab 的时间筛选(日期范围 / 按天):组件负责展示,useSearchTimeMode 负责 tab 与日期的本地记忆
*/
export { default as SearchTimeModePicker } from './search-time-mode-picker.vue';
export { useSearchTimeMode } from './use-search-time-mode';
export type {
SearchTimeMode,
UseSearchTimeModeOptions,
} from './use-search-time-mode';

View File

@@ -0,0 +1,352 @@
<script setup lang="ts">
/**
* 带 tab 的时间筛选:「日期范围」复用 SearchTimeRangePicker;「按天」是常驻小月历,点哪天就查那天 00:00:00 ~ 23:59:59
*
* 为什么不直接改 SearchTimeRangePicker:它被 10 个页面(含 VbenForm 搜索表单)共用,加 tab 会改动所有页面;
* 本组件按需接入,tab 与所选日期的本地记忆交给 useSearchTimeMode,组件只负责展示和交互。
*
* 用法:
* const range = ref(loadRange());
* const { mode, day, searchTime } = useSearchTimeMode({ storageKey: 'xxx.page', range });
* <SearchTimeModePicker v-model:mode="mode" v-model:range="range" v-model:day="day" @change="reload" />
* 查询统一读 searchTime
*/
import type { Dayjs } from 'dayjs';
import type { SearchTimeMode } from './use-search-time-mode';
import { computed, shallowRef, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Calendar, Space, TabPane, Tabs } from 'ant-design-vue';
import dayjs from 'dayjs';
import SearchTimeRangePicker from '#/components/form/components/search-time-range-picker.vue';
import { todayDayjs, toSearchDayBounds } from '#/utils/search-time-range';
defineOptions({ name: 'SearchTimeModePicker' });
const props = withDefaults(
defineProps<{
/** 「按天」是否禁止选今天之后的日期:对账、流水类数据未来日期必为空,禁掉免得误点 */
disabledFuture?: boolean;
/** 「日期范围」输入框的展示格式 */
format?: string;
/** 「日期范围」弹层挂载节点(组件放在 Tabs / 滚动容器里时挂到 body,避免弹层被裁剪) */
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
/** tab 栏左侧标题,如「时间筛选(全页共用)」,不传不显示 */
label?: string;
}>(),
{
disabledFuture: true,
format: 'YYYY-MM-DD',
getPopupContainer: undefined,
label: '',
},
);
const emit = defineEmits<{
/** 生效区间变了(切 tab / 改范围 / 选某天);对应 v-model 已先同步写回,载荷就是新区间 */
change: [range: [Dayjs, Dayjs]];
}>();
/** 当前 tab */
const mode = defineModel<SearchTimeMode>('mode', { default: 'range' });
/** 「日期范围」tab 的区间 */
const range = defineModel<[Dayjs, Dayjs]>('range', { required: true });
/** 「按天」tab 选中的日期 */
const day = defineModel<Dayjs>('day', { required: true });
const WEEK_LABELS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
/**
* 月历当前显示的月份,和已选日期分开存。
* antd Calendar 的 value 同时决定「显示哪个月」和「选中哪天」,直接绑 day 的话翻月会把选中日期一起挪走并触发查询,
* 所以翻月只改 panelDate,点了日期格才写回 day。
*/
const panelDate = shallowRef<Dayjs>(day.value);
// 外部改了日期(页面重置等),月历跟着跳到那个月
watch(day, (value) => {
panelDate.value = value;
});
/** 已选日期文案,如「2026-10-13 周二」 */
const dayLabel = computed(
() => `${day.value.format('YYYY-MM-DD')} ${WEEK_LABELS[day.value.day()]}`,
);
/** 是否还能往后翻:禁未来时最多翻到本月 */
const canPanelForward = computed(
() => !props.disabledFuture || panelDate.value.isBefore(todayDayjs(), 'month'),
);
/** 「后一天」是否可点:禁未来时已选今天就到头 */
const canNextDay = computed(
() => !props.disabledFuture || day.value.isBefore(todayDayjs(), 'day'),
);
/** 单日查询边界:当天 00:00:00 ~ 23:59:59 */
function dayBounds(value: Dayjs): [Dayjs, Dayjs] {
return toSearchDayBounds([value, value]);
}
/** 月历禁用日:禁未来时今天之后的格子不可点 */
function isDisabledDay(current: Dayjs) {
return props.disabledFuture && current.isAfter(todayDayjs(), 'day');
}
/** 月历格子状态:已选中的那天实心高亮,今天加描边 */
function cellClass(current: Dayjs) {
return {
'is-picked': current.isSame(day.value, 'day'),
'is-today': current.isSame(todayDayjs(), 'day'),
};
}
/** 切 tab:立即按新 tab 的区间通知页面刷新 */
function onTabChange(key: number | string) {
const next = key as SearchTimeMode;
if (next === mode.value) return;
mode.value = next;
emit('change', next === 'day' ? dayBounds(day.value) : range.value);
}
/** 「日期范围」变更;清空不生效(已关掉 allowClear,这里兜底),因为查询必须带区间 */
function onRangeChange(next: [Dayjs, Dayjs] | [string, string] | null) {
if (!next?.[0] || !next?.[1]) return;
const bounded = toSearchDayBounds([dayjs(next[0]), dayjs(next[1])]);
range.value = bounded;
emit('change', bounded);
}
/** 选定某天:写回 v-model、月历跳到该月,并通知页面按当天刷新 */
function pickDay(next: Dayjs) {
const value = next.startOf('day');
day.value = value;
panelDate.value = value;
emit('change', dayBounds(value));
}
/** 「前一天 / 后一天」 */
function shiftDay(amount: number) {
pickDay(day.value.add(amount, 'day'));
}
/** 月历点选。翻月走自定义表头直接改 panelDate,不会进这里;source 判断只是兜底 */
function onCalendarSelect(date: Dayjs, info: { source: string }) {
if (info?.source && info.source !== 'date') return;
pickDay(date);
}
/** 月历翻页:只换显示的月份,不改已选日期;禁未来时最多翻到本月 */
function shiftPanel(amount: number, unit: 'month' | 'year') {
let target = panelDate.value.add(amount, unit);
if (props.disabledFuture && target.isAfter(todayDayjs(), 'month')) {
target = todayDayjs();
}
panelDate.value = target;
}
</script>
<template>
<div class="search-time-mode-picker">
<Tabs :active-key="mode" size="small" @change="onTabChange">
<template v-if="label" #leftExtra>
<span class="search-time-mode-picker__label">{{ label }}</span>
</template>
<TabPane key="range" tab="日期范围">
<SearchTimeRangePicker
:value="range"
:format="format"
:get-popup-container="getPopupContainer"
:allow-clear="false"
class="w-full max-w-[420px]"
@change="onRangeChange"
/>
</TabPane>
<TabPane key="day" tab="按天">
<div class="search-time-mode-picker__day">
<div class="search-time-mode-picker__calendar">
<Calendar
:value="panelDate"
:fullscreen="false"
:disabled-date="isDisabledDay"
@select="onCalendarSelect"
>
<template #headerRender>
<div class="stmp-header">
<button
type="button"
class="stmp-header__nav"
aria-label="上一年"
title="上一年"
@click="shiftPanel(-1, 'year')"
>
<IconifyIcon icon="lucide:chevrons-left" />
</button>
<button
type="button"
class="stmp-header__nav"
aria-label="上个月"
title="上个月"
@click="shiftPanel(-1, 'month')"
>
<IconifyIcon icon="lucide:chevron-left" />
</button>
<span class="stmp-header__title">{{ panelDate.format('YYYY年M月') }}</span>
<button
type="button"
class="stmp-header__nav"
aria-label="下个月"
title="下个月"
:disabled="!canPanelForward"
@click="shiftPanel(1, 'month')"
>
<IconifyIcon icon="lucide:chevron-right" />
</button>
<button
type="button"
class="stmp-header__nav"
aria-label="下一年"
title="下一年"
:disabled="!canPanelForward"
@click="shiftPanel(1, 'year')"
>
<IconifyIcon icon="lucide:chevrons-right" />
</button>
</div>
</template>
<template #dateFullCellRender="{ current }">
<div class="stmp-cell" :class="cellClass(current)">
{{ current.date() }}
</div>
</template>
</Calendar>
</div>
<div class="search-time-mode-picker__summary">
<span class="text-xs text-muted-foreground">已选日期</span>
<span class="search-time-mode-picker__day-label">{{ dayLabel }}</span>
<span class="text-xs text-muted-foreground">统计区间:当天 00:00:00 ~ 23:59:59</span>
<Space :size="8" class="mt-2">
<Button size="small" @click="shiftDay(-1)">前一天</Button>
<Button size="small" @click="pickDay(todayDayjs())">今天</Button>
<Button size="small" :disabled="!canNextDay" @click="shiftDay(1)">后一天</Button>
</Space>
</div>
</div>
</TabPane>
</Tabs>
</div>
</template>
<style scoped lang="scss">
.search-time-mode-picker {
width: 100%;
// tab 栏贴近内容,放在筛选框里不留大块空白
:deep(.ant-tabs-nav) {
margin-bottom: 12px;
}
}
.search-time-mode-picker__label {
margin-right: 16px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.search-time-mode-picker__day {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
}
.search-time-mode-picker__calendar {
width: 280px;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 8px;
// 自定义格子没有 antd 的 cell-inner 类,悬停底色要自己补
:deep(.ant-picker-cell:hover) .stmp-cell:not(.is-picked) {
background: hsl(var(--accent));
}
}
.search-time-mode-picker__summary {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.search-time-mode-picker__day-label {
font-size: 16px;
font-weight: 600;
color: hsl(var(--foreground));
}
.stmp-header {
display: flex;
gap: 2px;
align-items: center;
padding: 8px;
}
.stmp-header__title {
flex: 1;
font-weight: 600;
color: hsl(var(--foreground));
text-align: center;
}
.stmp-header__nav {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: hsl(var(--muted-foreground));
cursor: pointer;
background: transparent;
border: none;
border-radius: 4px;
&:hover:not(:disabled) {
color: hsl(var(--primary));
background: hsl(var(--accent));
}
&:disabled {
cursor: not-allowed;
opacity: 0.35;
}
}
// 不设 color,沿用 antd 格子自带的「本月 / 非本月 / 禁用」文字色
.stmp-cell {
// 盖过 antd 格子 ::before 的禁用底色条
position: relative;
z-index: 2;
display: inline-block;
min-width: 24px;
height: 24px;
line-height: 24px;
border-radius: 4px;
transition:
background 0.2s,
color 0.2s;
&.is-today {
box-shadow: inset 0 0 0 1px hsl(var(--primary));
}
&.is-picked {
color: hsl(var(--primary-foreground, 0 0% 100%));
background: hsl(var(--primary));
}
}
</style>

View File

@@ -0,0 +1,93 @@
import type { Dayjs } from 'dayjs';
import type { Ref } from 'vue';
import { computed, ref, shallowRef, watch } from 'vue';
import dayjs from 'dayjs';
import { todayDayjs, toSearchDayBounds } from '#/utils/search-time-range';
/**
* 时间筛选 tab
* range=日期范围(沿用 SearchTimeRangePicker),day=按天(单日 00:00:00 ~ 23:59:59)
*/
export type SearchTimeMode = 'day' | 'range';
export interface UseSearchTimeModeOptions {
/**
* localStorage 键前缀,建议 `<模块>.page`(如 `reconciliation.page`)
* 实际写入 `${storageKey}.search_time_mode`(当前 tab)与 `${storageKey}.search_day`(按天所选日期)
*/
storageKey: string;
/** 「日期范围」tab 的区间:由页面自己维护(要不要缓存由页面决定),这里只读不写 */
range: Ref<[Dayjs, Dayjs]>;
/** 没有本地记忆时默认停在哪个 tab,默认「日期范围」,和改版前的页面表现一致 */
defaultMode?: SearchTimeMode;
}
/** 读取记忆的 tab;值不认识(被手改或历史脏数据)时回退默认,避免渲染出没有选中项的 tab */
function readMode(key: string, fallback: SearchTimeMode): SearchTimeMode {
try {
const raw = localStorage.getItem(key);
if (raw === 'day' || raw === 'range') return raw;
} catch {
// localStorage 不可用(隐私模式等)时静默回退默认值
}
return fallback;
}
/** 读取记忆的「按天」日期;没有记录或解析失败时取今天 */
function readDay(key: string): Dayjs {
try {
const raw = localStorage.getItem(key);
if (raw) {
const d = dayjs(raw);
if (d.isValid()) return d.startOf('day');
}
} catch {
// 同上,读取失败按今天处理
}
return todayDayjs();
}
/** 写入失败(容量满、隐私模式)不影响筛选本身,忽略即可 */
function writeStorage(key: string, value: string) {
try {
localStorage.setItem(key, value);
} catch {
// ignore
}
}
/**
* 时间筛选 tab 记忆 Hook,配合 SearchTimeModePicker 使用
*
* 为什么封装:记住停在哪个 tab、记住按天选的日期、按当前 tab 算出实际查询区间,
* 每个接入页面都要做这三件事,统一放这里,页面只传 storageKey 和自己的范围 ref。
* 初始化时同步读 localStorage,保证页面首屏请求就用上次的 tab,不会先按默认 tab 查一遍再切过去。
*
* @returns mode 当前 tab;day「按天」所选日期;
* searchTime 当前 tab 实际生效的查询区间(按天时为当天 00:00:00 ~ 23:59:59)
*/
export function useSearchTimeMode(options: UseSearchTimeModeOptions) {
const modeKey = `${options.storageKey}.search_time_mode`;
const dayKey = `${options.storageKey}.search_day`;
const mode = ref<SearchTimeMode>(
readMode(modeKey, options.defaultMode ?? 'range'),
);
// Dayjs 是不可变对象,用 shallowRef 避免被深度代理
const day = shallowRef<Dayjs>(readDay(dayKey));
watch(mode, (value) => writeStorage(modeKey, value));
watch(day, (value) => writeStorage(dayKey, value.format('YYYY-MM-DD')));
const searchTime = computed<[Dayjs, Dayjs]>(() =>
mode.value === 'day'
? toSearchDayBounds([day.value, day.value])
: options.range.value,
);
return { day, mode, searchTime };
}

View File

@@ -2,10 +2,18 @@ import { initPreferences } from '@vben/preferences';
import { unmountGlobalLoading } from '@vben/utils';
import { overridesPreferences, preferencesExtension } from './preferences';
import {
isChunkLoadError,
reloadForChunkError,
setupChunkLoadRecovery,
} from './utils/chunk-load-recovery';
// eslint-disable-next-line n/no-extraneous-import
import '@fortawesome/fontawesome-free/css/all.min.css';
// 必须在动态 import('./bootstrap') 之前注册:发版后旧 bootstrap-*.js 404 时否则会一直卡在全局 loading
setupChunkLoadRecovery();
/**
* 应用初始化完成之后再进行页面加载渲染
*/
@@ -32,4 +40,11 @@ async function initApplication() {
unmountGlobalLoading();
}
initApplication();
initApplication().catch((error) => {
// bootstrap 等动态模块拉取失败时:硬刷新拉最新资源,避免全局 loading 永不关闭
if (isChunkLoadError(error)) {
reloadForChunkError(undefined, undefined, 'initApplication.catch');
return;
}
console.error(error);
});

View File

@@ -6,6 +6,8 @@ import {
import { resetStaticRoutes } from '@vben/utils';
import { setupChunkLoadRecovery } from '#/utils/chunk-load-recovery';
import { createRouterGuard } from './guard';
import { routes } from './routes';
@@ -36,5 +38,7 @@ const resetRoutes = () => resetStaticRoutes(router, routes);
// 创建路由守卫
createRouterGuard(router);
// 菜单懒加载 chunk 在发版后失效时自动硬刷新(与 main 里的全局监听互补)
setupChunkLoadRecovery(router);
export { resetRoutes, router };

View File

@@ -4,6 +4,8 @@ import { message, notification } from 'ant-design-vue';
import { debounce } from 'lodash-es';
import { defineStore } from 'pinia';
import { useUserStore as useVbenUserStore } from '@vben/stores';
import { useChatStore } from '#/views/business/chat/stores/chat';
import { useUserStore } from '#/views/business/chat/stores/user';
import { sendMessage } from '#/views/business/chat/utils/request';
@@ -160,12 +162,15 @@ export const usePrescriptionStore = defineStore('prescription', () => {
return totalProductCost.value + processingFee.value;
});
/** 实际履约/开方门店:自定义挂号店时用 selectedStoreId,否则用医生当前店 */
const resolvePrescribingStoreId = () => {
if (sendMode.value === 1 && selectedStoreId.value) {
return selectedStoreId.value;
return Number(selectedStoreId.value);
}
return myStoreId.value;
return Number(myStoreId.value || 0);
};
/** 供模板绑定:煎药预设等按此门店隔离加载 */
const prescribingStoreId = computed(() => resolvePrescribingStoreId());
const fetchStoreSeeRate = async () => {
try {
@@ -407,13 +412,33 @@ export const usePrescriptionStore = defineStore('prescription', () => {
}
};
/**
* 拉取医生门店列表并解析当前履约门店。
* 为什么:旧逻辑每次把 myStoreId 强制写成 res[0],换店/登录态门店会被冲掉,
* 煎药预设按 doctor+store 隔离时就会表现为「以前加的方案不见了」。
*/
const getMyStoreList = async () => {
try {
const res = await getMyStoreListApi();
myStoreList.value = res;
if (res.length > 0) {
myStoreId.value = res[0].id;
myStoreList.value = res || [];
if (!myStoreList.value.length) {
console.log('获取诊所列表成功(空)');
return;
}
// 优先:当前已选 → 后台登录态门店 → 本地缓存;最后才回落列表第一项
const vbenStoreId = Number(
useVbenUserStore().userInfo?.store_id || 0,
);
const preferred =
Number(myStoreId.value || 0) ||
vbenStoreId ||
Number(localStorage.getItem('myStoreId') || 0);
const hit = myStoreList.value.find(
(s: any) => Number(s.id) === preferred,
);
myStoreId.value = hit
? Number(hit.id)
: Number(myStoreList.value[0].id);
console.log('获取诊所列表成功');
} catch (error) {
console.error('获取诊所列表失败:', error);
@@ -1015,6 +1040,11 @@ export const usePrescriptionStore = defineStore('prescription', () => {
const oldRuleType = ruleType.value;
const oldProcessRuleId = processRuleId.value;
const oldChildProcessRuleId = childProcessRuleId.value;
// 预设整包回填时同一次已带下级 ID,勿误清空(对齐接诊页 / 小程序)
const keepChild =
payload.childProcessRuleId != null && Number(payload.childProcessRuleId) > 0;
const keepNote =
payload.processRuleNoteId != null && Number(payload.processRuleNoteId) > 0;
if (payload.ruleType === 2 && payload.ruleType !== oldRuleType) {
if (processRuleList.value.length === 0) {
@@ -1030,13 +1060,17 @@ export const usePrescriptionStore = defineStore('prescription', () => {
processRuleNoteList.value = [];
}
if (payload.processRuleId != null && payload.processRuleId !== oldProcessRuleId) {
childProcessRuleId.value = undefined;
processRuleNoteId.value = undefined;
if (!keepChild) {
childProcessRuleId.value = undefined;
processRuleNoteId.value = undefined;
}
childProcessRuleList.value = [];
processRuleNoteList.value = [];
}
if (payload.childProcessRuleId != null && payload.childProcessRuleId !== oldChildProcessRuleId) {
processRuleNoteId.value = undefined;
if (!keepNote) {
processRuleNoteId.value = undefined;
}
processRuleNoteList.value = [];
}
@@ -1446,6 +1480,8 @@ export const usePrescriptionStore = defineStore('prescription', () => {
registerStoreInfo,
selectedStoreId,
sendMode,
resolvePrescribingStoreId,
prescribingStoreId,
fetchRegisterStoreInfo,
toggleStoreSelection,
currentStoreName,

View File

@@ -138,13 +138,23 @@ export const usePrescriptionStore = () => {
await Promise.all([getMyStoreList(), getDrugUseListData(), getProcessRuleListData(), getPatientInfo()])
}
/**
* 拉取门店并保留已选/本地缓存门店,避免每次重置为列表第一项导致预设按店查空
*/
const getMyStoreList = async () => {
try {
const res = await getMyStoreListApi()
myStoreList.value = res
if (res.length > 0) {
myStoreId.value = res[0].id
}
myStoreList.value = res || []
if (!myStoreList.value.length) return
const preferred =
Number(myStoreId.value || 0) ||
Number(localStorage.getItem("myStoreId") || 0)
const hit = myStoreList.value.find(
(s: any) => Number(s.id) === preferred,
)
myStoreId.value = hit
? Number(hit.id)
: Number(myStoreList.value[0].id)
} catch (error) {
console.error("获取诊所列表失败:", error)
}

View File

@@ -22,6 +22,9 @@ interface Window {
deviceName?: string;
html: string;
silent?: boolean;
/** 纸张尺寸(微米),如 80mm 宽 = { width: 80000, height: 200000 } */
pageSize?: { height: number; width: number } | string;
preferCSSPageSize?: boolean;
}) => Promise<{ message?: string; ok: boolean }>;
getSettings: () => Promise<{ printerName: string; silentPrint: boolean }>;
setSettings: (

View File

@@ -70,11 +70,13 @@ export const desktop = {
async getPrinters(): Promise<DesktopPrinter[]> {
return (await api()?.getPrinters()) ?? [];
},
/** 静默打印:传入拼好的完整 HTML,由主进程隐藏窗口出纸 */
/** 静默打印:传入拼好的完整 HTML,由主进程隐藏窗口出纸;可带 pageSize 指定热敏纸宽 */
async printHTML(options: {
deviceName?: string;
html: string;
silent?: boolean;
pageSize?: { height: number; width: number } | string;
preferCSSPageSize?: boolean;
}): Promise<{ message?: string; ok: boolean }> {
return (
(await api()?.printHTML(options)) ?? {

View File

@@ -0,0 +1,119 @@
/**
* 发版后旧 hash 的懒加载 chunk 失效时的恢复逻辑。
* 为什么要写:Vite 产物文件名带 hash,用户长时间开着页再点菜单/二次进入时,
* 浏览器仍按旧入口去拉已删除的 js,表现为一直 loading、控制台 Failed to fetch dynamically imported module,
* 手动刷新又能进——这里自动硬刷新一次拿到最新 index 与 chunk。
*/
import type { Router } from 'vue-router';
const RELOAD_KEY = 'xk_admin_chunk_reload_at';
/** 同一会话内防死循环:15 秒内只允许自动刷新一次 */
const RELOAD_COOLDOWN_MS = 15_000;
/**
* 判断是否为「动态 chunk 拉取失败」类错误(含多浏览器文案差异)
*/
export function isChunkLoadError(error: unknown): boolean {
const msg = String(
(error as { message?: string })?.message || error || '',
);
return (
msg.includes('Failed to fetch dynamically imported module') ||
msg.includes('Importing a module script failed') ||
msg.includes('error loading dynamically imported module')
);
}
/**
* 是否允许本次自动硬刷新(用 sessionStorage 防无限 reload)
*/
function canAutoReload(): boolean {
try {
const last = Number(sessionStorage.getItem(RELOAD_KEY) || 0);
if (last && Date.now() - last < RELOAD_COOLDOWN_MS) {
return false;
}
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
return true;
} catch {
return true;
}
}
/**
* 执行一次硬刷新;若传入目标路由则跳到该地址(兼容 hash 模式)
*/
export function reloadForChunkError(
router?: Router,
toFullPath?: string,
source = 'unknown',
): void {
if (!canAutoReload()) {
console.warn(
'[chunk-load-recovery] 短时间内已自动刷新过,跳过,避免死循环',
source,
);
return;
}
console.info(
'[chunk-load-recovery] 检测到静态资源/chunk 加载失败,正在刷新以拉取最新版本…',
source,
);
if (router && toFullPath) {
try {
window.location.assign(router.resolve(toFullPath).href);
return;
} catch {
// resolve 失败则退回整页 reload
}
}
window.location.reload();
}
/**
* 注册全局与路由级 chunk 失败恢复:
* - vite:preloadError:Vite 预加载失败
* - unhandledrejection:如 main 里 await import('./bootstrap') 失败(此时路由可能尚未就绪)
* - router.onError:菜单切换时懒加载页面组件失败
*
* 可分两次调用:main 里先挂全局监听,router 创建后再补挂 onError(用独立标记防重复)。
*/
export function setupChunkLoadRecovery(router?: Router): void {
if (typeof window === 'undefined') {
return;
}
const w = window as Window & {
__xkChunkLoadRecoveryGlobal?: boolean;
__xkChunkLoadRecoveryRouter?: boolean;
};
// 全局监听只注册一次(main 最早调用时 router 可能还不存在)
if (!w.__xkChunkLoadRecoveryGlobal) {
w.__xkChunkLoadRecoveryGlobal = true;
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault();
reloadForChunkError(
undefined,
undefined,
'vite:preloadError',
);
});
window.addEventListener('unhandledrejection', (event) => {
if (!isChunkLoadError(event.reason)) {
return;
}
reloadForChunkError(undefined, undefined, 'unhandledrejection');
});
}
// 路由级监听在拿到 router 后再挂,保证菜单懒加载失败能跳到目标 fullPath
if (router && !w.__xkChunkLoadRecoveryRouter) {
w.__xkChunkLoadRecoveryRouter = true;
router.onError((error, to) => {
if (!isChunkLoadError(error)) {
return;
}
reloadForChunkError(router, to?.fullPath, 'router.onError');
});
}
}

View File

@@ -0,0 +1,73 @@
/**
* PC 图片压缩阈值(系统配置项 pc_image_compress_threshold)
*
* 为什么要单独抽一层:
* 上传入口分布在表单上传、富文本、聊天发图等多处,阈值现在由后台「系统配置-上传」控制,
* 不能在各个组件里各拉一次接口。这里做进程内缓存 + 并发去重,全站只打一次 get-by-keys。
*
* 取值约定:
* - 未配置 / 配置非法 → 回落默认 0.8MB
* - 配置为 0 → 关闭压缩提示(所有图都直接上传)
*/
import { getSystemConfigByKeys } from '#/views/system/system-config/api';
import { IMAGE_COMPRESS_THRESHOLD } from './image-compress';
export const IMAGE_COMPRESS_THRESHOLD_CONFIG_KEY =
'pc_image_compress_threshold';
/** 默认阈值(字节),与后台未配置时的落库默认值保持一致 */
export const DEFAULT_IMAGE_COMPRESS_THRESHOLD = IMAGE_COMPRESS_THRESHOLD;
let cachedThreshold: null | number = null;
let pendingFetch: null | Promise<number> = null;
/**
* 把后台配置值规范成字节数
* 非法值(null/空串/负数/非数字)一律回落默认,避免出现 NaN 让所有图都被判定为「过大」
*/
export function resolveImageCompressThreshold(raw: unknown): number {
if (raw === null || raw === undefined || raw === '') {
return DEFAULT_IMAGE_COMPRESS_THRESHOLD;
}
const bytes = Math.floor(Number(raw));
if (!Number.isFinite(bytes) || bytes < 0) {
return DEFAULT_IMAGE_COMPRESS_THRESHOLD;
}
return bytes;
}
/** 直接写入缓存(后台保存后调用,让当前会话立即生效) */
export function setImageCompressThreshold(raw: unknown) {
cachedThreshold = resolveImageCompressThreshold(raw);
}
/**
* 读取阈值:已缓存直接返回;并发调用共享同一个请求
* 接口异常时回落默认值并缓存,避免每次上传都白等一次失败请求
*/
export async function getImageCompressThreshold(): Promise<number> {
if (cachedThreshold !== null) {
return cachedThreshold;
}
if (!pendingFetch) {
pendingFetch = (async () => {
try {
const res = await getSystemConfigByKeys([
IMAGE_COMPRESS_THRESHOLD_CONFIG_KEY,
]);
setImageCompressThreshold(res?.[IMAGE_COMPRESS_THRESHOLD_CONFIG_KEY]);
} catch {
setImageCompressThreshold(null);
}
return cachedThreshold ?? DEFAULT_IMAGE_COMPRESS_THRESHOLD;
})();
}
return pendingFetch;
}
/** 清掉缓存,下次读取重新打接口(保存配置后调用) */
export function resetImageCompressThresholdCache() {
cachedThreshold = null;
pendingFetch = null;
}

View File

@@ -1,18 +1,49 @@
export const IMAGE_COMPRESS_THRESHOLD = 0.8 * 1024 * 1024;
/**
* PC 端图片压缩:统一转 WebP + 体积兜底
*
* 为什么统一转 WebP:
* 1. 原实现里 PNG 原样输出 PNG,而 PNG 是无损格式,canvas 的 quality 参数对它无效,
* 浏览器重新编码 PNG 只会存 32 位真彩,常出现「越压越大」;
* 2. WebP 有损编码对照片、截图都能显著变小,且支持透明通道,PNG 透明背景不会丢。
* 3. 浏览器不支持 WebP 编码时(少数老内核)canvas 会静默回落到 PNG,这里按实际
* 产出的 blob.type 决定文件后缀,避免「内容是 PNG、后缀是 .webp」。
*
* 兜底原则:压完体积没有变小就直接用原图(skippedReason=no_gain),
* 保证「压缩」永远不会把图变大。
*/
/** 默认阈值 0.8MB(整数字节);实际值由系统配置 pc_image_compress_threshold 决定 */
export const IMAGE_COMPRESS_THRESHOLD = Math.round(0.8 * 1024 * 1024);
export interface CompressImageOptions {
/** 目标体积上限(字节),超过则继续降质量 */
maxBytes?: number;
/** 长边上限,超出按比例缩小(缩放是最有效的瘦身手段) */
maxWidth?: number;
/** 质量下限,降到该值就不再降 */
minQuality?: number;
/** 起始质量 */
initialQuality?: number;
onProgress?: (message: string) => void;
}
/** 压缩跳过原因:gif=动图不压;no_gain=压完没变小 */
export type CompressSkipReason = 'gif' | 'no_gain';
export interface CompressImageResult {
file: File;
/** 是否直接沿用了原图(未采用压缩结果) */
skipped: boolean;
skippedReason?: CompressSkipReason;
}
/** 产出 MIME → 文件后缀,后缀必须跟真实内容一致 */
const OUTPUT_EXT_MAP: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
};
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -72,19 +103,45 @@ function canvasToBlob(
});
}
function getOutputType(file: File): string {
if (file.type === 'image/png' || file.type === 'image/webp') {
return file.type;
/** 探测当前浏览器能否把 canvas 编码成 WebP(结果缓存,只探一次) */
let webpEncodeSupport: boolean | null = null;
function canEncodeWebp(): boolean {
if (webpEncodeSupport !== null) {
return webpEncodeSupport;
}
return 'image/jpeg';
try {
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
webpEncodeSupport = canvas
.toDataURL('image/webp')
.startsWith('data:image/webp');
} catch {
webpEncodeSupport = false;
}
return webpEncodeSupport;
}
function buildCompressedFile(
blob: Blob,
originalFile: File,
outputType: string,
): File {
const ext = outputType === 'image/png' ? '.png' : '.jpg';
/**
* 期望的产出格式:优先 WebP;不支持时保持原格式(PNG 保透明,其余转 JPG)
*/
function getPreferredOutputType(file: File): string {
if (canEncodeWebp()) {
return 'image/webp';
}
return file.type === 'image/png' ? 'image/png' : 'image/jpeg';
}
/**
* 用实际产出的 blob.type 组装 File,保证后缀与内容一致
* 浏览器不支持 WebP 时会回落成 PNG/JPG,这里要跟着变
*/
function buildCompressedFile(blob: Blob, originalFile: File): File {
const outputType = OUTPUT_EXT_MAP[blob.type]
? blob.type
: (blob.type || 'image/jpeg');
const ext = OUTPUT_EXT_MAP[outputType] ?? '.jpg';
const baseName = originalFile.name.replace(/\.[^.]+$/, '') || 'image';
return new File([blob], `${baseName}${ext}`, {
type: outputType,
@@ -104,8 +161,9 @@ export async function compressImageFile(
onProgress,
} = options;
// GIF 动图压缩会丢帧,直接沿用原图
if (file.type === 'image/gif') {
return { file, skipped: true };
return { file, skipped: true, skippedReason: 'gif' };
}
onProgress?.('正在加载图片…');
@@ -131,9 +189,12 @@ export async function compressImageFile(
if (!ctx) {
throw new Error('无法创建 Canvas 上下文');
}
// 缩放后的边缘会更平滑,避免直接 drawImage 产生锯齿
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, width, height);
const outputType = getOutputType(file);
const outputType = getPreferredOutputType(file);
let quality = initialQuality;
onProgress?.(`正在压缩(质量 ${Math.round(quality * 100)}%)…`);
let blob = await canvasToBlob(canvas, outputType, quality);
@@ -144,6 +205,10 @@ export async function compressImageFile(
blob = await canvasToBlob(canvas, outputType, quality);
}
const compressedFile = buildCompressedFile(blob, file, outputType);
return { file: compressedFile, skipped: false };
// 兜底:压完没有变小(多见于已优化过的图片)就直接用原图,绝不把图压大
if (blob.size >= file.size) {
return { file, skipped: true, skippedReason: 'no_gain' };
}
return { file: buildCompressedFile(blob, file), skipped: false };
}

View File

@@ -1,18 +1,26 @@
import type { PrepareImageUploadMeta } from '#/utils/prepare-image-upload';
import { createApp, h } from 'vue';
import ImageCompressModal, {
type ImageCompressModalResult,
} from '#/components/modal/ImageCompressModal.vue';
import type { PrepareImageUploadMeta } from '#/utils/prepare-image-upload';
export interface OpenImageCompressModalResult {
file: File | null;
meta?: PrepareImageUploadMeta;
}
/**
* 以命令式方式挂载压缩弹窗(上传入口分散在表单/富文本/聊天,无法都用 useVbenModal 的组件作用域)
* 弹窗内部自身基于 useVbenModal 渲染,这里只负责挂载与 Promise 收敛
*
* @param file 待压缩的原始文件
* @param maxBytes 目标体积上限(字节),来自系统配置的压缩阈值
*/
export function openImageCompressModal(
file: File,
maxBytes?: number,
): Promise<OpenImageCompressModalResult> {
return new Promise((resolve) => {
const container = document.createElement('div');
@@ -22,6 +30,7 @@ export function openImageCompressModal(
render() {
return h(ImageCompressModal, {
file,
maxBytes,
onResolve: (result: ImageCompressModalResult) => {
app.unmount();
container.remove();

View File

@@ -1,4 +1,4 @@
import { IMAGE_COMPRESS_THRESHOLD } from '#/utils/image-compress';
import { getImageCompressThreshold } from '#/utils/image-compress-config';
import { openImageCompressModal } from '#/utils/open-image-compress-modal';
export interface PrepareImageUploadMeta {
@@ -7,10 +7,20 @@ export interface PrepareImageUploadMeta {
usedCompress: boolean;
}
/**
* 上传前的图片准备:超过后台配置的阈值才弹压缩窗,用户可选择压缩图或原图
*
* 阈值来自系统配置(pc_image_compress_threshold):
* - 阈值 > 0:体积超过阈值时弹窗
* - 阈值 = 0:后台关闭了压缩提示,直接上传原图
* 返回值 file 为 null 表示用户取消了本次上传(调用方应终止上传)。
*/
export async function prepareImageForUpload(
file: File,
): Promise<{ file: File | null; meta?: PrepareImageUploadMeta }> {
if (file.size <= IMAGE_COMPRESS_THRESHOLD) {
const threshold = await getImageCompressThreshold();
if (threshold <= 0 || file.size <= threshold) {
return {
file,
meta: {
@@ -20,5 +30,5 @@ export async function prepareImageForUpload(
};
}
return openImageCompressModal(file);
return openImageCompressModal(file, threshold);
}

View File

@@ -49,26 +49,24 @@ export function matchAccept(file: File, accept: string): boolean {
}
/**
* 从粘贴事件中收集 File(files + items 截图等)
* 从粘贴事件中收集 File(截图常见于 items;本机复制文件常见于 files)
* 为什么不能两个源都全量合并:同一张截图会同时出现在 files 与 items 里,
* 且 name/lastModified 经常不一致(一边空名、一边 image.png),旧去重键会当成两张,
* 表现为「粘贴一次上传两张」。
*/
export function collectPasteFiles(event: ClipboardEvent): File[] {
const result: File[] = [];
const seen = new Set<string>();
const push = (file: File | null | undefined) => {
if (!file) return;
const key = `${file.name}|${file.size}|${file.type}|${file.lastModified}`;
// 用 size+type 去重:截图双通道时这两项稳定,name/lastModified 不稳定
const key = `${file.size}|${file.type || ''}`;
if (seen.has(key)) return;
seen.add(key);
result.push(file);
};
const files = event.clipboardData?.files;
if (files?.length) {
for (let i = 0; i < files.length; i++) {
push(files.item(i));
}
}
// 优先 items(Ctrl+V 截图主要在这里)
const items = event.clipboardData?.items;
if (items?.length) {
for (let i = 0; i < items.length; i++) {
@@ -78,6 +76,15 @@ export function collectPasteFiles(event: ClipboardEvent): File[] {
}
}
}
// items 没拿到再回落 files(部分浏览器只填 FileList)
if (!result.length) {
const files = event.clipboardData?.files;
if (files?.length) {
for (let i = 0; i < files.length; i++) {
push(files.item(i));
}
}
}
return result;
}

View File

@@ -2175,6 +2175,7 @@ const cancelSaveCommonPrescription = () => {
<ChineseMedicineConfig
v-if="prescriptionStore.activeCategory === 1"
:key="`cmc-chat-${prescriptionStore.currentRegisterId || 0}-${prescriptionStore.prescribingStoreId || 0}`"
:rule-type="prescriptionStore.ruleType"
:package-method-id="prescriptionStore.packageMethodId"
:process-rule-id="prescriptionStore.processRuleId"
@@ -2185,6 +2186,8 @@ const cancelSaveCommonPrescription = () => {
:process-rule-list="prescriptionStore.processRuleList"
:child-process-rule-list="prescriptionStore.childProcessRuleList"
:process-rule-note-list="prescriptionStore.processRuleNoteList"
:store-id="prescriptionStore.prescribingStoreId"
:auto-apply-default="true"
class="mb-5 border-t pt-4"
@change="prescriptionStore.handleChineseConfigChange"
/>

View File

@@ -84,7 +84,13 @@ async function load() {
}
}
function onTimeChange() {
/**
* 时间变更后刷新列表;优先用 change 载荷写回,避免偶发读到旧 timeRange。
*/
function onTimeChange(range?: [string, string] | null) {
if (range?.[0] && range?.[1]) {
timeRange.value = range;
}
page.value = 1;
void load();
}

View File

@@ -81,13 +81,45 @@ export async function deleteOrder(data: Record<string, any>) {
}
/**
* 订单发货
* 订单发货(可同时提交收件信息写回订单)
* @param data
*/
export async function sendOrder(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}ware-send`, data);
}
/**
* 发货弹窗打开:拉取订单当前收件信息(库内最新)
*/
export async function getShipReceiver(orderId: number) {
return requestClient.get<{
order_id: number;
order_no: string;
delivery_method: number;
express_name: string;
express_mobile: string;
express_region: string;
express_address: string;
}>(`${prefix}ship-receiver`, {
params: { order_id: orderId },
});
}
/**
* 发货弹窗「用诊所地址」:按订单拉取所属诊所联系人/手机/地址
*/
export async function getStoreShipAddress(orderId: number) {
return requestClient.get<{
express_name: string;
express_mobile: string;
express_region: string;
express_address: string;
store_name: string;
}>(`${prefix}store-ship-address`, {
params: { order_id: orderId },
});
}
/**
* 订单物流追踪
* @param data

View File

@@ -530,6 +530,8 @@ onMounted(() => {
异常
</Tag>
</span>
<!-- 运费金额:与包邮开关区分,列表接口字段 trans_expenses -->
<span>运费 ¥{{ row.trans_expenses ?? '0.00' }}</span>
</div>
</div>
<div class="po-card__footer-actions">
@@ -914,8 +916,9 @@ onMounted(() => {
.po-card__amount-parts {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 4px 16px;
/* 药品/挂号/诊疗/采购/运费文案短,三列更紧凑不浪费横向空间 */
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 4px 12px;
align-items: center;
font-size: 13px;
color: hsl(var(--muted-foreground));

View File

@@ -3,12 +3,14 @@
* 独立「物流动态」弹窗:只请求 express-detail,不展示整单其它信息。
* 仓名为空时 Tab 仅显示「包裹N」,与后端诊所开关抹名约定一致。
* 已发货包裹展示脱敏收件人/查件手机号,并支持修改手机号与快递单号。
* 第三方轨迹查询失败时展示 last_sync_error(快递100返回文案)。
*/
import { computed, reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Alert,
Button,
Descriptions,
Form,
@@ -112,6 +114,13 @@ function getColor(status: any) {
}
}
/**
* 读取运单上的第三方查询失败文案(无轨迹时展示给运营)
*/
function syncFailMessage(express: Record<string, any> | null | undefined): string {
return String(express?.last_sync_error || '').trim();
}
async function loadExpress(id: number, keepActive = false) {
loading.value = true;
try {
@@ -300,10 +309,17 @@ const [Modal, modalApi] = useVbenModal({
<p>{{ detail.detail }}</p>
</Timeline.Item>
</Timeline>
<div v-else class="text-gray-400">暂无物流轨迹</div>
<Alert
v-else-if="syncFailMessage(pkg.express)"
type="warning"
show-icon
:message="syncFailMessage(pkg.express)"
class="sync-fail-alert"
/>
<div v-else class="track-empty">暂无物流轨迹</div>
</div>
</template>
<div v-else class="text-gray-400 py-2">本包裹尚未发货</div>
<div v-else class="track-empty py-2">本包裹尚未发货</div>
</Tabs.TabPane>
</Tabs>
<template v-else>
@@ -383,7 +399,14 @@ const [Modal, modalApi] = useVbenModal({
<p>{{ detail.detail }}</p>
</Timeline.Item>
</Timeline>
<div v-else class="text-gray-400">暂无物流轨迹</div>
<Alert
v-else-if="syncFailMessage(expressDetail)"
type="warning"
show-icon
:message="syncFailMessage(expressDetail)"
class="sync-fail-alert"
/>
<div v-else class="track-empty">暂无物流轨迹</div>
</div>
</template>
</template>
@@ -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%);
}
</style>

View File

@@ -1,23 +1,175 @@
<script lang="ts" setup>
/**
* 商品订单发货弹窗
* 确认时自行校验并调发货接口;不再走已弃用的 validateAndSubmitForm(会表现为点确定无反应)
* 商品订单发货弹窗(左右分栏)
* 左:收件信息默认只读展示,可改/取消;右:快递单号/公司/备注
* 注意:收件表单用 v-show 常驻挂载,避免加载中 setValues 等不到节点而卡住
*/
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Button, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { sendOrder } from '#/views/business/order/product-order/api';
import { modalFormProps } from '#/views/business/order/product-order/config/form';
import {
getShipReceiver,
getStoreShipAddress,
sendOrder,
} from '#/views/business/order/product-order/api';
import {
receiverFormProps,
shipFormProps,
} from '#/views/business/order/product-order/config/form';
const isUpdate = ref(false);
const gridApi = ref();
const orderNo = ref('');
const orderId = ref(0);
const storeAddrLoading = ref(false);
/** 打开弹窗时拉当前收件信息 */
const receiverLoading = ref(false);
/** 左侧是否处于编辑收件信息;默认展示态 */
const receiverEditing = ref(false);
/** 展示态用的收件快照(与左侧表单同步) */
const receiverView = ref({
express_name: '',
express_mobile: '',
express_region: '',
express_address: '',
});
/** 进入编辑前的快照,点「取消」时还原 */
const receiverSnapshot = ref({
express_name: '',
express_mobile: '',
express_region: '',
express_address: '',
});
const [Form, formApi] = useVbenForm(modalFormProps);
const [ReceiverForm, receiverFormApi] = useVbenForm(receiverFormProps);
const [ShipForm, shipFormApi] = useVbenForm(shipFormProps);
const fullAddressText = computed(() => {
const region = (receiverView.value.express_region || '').trim();
const detail = (receiverView.value.express_address || '').trim();
return [region, detail].filter(Boolean).join(' ') || '—';
});
/**
* 从订单行/详情解析收件信息(接口失败时的兜底)
*/
function pickReceiverFromOrder(data: Record<string, any> = {}) {
const addr = data?.address && typeof data.address === 'object' ? data.address : {};
return {
express_name: String(data?.express_name || addr.name || ''),
express_mobile: String(data?.express_mobile || addr.mobile || ''),
express_region: String(data?.express_region || addr.region || ''),
express_address: String(
data?.express_address || addr.detail_address || '',
),
};
}
/** 规范化收件四字段 */
function normalizeReceiver(next: {
express_name?: string;
express_mobile?: string;
express_region?: string;
express_address?: string;
}) {
return {
express_name: String(next.express_name || ''),
express_mobile: String(next.express_mobile || ''),
express_region: String(next.express_region || ''),
express_address: String(next.express_address || ''),
};
}
/**
* 写入展示态;表单常驻挂载,setValues 不阻塞 UI(避免挂载时序卡死)
*/
function applyReceiver(next: {
express_name?: string;
express_mobile?: string;
express_region?: string;
express_address?: string;
}) {
const payload = normalizeReceiver(next);
receiverView.value = { ...payload };
// 不 await:表单若尚未就绪也不阻塞 loading 结束
void receiverFormApi.setValues(payload);
}
/**
* 打开弹窗后请求后端当前收件信息;失败则回落列表行字段
*/
async function loadShipReceiver(fallback?: Record<string, any>) {
if (!orderId.value) return;
receiverLoading.value = true;
try {
const res = await getShipReceiver(orderId.value);
applyReceiver(res || {});
} catch (err) {
console.error(err);
if (fallback) {
applyReceiver(pickReceiverFromOrder(fallback));
}
} finally {
receiverLoading.value = false;
}
}
/** 把左侧表单当前值同步到展示态 */
async function syncReceiverViewFromForm() {
const values = await receiverFormApi.getValues();
receiverView.value = normalizeReceiver(values);
}
/** 进入收件编辑:先存快照,再把当前展示值灌进表单 */
async function enterReceiverEdit() {
if (receiverLoading.value) return;
receiverSnapshot.value = { ...receiverView.value };
await receiverFormApi.setValues({ ...receiverView.value });
receiverEditing.value = true;
}
/** 完成编辑:校验通过后写回展示态 */
async function finishReceiverEdit() {
const e = await receiverFormApi.validate();
if (!e.valid) return;
await syncReceiverViewFromForm();
receiverEditing.value = false;
}
/** 取消编辑:还原进入编辑前的快照,不保存表单改动 */
async function cancelReceiverEdit() {
applyReceiver(receiverSnapshot.value);
receiverEditing.value = false;
}
/**
* 点击「用诊所地址」:拉取诊所资料写入左侧,并切到编辑态便于确认
*/
async function fillStoreAddress() {
if (!orderId.value) {
message.warning('订单信息缺失');
return;
}
storeAddrLoading.value = true;
try {
const res = await getStoreShipAddress(orderId.value);
// 若当前是展示态,先进编辑再填,方便用户改完点完成/取消
if (!receiverEditing.value) {
receiverSnapshot.value = { ...receiverView.value };
receiverEditing.value = true;
}
applyReceiver(res || {});
message.success('已填入诊所地址');
} catch (err) {
console.error(err);
} finally {
storeAddrLoading.value = false;
}
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
@@ -26,16 +178,36 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
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 (receiverEditing.value) {
const re = await receiverFormApi.validate();
if (!re.valid) return;
await syncReceiverViewFromForm();
receiverEditing.value = false;
} else {
// 展示态也再校验一次,防止空收件直接发货
const re = await receiverFormApi.validate();
if (!re.valid) {
receiverEditing.value = true;
return;
}
}
const se = await shipFormApi.validate();
if (!se.valid) return;
const receiver = await receiverFormApi.getValues();
const ship = await shipFormApi.getValues();
const wid = Number(ship.warehouse_id);
const payload: Record<string, any> = {
...ship,
express_name: receiver.express_name,
express_mobile: receiver.express_mobile,
express_region: receiver.express_region,
express_address: receiver.express_address,
};
if (
Number.isFinite(wid) &&
wid >= 0 &&
String(values.warehouse_id) !== '[object Object]'
String(ship.warehouse_id) !== '[object Object]'
) {
payload.warehouse_id = wid;
} else {
@@ -57,12 +229,24 @@ const [Modal, modalApi] = useVbenModal({
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values, update } = modalApi.getData<Record<string, any>>() || {};
// 先重置,避免上次发货残留的 warehouse_id
formApi.resetForm();
receiverEditing.value = false;
receiverLoading.value = false;
receiverFormApi.reset();
shipFormApi.reset();
if (values) {
orderNo.value = values.order_no;
orderId.value = Number(values.order_id || values.id || 0);
isUpdate.value = update;
formApi.setValues(values);
// 先用列表字段占位,再请求后端最新收件信息覆盖
applyReceiver(pickReceiverFromOrder(values));
shipFormApi.setValues({
order_id: values.order_id,
warehouse_id: values.warehouse_id,
express_no: values.express_no || '',
express_company_code: values.express_company_code || '',
introduce: values.introduce || '',
});
loadShipReceiver(values);
}
}
},
@@ -71,8 +255,212 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal
:title="`订单:【 ${orderNo} 】发货`"
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
class="ship-modal w-[92%] md:w-[860px] lg:w-[960px] h-[85%]"
>
<Form />
<div class="ship-layout">
<!-- 左侧:收件信息(默认展示,点击切换编辑) -->
<section class="ship-pane ship-pane--receiver">
<div class="ship-pane__head">
<h4 class="ship-pane__title">收件信息</h4>
<div class="ship-pane__actions">
<Button
type="link"
size="small"
class="!px-1"
:loading="storeAddrLoading"
:disabled="receiverLoading"
@click="fillStoreAddress"
>
用诊所地址
</Button>
<template v-if="!receiverEditing">
<Button
type="link"
size="small"
class="!px-1"
:disabled="receiverLoading"
@click="enterReceiverEdit"
>
修改
</Button>
</template>
<template v-else>
<Button
type="link"
size="small"
class="!px-1"
@click="cancelReceiverEdit"
>
取消
</Button>
<Button
type="link"
size="small"
class="!px-1"
@click="finishReceiverEdit"
>
完成
</Button>
</template>
</div>
</div>
<!-- 展示态:v-show 保留节点;加载中半透明提示 -->
<div
v-show="!receiverEditing"
class="receiver-view"
:class="{ 'is-loading': receiverLoading }"
role="button"
tabindex="0"
@click="enterReceiverEdit"
@keydown.enter.prevent="enterReceiverEdit"
>
<div v-if="receiverLoading" class="receiver-loading">
收件信息加载中…
</div>
<template v-else>
<div class="receiver-view__row">
<span class="receiver-view__label">收件人</span>
<span class="receiver-view__value">
{{ receiverView.express_name || '—' }}
</span>
</div>
<div class="receiver-view__row">
<span class="receiver-view__label">手机号</span>
<span class="receiver-view__value">
{{ receiverView.express_mobile || '—' }}
</span>
</div>
<div class="receiver-view__row receiver-view__row--block">
<span class="receiver-view__label">地址</span>
<span class="receiver-view__value">{{ fullAddressText }}</span>
</div>
<p class="receiver-view__hint">点击此处可修改收件信息</p>
</template>
</div>
<!-- 编辑表单常驻挂载(v-show),避免 setValues 等不到节点 -->
<div v-show="receiverEditing" class="receiver-edit">
<ReceiverForm />
</div>
</section>
<!-- 右侧:发货物流 -->
<section class="ship-pane ship-pane--ship">
<div class="ship-pane__head">
<h4 class="ship-pane__title">发货信息</h4>
</div>
<ShipForm />
</section>
</div>
</Modal>
</template>
<style scoped>
.ship-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
min-height: 420px;
height: 100%;
}
.ship-pane {
display: flex;
flex-direction: column;
min-height: 0;
padding: 16px;
border-radius: 10px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
box-shadow: 0 0 6px hsl(var(--primary) / 10%);
}
.ship-pane--receiver {
border-color: hsl(var(--primary) / 28%);
box-shadow: 0 0 8px hsl(var(--primary) / 14%);
}
.ship-pane__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.ship-pane__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: hsl(var(--foreground));
}
.ship-pane__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.receiver-view {
flex: 1;
display: flex;
flex-direction: column;
gap: 14px;
padding: 8px 4px;
cursor: pointer;
border-radius: 8px;
outline: none;
transition: background 0.15s ease;
}
.receiver-view.is-loading {
cursor: wait;
pointer-events: none;
}
.receiver-view:hover,
.receiver-view:focus-visible {
background: hsl(var(--primary) / 0.06);
}
.receiver-view__row {
display: flex;
align-items: baseline;
gap: 12px;
}
.receiver-view__row--block {
flex-direction: column;
gap: 6px;
}
.receiver-view__label {
flex-shrink: 0;
width: 52px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.receiver-view__row--block .receiver-view__label {
width: auto;
}
.receiver-view__value {
flex: 1;
font-size: 15px;
line-height: 1.5;
color: hsl(var(--foreground));
word-break: break-all;
}
.receiver-view__hint {
margin: auto 0 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.receiver-edit {
flex: 1;
min-height: 0;
overflow: auto;
}
.receiver-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
@media (max-width: 768px) {
.ship-layout {
grid-template-columns: 1fr;
min-height: 0;
}
}
</style>

View File

@@ -1,25 +1,79 @@
import type { VbenFormProps } from '#/adapter/form';
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12', // 24栅格,
/** 发货弹窗左侧:收件信息编辑表单 */
export const receiverFormProps: VbenFormProps = {
layout: 'vertical',
commonConfig: {
formItemClass: 'col-span-12',
// 所有表单项
componentProps: {
class: 'w-full',
},
},
// handleSubmit: onSubmit,
layout: 'horizontal',
wrapperClass: 'grid-cols-12',
schema: [
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入收件人',
maxlength: 50,
},
fieldName: 'express_name',
label: '收件人',
rules: 'required',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入收件手机号',
maxlength: 11,
},
fieldName: 'express_mobile',
label: '手机号',
rules: 'required',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '省市区,可与详细地址合并填写',
maxlength: 100,
},
fieldName: 'express_region',
label: '所在地区',
},
{
component: 'Textarea',
componentProps: {
placeholder: '请输入详细地址',
rows: 3,
maxlength: 255,
showCount: true,
},
fieldName: 'express_address',
label: '详细地址',
rules: 'required',
},
],
showDefaultActions: false,
};
/** 发货弹窗右侧:物流发货表单 */
export const shipFormProps: VbenFormProps = {
layout: 'vertical',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
wrapperClass: 'grid-cols-12',
schema: [
{
component: 'VbenInput',
fieldName: 'order_id',
label: 'ID',
formItemClass: 'col-span-6',
dependencies: {
show: false,
triggerFields: ['oreder_id'],
triggerFields: ['order_id'],
},
},
{
@@ -48,7 +102,6 @@ export const modalFormProps: VbenFormProps = {
placeholder: '请选择快递公司',
},
fieldName: 'express_company_code',
formItemClass: 'col-span-6',
label: '快递公司',
rules: 'required',
// 单号变化时传入 trackingNo,组件按前缀自动匹配快递公司
@@ -65,7 +118,8 @@ export const modalFormProps: VbenFormProps = {
{
component: 'Textarea',
componentProps: {
placeholder: '请输入订单备注',
placeholder: '请输入发货备注',
rows: 3,
},
fieldName: 'introduce',
label: '发货备注',
@@ -73,6 +127,10 @@ export const modalFormProps: VbenFormProps = {
],
showDefaultActions: false,
};
/** @deprecated 兼容旧引用,发货请用 shipFormProps + receiverFormProps */
export const modalFormProps = shipFormProps;
export const modalRefundFormProps: VbenFormProps = {
layout: 'vertical',
commonConfig: {

View File

@@ -72,7 +72,11 @@ 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 ApplyPublicAccountPayModal from '#/views/finance/public-account-pay/components/apply-modal.vue';
import {
applyPublicAccountPay,
getPublicAccountPayConfig,
} from '#/views/finance/public-account-pay/api';
import {
ORDER_POPUP_Z_INDEX,
openOrderPopup,
@@ -98,6 +102,8 @@ const canViewInviterCommission = isPlatformSuperAdmin(
const hasTopTableDropDownActions = ref(false);
/** 公账出账模式:未拉到配置前不当成 order,避免按天误露出逐单确认 */
const publicAccountBillMode = ref('');
/** 公账触发时机:仅 doctor_confirm 露出「申请公账」;immediate 发方后自动走 */
const publicAccountPayTrigger = ref<'immediate' | 'doctor_confirm'>('immediate');
/** 打开挂号详情时防重复点击 */
const openingRegisterDetail = ref(false);
@@ -329,14 +335,19 @@ onMounted(() => {
if (viewMode.value === 'card') {
saleAmount(searchValues.value);
}
// 按天先用后付禁止走 confirm-by-order,行操作「公账支付」只在按订单模式显示
// 出账模式 + 触发时机:申请入口与超管「确认到账」分开控制
getPublicAccountPayConfig()
.then((cfg) => {
publicAccountBillMode.value =
cfg?.bill_mode === 'daily' ? 'daily' : 'order';
publicAccountPayTrigger.value =
String(cfg?.pay_trigger || '') === 'doctor_confirm'
? 'doctor_confirm'
: 'immediate';
})
.catch(() => {
publicAccountBillMode.value = '';
publicAccountPayTrigger.value = 'immediate';
});
});
const [FormModal, formModalApi] = useVbenModal({
@@ -355,6 +366,9 @@ const [RefundModal, RefundModalApi] = useVbenModal({
const [PublicAccountConfirmModals, publicAccountConfirmModalApi] = useVbenModal({
connectedComponent: PublicAccountConfirmModal,
});
const [ApplyPublicAccountPayModals, applyPublicAccountPayModalApi] = useVbenModal({
connectedComponent: ApplyPublicAccountPayModal,
});
const [ExportModal, exportModalApi] = useVbenModal({
connectedComponent: ProductOrderExportModal,
@@ -402,9 +416,16 @@ const openLogisticsModal = (data: Record<string, any> = {}) => {
* 注意:勿用 bind(null,row),table-action 的 click 会把事件当成第 2 参污染 warehouse_id。
*/
const wareSend = (data: Record<string, any> = {}, warehouseId?: number) => {
// 带上当前收件信息,发货弹窗可确认/修改后再写回
const addr =
data?.address && typeof data.address === 'object' ? data.address : {};
const values: Record<string, any> = {
order_id: data?.id,
order_no: data?.order_no,
express_name: data?.express_name || addr.name || '',
express_mobile: data?.express_mobile || addr.mobile || '',
express_region: data?.express_region || addr.region || '',
express_address: data?.express_address || addr.detail_address || '',
};
if (
warehouseId !== undefined &&
@@ -725,6 +746,50 @@ function openPublicAccountPay(row: Record<string, any>) {
publicAccountConfirmModalApi.open();
}
/** 待支付订单是否可申请公账(跟接诊成功页条件对齐) */
function canApplyPublicAccountPay(row: Record<string, any>) {
return (
publicAccountPayTrigger.value === 'doctor_confirm' &&
Number(row.is_pay) === 0 &&
Number(row.cancel_status) === 0 &&
Number(row.store?.public_account_pay_enabled ?? 0) === 1
);
}
/** 一单一结:打开申请公账弹窗(凭证选填) */
function openApplyPublicAccountPay(row: Record<string, any>) {
applyPublicAccountPayModalApi.setData({
order_id: row.id,
order_no: row.order_no,
total_pay_price: row.total_pay_price,
bill_mode: 'order',
onSuccess: () => refreshCurrentView(),
});
applyPublicAccountPayModalApi.open();
}
/** 按天先用后付:列表/卡片上一键确认公账已支付 */
function confirmDailyPublicAccountPay(row: Record<string, any>) {
AntdModal.confirm({
title: '确认公账已支付',
content: `订单 ${row.order_no || row.id} 将标记为已支付,并计入当日日汇总。`,
okText: '确认已支付',
cancelText: '取消',
async onOk() {
const res = await applyPublicAccountPay({
order_id: Number(row.id),
apply_remark: '医生确认公账已支付',
});
message.success(
res?.bill_mode === 'daily'
? '订单已标记为已支付,将计入当日日汇总'
: '已提交公账支付申请',
);
await refreshCurrentView();
},
});
}
function handleSimulatePay(row: Record<string, any>) {
AntdModal.confirm({
title: '模拟支付',
@@ -1148,10 +1213,29 @@ function buildRowActions(row: Record<string, any>): {
ifShow: row.is_pay === 0,
onClick: handleSimulatePay.bind(null, row),
},
{
// 医生/业务侧申请(doctor_confirm);与下方超管「确认到账」区分
label: '申请公账支付',
type: 'link',
icon: 'mdi:bank-outline',
ifShow:
canApplyPublicAccountPay(row) &&
publicAccountBillMode.value === 'order',
onClick: openApplyPublicAccountPay.bind(null, row),
},
{
label: '公账支付',
type: 'link',
icon: 'mdi:bank',
ifShow:
canApplyPublicAccountPay(row) &&
publicAccountBillMode.value === 'daily',
onClick: confirmDailyPublicAccountPay.bind(null, row),
},
{
label: '确认公账到账',
type: 'link',
icon: 'mdi:bank-check',
auth: ['Super Admin', 'Admin'],
ifShow:
publicAccountBillMode.value === 'order' &&
@@ -1210,6 +1294,7 @@ function buildRowActions(row: Record<string, any>): {
<Page auto-content-height title="订单管理">
<FormModal />
<PublicAccountConfirmModals />
<ApplyPublicAccountPayModals />
<Modal />
<LogisticsModalComp />
<AntdModal

View File

@@ -89,3 +89,13 @@ export async function rescheduleRegisterApi(data: {
}) {
return requestClient.post<any>(`${prefix}reschedule`, data);
}
/**
* 挂号凭证小程序码(打印「扫码查看排队」用)
* 后端 scene=rid={register_id},落地页 pages/home/index
*/
export async function getRegisterVoucherQrcodeApi(registerId: number) {
return requestClient.post<{ qrcode: string }>(`${prefix}voucher-qrcode`, {
register_id: registerId,
});
}

View File

@@ -6,15 +6,20 @@
* 无需再请求详情接口。父级通过 modalApi.setData({ row }) 传入。
*
* 展示分组:挂号信息 / 就诊人 / 下单用户 / 诊所 / 医生 / 价格与支付 / 关联处方 / 推广员
* 打印预览在本弹窗内挂载,任意复用入口均可直接打挂号凭证
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Avatar } from 'ant-design-vue';
import { Avatar, Button, message } from 'ant-design-vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { pillStyle, tagColorHex } from '#/utils/tagColor';
import {
ORDER_POPUP_Z_INDEX,
openOrderPopup,
} from '#/views/business/order/utils/popup-z-index';
import {
REGISTER_PAY_TYPE_MAP,
@@ -22,6 +27,7 @@ import {
REGISTER_STATUS_MAP,
REGISTER_TYPE_MAP,
} from '../config/constants';
import RegisterReceiptPrintModal from './RegisterReceiptPrintModal.vue';
const emit = defineEmits<{
/** 点击处方号:由父级打开已有的处方详情弹窗 */
@@ -42,6 +48,27 @@ const [Modal, modalApi] = useVbenModal({
},
});
/** 嵌套打印预览:用 NESTED2,覆盖详情本身可能已是 NESTED 的场景 */
const [ReceiptPrintModal, receiptPrintModalApi] = useVbenModal({
connectedComponent: RegisterReceiptPrintModal,
zIndex: ORDER_POPUP_Z_INDEX.NESTED2,
});
/**
* 打开挂号凭证打印预览
*/
function openPrintReceipt() {
if (!String(row.value?.order_no || '').trim()) {
message.warning('缺少订单号,无法打印预览');
return;
}
openOrderPopup(
receiptPrintModalApi,
{ row: row.value },
ORDER_POPUP_Z_INDEX.NESTED2,
);
}
const typeMeta = computed(
() => REGISTER_TYPE_MAP[Number(row.value?.type)] ?? { label: '未知', color: 'default' },
);
@@ -85,18 +112,28 @@ function money(value: unknown) {
<template>
<Modal title="挂号详情" class="w-[640px]" :footer="false">
<div class="rd-body">
<!-- 顶部概要:订单号 + 状态/方式 -->
<!-- 顶部概要:订单号 + 状态/方式 + 打印预览 -->
<div class="rd-hero">
<div class="min-w-0">
<div class="rd-hero__no">{{ row.order_no || '—' }}</div>
<div class="rd-hero__time">下单时间:{{ row.created_at || '—' }}</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<span class="rd-pill" :style="pillStyle(typeMeta.color)">{{ typeMeta.label }}</span>
<span class="rd-status" :style="{ color: tagColorHex(statusMeta.color) }">
<i class="rd-status__dot" :style="{ background: tagColorHex(statusMeta.color) }"></i>
{{ statusMeta.label }}
</span>
<div class="flex shrink-0 flex-col items-end gap-1.5">
<div class="flex items-center gap-1.5">
<span class="rd-pill" :style="pillStyle(typeMeta.color)">{{ typeMeta.label }}</span>
<span class="rd-status" :style="{ color: tagColorHex(statusMeta.color) }">
<i class="rd-status__dot" :style="{ background: tagColorHex(statusMeta.color) }"></i>
{{ statusMeta.label }}
</span>
</div>
<Button
type="primary"
size="small"
ghost
@click="openPrintReceipt"
>
打印预览
</Button>
</div>
</div>
<!-- 就诊人信息 -->
@@ -242,6 +279,7 @@ function money(value: unknown) {
</div>
</div>
</Modal>
<ReceiptPrintModal />
</template>
<style scoped>

View File

@@ -43,6 +43,8 @@ const emit = defineEmits<{
openPrescription: [prescriptionId: number];
/** 点诊所名打开诊所卡片 */
openStore: [storeId: number];
/** 打印预览挂号凭证 */
printReceipt: [row: Record<string, any>];
}>();
const items = ref<any[]>([]);
@@ -81,7 +83,7 @@ function money(value: unknown) {
return Number.isFinite(n) ? n.toFixed(2) : '—';
}
/** 行操作:详情 / 改期 / 退款(显隐与列表一致),通过 emit 复用父级弹窗 */
/** 行操作:详情 / 打印预览 / 改期 / 退款(显隐与列表一致),通过 emit 复用父级弹窗 */
function buildActions(row: Record<string, any>): ActionItem[] {
return [
{
@@ -90,6 +92,12 @@ function buildActions(row: Record<string, any>): ActionItem[] {
icon: 'marketeq:eye',
onClick: () => emit('openDetail', row),
},
{
label: '打印预览',
type: 'link',
icon: 'ant-design:printer-outlined',
onClick: () => emit('printReceipt', row),
},
{
label: '改期',
type: 'link',

View File

@@ -0,0 +1,419 @@
<script lang="ts" setup>
/**
* 挂号凭证打印预览弹窗
* 热敏小票版式:诊所名 / 挂号凭证 / 条形码(order_no) / 小程序码(扫码查看排队) /
* 病历号=订单号 / 患者与挂号信息 / 页脚
* 打印走 utils/print-receipt(iframe 或 Electron 静默)
*/
import { computed, nextTick, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import JsBarcode from 'jsbarcode';
import { desktop, isElectron } from '#/util/desktop';
import { getRegisterVoucherQrcodeApi } from '../api';
import { printReceipt } from '../utils/print-receipt';
defineOptions({
name: 'RegisterReceiptPrintModal',
});
const row = ref<Record<string, any>>({});
/** 条码用 PNG:避免 SVG 被 CSS 压扁 / 部分打印机矢量渲染失真导致扫不出 */
const barcodeSrc = ref('');
/** 小程序码 dataURL(后端 base64 PNG,前缀可能已带 data:image) */
const qrcodeSrc = ref('');
const qrcodeLoading = ref(false);
const previewRoot = ref<HTMLElement | null>(null);
const printing = ref(false);
/** 预览区展示的打印时间;点「打印」时刷新为当前时刻 */
const printAt = ref('');
const isDesktop = isElectron();
const printSettings = ref<{ printerName: string; silentPrint: boolean }>({
printerName: '',
silentPrint: false,
});
if (isDesktop) {
desktop.getSettings().then((s) => {
printSettings.value = s;
});
}
const patient = computed(
() => row.value?.userPatient ?? row.value?.user_patient ?? {},
);
const store = computed(() => row.value?.store ?? {});
const doctor = computed(
() => row.value?.doctorInfo ?? row.value?.doctor_info ?? {},
);
const orderNo = computed(() => String(row.value?.order_no || '').trim());
const registerId = computed(() => Number(row.value?.id || 0));
const canPrint = computed(() => orderNo.value !== '');
const patientSex = computed(() => {
const sex = Number(patient.value?.sex);
if (sex === 1) return '男';
if (sex === 2) return '女';
return '';
});
const patientLine = computed(() => {
const name = String(patient.value?.name || '').trim() || '—';
const sex = patientSex.value;
const age = patient.value?.age;
const ageTxt =
age !== undefined && age !== null && age !== '' ? `${age}岁` : '';
return [name, sex, ageTxt].filter(Boolean).join(' ');
});
const outpatientNo = computed(() => {
const n = row.value?.order_number;
if (n === undefined || n === null || n === '') return '—';
return `${n}号`;
});
const registerDate = computed(() => {
const raw = row.value?.created_at;
if (!raw) return '—';
const d = dayjs(raw);
return d.isValid() ? d.format('YYYY/MM/DD') : String(raw).slice(0, 10);
});
const registerFee = computed(() => {
const n = Number(row.value?.price);
return Number.isFinite(n) ? String(n) : '0';
});
const departName = computed(() => doctor.value?.depart?.name || '—');
const doctorName = computed(() => doctor.value?.name || '—');
const storeName = computed(() => String(store.value?.name || '').trim() || '—');
const storeAddress = computed(
() => String(store.value?.position || '').trim() || '—',
);
const storeMobile = computed(
() => String(store.value?.mobile || '').trim() || '—',
);
/**
* 把接口返回的 base64 规范成 img src
* 为何:部分接口已带 data:image 前缀,直接拼接会坏图
*/
function toDataUrl(raw: string): string {
const s = String(raw || '').trim();
if (!s) return '';
if (s.startsWith('data:')) return s;
return `data:image/png;base64,${s}`;
}
/**
* 用订单号生成可扫描 CODE128(canvas→PNG)
* 为何:原先 SVG + margin:0 + max-width 压扁会导致静区不足、条宽失真,扫码枪读不出
*/
function renderBarcode() {
const code = orderNo.value;
if (!code) {
barcodeSrc.value = '';
return;
}
try {
const canvas = document.createElement('canvas');
// 小票内容区约 260px:按字符估模块宽,保证整图落在纸宽内且不靠 CSS 横向压缩
const maxPx = 260;
const quiet = 14;
const modules = 35 + code.length * 11;
const moduleWidth = Math.min(
2.4,
Math.max(1.6, (maxPx - quiet * 2) / modules),
);
JsBarcode(canvas, code, {
format: 'CODE128',
displayValue: false,
margin: quiet,
height: 72,
width: moduleWidth,
background: '#ffffff',
lineColor: '#000000',
});
barcodeSrc.value = canvas.toDataURL('image/png');
} catch (e) {
console.error('条形码生成失败', e);
barcodeSrc.value = '';
message.error('条形码生成失败,请检查订单号');
}
}
/**
* 拉取小程序码:scene=rid={register_id},供患者扫码看排队
*/
async function loadVoucherQrcode() {
const id = registerId.value;
qrcodeSrc.value = '';
if (!id) return;
qrcodeLoading.value = true;
try {
const res = await getRegisterVoucherQrcodeApi(id);
qrcodeSrc.value = toDataUrl(res?.qrcode || '');
} catch (e) {
console.error('小程序码获取失败', e);
qrcodeSrc.value = '';
} finally {
qrcodeLoading.value = false;
}
}
function refreshPrintAt() {
printAt.value = dayjs().format('YYYY-MM-DD HH:mm');
}
const [Modal, modalApi] = useVbenModal({
title: '挂号凭证预览',
class: 'w-[420px]',
fullscreenButton: false,
draggable: true,
showConfirmButton: false,
cancelText: '关闭',
onCancel() {
modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData() as Record<string, any> | undefined;
row.value = data?.row ?? {};
refreshPrintAt();
nextTick(() => {
renderBarcode();
void loadVoucherQrcode();
});
} else {
row.value = {};
barcodeSrc.value = '';
qrcodeSrc.value = '';
}
},
});
watch(orderNo, () => {
nextTick(() => renderBarcode());
});
/**
* 触发打印:刷新打印时间后交给 printReceipt
*/
async function handlePrint() {
if (!canPrint.value) {
message.warning('缺少订单号,无法打印');
return;
}
refreshPrintAt();
await nextTick();
renderBarcode();
await nextTick();
printing.value = true;
try {
await printReceipt({
root: previewRoot.value,
fileName: '挂号凭证',
silentPrint: printSettings.value.silentPrint,
printerName: printSettings.value.printerName,
});
} finally {
printing.value = false;
}
}
</script>
<template>
<Modal>
<div ref="previewRoot" class="receipt-preview-wrap">
<!-- 打印区:白底黑字固定小票宽度,不跟后台暗色主题 -->
<div class="print-box receipt-slip">
<div class="receipt-slip__center">
<div class="receipt-slip__clinic">{{ storeName }}</div>
<div class="receipt-slip__title">挂号凭证</div>
</div>
<div class="receipt-slip__barcode">
<img
v-if="barcodeSrc"
:src="barcodeSrc"
alt="条形码"
class="receipt-slip__barcode-img"
/>
</div>
<!-- 左:病历信息;右:缩小的小程序码(对齐标注位置) -->
<div class="receipt-slip__body">
<div class="receipt-slip__info">
<div class="receipt-slip__line">病历号:{{ orderNo || '—' }}</div>
<div class="receipt-slip__line">姓名:{{ patientLine }}</div>
<div class="receipt-slip__line">门诊号:{{ outpatientNo }}</div>
<div class="receipt-slip__line">挂号类型:自费</div>
<div class="receipt-slip__line">挂号日期:{{ registerDate }}</div>
<div class="receipt-slip__line">挂号费:{{ registerFee }}</div>
<div class="receipt-slip__line">科室:{{ departName }}</div>
<div class="receipt-slip__line">医生:{{ doctorName }}</div>
</div>
<div class="receipt-slip__qr">
<img
v-if="qrcodeSrc"
:src="qrcodeSrc"
alt="小程序码"
class="receipt-slip__qr-img"
/>
<div
v-else-if="qrcodeLoading"
class="receipt-slip__qr-placeholder"
>
加载中…
</div>
<div v-else class="receipt-slip__qr-placeholder">暂无</div>
<div class="receipt-slip__qr-tip">扫码查看排队</div>
</div>
</div>
<div class="receipt-slip__dash">--------------------</div>
<div class="receipt-slip__foot">打印时间:{{ printAt }}</div>
<div class="receipt-slip__foot">机构地址:{{ storeAddress }}</div>
<div class="receipt-slip__foot">机构联系方式:{{ storeMobile }}</div>
</div>
</div>
<template #append-footer>
<Button
type="primary"
:loading="printing"
:disabled="!canPrint"
@click="handlePrint"
>
打印
</Button>
</template>
</Modal>
</template>
<style scoped>
.receipt-preview-wrap {
display: flex;
justify-content: center;
padding: 8px 0 4px;
background: hsl(var(--muted) / 0.25);
border-radius: 8px;
}
/* 小票本体:预览固定 80mm;打印走 print-receipt 专用 CSS,不依赖 max-width */
.receipt-slip {
width: 80mm;
box-sizing: border-box;
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;
/* 两侧留白:配合生成时的 quiet zone,避免贴边扫不出 */
padding: 4px 8px;
background: #fff;
}
.receipt-slip__barcode-img {
display: block;
/* 按 canvas 原始像素输出,禁止固定宽高拉伸导致条宽失真 */
width: auto;
height: auto;
max-width: 100%;
image-rendering: crisp-edges;
}
/* 主体:左文字 + 右下角小程序码(约 18mm) */
.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;
height: 18mm;
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;
}
</style>

View File

@@ -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<string, any>) {
registerDetailModalApi.setData({ row });
registerDetailModalApi.open();
}
/**
* 打开挂号凭证打印预览(无订单号时提示)
*/
function openRegisterReceiptPrint(row: Record<string, any>) {
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<string, any>) {
<RefundModal />
<RescheduleModalComp />
<StoreCardModalComp />
<!-- 挂号详情弹窗:处方号/诊所名点击透传到已有弹窗 -->
<RegisterReceiptPrintModalComp />
<!-- 挂号详情弹窗:处方号/诊所名透传;打印预览由详情内部自挂载 -->
<RegisterDetailModalComp
@open-prescription="openPrescriptionDetail"
@open-store="openStoreCard"
@@ -419,6 +441,12 @@ function rowRefundMeta(row: Record<string, any>) {
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<string, any>) {
@reschedule="openRescheduleModal"
@open-prescription="openPrescriptionDetail"
@open-store="openStoreCard"
@print-receipt="openRegisterReceiptPrint"
/>
</div>
</Page>

View File

@@ -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 `<!DOCTYPE html><html><head><meta charset="utf-8" /><title>${title}</title><style>${RECEIPT_PRINT_CSS}</style></head><body>${boxHtml}</body></html>`;
}
/**
* 执行打印:桌面端且开启静默打印则直接出纸,否则弹出系统打印对话框
*/
export async function printReceipt(options: {
root: HTMLElement | null | undefined;
fileName?: string;
silentPrint?: boolean;
printerName?: string;
}): Promise<void> {
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<void>((resolve) => {
if (img.complete) {
resolve();
return;
}
img.onload = () => resolve();
img.onerror = () => resolve();
}),
),
);
setTimeout(runPrint, 50);
}

View File

@@ -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<string, any> | null;
/** 患者端 Short Link 落库数据 */
mp_short_link?: Record<string, any> | 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<HTMLElement | null>(null);
const htmlToImage = ref<HTMLElement | null>(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 = () => {
<template>
<Page v-if="data" :title="`${data.name}的配置中心`" auto-content-height>
<DoctorQrCodePreviewModal />
<StoreQrCodePreviewModal />
<DoctorCardModals />
<UploadModals />
<div class="pb-2">
@@ -546,6 +578,9 @@ const handleSwitchClinicType = () => {
</Card>
<Card v-else-if="activeTabBar === 3" :title="isPharmacy ? '药店二维码' : '诊所二维码'">
<template #extra>
<Button type="link" @click="openStoreShortLinkModal">
生成/复制短链
</Button>
<Button
style="margin: 20px auto"
type="link"

View File

@@ -9,6 +9,8 @@ const prefix = 'clinic-image-transfer/';
/** 处方图 OCR */
export async function clinicOcrPrescriptionImageApi(data: {
image_urls: string[];
/** 识别前粘贴的收货人信息,可空 */
consignee_text?: string;
}) {
return requestClient.post<any>(`${prefix}ocr-prescription-image`, data);
}

View File

@@ -77,6 +77,41 @@ export async function getProcessRuleList(data: any) {
return requestClient.get<any>(`${prefix}process-rule-list`, { params: data });
}
/** 煎药预设方案列表 doctor-reception GET process-rule-preset-list */
export async function getProcessRulePresetList(params: { store_id: number }) {
return requestClient.get<any>(`${prefix}get-process-rule-preset-list`, {
params,
});
}
/** 保存煎药预设方案 doctor-reception POST save-process-rule-preset */
export async function saveProcessRulePreset(data: {
store_id: number;
name: string;
id?: number;
is_default?: number;
rule_type: number;
package_method_id?: number | null;
process_rule_id?: number | null;
child_process_rule_id?: number | null;
process_rule_note_id?: number | null;
}) {
return requestClient.post<any>(`${prefix}save-process-rule-preset`, data);
}
/** 设为默认煎药预设 doctor-reception POST set-process-rule-preset-default */
export async function setProcessRulePresetDefault(data: { id: number }) {
return requestClient.post<any>(
`${prefix}set-process-rule-preset-default`,
data,
);
}
/** 删除煎药预设 doctor-reception POST delete-process-rule-preset */
export async function deleteProcessRulePreset(data: { id: number }) {
return requestClient.post<any>(`${prefix}delete-process-rule-preset`, data);
}
/**
* 获取药品使用方式列表
*/
@@ -701,6 +736,8 @@ export async function ocrPrescriptionImageApi(data: {
mode: 'reception' | 'transfer';
register_id?: number;
store_id?: number;
/** 传方识别前粘贴的收货人信息,仅 transfer 生效 */
consignee_text?: string;
}) {
return requestClient.post<any>(`${prefix}ocr-prescription-image`, data);
}

View File

@@ -2,11 +2,20 @@
/**
* 中药制剂方式 / 代煎 / 用量配置
* 与小程序对齐:标题带必填星号;父页提交失败可传 errorKeys 标红
* 制剂方式同行展示「预设方案」:单击回填、常显「编辑」/双击/右键改方案、+ 新增
*/
import { computed } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { InputNumber } from 'ant-design-vue';
import { Checkbox, Input, InputNumber, Modal, message } from 'ant-design-vue';
import {
deleteProcessRulePreset,
getProcessRuleList,
getProcessRulePresetList,
saveProcessRulePreset,
setProcessRulePresetDefault,
} from '../api/index';
/** 中药配置变更载荷 */
export type ChineseMedicineConfigChange = {
@@ -19,6 +28,23 @@ export type ChineseMedicineConfigChange = {
dayDosage?: number;
};
/** 预设方案列表项 */
export type ProcessRulePresetItem = {
id: number;
name: string;
rule_type: number;
package_method_id: number;
process_rule_id: number;
child_process_rule_id: number;
process_rule_note_id: number;
is_default: number;
ruleType?: number;
packageMethodId?: number | null;
processRuleId?: number | null;
childProcessRuleId?: number | null;
processRuleNoteId?: number | null;
};
const props = withDefaults(
defineProps<{
ruleType: number;
@@ -34,6 +60,10 @@ const props = withDefaults(
disabled?: boolean;
/** 未填字段名;父页提交失败传入,改选后由父清空 */
errorKeys?: string[];
/** 当前履约门店,用于加载/保存预设 */
storeId?: number | null;
/** 进入中药且无草稿覆盖时自动应用默认方案 */
autoApplyDefault?: boolean;
}>(),
{
processRuleList: () => [],
@@ -41,6 +71,8 @@ const props = withDefaults(
processRuleNoteList: () => [],
disabled: false,
errorKeys: () => [],
storeId: null,
autoApplyDefault: false,
},
);
@@ -54,6 +86,30 @@ const packageMethodList = [
{ id: 2, name: '剂包' },
];
const presets = ref<ProcessRulePresetItem[]>([]);
const presetsLoading = ref(false);
const activePresetId = ref<number | null>(null);
/** 每个门店只自动应用一次,避免反复覆盖医生手动改选 */
const autoAppliedStoreIds = ref<Set<number>>(new Set());
/** 新增/编辑预设弹窗 */
const editVisible = ref(false);
const editSaving = ref(false);
const editForm = ref({
id: 0,
name: '',
is_default: false,
ruleType: 1 as number,
packageMethodId: 2 as number | null,
processRuleId: null as number | null,
childProcessRuleId: null as number | null,
processRuleNoteId: null as number | null,
});
const editProcessRuleList = ref<Array<{ id: number; name: string }>>([]);
const editChildProcessRuleList = ref<Array<{ id: number; name: string }>>([]);
const editProcessRuleNoteList = ref<
Array<{ id: number; note?: string; name?: string }>
>([]);
/** 未填字段 → 区块标红 */
const errorMap = computed(() => {
const map: Record<string, boolean> = {
@@ -84,6 +140,7 @@ function emitChange(payload: ChineseMedicineConfigChange) {
*/
function handleRuleTypeChange(value: number) {
if (props.disabled || props.ruleType === value) return;
activePresetId.value = null;
if (value === 1) {
emitChange({
ruleType: 1,
@@ -103,12 +160,14 @@ function handleRuleTypeChange(value: number) {
/** 选择包法标签 */
function handlePackageMethodClick(id: number) {
if (props.disabled) return;
activePresetId.value = null;
emitChange({ packageMethodId: id });
}
/** 选择制剂标签,级联清空煎法/备注 */
function handleProcessRuleClick(item: { id: number }) {
if (props.disabled) return;
activePresetId.value = null;
emitChange({
processRuleId: item.id,
childProcessRuleId: null,
@@ -119,6 +178,7 @@ function handleProcessRuleClick(item: { id: number }) {
/** 选择煎法标签,级联清空备注 */
function handleChildProcessRuleClick(item: { id: number }) {
if (props.disabled) return;
activePresetId.value = null;
emitChange({
childProcessRuleId: item.id,
processRuleNoteId: null,
@@ -128,6 +188,7 @@ function handleChildProcessRuleClick(item: { id: number }) {
/** 选择备注标签 */
function handleProcessRuleNoteClick(item: { id: number }) {
if (props.disabled) return;
activePresetId.value = null;
emitChange({ processRuleNoteId: item.id });
}
@@ -138,31 +199,493 @@ function updateDosage(val: number) {
function updateDayDosage(val: number) {
emitChange({ dayDosage: val });
}
/** 拉取当前门店预设列表(按 store_id 隔离,其它门店的方案不会出现在这里) */
async function loadPresets() {
const sid = Number(props.storeId || 0);
if (sid <= 0) {
presets.value = [];
console.warn('[煎药预设] storeId 无效,跳过加载', props.storeId);
return;
}
presetsLoading.value = true;
try {
const res = await getProcessRulePresetList({ store_id: sid });
// jok 数组经 requestClient 已解成 result;兼容偶发包一层 items/list
let list: ProcessRulePresetItem[] = [];
if (Array.isArray(res)) {
list = res as ProcessRulePresetItem[];
} else if (Array.isArray(res?.items)) {
list = res.items;
} else if (Array.isArray(res?.list)) {
list = res.list;
} else if (Array.isArray(res?.result)) {
list = res.result;
}
presets.value = list;
if (!list.length) {
console.warn('[煎药预设] 接口返回空列表 store_id=', sid, 'raw=', res);
}
await maybeAutoApplyDefault(sid);
} catch (e) {
// 失败只记日志,不弹 toast,避免打断正常开方
console.error('加载煎药预设失败 store_id=', sid, e);
presets.value = [];
} finally {
presetsLoading.value = false;
}
}
/**
* 自动应用默认方案(每门店一次)
* 为什么:开方进入中药希望直接落到医生常用煎药组合
*/
async function maybeAutoApplyDefault(storeId: number) {
if (!props.autoApplyDefault || props.disabled) return;
if (autoAppliedStoreIds.value.has(storeId)) return;
const def = presets.value.find((p) => Number(p.is_default) === 1);
if (!def) {
autoAppliedStoreIds.value.add(storeId);
return;
}
autoAppliedStoreIds.value.add(storeId);
applyPreset(def);
}
/** 点击方案:整包回填(不含剂数) */
function applyPreset(item: ProcessRulePresetItem) {
if (props.disabled) return;
activePresetId.value = item.id;
const ruleType = Number(item.ruleType ?? item.rule_type ?? 0);
if (ruleType === 1) {
emitChange({
ruleType: 1,
packageMethodId: Number((item.packageMethodId ?? item.package_method_id) || 2),
processRuleId: null,
childProcessRuleId: null,
processRuleNoteId: null,
});
return;
}
emitChange({
ruleType: 2,
packageMethodId: null,
processRuleId: Number((item.processRuleId ?? item.process_rule_id) || 0) || null,
childProcessRuleId:
Number((item.childProcessRuleId ?? item.child_process_rule_id) || 0) || null,
processRuleNoteId:
Number((item.processRuleNoteId ?? item.process_rule_note_id) || 0) || null,
});
}
/** 弹窗内加载制剂级联列表 */
async function loadEditProcessRules(pid = 0, ruleId = 0) {
const data = ruleId === 0 ? { pid } : { rule_id: ruleId };
const value = await getProcessRuleList(data);
const list = Array.isArray(value) ? value : [];
if (ruleId !== 0) {
editProcessRuleNoteList.value = list;
} else if (pid === 0) {
editProcessRuleList.value = list;
} else {
editChildProcessRuleList.value = list;
}
}
/**
* 打开新增/编辑弹窗;新增时用当前页面配置预填。
* 注意:必须用 openPresetEditor() 调新增;若误传 MouseEvent,不能当成编辑项(event 无业务 id)。
*/
async function openPresetEditor(item?: ProcessRulePresetItem | null) {
if (props.disabled) return;
const sid = Number(props.storeId || 0);
if (sid <= 0) {
message.warning('请先选择门店');
return;
}
// 仅业务预设可进编辑:必须有正 id + name;排除误传入的 MouseEvent(有 type 无 name)
const raw = item as ProcessRulePresetItem | (Event & { id?: number }) | null | undefined;
const isEditItem = !!(
raw &&
Number((raw as ProcessRulePresetItem).id) > 0 &&
typeof (raw as ProcessRulePresetItem).name === 'string'
);
if (isEditItem && raw) {
item = raw as ProcessRulePresetItem;
const ruleType = Number(item.ruleType ?? item.rule_type ?? 1);
editForm.value = {
id: item.id,
name: String(item.name || ''),
is_default: Number(item.is_default) === 1,
ruleType,
packageMethodId:
ruleType === 1
? Number((item.packageMethodId ?? item.package_method_id) || 2)
: null,
processRuleId:
ruleType === 2
? Number((item.processRuleId ?? item.process_rule_id) || 0) || null
: null,
childProcessRuleId:
ruleType === 2
? Number((item.childProcessRuleId ?? item.child_process_rule_id) || 0) ||
null
: null,
processRuleNoteId:
ruleType === 2
? Number((item.processRuleNoteId ?? item.process_rule_note_id) || 0) ||
null
: null,
};
} else {
const ruleType = Number(props.ruleType || 1);
editForm.value = {
id: 0,
name: '',
is_default: false,
ruleType,
packageMethodId: ruleType === 1 ? Number(props.packageMethodId || 2) : null,
processRuleId: ruleType === 2 ? Number(props.processRuleId || 0) || null : null,
childProcessRuleId:
ruleType === 2 ? Number(props.childProcessRuleId || 0) || null : null,
processRuleNoteId:
ruleType === 2 ? Number(props.processRuleNoteId || 0) || null : null,
};
}
editProcessRuleList.value = [];
editChildProcessRuleList.value = [];
editProcessRuleNoteList.value = [];
editVisible.value = true;
if (editForm.value.ruleType === 2) {
await loadEditProcessRules(0, 0);
if (editForm.value.processRuleId) {
await loadEditProcessRules(Number(editForm.value.processRuleId), 0);
}
if (editForm.value.childProcessRuleId) {
await loadEditProcessRules(0, Number(editForm.value.childProcessRuleId));
}
}
}
async function onEditRuleTypeChange(value: number) {
editForm.value.ruleType = value;
if (value === 1) {
editForm.value.processRuleId = null;
editForm.value.childProcessRuleId = null;
editForm.value.processRuleNoteId = null;
editForm.value.packageMethodId = editForm.value.packageMethodId || 2;
editChildProcessRuleList.value = [];
editProcessRuleNoteList.value = [];
} else {
editForm.value.packageMethodId = null;
if (editProcessRuleList.value.length === 0) {
await loadEditProcessRules(0, 0);
}
}
}
async function onEditProcessRuleClick(id: number) {
editForm.value.processRuleId = id;
editForm.value.childProcessRuleId = null;
editForm.value.processRuleNoteId = null;
editChildProcessRuleList.value = [];
editProcessRuleNoteList.value = [];
await loadEditProcessRules(id, 0);
}
async function onEditChildProcessRuleClick(id: number) {
editForm.value.childProcessRuleId = id;
editForm.value.processRuleNoteId = null;
editProcessRuleNoteList.value = [];
await loadEditProcessRules(0, id);
}
function validateEditForm(): string {
const name = String(editForm.value.name || '').trim();
if (!name) return '请填写方案名称';
const ruleType = Number(editForm.value.ruleType || 0);
if (ruleType !== 1 && ruleType !== 2) return '请选择制剂方式';
if (ruleType === 1) {
if (!Number(editForm.value.packageMethodId)) return '请选择包法';
} else {
if (!Number(editForm.value.processRuleId)) return '请选择制剂要求';
if (!Number(editForm.value.childProcessRuleId)) return '请选择煎法说明';
if (!Number(editForm.value.processRuleNoteId)) return '请选择附加备注';
}
return '';
}
async function submitPresetEditor() {
const sid = Number(props.storeId || 0);
const err = validateEditForm();
if (err) {
message.warning(err);
return Promise.reject();
}
editSaving.value = true;
try {
const ruleType = Number(editForm.value.ruleType);
await saveProcessRulePreset({
id: editForm.value.id || undefined,
store_id: sid,
name: String(editForm.value.name || '').trim(),
is_default: editForm.value.is_default ? 1 : 0,
rule_type: ruleType,
package_method_id: ruleType === 1 ? Number(editForm.value.packageMethodId) : 0,
process_rule_id: ruleType === 2 ? Number(editForm.value.processRuleId) : 0,
child_process_rule_id:
ruleType === 2 ? Number(editForm.value.childProcessRuleId) : 0,
process_rule_note_id:
ruleType === 2 ? Number(editForm.value.processRuleNoteId) : 0,
});
message.success(editForm.value.id ? '已更新方案' : '已保存方案');
editVisible.value = false;
await loadPresets();
} catch (e) {
console.error(e);
return Promise.reject(e);
} finally {
editSaving.value = false;
}
}
/** 设为默认 */
async function handleSetDefault(item: ProcessRulePresetItem) {
if (props.disabled) return;
try {
await setProcessRulePresetDefault({ id: item.id });
message.success('已设为默认');
await loadPresets();
} catch (e) {
console.error(e);
}
}
/** 删除方案 */
function handleDeletePreset(item: ProcessRulePresetItem) {
if (props.disabled) return;
Modal.confirm({
title: '删除方案',
content: `确定删除「${item.name}」?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
await deleteProcessRulePreset({ id: item.id });
if (activePresetId.value === item.id) activePresetId.value = null;
editVisible.value = false;
message.success('已删除');
await loadPresets();
},
});
}
/** 单击选用方案;点「编辑」按钮时已 stop,不会进这里 */
function onPresetPillClick(item: ProcessRulePresetItem) {
applyPreset(item);
}
/** 双击打开编辑(与常显「编辑」按钮等价) */
function onPresetPillDblClick(item: ProcessRulePresetItem) {
if (props.disabled) return;
openPresetEditor(item);
}
/** 右键直接打开编辑(删除/设默认在编辑弹窗与「编辑」按钮侧完成) */
function onPresetContextMenu(item: ProcessRulePresetItem, e: MouseEvent) {
if (props.disabled) return;
e.preventDefault();
e.stopPropagation();
openPresetEditor(item);
}
watch(
() => Number(props.storeId || 0),
(sid) => {
if (sid > 0) {
nextTick(() => loadPresets());
} else {
presets.value = [];
}
},
{ immediate: true },
);
</script>
<template>
<div class="chinese-medicine-config">
<!-- 制剂方式(必填) -->
<!-- 制剂方式 + 预设方案同一行,中间格挡隔离 -->
<div class="config-section mb-4" :class="{ 'sec-error': errorMap.ruleType }">
<div class="section-title mb-2">
<span class="title-text">制剂方式</span>
<span class="req-star">*</span>
</div>
<div class="pill-group">
<span
class="pill"
:class="{ active: ruleType === 1, disabled }"
@click="handleRuleTypeChange(1)"
>自制剂</span>
<span
class="pill"
:class="{ active: ruleType === 2, disabled }"
@click="handleRuleTypeChange(2)"
>委托调剂</span>
<div class="rule-preset-row">
<div class="pill-group">
<span
class="pill"
:class="{ active: ruleType === 1, disabled }"
@click="handleRuleTypeChange(1)"
>自制剂</span>
<span
class="pill"
:class="{ active: ruleType === 2, disabled }"
@click="handleRuleTypeChange(2)"
>委托调剂</span>
</div>
<span class="row-divider" aria-hidden="true"></span>
<div class="preset-side">
<span class="preset-label">预设方案</span>
<div class="pill-group preset-pills">
<!-- 不用 Dropdown 包 pill:部分环境下 trigger 包裹导致标签不渲染,表现为「有数据但看不到」 -->
<span
v-for="item in presets"
:key="item.id"
class="pill outline preset-pill"
:class="{
active: activePresetId === item.id,
'is-default': Number(item.is_default) === 1,
disabled,
}"
title="单击选用;点「编辑」/双击/右键可改"
@click="onPresetPillClick(item)"
@dblclick.stop="onPresetPillDblClick(item)"
@contextmenu="onPresetContextMenu(item, $event)"
>
<span class="preset-name">{{ item.name }}</span>
<i v-if="Number(item.is_default) === 1" class="default-badge">默</i>
<button
v-if="!disabled"
type="button"
class="preset-edit-btn"
title="编辑方案"
@click.stop="openPresetEditor(item)"
>编辑</button>
</span>
<span
class="pill outline add-preset"
:class="{ disabled: disabled || presetsLoading }"
title="新增预设方案"
@click="openPresetEditor()"
>+</span>
<span
v-if="!presetsLoading && Number(storeId || 0) > 0 && !presets.length"
class="preset-empty-tip"
>暂无方案</span>
<span
v-else-if="!presetsLoading && Number(storeId || 0) <= 0"
class="preset-empty-tip"
>请先选门店</span>
</div>
</div>
</div>
<div v-if="errorMap.ruleType" class="err-tip">请选择制剂方式</div>
</div>
<!-- 新增/编辑预设:名称 + 是否默认 + 制剂方式级联 -->
<Modal
v-model:open="editVisible"
:title="editForm.id ? '编辑预设方案' : '新增预设方案'"
:confirm-loading="editSaving"
ok-text="保存"
cancel-text="取消"
destroy-on-close
width="560px"
@ok="submitPresetEditor"
>
<div class="preset-edit-form">
<div class="edit-row">
<span class="edit-label">方案名称</span>
<Input
v-model:value="editForm.name"
:maxlength="50"
placeholder="如:常规代煎"
allow-clear
/>
</div>
<div class="edit-row edit-row-actions">
<Checkbox v-model:checked="editForm.is_default">设为默认方案</Checkbox>
<button
v-if="editForm.id"
type="button"
class="preset-edit-btn preset-del-btn"
@click="
handleDeletePreset({
id: editForm.id,
name: editForm.name,
is_default: editForm.is_default ? 1 : 0,
} as ProcessRulePresetItem)
"
>删除此方案</button>
</div>
<div class="edit-row">
<span class="edit-label">制剂方式</span>
<div class="pill-group">
<span
class="pill"
:class="{ active: editForm.ruleType === 1 }"
@click="onEditRuleTypeChange(1)"
>自制剂</span>
<span
class="pill"
:class="{ active: editForm.ruleType === 2 }"
@click="onEditRuleTypeChange(2)"
>委托调剂</span>
</div>
</div>
<div v-if="editForm.ruleType === 1" class="edit-row">
<span class="edit-label">包法选择</span>
<div class="pill-group">
<span
v-for="pm in packageMethodList"
:key="pm.id"
class="pill outline"
:class="{ active: editForm.packageMethodId === pm.id }"
@click="editForm.packageMethodId = pm.id"
>{{ pm.name }}</span>
</div>
</div>
<template v-if="editForm.ruleType === 2">
<div class="edit-row">
<span class="edit-label">制剂要求</span>
<div class="pill-group">
<span
v-for="pr in editProcessRuleList"
:key="pr.id"
class="pill outline"
:class="{ active: editForm.processRuleId === pr.id }"
@click="onEditProcessRuleClick(pr.id)"
>{{ pr.name }}</span>
</div>
</div>
<div v-if="editChildProcessRuleList.length" class="edit-row">
<span class="edit-label">煎法说明</span>
<div class="pill-group">
<span
v-for="cr in editChildProcessRuleList"
:key="cr.id"
class="pill outline"
:class="{ active: editForm.childProcessRuleId === cr.id }"
@click="onEditChildProcessRuleClick(cr.id)"
>{{ cr.name }}</span>
</div>
</div>
<div v-if="editProcessRuleNoteList.length" class="edit-row">
<span class="edit-label">附加备注</span>
<div class="pill-group">
<span
v-for="nr in editProcessRuleNoteList"
:key="nr.id"
class="pill outline"
:class="{ active: editForm.processRuleNoteId === nr.id }"
@click="editForm.processRuleNoteId = nr.id"
>{{ nr.note || nr.name }}</span>
</div>
</div>
</template>
</div>
</Modal>
<!-- 自制剂:包法 -->
<div
v-if="ruleType === 1"
@@ -335,12 +858,39 @@ function updateDayDosage(val: number) {
border: 1px solid hsl(var(--destructive, 0 84% 60%) / 45%);
box-shadow: 0 0 6px hsl(var(--destructive, 0 84% 60%) / 18%);
}
.rule-preset-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 12px;
}
.row-divider {
width: 1px;
align-self: stretch;
min-height: 28px;
background: hsl(var(--border));
flex-shrink: 0;
}
.preset-side {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.preset-label {
font-size: 12px;
color: hsl(var(--muted-foreground));
flex-shrink: 0;
}
.pill-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pill {
position: relative;
padding: 4px 16px;
background-color: hsl(var(--muted) / 0.55);
color: hsl(var(--muted-foreground));
@@ -370,6 +920,93 @@ function updateDayDosage(val: number) {
opacity: 0.5;
pointer-events: none;
}
.preset-pill.is-default {
border-color: hsl(var(--primary) / 40%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.default-badge {
display: inline-block;
margin-left: 2px;
padding: 0 4px;
font-size: 10px;
font-style: normal;
line-height: 14px;
border-radius: 3px;
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
}
.preset-pill {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
}
.preset-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 常显编辑入口:不依赖悬停/右键,避免「点了没反应」 */
.preset-edit-btn {
flex-shrink: 0;
margin: 0;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
border-radius: 10px;
border: 1px solid hsl(var(--primary) / 40%);
background: hsl(var(--background));
color: hsl(var(--primary));
cursor: pointer;
pointer-events: auto;
}
.preset-edit-btn:hover {
background: hsl(var(--primary) / 0.1);
}
.preset-del-btn {
border-color: hsl(var(--destructive, 0 84% 60%) / 40%);
color: hsl(var(--destructive, 0 84% 60%));
}
.pill.outline.active .preset-edit-btn,
.preset-pill.is-default .preset-edit-btn {
border-color: hsl(var(--primary) / 50%);
}
.preset-empty-tip {
font-size: 12px;
color: hsl(var(--muted-foreground));
line-height: 28px;
}
.add-preset {
min-width: 32px;
text-align: center;
font-weight: 700;
font-size: 16px;
line-height: 1.2;
}
.preset-edit-form {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 8px;
}
.edit-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.edit-row-actions {
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.edit-label {
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.dosage-input {
width: 140px;
}

View File

@@ -6,7 +6,7 @@
*/
import { computed, ref, watch } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import {
Button,
Input,
@@ -28,6 +28,7 @@ import { uploadToOss } from '#/utils/oss-upload';
import { usePasteUploadListen } from '#/utils/use-paste-upload-listen';
import { preferences } from '@vben/preferences';
import OcrImageLoading from '#/components/loading/OcrImageLoading.vue';
import ConsigneeModalComp from '#/views/doctor/image-transfer-prescription/components/ConsigneeModal.vue';
import { OcrOriginImagePanel } from '#/components/ocr-origin-image-panel';
import {
ChineseDrugNameBubble,
@@ -81,6 +82,10 @@ const patientSex = ref<number>(0);
const patientAge = ref<number>(0);
const userPatientId = ref(0);
const patientMatched = ref(false);
/** 传方识别前粘贴的收货人文本;同图重识别回填,开抽屉/换新图清空 */
const consigneeText = ref('');
/** 后端补位来源标记:处方缺失、改用收货人信息的字段 */
const fromConsignee = ref({ name: false, mobile: false, address: false });
const matchedList = ref<any[]>([]);
const unmatchedList = ref<any[]>([]);
/** 未匹配行:气泡手选结果(接诊同款搜药) */
@@ -237,6 +242,11 @@ function applyOcrPayload(data: {
patientAddress.value = String(ocr.patient_address || patientAddress.value || '');
patientSex.value = Number(ocr.sex || patientSex.value || 0);
patientAge.value = Number(ocr.age || patientAge.value || 0);
fromConsignee.value = {
name: Number(ocr.name_from_consignee || 0) === 1,
mobile: Number(ocr.mobile_from_consignee || 0) === 1,
address: Number(ocr.address_from_consignee || 0) === 1,
};
const pm = data.patient_match || {};
patientMatched.value = !!pm.matched;
userPatientId.value = Number(pm.user_patient_id || 0);
@@ -303,7 +313,7 @@ async function rerunHistory(row: any) {
return;
}
imageUrls.value = urls;
await runOcr();
runOcr();
} catch (e: any) {
message.error(e?.message || '重新识别失败');
} finally {
@@ -328,6 +338,7 @@ function backToUploadMode() {
patientAge.value = 0;
userPatientId.value = 0;
patientMatched.value = false;
fromConsignee.value = { name: false, mobile: false, address: false };
}
/**
@@ -337,6 +348,7 @@ function backToUploadMode() {
function startNewUpload() {
backToUploadMode();
imageUrls.value = [];
consigneeText.value = '';
}
/**
@@ -417,14 +429,39 @@ function onTogglePaste() {
}
}
/** 传方识别前的收货人粘贴弹窗(复用识图传方页同款组件) */
const [ConsigneeFormModal, consigneeModalApi] = useVbenModal({
connectedComponent: ConsigneeModalComp,
});
/**
* 调用后端 OCR + 药名别名/拼音对照
* 点「开始识别」:传方先弹收货人粘贴框(可跳过);接诊开方没有患者字段,直接识别
*/
async function runOcr() {
function runOcr() {
if (!imageUrls.value.length) {
message.warning('请先上传处方图片');
return;
}
if (loading.value) return;
if (!isTransfer.value) {
void requestOcr();
return;
}
consigneeModalApi
.setData({
text: consigneeText.value,
onSubmit: (text: string) => {
consigneeText.value = text;
void requestOcr();
},
})
.open();
}
/**
* 调用后端 OCR + 药名别名/拼音对照
*/
async function requestOcr() {
loading.value = true;
matchedList.value = [];
unmatchedList.value = [];
@@ -435,6 +472,7 @@ async function runOcr() {
mode: mode.value,
register_id: registerId.value || undefined,
store_id: storeId.value || undefined,
consignee_text: isTransfer.value ? consigneeText.value || undefined : undefined,
});
if (!res?.ok) {
message.error(res?.message || '识图失败');
@@ -686,6 +724,8 @@ defineExpose({
patientAge.value = 0;
userPatientId.value = 0;
patientMatched.value = false;
consigneeText.value = '';
fromConsignee.value = { name: false, mobile: false, address: false };
unmatchedPicked.value = {};
statedDrugCount.value = 0;
dosageFromDays.value = false;
@@ -870,10 +910,12 @@ defineExpose({
<div>
<div class="mb-1 text-xs text-muted-foreground">姓名</div>
<Input v-model:value="patientName" placeholder="患者姓名" />
<div v-if="fromConsignee.name" class="consignee-hint">处方未写姓名,来自收货人信息</div>
</div>
<div>
<div class="mb-1 text-xs text-muted-foreground">手机号</div>
<Input v-model:value="patientMobile" placeholder="手机号" />
<div v-if="fromConsignee.mobile" class="consignee-hint">处方未写手机号,来自收货人信息</div>
</div>
<div>
<div class="mb-1 text-xs text-muted-foreground">性别</div>
@@ -894,6 +936,7 @@ defineExpose({
<div class="col-span-2">
<div class="mb-1 text-xs text-muted-foreground">地址</div>
<Input v-model:value="patientAddress" placeholder="收货地址(可后补)" />
<div v-if="fromConsignee.address" class="consignee-hint">处方未写地址,来自收货人信息</div>
</div>
<div class="col-span-2 text-xs" :class="patientMatched ? 'text-primary' : 'text-warning'">
{{ patientMatched ? '已匹配到已有就诊人' : '未匹配,确认后将新建就诊人' }}
@@ -993,9 +1036,17 @@ defineExpose({
</div>
</div>
</Drawer>
<ConsigneeFormModal />
</template>
<style scoped>
/** 收货人补位来源提示:次要文案色,自动适配暗色 */
.consignee-hint {
margin-top: 4px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.ocr-dragger {
background: hsl(var(--card, var(--background)));
border: 1px dashed hsl(var(--primary) / 40%) !important;

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
/**
* 患者处方记录抽屉:本次 | 历史,支持按订单号/订单ID搜索、展示支付态、复用/撤回
* 患者处方记录抽屉:本次 | 历史,支持按订单号/订单ID搜索、展示支付态、复用/撤回/申请公账
*/
import { computed, ref } from 'vue';
@@ -39,11 +39,22 @@ type DrawerData = {
onWithdrawn?: () => void;
/** 医生代支付:打开小程序码 */
onProxyPay?: (orderId: number) => void;
/** 履约诊所是否开通公账(与开方成功页同源) */
publicAccountPayEnabled?: boolean;
/** 触发时机:仅 doctor_confirm 露出申请入口 */
publicAccountPayTrigger?: 'immediate' | 'doctor_confirm';
/** 出账模式:daily 一键确认 / order 弹窗申请 */
publicAccountBillMode?: 'order' | 'daily';
/** 一单一结:打开申请公账弹窗 */
onApplyPublicAccountPay?: (row: Record<string, any>) => void;
/** 按天先用后付:确认公账已支付 */
onConfirmDailyPublicAccountPay?: (row: Record<string, any>) => void | Promise<void>;
};
const meta = ref<DrawerData>({});
const loading = ref(false);
const withdrawingId = ref(0);
const papSubmittingId = ref(0);
const listTab = ref<'current' | 'history'>('current');
const keyword = ref('');
const list = ref<Record<string, any>[]>([]);
@@ -51,6 +62,18 @@ const page = ref(1);
const hasMore = ref(false);
const pageSize = 20;
/** 待支付且诊所开通、触发为医生确认时才露出公账入口 */
function canApplyPublicAccount(row: Record<string, any>) {
const orderId = Number(row.order_id || 0);
if (!orderId) return false;
if (!meta.value.publicAccountPayEnabled) return false;
if (meta.value.publicAccountPayTrigger !== 'doctor_confirm') return false;
const unpaid =
Number(row.order_is_pay) === 0 ||
String(row.order_pay_text || '').includes('待支付');
return unpaid;
}
const categoryLabel = (c: number) => (Number(c) === 2 ? '医保' : '自费');
const typeLabel = (t: number) => {
@@ -175,6 +198,32 @@ async function handleWithdraw(row: Record<string, any>) {
}
}
/** 按天模式:父级确认后刷新列表支付态;取消确认不刷 */
async function handleDailyPublicAccountPay(row: Record<string, any>) {
const orderId = Number(row.order_id || 0);
if (!orderId || typeof meta.value.onConfirmDailyPublicAccountPay !== 'function') {
return;
}
papSubmittingId.value = orderId;
try {
await meta.value.onConfirmDailyPublicAccountPay(row);
await loadList(true);
} catch (e: any) {
if (e?.message === 'cancel') return;
message.error(e?.message || '公账支付失败');
} finally {
papSubmittingId.value = 0;
}
}
/** 一单一结:打开父级申请弹窗,成功回调里再刷列表 */
function handleApplyPublicAccountPay(row: Record<string, any>) {
meta.value.onApplyPublicAccountPay?.({
...row,
onSuccess: () => loadList(true),
});
}
const titleText = computed(() => {
const name = meta.value.patientName ? ` - ${meta.value.patientName}` : '';
return `患者处方记录${name}`;
@@ -285,6 +334,29 @@ defineExpose({ drawerApi });
>
代支付
</Button>
<Button
v-if="
canApplyPublicAccount(row) &&
meta.publicAccountBillMode === 'daily'
"
type="link"
size="small"
:loading="papSubmittingId === Number(row.order_id)"
@click="handleDailyPublicAccountPay(row)"
>
公账支付
</Button>
<Button
v-if="
canApplyPublicAccount(row) &&
meta.publicAccountBillMode === 'order'
"
type="link"
size="small"
@click="handleApplyPublicAccountPay(row)"
>
申请公账支付
</Button>
<Button
v-if="meta.allowWithdraw && row.can_withdraw && listTab === 'current'"
type="link"

View File

@@ -41,6 +41,31 @@ const showAuditActions = computed(
() => auditMode.value && Number(item.value?.status) === 0,
);
/**
* status=2「未通过」原因括号文案
* 超时自动失效 / 时间已过期 → 过期;有药师拒绝或其它拒绝原因 → 药师审核未通过
*/
const failAuditReasonLabel = computed(() => {
const row = item.value || {};
if (Number(row.status) !== 2) return '';
const rejectReason = String(row.reject_reason || '');
if (
rejectReason.includes('超时') ||
rejectReason.includes('自动失效') ||
rejectReason.includes('过期')
) {
return '过期';
}
const expireTxt = String(row.auro_expire_txt || row.auto_expire_txt || '');
if (expireTxt && Number(row.pharmacist_id || 0) < 1) {
return '过期';
}
if (Number(row.pharmacist_id || 0) > 0 || rejectReason) {
return '药师审核未通过';
}
return '药师审核未通过';
});
const [RejectionReasonModal, RejectionReasonModalApi] = useVbenModal({
connectedComponent: RejectionReason,
zIndex: ORDER_POPUP_Z_INDEX.NESTED3,
@@ -499,7 +524,7 @@ function resolveExcipients(prescription: Record<string, any>, recipe: Record<str
class="validity"
style="font-weight: bold; color: red"
>
该处方未通过审核
该处方未通过审核({{ failAuditReasonLabel }})
</div>
<div v-else-if="item.auto_expire_txt === ''" class="validity">
处方有效期: {{ item.valid_hours }}小时

View File

@@ -1004,6 +1004,8 @@ const selectPatient = async (patient: Patient, isUpdateTabType = true) => {
}
// 优先恢复完整草稿(诊断、医嘱、诊疗费、中药配置等)
const hasDraft = await restoreReceptionDraft(registerId);
// 有草稿则不再用默认煎药方案覆盖医生已填配置
processPresetAutoApply.value = !hasDraft;
if (!hasDraft) {
const savedCategory = localStorage.getItem(`activeCategory${registerId}`);
if (savedCategory) {
@@ -1866,55 +1868,105 @@ function openProxyPayQrcode(orderId: number) {
ProxyPayQrcodeModalApi.open();
}
/** 开方成功页申请公账支付(一单一结:弹窗可选凭证) */
function openApplyPublicAccountPay() {
const info = sentPrescriptionInfo.value || {};
const oid = Number(info.order_id || 0);
/**
* 申请公账支付(一单一结:弹窗可选凭证)
* 开方成功页 / 处方记录抽屉共用,避免两处条件分叉
*/
function openApplyPublicAccountPayForOrder(payload: {
order_id: number;
order_no?: string;
total_pay_price?: string | number;
onSuccess?: (res?: any) => void | Promise<void>;
}) {
const oid = Number(payload.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,
order_no: payload.order_no,
total_pay_price: payload.total_pay_price,
bill_mode: 'order',
onSuccess: payload.onSuccess,
});
ApplyPublicAccountPayModalApi.open();
}
/** 按天先用后付:一键确认公账已支付,无需上传凭证 */
function confirmDailyPublicAccountPay() {
/** 开方成功页:申请公账支付 */
function openApplyPublicAccountPay() {
const info = sentPrescriptionInfo.value || {};
const oid = Number(info.order_id || 0);
openApplyPublicAccountPayForOrder({
order_id: Number(info.order_id || 0),
order_no: info.order_no,
total_pay_price: info.total_pay_price,
});
}
/**
* 按天先用后付:一键确认公账已支付
* 返回 Promise,供处方记录抽屉 await 后刷新列表
*/
function confirmDailyPublicAccountPayForOrder(payload: {
order_id: number;
order_no?: string;
}): Promise<void> {
const oid = Number(payload.order_id || 0);
if (!oid) {
message.error('订单ID无效');
return;
return Promise.reject(new Error('订单ID无效'));
}
AntModal.confirm({
title: '确认公账已支付',
content: `订单 ${info.order_no || oid} 将标记为已支付,并计入当日日汇总。`,
okText: '确认已支付',
cancelText: '取消',
async onOk() {
publicAccountPaySubmitting.value = true;
try {
const res = await applyPublicAccountPay({
order_id: oid,
apply_remark: '医生确认公账已支付',
});
message.success(
res?.bill_mode === 'daily'
? '订单已标记为已支付,将计入当日日汇总'
: '已提交公账支付申请',
);
if (sentPrescriptionInfo.value && res?.bill_mode === 'daily') {
sentPrescriptionInfo.value = { ...sentPrescriptionInfo.value, is_pay: 1 };
return new Promise((resolve, reject) => {
AntModal.confirm({
title: '确认公账已支付',
content: `订单 ${payload.order_no || oid} 将标记为已支付,并计入当日日汇总。`,
okText: '确认已支付',
cancelText: '取消',
async onOk() {
publicAccountPaySubmitting.value = true;
try {
const res = await applyPublicAccountPay({
order_id: oid,
apply_remark: '医生确认公账已支付',
});
message.success(
res?.bill_mode === 'daily'
? '订单已标记为已支付,将计入当日日汇总'
: '已提交公账支付申请',
);
if (sentPrescriptionInfo.value && Number(sentPrescriptionInfo.value.order_id) === oid) {
if (res?.bill_mode === 'daily') {
sentPrescriptionInfo.value = {
...sentPrescriptionInfo.value,
is_pay: 1,
};
}
}
resolve();
} catch (e) {
reject(e);
throw e;
} finally {
publicAccountPaySubmitting.value = false;
}
} finally {
publicAccountPaySubmitting.value = false;
}
},
},
onCancel() {
reject(new Error('cancel'));
},
});
});
}
/** 开方成功页:按天公账确认 */
function confirmDailyPublicAccountPay() {
const info = sentPrescriptionInfo.value || {};
void confirmDailyPublicAccountPayForOrder({
order_id: Number(info.order_id || 0),
order_no: info.order_no,
}).catch((e) => {
if (e?.message !== 'cancel') {
console.error(e);
}
});
}
@@ -3137,13 +3189,15 @@ function openPatientMrCiteDrawer() {
}
/** 打开处方记录抽屉 */
function openPatientRxHistoryDrawer() {
async function openPatientRxHistoryDrawer() {
// 挂号 id 必须用 getRegisterId(当前选中挂号),禁止用就诊人 id 冒充
const registerId = getRegisterId();
if (!registerId) {
message.warning('请先选择接诊患者');
return;
}
// 打开前重拉履约店公账开关,避免锁店 4101 后脏状态导致一直不显示申请按钮
await fetchStoreSeeRate();
patientRxHistoryDrawerApi.setData({
registerId,
userPatientId: Number(activePatient.value?.id || 0),
@@ -3153,6 +3207,22 @@ function openPatientRxHistoryDrawer() {
onReuse: reusePrescriptionFromList,
// 点击处方号打开详情(与医生名片/患者管理一致)
onDetail: (prescriptionId: number) => openPrescriptionDetail(prescriptionId),
publicAccountPayEnabled: publicAccountPayEnabled.value,
publicAccountPayTrigger: publicAccountPayTrigger.value,
publicAccountBillMode: publicAccountBillMode.value,
onApplyPublicAccountPay: (row: Record<string, any>) => {
openApplyPublicAccountPayForOrder({
order_id: Number(row.order_id || 0),
order_no: row.order_no,
total_pay_price: row.total_pay_price,
onSuccess: row.onSuccess,
});
},
onConfirmDailyPublicAccountPay: (row: Record<string, any>) =>
confirmDailyPublicAccountPayForOrder({
order_id: Number(row.order_id || 0),
order_no: row.order_no,
}),
onWithdrawn: () => {
if (selectPatientId.value) {
getPatientItem(selectPatientId.value).then((value) => {
@@ -3215,6 +3285,11 @@ async function handleChineseConfigChange(payload: ChineseMedicineConfigChange) {
const oldRuleType = ruleType.value;
const oldProcessRuleId = processRuleId.value;
const oldChildProcessRuleId = childProcessRuleId.value;
// 预设整包回填时同一次 payload 已带下级 ID,不能先清空再丢值(对齐小程序)
const keepChild =
payload.childProcessRuleId != null && Number(payload.childProcessRuleId) > 0;
const keepNote =
payload.processRuleNoteId != null && Number(payload.processRuleNoteId) > 0;
if (payload.ruleType === 2 && payload.ruleType !== oldRuleType) {
if (processRuleList.value.length === 0) {
@@ -3230,13 +3305,17 @@ async function handleChineseConfigChange(payload: ChineseMedicineConfigChange) {
processRuleNoteList.value = [];
}
if (payload.processRuleId != null && payload.processRuleId !== oldProcessRuleId) {
childProcessRuleId.value = undefined;
processRuleNoteId.value = undefined;
if (!keepChild) {
childProcessRuleId.value = undefined;
processRuleNoteId.value = undefined;
}
childProcessRuleList.value = [];
processRuleNoteList.value = [];
}
if (payload.childProcessRuleId != null && payload.childProcessRuleId !== oldChildProcessRuleId) {
processRuleNoteId.value = undefined;
if (!keepNote) {
processRuleNoteId.value = undefined;
}
processRuleNoteList.value = [];
}
@@ -3259,6 +3338,8 @@ async function handleChineseConfigChange(payload: ChineseMedicineConfigChange) {
const packageMethodId = ref(2);
const dosage = ref(7);
const dayDosage = ref(2);
/** 无草稿覆盖时才自动应用煎药默认方案 */
const processPresetAutoApply = ref(true);
watch(
[
@@ -4068,7 +4149,7 @@ function onStoreSelectOpenChange(open: boolean) {
<ReloadOutlined />
</Button>
</div>
<div class="patient-list-actions mb-2 mt-1 flex flex-wrap gap-1">
<div class="patient-list-actions mb-1 mt-1 flex flex-wrap gap-1">
<Button size="small" type="primary" ghost @click="openCreateUserPatientModal">
<UserAddOutlined />
就诊人
@@ -4101,30 +4182,40 @@ function onStoreSelectOpenChange(open: boolean) {
class="patient-card"
@click="selectPatient(patient)"
>
<!-- 选中态粒子:纯 CSS 浮动光点,不挡点击 -->
<span class="card-particles" aria-hidden="true">
<i v-for="n in 6" :key="n" :style="{ '--i': n - 1 }"></i>
</span>
<div class="patient-info">
<h3 class="flex flex-wrap items-center gap-2">
{{ patient.user_patient.name }}
<Tag
v-if="patient.special_prescription_patient_record"
:color="patient.special_prescription_patient_record.status === 0 ? 'orange' : 'default'"
>
特色方{{
patient.special_prescription_patient_record.status === 0
? '·待导入'
: ''
}}
</Tag>
</h3>
<p class="phone">
<SensitiveText :record="patient.user_patient" field="mobile" :show-eye="false" />
</p>
<p class="text-sm text-gray-500">
<!-- 姓名+手机同一行,压缩行高 -->
<div class="patient-title-row">
<h3 class="patient-name">
{{ patient.user_patient.name }}
<Tag
v-if="patient.special_prescription_patient_record"
class="patient-special-tag"
:color="patient.special_prescription_patient_record.status === 0 ? 'orange' : 'default'"
>
特色方{{
patient.special_prescription_patient_record.status === 0
? '·待导入'
: ''
}}
</Tag>
</h3>
<p class="phone">
<SensitiveText :record="patient.user_patient" field="mobile" :show-eye="false" />
</p>
</div>
<p class="patient-salesperson">
推广员:{{ patient.salesperson?.nick_name || '无' }}
</p>
<span :class="getRegisterStatus(patient.status)" class="status">{{
<div class="patient-meta">
<span :class="getRegisterStatus(patient.status)" class="status">{{
getRegisterStatus(patient.status)
}}</span>
{{ patient.updated_at }}
<span class="patient-time">{{ patient.updated_at }}</span>
</div>
</div>
</div>
</div>
@@ -4529,9 +4620,10 @@ function onStoreSelectOpenChange(open: boolean) {
<template #addonAfter>{{ drug.unit?.name || 'g' }}</template>
</InputNumber>
</div>
<!-- 煎法放到第二行文字链,第一行只留药名和克数,避免下拉盖住单位 -->
<!-- 三块同一行:煎法 | 价格(可换行) | 更新价格;避开 .card-price 绝对定位 -->
<div class="chinese-drug-meta">
<ChineseDrugWayBubble
class="meta-way"
:model-value="drug?.way_id || 0"
:options="drugUseWay"
:disabled="isSpecialPrescriptionCartLocked"
@@ -4539,17 +4631,20 @@ function onStoreSelectOpenChange(open: boolean) {
placeholder="煎法"
@change="(id) => onChineseDrugWayChange(index, id)"
/>
<p class="card-price">
¥<span>{{ (drug.price * (drug.number || 0)).toFixed(2) }}</span>
<div class="chinese-drug-meta-price">
<span class="chinese-drug-price-amount">
¥{{ (drug.price * (drug.number || 0)).toFixed(2) }}
</span>
<span
v-if="getChineseItemMargin(drug) && getChineseItemMargin(drug) !== '--'"
class="ml-2 text-gray-500 text-sm"
class="chinese-drug-margin"
>
毛利率 {{ getChineseItemMargin(drug) }}%
</span>
</p>
</div>
<Button
v-if="!isSpecialPrescriptionCartLocked"
class="meta-update"
type="link"
size="small"
@click="updateOneChinesePrice(index)"
@@ -5018,6 +5113,7 @@ function onStoreSelectOpenChange(open: boolean) {
<div class="diagnosis-area">
<ChineseMedicineConfig
v-if="activeCategory === 1"
:key="`cmc-${selectPatientId || 0}-${Number(myStoreId || 0)}`"
:rule-type="ruleType"
:package-method-id="packageMethodId"
:process-rule-id="processRuleId"
@@ -5030,6 +5126,8 @@ function onStoreSelectOpenChange(open: boolean) {
:process-rule-note-list="processRuleNoteList"
:disabled="isSpecialPrescriptionCartLocked"
:error-keys="chineseErrorKeys"
:store-id="Number(myStoreId || 0)"
:auto-apply-default="processPresetAutoApply"
class="mb-5 p-3"
@change="handleChineseConfigChange"
/>
@@ -5442,28 +5540,61 @@ function onStoreSelectOpenChange(open: boolean) {
overflow: visible;
}
/* 第二行:左侧煎法文字链,右侧价格,互不抢第一行宽度 */
/* 中药第二行三块同行:煎法 | 价格 | 更新价格;仅价格区可内部换行 */
.chinese-drug-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 4px 8px;
flex-wrap: nowrap;
align-items: flex-start;
gap: 8px;
margin-top: 2px;
padding-left: 38px;
padding-right: 36px;
min-height: 22px;
box-sizing: border-box;
}
.chinese-drug-meta .card-price {
position: static;
right: auto;
bottom: auto;
margin: 0;
flex: 0 1 auto;
line-height: 1.3;
.chinese-drug-meta .meta-way {
flex: 0 0 auto;
}
.chinese-drug-meta-price {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-content: flex-start;
align-items: baseline;
gap: 0 8px;
flex: 1 1 auto;
min-width: 0;
text-align: right;
}
.chinese-drug-price-amount {
color: red;
line-height: 1.3;
white-space: nowrap;
}
.chinese-drug-margin {
color: hsl(var(--muted-foreground));
font-size: 12px;
line-height: 1.3;
white-space: nowrap;
}
.chinese-drug-meta .meta-update {
flex: 0 0 auto;
padding: 0;
height: auto;
line-height: 1.3;
}
.chinese-drug-meta :deep(.ant-btn-link) {
padding-inline: 0;
height: auto;
line-height: 1.3;
}
.chinese-drug-sep {
flex: 0 0 auto;
color: hsl(var(--muted-foreground));
@@ -5496,7 +5627,6 @@ function onStoreSelectOpenChange(open: boolean) {
}
.chinese-drug-meta :deep(.chinese-drug-way-bubble) {
flex: 0 1 auto !important;
width: auto !important;
min-width: 0 !important;
max-width: none !important;
@@ -5581,7 +5711,7 @@ function onStoreSelectOpenChange(open: boolean) {
overflow-x: hidden;
opacity: 1;
transition: opacity 0.2s ease;
padding: 0 8px 12px;
padding: 0 6px 8px;
}
.reception-layout.is-left-collapsed .patient-panel {
opacity: 0;
@@ -5630,26 +5760,157 @@ function onStoreSelectOpenChange(open: boolean) {
}
.patient-card {
padding: 1rem;
border-bottom: 1px solid #eee;
position: relative;
/* 粒子由 .card-particles 自己裁切,卡片不设 overflow:hidden,否则呼吸光晕会被裁掉 */
padding: 8px 10px;
margin: 0 4px 4px;
border-radius: 6px;
border: 1px solid transparent;
border-bottom: 1px solid hsl(var(--border));
background: transparent;
cursor: pointer;
transition: all 0.3s;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.dark .patient-card {
border-bottom: 1px solid #333;
.patient-card:hover {
background: hsl(var(--muted) / 0.35);
}
/* 选中态:主题色边框 + 呼吸微光 + 粒子,禁止左侧竖条 */
.patient-card.active {
border-left: 4px solid #455cda;
background-color: rgba(69, 92, 218, 0.1);
border: 1px solid hsl(var(--primary) / 32%);
background: hsl(var(--primary) / 0.08);
animation: card-breathe 2.4s ease-in-out infinite;
}
.card-particles {
pointer-events: none;
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
opacity: 0;
z-index: 0;
}
.patient-card.active .card-particles {
opacity: 1;
}
.card-particles i {
position: absolute;
width: 3px;
height: 3px;
border-radius: 50%;
background: hsl(var(--primary));
box-shadow: 0 0 4px hsl(var(--primary) / 70%);
left: calc(8% + var(--i) * 15%);
bottom: 6%;
opacity: 0;
animation: particle-float 2.6s ease-in-out infinite;
animation-delay: calc(var(--i) * 0.28s);
}
.patient-info {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.patient-title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.patient-name {
font-size: 14px;
font-weight: 600;
margin: 0;
line-height: 1.3;
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
}
.patient-special-tag {
margin-inline-end: 0 !important;
line-height: 16px;
font-size: 11px;
padding-inline: 4px;
}
.patient-card .phone {
font-size: 12px;
margin: 0;
line-height: 1.3;
color: hsl(var(--muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.patient-salesperson {
font-size: 11px;
margin: 0;
line-height: 1.3;
color: hsl(var(--muted-foreground));
}
.patient-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.patient-time {
font-size: 11px;
color: hsl(var(--muted-foreground));
flex-shrink: 0;
}
@keyframes card-breathe {
0%,
100% {
box-shadow: 0 0 5px hsl(var(--primary) / 16%);
background: hsl(var(--primary) / 0.06);
border-color: hsl(var(--primary) / 26%);
}
50% {
box-shadow: 0 0 14px hsl(var(--primary) / 34%);
background: hsl(var(--primary) / 0.14);
border-color: hsl(var(--primary) / 48%);
}
}
@keyframes particle-float {
0% {
transform: translateY(0) scale(0.5);
opacity: 0;
}
18% {
opacity: 0.75;
}
100% {
transform: translateY(-34px) scale(1);
opacity: 0;
}
}
.status {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8em;
padding: 0 6px;
border-radius: 3px;
font-size: 11px;
line-height: 18px;
}
.status.待支付 {

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
/**
* 识图传方「开始识别」前的收货人粘贴弹窗(可选)
* 粘贴的整段文本随图片一起交给同一次 AI 识别,由后端拆出姓名/手机/地址;
* 处方上没有手机号/地址/姓名时,就诊人用收货人的信息补位。
*
* 打开时通过 modalApi.setData 传入:
* - text: 回填上次粘贴内容(同图重新识别时不用再贴一遍)
* - onSubmit(text): 点「识别」或「跳过直接识别」后的回调,跳过时 text 为空串
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
const onSubmitRef = ref<null | ((text: string) => void)>(null);
const [Form, formApi] = useVbenForm({
showDefaultActions: false,
commonConfig: { hideLabel: true, formItemClass: 'col-span-1' },
schema: [
{
component: 'Textarea',
fieldName: 'consignee_text',
label: '收货人信息',
componentProps: {
rows: 5,
maxlength: 500,
showCount: true,
placeholder:
'可粘贴整段收货信息,例如:\n张三 13800000000\n浙江省杭州市西湖区文三路 100 号',
},
},
],
});
/**
* 统一出口:先关弹窗再回调,避免识别 loading 期间弹窗还挡在上面
*/
function submit(text: string) {
const cb = onSubmitRef.value;
modalApi.close();
cb?.(text.trim());
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
confirmText: '识别',
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const values = await formApi.getValues();
submit(String(values.consignee_text || ''));
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = (modalApi.getData() || {}) as Record<string, any>;
onSubmitRef.value = typeof data.onSubmit === 'function' ? data.onSubmit : null;
formApi.setValues({ consignee_text: String(data.text || '') });
},
});
/** 不填收货人,按原逻辑只识别处方图 */
function handleSkip() {
submit('');
}
</script>
<template>
<Modal title="粘贴收货人信息(可选)" class="w-[520px]">
<div class="mb-2 text-xs text-muted-foreground">
处方上没有手机号或地址时,就诊人将使用这里识别出的收货人信息;不需要可直接跳过。
</div>
<Form />
<template #prepend-footer>
<Button @click="handleSkip">跳过直接识别</Button>
</template>
</Modal>
</template>

View File

@@ -8,7 +8,7 @@
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { useDebounceFn } from '@vueuse/core';
import {
@@ -44,6 +44,7 @@ import {
import { buildIncrementalQuotePayload } from '#/utils/imageTransferQuote';
import FullscreenImageUpload from './components/FullscreenImageUpload.vue';
import ConsigneeModalComp from './components/ConsigneeModal.vue';
import OcrImageLoading from '#/components/loading/OcrImageLoading.vue';
import { OcrOriginImagePanel } from '#/components/ocr-origin-image-panel';
import EntrustedProcessPicker from '#/components/entrusted-process-picker/EntrustedProcessPicker.vue';
@@ -104,6 +105,10 @@ const patientSex = ref(0);
const patientAge = ref(0);
const userPatientId = ref(0);
const patientMatched = ref(false);
/** 识别前粘贴的收货人文本;同图重新识别时回填,换新图清空 */
const consigneeText = ref('');
/** 后端补位来源标记:处方上缺失、改用收货人信息的字段 */
const fromConsignee = ref({ name: false, mobile: false, address: false });
const matchedList = ref<any[]>([]);
const unmatchedList = ref<any[]>([]);
/** 未匹配行:接诊同款气泡手选结果 */
@@ -419,6 +424,11 @@ function applyOcrPayload(data: {
patientAddress.value = String(ocr.patient_address || '');
patientSex.value = Number(ocr.sex || 0);
patientAge.value = Number(ocr.age || 0);
fromConsignee.value = {
name: Number(ocr.name_from_consignee || 0) === 1,
mobile: Number(ocr.mobile_from_consignee || 0) === 1,
address: Number(ocr.address_from_consignee || 0) === 1,
};
const pm = data.patient_match || {};
patientMatched.value = !!pm.matched;
userPatientId.value = Number(pm.user_patient_id || 0);
@@ -560,6 +570,7 @@ function resetFormState() {
patientAge.value = 0;
userPatientId.value = 0;
patientMatched.value = false;
fromConsignee.value = { name: false, mobile: false, address: false };
matchedList.value = [];
unmatchedList.value = [];
unmatchedPicked.value = {};
@@ -584,6 +595,7 @@ function startNewUpload() {
submitResult.value = null;
phase.value = 'upload';
imageUrls.value = [];
consigneeText.value = '';
resetFormState();
activeGenerationId.value = 0;
}
@@ -617,12 +629,34 @@ function normalizeStoreProductRow(row: any) {
};
}
/** 上传区点「开始识别」 */
async function handleRecognize() {
/** 识别前的收货人粘贴弹窗 */
const [ConsigneeFormModal, consigneeModalApi] = useVbenModal({
connectedComponent: ConsigneeModalComp,
});
/**
* 上传区点「开始识别」:先弹收货人粘贴框(可跳过),确定后再真正识图
* 为什么放在识别前:收货人文本和图片同一次交给 AI,不额外多一次调用
*/
function handleRecognize() {
if (!imageUrls.value.length) {
message.warning('请先上传处方图');
return;
}
if (recognizing.value) return;
consigneeModalApi
.setData({
text: consigneeText.value,
onSubmit: (text: string) => {
consigneeText.value = text;
void runRecognize();
},
})
.open();
}
/** 带上收货人文本调用识图接口并回填表单 */
async function runRecognize() {
recognizing.value = true;
resetFormState();
try {
@@ -633,6 +667,7 @@ async function handleRecognize() {
image_urls: imageUrls.value,
mode: 'transfer',
store_id: storeId.value || undefined,
consignee_text: consigneeText.value || undefined,
});
if (!res?.ok) {
message.error(res?.message || '识图失败');
@@ -1065,10 +1100,16 @@ async function handleConfirm() {
<div>
<div class="mb-1 text-xs text-muted-foreground">姓名</div>
<Input v-model:value="patientName" placeholder="患者姓名" />
<div v-if="fromConsignee.name" class="consignee-hint">
处方未写姓名,来自收货人信息
</div>
</div>
<div>
<div class="mb-1 text-xs text-muted-foreground">手机号</div>
<Input v-model:value="patientMobile" placeholder="手机号" />
<div v-if="fromConsignee.mobile" class="consignee-hint">
处方未写手机号,来自收货人信息
</div>
</div>
<div>
<div class="mb-1 text-xs text-muted-foreground">性别</div>
@@ -1093,6 +1134,9 @@ async function handleConfirm() {
v-model:value="patientAddress"
placeholder="收货地址(可后补)"
/>
<div v-if="fromConsignee.address" class="consignee-hint">
处方未写地址,来自收货人信息
</div>
</div>
<div
class="text-xs sm:col-span-2"
@@ -1226,10 +1270,18 @@ async function handleConfirm() {
</div>
</div>
</div>
<ConsigneeFormModal />
</Page>
</template>
<style scoped>
/** 收货人补位来源提示:次要文案色,自动适配暗色 */
.consignee-hint {
margin-top: 4px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.transfer-upload-phase {
position: relative;
min-height: 0;

View File

@@ -155,21 +155,28 @@ defineExpose({
<div
v-for="(patient, index) in patients"
:key="`patient-${getPatientUniqueId(patient) || index}`"
:class="['patient-card', 'border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800', { 'active border-l-4 border-l-blue-600 bg-blue-50 dark:bg-blue-900/20': selectedPatientId === getPatientUniqueId(patient) }]"
:class="['patient-card', { active: selectedPatientId === getPatientUniqueId(patient) }]"
@click.stop="selectPatient(patient)"
>
<!-- 选中态粒子:纯 CSS 浮动光点,不挡点击 -->
<span class="card-particles" aria-hidden="true">
<i v-for="n in 6" :key="n" :style="{ '--i': n - 1 }"></i>
</span>
<div class="patient-info">
<h3 class="patient-name">
{{ patient.user_patient?.name || patient.user?.nick_name || '未知患者' }}
</h3>
<p class="patient-phone">
<SensitiveText
:record="patient.user_patient"
field="mobile"
:fallback="{ record: patient.user, field: 'mobile' }"
:show-eye="false"
/>
</p>
<!-- 姓名+手机同一行,压缩行高 -->
<div class="patient-title-row">
<h3 class="patient-name">
{{ patient.user_patient?.name || patient.user?.nick_name || '未知患者' }}
</h3>
<p class="patient-phone">
<SensitiveText
:record="patient.user_patient"
field="mobile"
:fallback="{ record: patient.user, field: 'mobile' }"
:show-eye="false"
/>
</p>
</div>
<p v-if="patient.order_no" class="order-no">订单号:{{ patient.order_no }}</p>
<div class="patient-meta">
<span :class="['status', getStatusClass(patient.status)]">
@@ -191,7 +198,7 @@ defineExpose({
}
.list-header {
padding: 16px;
padding: 8px 4px 10px;
}
.header-top {
@@ -228,48 +235,144 @@ defineExpose({
.patient-list {
flex: 1;
overflow-y: auto;
/* 给选中态微光留出左右边距,避免被 overflow 裁切 */
padding: 4px 6px;
}
.patient-card {
padding: 16px;
position: relative;
/* 粒子由 .card-particles 自己裁切,卡片不设 overflow:hidden,否则呼吸光晕会被裁掉 */
padding: 8px 10px;
margin-bottom: 4px;
cursor: pointer;
transition: all 0.3s;
border-radius: 6px;
border: 1px solid transparent;
border-bottom: 1px solid hsl(var(--border));
background: transparent;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.patient-card:hover {
background: hsl(var(--muted) / 0.35);
}
/* 选中态:主题色边框 + 呼吸微光 + 粒子,禁止左侧竖条 */
.patient-card.active {
border: 1px solid hsl(var(--primary) / 32%);
background: hsl(var(--primary) / 0.08);
animation: card-breathe 2.4s ease-in-out infinite;
}
.card-particles {
pointer-events: none;
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
opacity: 0;
z-index: 0;
}
.patient-card.active .card-particles {
opacity: 1;
}
.card-particles i {
position: absolute;
width: 3px;
height: 3px;
border-radius: 50%;
background: hsl(var(--primary));
box-shadow: 0 0 4px hsl(var(--primary) / 70%);
left: calc(8% + var(--i) * 15%);
bottom: 6%;
opacity: 0;
animation: particle-float 2.6s ease-in-out infinite;
animation-delay: calc(var(--i) * 0.28s);
}
.patient-info {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
@keyframes card-breathe {
0%,
100% {
box-shadow: 0 0 5px hsl(var(--primary) / 16%);
background: hsl(var(--primary) / 0.06);
border-color: hsl(var(--primary) / 26%);
}
50% {
box-shadow: 0 0 14px hsl(var(--primary) / 34%);
background: hsl(var(--primary) / 0.14);
border-color: hsl(var(--primary) / 48%);
}
}
@keyframes particle-float {
0% {
transform: translateY(0) scale(0.5);
opacity: 0;
}
18% {
opacity: 0.75;
}
100% {
transform: translateY(-34px) scale(1);
opacity: 0;
}
}
.patient-title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.patient-name {
font-size: 16px;
font-size: 14px;
font-weight: 600;
margin: 0;
line-height: 1.3;
flex-shrink: 0;
}
.patient-phone {
font-size: 14px;
font-size: 12px;
margin: 0;
line-height: 1.3;
color: hsl(var(--muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-no {
font-size: 12px;
margin: 4px 0 0 0;
font-size: 11px;
margin: 0;
line-height: 1.3;
color: hsl(var(--muted-foreground));
}
.patient-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
margin-top: 2px;
}
.status {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
padding: 0 6px;
border-radius: 3px;
font-size: 11px;
line-height: 18px;
font-weight: 500;
}
@@ -289,7 +392,8 @@ defineExpose({
}
.time {
font-size: 12px;
font-size: 11px;
flex-shrink: 0;
}
/* 暗色模式适配 */

View File

@@ -164,21 +164,28 @@ defineExpose({
<div
v-for="(patient, index) in patients"
:key="`patient-${getPatientUniqueId(patient) || index}`"
:class="['patient-card', 'border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800', { 'active border-l-4 border-l-blue-600 bg-blue-50 dark:bg-blue-900/20': selectedPatientId === getPatientUniqueId(patient) }]"
:class="['patient-card', { active: selectedPatientId === getPatientUniqueId(patient) }]"
@click.stop="selectPatient(patient)"
>
<!-- 选中态粒子:纯 CSS 浮动光点,不挡点击 -->
<span class="card-particles" aria-hidden="true">
<i v-for="n in 6" :key="n" :style="{ '--i': n - 1 }"></i>
</span>
<div class="patient-info">
<h3 class="patient-name">
{{ patient.user_patient?.name || patient.user?.nick_name || '未知患者' }}
</h3>
<p class="patient-phone">
<SensitiveText
:record="patient.user_patient"
field="mobile"
:fallback="{ record: patient.user, field: 'mobile' }"
:show-eye="false"
/>
</p>
<!-- 姓名+手机同一行,压缩行高 -->
<div class="patient-title-row">
<h3 class="patient-name">
{{ patient.user_patient?.name || patient.user?.nick_name || '未知患者' }}
</h3>
<p class="patient-phone">
<SensitiveText
:record="patient.user_patient"
field="mobile"
:fallback="{ record: patient.user, field: 'mobile' }"
:show-eye="false"
/>
</p>
</div>
<p v-if="patient.order_no" class="order-no">订单号:{{ patient.order_no }}</p>
<div class="patient-meta">
<div class="meta-left">
@@ -208,7 +215,7 @@ defineExpose({
}
.list-header {
padding: 16px;
padding: 8px 4px 10px;
}
.header-top {
@@ -245,54 +252,151 @@ defineExpose({
.patient-list {
flex: 1;
overflow-y: auto;
/* 给选中态微光留出左右边距,避免被 overflow 裁切 */
padding: 4px 6px;
}
.patient-card {
padding: 16px;
position: relative;
/* 粒子由 .card-particles 自己裁切,卡片不设 overflow:hidden,否则呼吸光晕会被裁掉 */
padding: 8px 10px;
margin-bottom: 4px;
cursor: pointer;
transition: all 0.3s;
border-radius: 6px;
border: 1px solid transparent;
border-bottom: 1px solid hsl(var(--border));
background: transparent;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.patient-card:hover {
background: hsl(var(--muted) / 0.35);
}
/* 选中态:主题色边框 + 呼吸微光 + 粒子,禁止左侧竖条 */
.patient-card.active {
border: 1px solid hsl(var(--primary) / 32%);
background: hsl(var(--primary) / 0.08);
animation: card-breathe 2.4s ease-in-out infinite;
}
.card-particles {
pointer-events: none;
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
opacity: 0;
z-index: 0;
}
.patient-card.active .card-particles {
opacity: 1;
}
.card-particles i {
position: absolute;
width: 3px;
height: 3px;
border-radius: 50%;
background: hsl(var(--primary));
box-shadow: 0 0 4px hsl(var(--primary) / 70%);
left: calc(8% + var(--i) * 15%);
bottom: 6%;
opacity: 0;
animation: particle-float 2.6s ease-in-out infinite;
animation-delay: calc(var(--i) * 0.28s);
}
.patient-info {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
@keyframes card-breathe {
0%,
100% {
box-shadow: 0 0 5px hsl(var(--primary) / 16%);
background: hsl(var(--primary) / 0.06);
border-color: hsl(var(--primary) / 26%);
}
50% {
box-shadow: 0 0 14px hsl(var(--primary) / 34%);
background: hsl(var(--primary) / 0.14);
border-color: hsl(var(--primary) / 48%);
}
}
@keyframes particle-float {
0% {
transform: translateY(0) scale(0.5);
opacity: 0;
}
18% {
opacity: 0.75;
}
100% {
transform: translateY(-34px) scale(1);
opacity: 0;
}
}
.patient-title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.patient-name {
font-size: 16px;
font-size: 14px;
font-weight: 600;
margin: 0;
line-height: 1.3;
flex-shrink: 0;
}
.patient-phone {
font-size: 14px;
font-size: 12px;
margin: 0;
line-height: 1.3;
color: hsl(var(--muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-no {
font-size: 12px;
margin: 4px 0 0 0;
font-size: 11px;
margin: 0;
line-height: 1.3;
color: hsl(var(--muted-foreground));
}
.patient-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
margin-top: 2px;
}
.meta-left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
gap: 4px;
}
.register-type-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
padding: 0 6px;
border-radius: 3px;
font-size: 11px;
line-height: 18px;
font-weight: 500;
background: #e6f7ff;
color: #1890ff;
@@ -307,9 +411,10 @@ defineExpose({
.transfer-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
padding: 0 6px;
border-radius: 3px;
font-size: 11px;
line-height: 18px;
font-weight: 500;
background: #fff7ed;
color: #ea580c;
@@ -324,9 +429,10 @@ defineExpose({
.status {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
padding: 0 6px;
border-radius: 3px;
font-size: 11px;
line-height: 18px;
font-weight: 500;
}
@@ -346,7 +452,8 @@ defineExpose({
}
.time {
font-size: 12px;
font-size: 11px;
flex-shrink: 0;
}
/* 暗色模式适配 */

View File

@@ -352,14 +352,12 @@ const handleAddPatientProductsToPrescription = async () => {
</script>
<template>
<div
class="chat-box-body flex bg-gradient-to-br from-blue-900 via-purple-900 to-pink-300 p-5"
style="height: 95vh"
>
<!-- 主容器 -->
<!-- 在线接诊主布局:去掉彩色渐变外框,左右栏铺满内容区,避免四周露底 -->
<div class="chat-box-body flex h-full min-h-0">
<!-- 主容器:占满可用高度/宽度,无圆角阴影空隙 -->
<div
:class="leftShow ? 'container-box-grid' : 'container-box'"
class="z-10 mx-auto flex flex-1 overflow-hidden rounded-2xl bg-white/90 shadow-2xl backdrop-blur-sm"
class="z-10 flex flex-1 overflow-hidden bg-[hsl(var(--background))]"
>
<!-- 连接状态指示器 -->
<ConnectionStatus />
@@ -370,7 +368,7 @@ const handleAddPatientProductsToPrescription = async () => {
<!-- 订单列表浮动按钮 -->
<OrderListFloatButton :register-id="registerId" />
<!-- 左侧患者列表 -->
<!-- 左侧患者列表:背景铺满侧栏,避免渐变去掉后露出空缺 -->
<div class="left-panel-wrapper">
<Button
:class="leftShow ? 'w-full mb-2' : 'sticky top-5 left-5'"
@@ -388,7 +386,7 @@ const handleAddPatientProductsToPrescription = async () => {
/>
</div>
<!-- 右侧内容区域 -->
<!-- 右侧内容区域:同样铺满高度 -->
<div class="right-content-area">
<!-- 列表模式:显示患者信息和接诊 -->
<template v-if="viewMode === 'list'">
@@ -403,9 +401,9 @@ const handleAddPatientProductsToPrescription = async () => {
/>
</template>
<!-- 聊天模式:显示聊天界面 -->
<!-- 聊天模式:必须有固定高度链,MessageInput 才不会被消息列表顶出视口 -->
<template v-else>
<div class="min-w-0 flex-1 min-h-0">
<div class="chat-mode-wrap">
<ChatArea
v-if="chatStore.currentFriend"
@open-prescription="openPrescriptionModule"
@@ -421,22 +419,42 @@ const handleAddPatientProductsToPrescription = async () => {
</template>
<style scoped>
/**
* 必须用固定 height(不能只用 min-height):
* ChatArea 是「头 + flex-1 消息 + 底栏输入」三截布局,
* 父级高度不封顶时消息区会把输入框顶出可视范围。
* 120px 约等于顶栏+标签栏+内容区内边距,与原先 95vh 效果接近。
*/
.chat-box-body {
height: calc(100vh - 120px);
max-height: calc(100vh - 120px);
overflow: hidden;
}
.container-box,
.container-box-grid {
height: 100%;
max-height: 100%;
overflow: hidden;
}
.container-box {
min-height: 90vh;
display: flex;
flex-direction: column;
}
.container-box-grid {
min-height: 90vh;
display: grid;
grid-template-columns: 300px 1fr;
gap: 0;
}
.left-panel-wrapper {
border-right: 1px solid #e4e7ed;
padding: 16px;
height: 100%;
min-height: 0;
border-right: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
padding: 10px;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -444,28 +462,32 @@ const handleAddPatientProductsToPrescription = async () => {
.patient-list-panel {
flex: 1;
min-height: 0;
overflow: hidden;
}
.right-content-area {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: hsl(var(--background));
}
.chat-mode-wrap {
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.patient-info-panel {
flex: 1;
overflow-y: auto;
}
.dark .left-panel-wrapper {
border-right-color: #334155;
background: #1e293b;
}
.dark .container-box-grid {
background: #1f2937;
}
</style>

View File

@@ -45,6 +45,11 @@ const [Modal, modalApi] = useVbenModal({
? '已申请公账支付,订单已标记已付,将计入当日日汇总'
: '已提交公账支付申请,请等待管理员确认到账',
);
// 列表/抽屉传入的刷新回调,避免申请成功后仍显示待支付
const data = modalApi.getData<Record<string, any>>() || {};
if (typeof data.onSuccess === 'function') {
await data.onSuccess(res);
}
modalApi.close();
} finally {
modalApi.unlock();

View File

@@ -8,7 +8,6 @@ import { debounce } from 'lodash-es';
import { Page, useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
import {
Alert,
Button,
@@ -25,7 +24,7 @@ import {
import { TableAction } from '#/components/table-action';
import StoreMultiSearch from '#/components/form/components/store-multi-search.vue';
import SearchTimeRangePicker from '#/components/form/components/search-time-range-picker.vue';
import { SearchTimeModePicker, useSearchTimeMode } from '#/components/search-time-mode';
import {
getReconciliationDrugList,
getReconciliationDrugOptions,
@@ -88,8 +87,17 @@ const canViewInvoicePrice = computed(
const activeTab = ref('store');
/** 整页共用时间 */
const searchTime = ref<[Dayjs, Dayjs]>(monthToTodayRangeDayjs());
/** 「日期范围」tab 的区间,本地缓存沿用原 key(reconciliation.page.search_time) */
const rangeTime = ref<[Dayjs, Dayjs]>(loadPageSearchTimeRange());
/**
* 时间筛选 tab(日期范围 / 按天)和「按天」选的日期由 useSearchTimeMode 记到本地;
* searchTime 是整页共用的生效区间,按天时为当天 00:00:00 ~ 23:59:59
*/
const {
day: searchDay,
mode: searchTimeMode,
searchTime,
} = useSearchTimeMode({ range: rangeTime, storageKey: 'reconciliation.page' });
/** 对账诊所 Tab 多选门店(诊所+药店) */
const storeIds = ref<number[]>([]);
const excludeZeroSales = ref(loadExcludeZeroSales());
@@ -225,11 +233,11 @@ async function fetchDrugList() {
}
}
/**
* 时间筛选变更(切 tab / 改范围 / 选某天)后立刻刷新当前列表。
* SearchTimeModePicker 先同步写回 v-model 再发 change,所以这里读到的 searchTime 已是新区间。
*/
function onSearchTimeChange() {
if (!searchTime.value?.[0] || !searchTime.value?.[1]) {
return;
}
savePageSearchTimeRange(searchTime.value);
if (activeTab.value === 'store') {
fetchStoreList();
} else if (activeTab.value === 'drug') {
@@ -348,11 +356,13 @@ function popupContainerBody() {
}
onMounted(() => {
searchTime.value = loadPageSearchTimeRange();
excludeZeroSales.value = loadExcludeZeroSales();
fetchStoreList();
});
// 只缓存「日期范围」tab 的区间;按天的单日区间不写进来,切回范围时还是用户原来选的
watch(rangeTime, (range) => savePageSearchTimeRange(range));
watch(activeTab, (key) => {
if (key === 'store') {
fetchStoreList();
@@ -380,10 +390,12 @@ watch(byOrder, () => {
:statistics="statistics"
/>
<div class="mb-3 flex flex-wrap items-center gap-3 rounded border px-3 py-2">
<span class="text-sm text-muted-foreground">时间范围(全页共用)</span>
<SearchTimeRangePicker
v-model:value="searchTime"
<div class="mb-3 rounded border px-3 py-2">
<SearchTimeModePicker
v-model:mode="searchTimeMode"
v-model:range="rangeTime"
v-model:day="searchDay"
label="时间筛选(全页共用)"
format="YYYY-MM-DD"
:get-popup-container="popupContainerBody"
@change="onSearchTimeChange"

View File

@@ -149,7 +149,13 @@ const initCharts = () => {
const searchTime = ref<[Dayjs, Dayjs]>(monthToTodayRangeDayjs());
const searchTimeTouched = ref(false);
const handleTimeChange = () => {
/**
* 时间变更后拉图表;优先用 change 载荷写回,避免偶发读到旧 searchTime。
*/
const handleTimeChange = (range?: [Dayjs, Dayjs] | null) => {
if (range?.[0] && range?.[1]) {
searchTime.value = range;
}
searchTimeTouched.value = true;
getData();
};

View File

@@ -15,6 +15,7 @@ import {
refuseApplicationApi,
} from '#/views/finance/withdrawal-audit/api';
import { getWithdrawalApplicationInfo } from '#/views/finance/withdrawal/api';
import { resolveWithdrawSubjectName } from '#/views/finance/withdrawal/utils/resolve-withdraw-subject-name';
import { withdrawalAudit } from '../config/form';
@@ -94,13 +95,7 @@ function viewOrder(od: Record<string, any>) {
<span class="label">申请主体</span>
<span class="value">
<Tag color="green">{{ preview.user_type_txt || '-' }}</Tag>
{{
preview.store?.name ||
preview.supplier?.name ||
preview.delivery_warehouse?.name ||
preview.deliveryWarehouse?.name ||
(Number(preview.user_type) === 2 ? '萧康云医' : '-')
}}
{{ resolveWithdrawSubjectName(preview) }}
</span>
</div>
<div class="preview-row">

View File

@@ -9,11 +9,14 @@ import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { useFreshSearchTime } from '#/composables/use-fresh-search-time';
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import StoreNameLink from '#/components/store-card/StoreNameLink.vue';
import { TableAction } from '#/components/table-action';
import { useFreshSearchTime } from '#/composables/use-fresh-search-time';
import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
import StatisticsReconciliation from '#/views/finance/reconciliation/components/statistics.vue';
import WithdrawalRelationDetail from '#/views/finance/withdrawal/components/withdrawal-relation-detail.vue';
import { resolveWithdrawSubjectName } from '#/views/finance/withdrawal/utils/resolve-withdraw-subject-name';
import { useWithdrawOrderDetailModals } from '#/views/finance/withdrawal/utils/use-withdraw-order-detail';
import WithdrawalAudit from './components/WithdrawalAudit.vue';
@@ -61,6 +64,21 @@ const [RelationDetailModal, relationDetailApi] = useVbenModal({
connectedComponent: WithdrawalRelationDetail,
});
/** 诊所详情弹窗:与订单列表 StoreNameLink 同一套交互 */
const [StoreCardModalComp, storeCardModalApi] = useVbenModal({
connectedComponent: StoreCardModal,
});
/**
* 点击诊所名打开门店详情卡
* 入参用 storeId,与订单列表 / StoreCardModal 约定一致
*/
const openStoreCard = (storeId?: number) => {
if (!storeId) return;
storeCardModalApi.setData({ storeId: Number(storeId) });
storeCardModalApi.open();
};
/**
* 打开审核弹窗
* @param id 申请 id
@@ -90,6 +108,7 @@ const openDetail = (id: number) => {
<RegisterDetailModal />
<TraceDrawer />
<RelationDetailModal />
<StoreCardModalComp />
<StatisticsReconciliation v-if="statistics" :statistics="statistics" />
<Grid>
<template #toolbar-actions>
@@ -102,16 +121,19 @@ const openDetail = (id: number) => {
</template>
</TableAction>
</template>
<!-- 主体名按 user_type 取值;诊所用 StoreNameLink 对齐订单列表样式 -->
<template #user-info="{ row }">
<div>
<Tag color="green">{{ row?.user_type_txt || '平台' }}</Tag>
<Tag class="mt-3" color="#455cda">{{
row.store?.name ||
row.supplier?.name ||
row.delivery_warehouse?.name ||
row.deliveryWarehouse?.name ||
'萧康云医'
}}</Tag>
<div class="mt-2">
<StoreNameLink
v-if="Number(row.user_type) === 1"
:store-id="Number(row.store?.id || row.user_id || 0) || undefined"
:name="row.store?.name"
@click="openStoreCard"
/>
<span v-else>{{ resolveWithdrawSubjectName(row) }}</span>
</div>
</div>
</template>
<template #charge-cash="{ row }">

View File

@@ -11,6 +11,7 @@ import { Button, Spin, Steps, Table, Tag } from 'ant-design-vue';
import { getChargeCashPayRecordDetailApi } from '#/views/finance/charge-cash-pay-record/api';
import { getWithdrawalApplicationInfo } from '#/views/finance/withdrawal/api';
import { resolveWithdrawSubjectName } from '#/views/finance/withdrawal/utils/resolve-withdraw-subject-name';
defineOptions({
name: 'WithdrawalRelationDetail',
@@ -183,12 +184,8 @@ function normalizeFromApply(res: Record<string, any>) {
dakuan_status_txt: res?.dakuan_status_txt,
dakuan_time: res?.dakuan_time,
user_type_txt: res?.user_type_txt,
subject_name:
res?.store?.name ||
res?.supplier?.name ||
res?.delivery_warehouse?.name ||
res?.deliveryWarehouse?.name ||
(Number(res?.user_type) === 2 ? '萧康云医' : '-'),
// 必须按 user_type 取值,避免配送仓误显示成同 id 供应商名
subject_name: resolveWithdrawSubjectName(res),
charge_status: res?.charge_status ?? charge?.status,
charge_status_txt: res?.charge_status_txt,
charge_created_at: charge?.created_at,

View File

@@ -0,0 +1,26 @@
/**
* 按 user_type 解析提现申请主体名称
* 为什么不能 store || supplier || delivery_warehouse:三者都挂 user_id,
* 配送仓(user_type=4) 若恰好存在同 id 供应商会被误显示成供应商名
*/
export function resolveWithdrawSubjectName(
row?: Record<string, any> | null,
): string {
if (!row) return '-';
const userType = Number(row.user_type);
if (userType === 1) {
return String(row.store?.name || '-');
}
if (userType === 2) {
return '萧康云医';
}
if (userType === 3) {
return String(row.supplier?.name || '-');
}
if (userType === 4) {
return String(
row.delivery_warehouse?.name || row.deliveryWarehouse?.name || '-',
);
}
return '-';
}

View File

@@ -3,6 +3,7 @@
* 布局级站内信动作宿主
* - 消息详情走自定义 NoticeSceneHost(非 VbenModal)
* - 业务审核表单仍复用列表页 Vben Modal(仓品完整审核 / 提现审核等)
* - 物流查询失败:直接打开商品订单物流动态弹窗
*/
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -10,6 +11,7 @@ import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import WithdrawalAudit from '#/views/finance/withdrawal-audit/components/WithdrawalAudit.vue';
import LogisticsModal from '#/views/business/order/product-order/components/logistics-modal.vue';
import WarehouseAuditModal from '#/views/system/delivery-warehouse-product-audit/components/audit-modal.vue';
import { readApi } from '#/views/notice/api';
import NoticeSceneHost from '#/views/notice/scenes/NoticeSceneHost.vue';
@@ -28,6 +30,9 @@ const [WithdrawAuditModal, withdrawAuditApi] = useVbenModal({
const [WarehouseModal, warehouseModalApi] = useVbenModal({
connectedComponent: WarehouseAuditModal,
});
const [ProductOrderLogisticsModal, logisticsModalApi] = useVbenModal({
connectedComponent: LogisticsModal,
});
const readSentIds = new Set<number>();
@@ -64,6 +69,15 @@ function openWarehouseAudit(bizId: number) {
warehouseModalApi.open();
}
/**
* 打开商品订单物流动态(站内信 express_track_fail 入口)
*/
function openProductOrderLogistics(orderId: number) {
if (orderId <= 0) return;
logisticsModalApi.setData({ order_id: orderId });
logisticsModalApi.open();
}
/** 打开消息专属场景弹层 */
function openScene(item: NoticeItemLike) {
sceneNotice.value = { ...item };
@@ -76,6 +90,12 @@ 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_PRODUCT_ORDER_LOGISTICS) {
const orderId = Number(payload.order_id || payload.biz_id || 0);
openProductOrderLogistics(orderId);
return;
}
if (action === NOTICE_ACTION.OPEN_PUBLIC_ACCOUNT_PAY) {
const id = Number(payload.biz_id || payload.id || 0);
router.push({
@@ -137,4 +157,5 @@ onUnmounted(() => {
/>
<WithdrawAuditModal />
<WarehouseModal />
<ProductOrderLogisticsModal />
</template>

View File

@@ -13,6 +13,8 @@ export const NOTICE_ACTION = {
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',
/** 打开商品订单物流动态弹窗 */
OPEN_PRODUCT_ORDER_LOGISTICS: 'open_product_order_logistics',
} as const;
export type NoticeActionType =

View File

@@ -60,6 +60,12 @@ export const NOTICE_SCENE_META: Record<string, NoticeSceneMeta> = {
tone: 'danger',
size: 'lg',
},
express_track_fail: {
title: '物流查询失败',
kicker: 'EXPRESS',
tone: 'danger',
size: 'md',
},
warehouse_product_audit: {
title: '仓品待审',
kicker: 'WAREHOUSE',

View File

@@ -0,0 +1,93 @@
<script lang="ts" setup>
/**
* 消息场景 · 物流查询失败
* 展示运单号与第三方错误文案;主路径由 NoticeActionHost 直接打开物流动态弹窗
*/
import { computed } from 'vue';
import { Descriptions } from 'ant-design-vue';
const props = defineProps<{
notice: Record<string, any>;
}>();
const payload = computed(() => props.notice?.action_payload || {});
const orderId = computed(() =>
Number(payload.value.order_id || payload.value.biz_id || 0),
);
const expressNo = computed(() => String(payload.value.express_no || '').trim());
const thirdMessage = computed(() =>
String(payload.value.message || props.notice?.detail || '').trim(),
);
</script>
<template>
<div class="express-fail-view">
<div class="hero">
<div class="kicker">物流查询失败</div>
<h3 class="title">{{ notice.title || '物流查询失败' }}</h3>
<p class="desc">{{ notice.detail || '第三方物流查询失败,请核对运单信息' }}</p>
</div>
<Descriptions :column="1" bordered size="small">
<Descriptions.Item v-if="orderId > 0" label="订单 ID">
{{ orderId }}
</Descriptions.Item>
<Descriptions.Item v-if="expressNo" label="运单号">
{{ expressNo }}
</Descriptions.Item>
<Descriptions.Item v-if="thirdMessage" label="第三方返回">
<span class="err-msg">{{ thirdMessage }}</span>
</Descriptions.Item>
</Descriptions>
<p class="hint">点击本消息会同步打开该订单的物流动态弹窗。</p>
</div>
</template>
<style scoped>
.express-fail-view {
padding: 2px 0;
}
.hero {
margin-bottom: 14px;
padding: 16px;
border-radius: 14px;
background:
radial-gradient(
70% 120% at 100% 0%,
hsl(25 95% 53% / 0.16),
transparent 55%
),
hsl(var(--muted) / 0.35);
}
.kicker {
font-size: 12px;
font-weight: 650;
color: hsl(25 90% 45%);
}
.title {
margin: 8px 0 0;
font-size: 18px;
font-weight: 720;
color: hsl(var(--foreground));
}
.desc {
margin: 8px 0 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.err-msg {
color: hsl(var(--warning, 38 92% 50%));
word-break: break-all;
}
.hint {
margin: 12px 0 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -5,6 +5,7 @@
import type { Component } from 'vue';
import DoctorInputNoticeView from './DoctorInputNoticeView.vue';
import ExpressTrackFailView from './ExpressTrackFailView.vue';
import InvoiceNoticeView from './InvoiceNoticeView.vue';
import PeriodReportView from './PeriodReportView.vue';
import PriceChangeNoticeView from './PriceChangeNoticeView.vue';
@@ -22,6 +23,7 @@ export const NOTICE_TYPE_VIEW: Record<string, Component> = {
withdraw_audit: WithdrawNoticeView,
invoice_todo: InvoiceNoticeView,
queue_failure: QueueFailureView,
express_track_fail: ExpressTrackFailView,
doctor_input_audit: DoctorInputNoticeView,
store_input_audit: StoreInputNoticeView,
warehouse_product_audit: WarehouseProductNoticeView,

View File

@@ -31,6 +31,8 @@ export const ADMIN_ROLE_LIST: AdminRoleMeta[] = [
{ id: 14, title: '诊所推广员', slug: 'clinic-salesperson', formType: 'clinic' },
{ id: 15, title: '订单管理员', slug: 'order', formType: 'basic' },
{ id: 16, title: '配送仓库管理员', slug: 'delivery-warehouse', formType: 'deliveryWarehouse' },
// 传方员必须绑店:formType=clinic 才会出「所属诊所」必填,否则 JWT store_id=0 识图报「门店无效」
{ id: 17, title: '传方员', slug: 'transfer-clerk', formType: 'clinic' },
];
export const ROLE_CITY_MANAGER = 4;

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
/**
* 传方员账号管理(role_id=17)
* 复用诊所表单:强制所属诊所,避免未绑店导致小程序识图「门店无效」
*/
import AdminPage from '../_shared/admin-page.vue';
defineOptions({ name: 'SystemAdminTransferClerk' });
</script>
<template>
<AdminPage :role-id="17" />
</template>

View File

@@ -35,6 +35,17 @@ export async function getAiPlatformConfigOptions() {
return requestClient.get<any>(`${prefix}config-options`);
}
/**
* 查询平台余额(xk-api GET /ai-platform/balance)
* 用不支持余额查询的平台会返回 balance_txt='--';DeepSeek 用当前选用密钥查官方 /user/balance
*/
export async function getAiPlatformBalance(data: {
platform_code: string;
api_key_id?: number;
}) {
return requestClient.get<any>(`${prefix}balance`, { params: data });
}
/** 系统配置:AI 功能文案列表 */
export async function getAiFeatureCopyList() {
return requestClient.get<any>(`${prefix}copy-list`);

View File

@@ -26,8 +26,40 @@ export async function getDeliveryWarehouseOrderDetail(id: number | string) {
}
/**
* 配送仓库订单发货
* 配送仓库订单发货(可同时提交收件信息)
*/
export async function sendDeliveryWarehouseOrder(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}ware-send`, data);
}
/**
* 仓侧发货弹窗打开:拉取订单当前收件信息
*/
export async function getDeliveryWarehouseShipReceiver(orderId: number) {
return requestClient.get<{
order_id: number;
order_no: string;
delivery_method: number;
express_name: string;
express_mobile: string;
express_region: string;
express_address: string;
}>(`${prefix}ship-receiver`, {
params: { order_id: orderId },
});
}
/**
* 仓侧发货弹窗「用诊所地址」
*/
export async function getDeliveryWarehouseStoreShipAddress(orderId: number) {
return requestClient.get<{
express_name: string;
express_mobile: string;
express_region: string;
express_address: string;
store_name: string;
}>(`${prefix}store-ship-address`, {
params: { order_id: orderId },
});
}

View File

@@ -1,22 +1,151 @@
<script lang="ts" setup>
/**
* 配送仓库发货弹窗
* 确认时自行校验并调发货接口;不再走已弃用的 validateAndSubmitForm(会表现为点确定无反应)
* 配送仓库发货弹窗(左右分栏)
* 左:收件信息默认展示、可改/取消;右:物流发货
* 收件表单 v-show 常驻,避免加载中 setValues 卡死
*/
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Button, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { sendDeliveryWarehouseOrder } from '#/views/system/delivery-warehouse-order/api';
import { modalFormProps } from '#/views/system/delivery-warehouse-order/config/form';
import {
getDeliveryWarehouseShipReceiver,
getDeliveryWarehouseStoreShipAddress,
sendDeliveryWarehouseOrder,
} from '#/views/system/delivery-warehouse-order/api';
import {
receiverFormProps,
shipFormProps,
} from '#/views/system/delivery-warehouse-order/config/form';
const gridApi = ref();
const orderNo = ref('');
const orderId = ref(0);
const storeAddrLoading = ref(false);
const receiverLoading = ref(false);
const receiverEditing = ref(false);
const receiverView = ref({
express_name: '',
express_mobile: '',
express_region: '',
express_address: '',
});
const receiverSnapshot = ref({
express_name: '',
express_mobile: '',
express_region: '',
express_address: '',
});
const [Form, formApi] = useVbenForm(modalFormProps);
const [ReceiverForm, receiverFormApi] = useVbenForm(receiverFormProps);
const [ShipForm, shipFormApi] = useVbenForm(shipFormProps);
const fullAddressText = computed(() => {
const region = (receiverView.value.express_region || '').trim();
const detail = (receiverView.value.express_address || '').trim();
return [region, detail].filter(Boolean).join(' ') || '—';
});
function pickReceiverFromOrder(data: Record<string, any> = {}) {
const addr = data?.address && typeof data.address === 'object' ? data.address : {};
return {
express_name: String(data?.express_name || addr.name || ''),
express_mobile: String(data?.express_mobile || addr.mobile || ''),
express_region: String(data?.express_region || addr.region || ''),
express_address: String(
data?.express_address || addr.detail_address || '',
),
};
}
function normalizeReceiver(next: {
express_name?: string;
express_mobile?: string;
express_region?: string;
express_address?: string;
}) {
return {
express_name: String(next.express_name || ''),
express_mobile: String(next.express_mobile || ''),
express_region: String(next.express_region || ''),
express_address: String(next.express_address || ''),
};
}
function applyReceiver(next: {
express_name?: string;
express_mobile?: string;
express_region?: string;
express_address?: string;
}) {
const payload = normalizeReceiver(next);
receiverView.value = { ...payload };
void receiverFormApi.setValues(payload);
}
async function loadShipReceiver(fallback?: Record<string, any>) {
if (!orderId.value) return;
receiverLoading.value = true;
try {
const res = await getDeliveryWarehouseShipReceiver(orderId.value);
applyReceiver(res || {});
} catch (err) {
console.error(err);
if (fallback) {
applyReceiver(pickReceiverFromOrder(fallback));
}
} finally {
receiverLoading.value = false;
}
}
async function syncReceiverViewFromForm() {
const values = await receiverFormApi.getValues();
receiverView.value = normalizeReceiver(values);
}
async function enterReceiverEdit() {
if (receiverLoading.value) return;
receiverSnapshot.value = { ...receiverView.value };
await receiverFormApi.setValues({ ...receiverView.value });
receiverEditing.value = true;
}
async function finishReceiverEdit() {
const e = await receiverFormApi.validate();
if (!e.valid) return;
await syncReceiverViewFromForm();
receiverEditing.value = false;
}
async function cancelReceiverEdit() {
applyReceiver(receiverSnapshot.value);
receiverEditing.value = false;
}
async function fillStoreAddress() {
if (!orderId.value) {
message.warning('订单信息缺失');
return;
}
storeAddrLoading.value = true;
try {
const res = await getDeliveryWarehouseStoreShipAddress(orderId.value);
if (!receiverEditing.value) {
receiverSnapshot.value = { ...receiverView.value };
receiverEditing.value = true;
}
applyReceiver(res || {});
message.success('已填入诊所地址');
} catch (err) {
console.error(err);
} finally {
storeAddrLoading.value = false;
}
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
@@ -25,12 +154,32 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
if (receiverEditing.value) {
const re = await receiverFormApi.validate();
if (!re.valid) return;
await syncReceiverViewFromForm();
receiverEditing.value = false;
} else {
const re = await receiverFormApi.validate();
if (!re.valid) {
receiverEditing.value = true;
return;
}
}
const se = await shipFormApi.validate();
if (!se.valid) return;
const receiver = await receiverFormApi.getValues();
const ship = await shipFormApi.getValues();
const payload: Record<string, any> = {
...ship,
express_name: receiver.express_name,
express_mobile: receiver.express_mobile,
express_region: receiver.express_region,
express_address: receiver.express_address,
};
modalApi.setState({ loading: true, confirmLoading: true });
try {
await sendDeliveryWarehouseOrder(values);
await sendDeliveryWarehouseOrder(payload);
message.success('发货成功');
gridApi.value?.reload?.();
modalApi.close();
@@ -44,14 +193,21 @@ const [Modal, modalApi] = useVbenModal({
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values } = modalApi.getData<Record<string, any>>() || {};
receiverEditing.value = false;
receiverLoading.value = false;
receiverFormApi.reset();
shipFormApi.reset();
if (values) {
orderNo.value = values.order_no || '';
formApi.setValues({
...values,
orderId.value = Number(values.order_id || values.id || 0);
applyReceiver(pickReceiverFromOrder(values));
shipFormApi.setValues({
order_id: values.order_id,
express_no: values.express_no || '',
express_company_code: values.express_company_code || '',
introduce: values.introduce || '',
});
loadShipReceiver(values);
}
}
},
@@ -60,8 +216,208 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal
:title="`订单:【 ${orderNo} 】发货`"
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
class="ship-modal w-[92%] md:w-[860px] lg:w-[960px] h-[85%]"
>
<Form />
<div class="ship-layout">
<section class="ship-pane ship-pane--receiver">
<div class="ship-pane__head">
<h4 class="ship-pane__title">收件信息</h4>
<div class="ship-pane__actions">
<Button
type="link"
size="small"
class="!px-1"
:loading="storeAddrLoading"
:disabled="receiverLoading"
@click="fillStoreAddress"
>
用诊所地址
</Button>
<template v-if="!receiverEditing">
<Button
type="link"
size="small"
class="!px-1"
:disabled="receiverLoading"
@click="enterReceiverEdit"
>
修改
</Button>
</template>
<template v-else>
<Button
type="link"
size="small"
class="!px-1"
@click="cancelReceiverEdit"
>
取消
</Button>
<Button
type="link"
size="small"
class="!px-1"
@click="finishReceiverEdit"
>
完成
</Button>
</template>
</div>
</div>
<div
v-show="!receiverEditing"
class="receiver-view"
:class="{ 'is-loading': receiverLoading }"
role="button"
tabindex="0"
@click="enterReceiverEdit"
@keydown.enter.prevent="enterReceiverEdit"
>
<div v-if="receiverLoading" class="receiver-loading">
收件信息加载中…
</div>
<template v-else>
<div class="receiver-view__row">
<span class="receiver-view__label">收件人</span>
<span class="receiver-view__value">
{{ receiverView.express_name || '—' }}
</span>
</div>
<div class="receiver-view__row">
<span class="receiver-view__label">手机号</span>
<span class="receiver-view__value">
{{ receiverView.express_mobile || '—' }}
</span>
</div>
<div class="receiver-view__row receiver-view__row--block">
<span class="receiver-view__label">地址</span>
<span class="receiver-view__value">{{ fullAddressText }}</span>
</div>
<p class="receiver-view__hint">点击此处可修改收件信息</p>
</template>
</div>
<div v-show="receiverEditing" class="receiver-edit">
<ReceiverForm />
</div>
</section>
<section class="ship-pane ship-pane--ship">
<div class="ship-pane__head">
<h4 class="ship-pane__title">发货信息</h4>
</div>
<ShipForm />
</section>
</div>
</Modal>
</template>
<style scoped>
.ship-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
min-height: 420px;
height: 100%;
}
.ship-pane {
display: flex;
flex-direction: column;
min-height: 0;
padding: 16px;
border-radius: 10px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
box-shadow: 0 0 6px hsl(var(--primary) / 10%);
}
.ship-pane--receiver {
border-color: hsl(var(--primary) / 28%);
box-shadow: 0 0 8px hsl(var(--primary) / 14%);
}
.ship-pane__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.ship-pane__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: hsl(var(--foreground));
}
.ship-pane__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.receiver-view {
flex: 1;
display: flex;
flex-direction: column;
gap: 14px;
padding: 8px 4px;
cursor: pointer;
border-radius: 8px;
outline: none;
transition: background 0.15s ease;
}
.receiver-view.is-loading {
cursor: wait;
pointer-events: none;
}
.receiver-view:hover,
.receiver-view:focus-visible {
background: hsl(var(--primary) / 0.06);
}
.receiver-view__row {
display: flex;
align-items: baseline;
gap: 12px;
}
.receiver-view__row--block {
flex-direction: column;
gap: 6px;
}
.receiver-view__label {
flex-shrink: 0;
width: 52px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.receiver-view__row--block .receiver-view__label {
width: auto;
}
.receiver-view__value {
flex: 1;
font-size: 15px;
line-height: 1.5;
color: hsl(var(--foreground));
word-break: break-all;
}
.receiver-view__hint {
margin: auto 0 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.receiver-edit {
flex: 1;
min-height: 0;
overflow: auto;
}
.receiver-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
@media (max-width: 768px) {
.ship-layout {
grid-template-columns: 1fr;
min-height: 0;
}
}
</style>

View File

@@ -1,14 +1,71 @@
import type { VbenFormProps } from '#/adapter/form';
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
/** 仓侧发货弹窗左侧:收件信息编辑 */
export const receiverFormProps: VbenFormProps = {
layout: 'vertical',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
wrapperClass: 'grid-cols-12',
schema: [
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入收件人',
maxlength: 50,
},
fieldName: 'express_name',
label: '收件人',
rules: 'required',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入收件手机号',
maxlength: 11,
},
fieldName: 'express_mobile',
label: '手机号',
rules: 'required',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '省市区,可与详细地址合并填写',
maxlength: 100,
},
fieldName: 'express_region',
label: '所在地区',
},
{
component: 'Textarea',
componentProps: {
placeholder: '请输入详细地址',
rows: 3,
maxlength: 255,
showCount: true,
},
fieldName: 'express_address',
label: '详细地址',
rules: 'required',
},
],
showDefaultActions: false,
};
/** 仓侧发货弹窗右侧:物流表单 */
export const shipFormProps: VbenFormProps = {
layout: 'vertical',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
wrapperClass: 'grid-cols-12',
schema: [
{
component: 'VbenInput',
@@ -34,10 +91,8 @@ export const modalFormProps: VbenFormProps = {
placeholder: '请选择快递公司',
},
fieldName: 'express_company_code',
formItemClass: 'col-span-6',
label: '快递公司',
rules: 'required',
// 单号变化时传入 trackingNo,组件按前缀自动匹配快递公司
dependencies: {
triggerFields: ['express_no'],
componentProps(values) {
@@ -52,6 +107,7 @@ export const modalFormProps: VbenFormProps = {
component: 'Textarea',
componentProps: {
placeholder: '请输入发货备注',
rows: 3,
},
fieldName: 'introduce',
label: '发货备注',
@@ -59,3 +115,6 @@ export const modalFormProps: VbenFormProps = {
],
showDefaultActions: false,
};
/** @deprecated 兼容旧引用 */
export const modalFormProps = shipFormProps;

View File

@@ -112,12 +112,18 @@ const gridApiLike = {
query: () => loadList(),
};
/** 打开发货弹窗 */
/** 打开发货弹窗(带上当前收件信息供确认/修改) */
function wareSend(row: Record<string, any>) {
const addr =
row?.address && typeof row.address === 'object' ? row.address : {};
formModalApi.setData({
values: {
order_id: row?.id,
order_no: row?.order_no,
express_name: row?.express_name || addr.name || '',
express_mobile: row?.express_mobile || addr.mobile || '',
express_region: row?.express_region || addr.region || '',
express_address: row?.express_address || addr.detail_address || '',
},
gridApi: gridApiLike,
});

View File

@@ -112,13 +112,24 @@ function openStoreCard(row: Record<string, any>) {
/**
* 打开药店二维码预览(本页固定 type=1)
* 传入 mp_short_link:弹窗展示/复制已落库的 #小程序:// 短链,并可手动生成
*/
const openQrCodeModal = (url, title, address, type = 1) => {
const openQrCodeModal = (
url,
title,
address,
type = 1,
id = 0,
mpShortLink: Record<string, any> = {},
) => {
QrCodePreviewApi.setData({
values: url,
title,
address,
type,
id,
mp_short_link: mpShortLink,
onGenerated: () => gridApi.query(),
});
QrCodePreviewApi.open();
};
@@ -538,6 +549,8 @@ const batchSyncDrugPrice = () => {
row.name,
`${row.province.name}${row.city.name}${row.position}`,
row.type ?? 1,
row.id,
row.mp_short_link || {},
)
"
/>

View File

@@ -504,3 +504,8 @@ function canDrillRow(row: RegionRow) {
</div>
</Page>
</template>

View File

@@ -185,6 +185,23 @@ export async function openQrCodeApi(id: number) {
});
}
/** 查询门店患者端 Short Link */
export async function getStoreMpShortLinkApi(id: number) {
return requestClient.get<any>(`${prefix}mp-short-link`, { params: { id } });
}
/**
* 手动生成患者端 Short Link(#小程序://…)
* type: permanent=长期 temporary=短期;force=1 强制重生成长期
*/
export async function generateStoreMpShortLinkApi(data: {
force?: number;
id: number;
type: 'permanent' | 'temporary';
}) {
return requestClient.post<any>(`${prefix}generate-mp-short-link`, data);
}
/**
* 更新门店包邮状态
* @param data

View File

@@ -160,13 +160,24 @@ const openAddDoctorModal = (row: any) => {
/**
* 打开诊所二维码预览(本页固定 type=0)
* 传入 mp_short_link:弹窗展示/复制已落库的 #小程序:// 短链,并可手动生成
*/
const openQrCodeModal = (url, title, address, type = 0) => {
const openQrCodeModal = (
url,
title,
address,
type = 0,
id = 0,
mpShortLink: Record<string, any> = {},
) => {
QrCodePreviewApi.setData({
values: url,
title,
address,
type,
id,
mp_short_link: mpShortLink,
onGenerated: () => gridApi.query(),
});
QrCodePreviewApi.open();
};
@@ -732,6 +743,8 @@ const handleSwitchClinicType = (row: any) => {
row.name,
`${row.province.name}${row.city.name}${row.position}`,
row.type ?? 0,
row.id,
row.mp_short_link || {},
)
"
/>

View File

@@ -46,6 +46,29 @@ export async function saveFreeShippingConfig(data: {
return requestClient.post<any>(`${prefix}save-free-shipping-config`, data);
}
/** 患者端 Short Link 长/短期已用次数 */
export async function getMpShortLinkQuota() {
return requestClient.get<{
permanent_used: number;
permanent_limit: number;
permanent_remain: number;
temporary_used: number;
temporary_limit: number;
temporary_remain: null | number;
}>(`${prefix}mp-short-link-quota`);
}
/**
* 手动调整 Short Link 已用次数
* delta>0 增加已用(扣减剩余,测试环境对账用);delta<0 纠错回退
*/
export async function adjustMpShortLinkQuota(data: {
delta: number;
type: 'permanent' | 'temporary';
}) {
return requestClient.post<any>(`${prefix}adjust-mp-short-link-quota`, data);
}
const wxEntryPrefix = 'wx-workbench-entry/';
/** 某角色已分配的医生端功能入口(含 is_default_fav) */

View File

@@ -22,6 +22,7 @@ import {
import {
getAiFeatureCopyList,
getAiPlatformBalance,
getAiPlatformConfigOptions,
saveAiFeatureCopy,
} from '#/views/system/ai/platform/api';
@@ -41,6 +42,10 @@ interface PlatformCard {
description: string;
default_model: string;
api_url: string;
/** 1=支持第三方余额查询(如 DeepSeek),0=不支持 */
balance_supported?: number;
/** 展示文案;不支持或未选用密钥时为 -- */
balance_txt?: string;
models: {
code: string;
description: string;
@@ -78,6 +83,10 @@ const activeApiKeyId = ref<number | undefined>(undefined);
const activeModel = ref('');
const featureCopies = ref<FeatureCopyRow[]>([]);
const subTab = ref('model');
/** 首屏 load 完成后再监听密钥切换去刷余额,避免与 config-options 内嵌查询打架 */
const balanceWatchEnabled = ref(false);
/** 余额刷新中(仅当前选用且支持查询的平台) */
const balanceLoading = ref(false);
/** 病历配置:可选字段与已勾选编码 */
const mrFieldOptions = ref<{ label: string; value: string }[]>([]);
const mrSelectedFields = ref<string[]>([]);
@@ -164,7 +173,14 @@ async function load() {
getAiFeatureCopyList(),
]);
const data = optRes?.data || optRes || {};
platforms.value = Array.isArray(data.platforms) ? data.platforms : [];
balanceWatchEnabled.value = false;
platforms.value = (Array.isArray(data.platforms) ? data.platforms : []).map(
(p: PlatformCard) => ({
...p,
balance_supported: Number(p.balance_supported || 0),
balance_txt: String(p.balance_txt || '--'),
}),
);
const active = data.active || {};
activeProvider.value = String(
active.provider || platforms.value[0]?.code || '',
@@ -197,6 +213,10 @@ async function load() {
);
} finally {
loading.value = false;
// 开启监听:后续切换平台/密钥时按「当前选用密钥」刷新余额
balanceWatchEnabled.value = true;
// syncSelection 可能改过 key,再按页面当前选用刷一次,与卡片展示对齐
void refreshActivePlatformBalance();
}
}
@@ -235,6 +255,46 @@ watch(activeProvider, () => {
syncSelectionForPlatform();
});
/**
* 按当前选用平台 + 密钥刷新余额展示
* 为什么:不支持查询的平台固定「--」;支持的平台只用当前选用密钥查,切换密钥要即时更新
*/
async function refreshActivePlatformBalance() {
const code = String(activeProvider.value || '');
// 非当前选用的支持平台也先回落到 --,避免残留上一次查询结果
platforms.value.forEach((p) => {
if (!p.balance_supported || p.code !== code) {
p.balance_txt = '--';
}
});
const plat = platforms.value.find((p) => p.code === code);
if (!plat || !plat.balance_supported) {
return;
}
if (!activeApiKeyId.value) {
plat.balance_txt = '--';
return;
}
balanceLoading.value = true;
try {
const res = await getAiPlatformBalance({
platform_code: code,
api_key_id: Number(activeApiKeyId.value),
});
const data = res?.data || res || {};
plat.balance_txt = String(data.balance_txt || '--');
} catch {
plat.balance_txt = '--';
} finally {
balanceLoading.value = false;
}
}
watch([activeProvider, activeApiKeyId], () => {
if (!balanceWatchEnabled.value) return;
void refreshActivePlatformBalance();
});
function onSubTabChange(key: number | string) {
subTab.value = String(key);
localStorage.setItem(SUB_TAB_STORAGE_KEY, String(key));
@@ -791,6 +851,20 @@ onMounted(() => {
>
{{ p.code }} · {{ p.default_model || '—' }}
</div>
<div
class="mt-1 text-xs text-[hsl(var(--muted-foreground))]"
>
余额
<span
class="ml-1 font-medium text-[hsl(var(--foreground))]"
>
{{
balanceLoading && activeProvider === p.code
? '查询中…'
: p.balance_txt || '--'
}}
</span>
</div>
<div
class="mt-1 line-clamp-2 text-xs text-[hsl(var(--muted-foreground))]"
>

View File

@@ -20,6 +20,12 @@ const REMIND_ICON_FIELDS = [
label: '待支付挂号',
sort: 82,
},
{
key: 'queue_register',
configKey: 'home_remind_icon_queue_register',
label: '排队中挂号',
sort: 86,
},
{
key: 'unpaid_product',
configKey: 'home_remind_icon_unpaid_product',
@@ -46,12 +52,13 @@ const zoneLayout = ref<'scroll' | 'wrap2' | 'wrap'>('scroll');
const orderRemindStyle = ref<'grid' | 'zone'>('grid');
const remindIcons = reactive<Record<string, string>>({
unpaid_register: '',
queue_register: '',
unpaid_product: '',
shipped: '',
arrived: '',
});
/** 读取排布、样式和四张待办入口图 */
/** 读取排布、样式和待办入口图 */
async function load() {
loading.value = true;
try {
@@ -124,7 +131,7 @@ onMounted(load);
待办入口排布
</div>
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
控制首页待支付挂号、待支付商品、已发货、待确认收货的排列方式。选「图标入口」时生效;药店专区入口也沿用同一排布。
控制首页待支付/排队中挂号、待支付商品、已发货、待确认收货的排列方式。选「图标入口」时生效;药店专区入口也沿用同一排布。
</div>
<div class="layout-pick">
<button
@@ -198,7 +205,7 @@ onMounted(load);
订单待办样式
</div>
<div class="mb-2 text-sm text-[hsl(var(--muted-foreground))]">
首页展示待支付挂号、待支付商品、已发货(快递待收货)、已到货待确认收货(自提)。
首页展示待支付挂号、排队中挂号(仅诊所态)、待支付商品、已发货(快递待收货)、已到货待确认收货(自提)。
</div>
<Radio.Group v-model:value="orderRemindStyle">
<Radio value="grid">一行两列卡片</Radio>

View File

@@ -0,0 +1,187 @@
<script lang="ts" setup>
/**
* 系统配置 - 上传:PC 上传图片的压缩阈值
*
* 只影响 PC 管理端(表单上传 / 富文本 / 聊天发图):图片超过阈值时弹压缩窗。
* 存 xk_system_config 的 pc_image_compress_threshold,值统一用「字节」存整数,
* 后台按「数字 + 单位(KB / MB)」录入,避免各单位混存导致比较时口径不一致。
* 关闭开关时写入 0,前端读到 0 表示不弹压缩窗、直接上传原图。
*/
import { computed, onMounted, ref } from 'vue';
import { Button, InputNumber, message, Select, Switch } from 'ant-design-vue';
import { formatFileSize } from '#/utils/image-compress';
import {
DEFAULT_IMAGE_COMPRESS_THRESHOLD,
IMAGE_COMPRESS_THRESHOLD_CONFIG_KEY,
resetImageCompressThresholdCache,
resolveImageCompressThreshold,
setImageCompressThreshold,
} from '#/utils/image-compress-config';
import { getSystemConfigByKeys, saveSystemConfig } from '../api';
defineOptions({ name: 'ImageCompressConfigPanel' });
/** 单位换算基数 */
const UNIT_FACTOR: Record<'KB' | 'MB', number> = {
KB: 1024,
MB: 1024 * 1024,
};
const loading = ref(false);
const saving = ref(false);
/** 是否开启「超过阈值提示压缩」 */
const enabled = ref(true);
/** 阈值数值(配合单位使用) */
const thresholdValue = ref<number>(0.8);
const thresholdUnit = ref<'KB' | 'MB'>('MB');
/** 是否已从后端拿到配置(未拿到时禁用保存,避免覆盖成默认值) */
const loaded = ref(false);
/** 当前录入值换算成字节,用于保存与回显校验 */
const thresholdBytes = computed(() => {
if (!enabled.value) return 0;
const value = Number(thresholdValue.value ?? 0);
if (!Number.isFinite(value) || value <= 0) return 0;
return Math.round(value * UNIT_FACTOR[thresholdUnit.value]);
});
/** 把字节数拆成「数值 + 单位」回填到表单 */
function applyBytesToForm(bytes: number) {
if (bytes <= 0) {
// 0 = 关闭:保留默认数值,开关置为关闭,方便用户重新开启
enabled.value = false;
thresholdUnit.value = 'MB';
thresholdValue.value = Number(
(DEFAULT_IMAGE_COMPRESS_THRESHOLD / UNIT_FACTOR.MB).toFixed(2),
);
return;
}
enabled.value = true;
if (bytes >= UNIT_FACTOR.MB) {
thresholdUnit.value = 'MB';
thresholdValue.value = Number((bytes / UNIT_FACTOR.MB).toFixed(2));
} else {
thresholdUnit.value = 'KB';
thresholdValue.value = Number((bytes / UNIT_FACTOR.KB).toFixed(2));
}
}
async function load() {
loading.value = true;
try {
const res = await getSystemConfigByKeys([
IMAGE_COMPRESS_THRESHOLD_CONFIG_KEY,
]);
const raw = res?.[IMAGE_COMPRESS_THRESHOLD_CONFIG_KEY];
// 未配置时后端返回 null,此处回落到默认阈值
applyBytesToForm(resolveImageCompressThreshold(raw));
loaded.value = true;
} catch {
applyBytesToForm(DEFAULT_IMAGE_COMPRESS_THRESHOLD);
} finally {
loading.value = false;
}
}
async function handleSave() {
if (enabled.value && thresholdBytes.value <= 0) {
message.warning('请填写大于 0 的压缩阈值');
return;
}
saving.value = true;
try {
await saveSystemConfig([
{
config_group: 'upload',
config_key: IMAGE_COMPRESS_THRESHOLD_CONFIG_KEY,
config_value: String(thresholdBytes.value),
value_type: 'int',
description: 'PC 上传图片压缩阈值(字节),0 表示不提示压缩',
sort: 90,
},
]);
// 同步刷新进程内缓存,让当前会话后续上传立即按新阈值判断
resetImageCompressThresholdCache();
setImageCompressThreshold(thresholdBytes.value);
message.success('保存成功');
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
上传图片超过阈值时提示压缩
</div>
<div class="mb-4 text-sm text-[hsl(var(--muted-foreground))]">
仅影响 PC 管理端(表单上传、富文本、聊天发图)。图片体积超过阈值时弹出压缩窗,
压缩统一转为 WebP;压缩后体积没有变小则自动沿用原图。
</div>
<Switch
v-model:checked="enabled"
class="mb-4"
checked-children="开启"
un-checked-children="关闭"
/>
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">压缩阈值</div>
<div class="ic-cfg-row">
<InputNumber
v-model:value="thresholdValue"
:min="0.01"
:precision="2"
:step="thresholdUnit === 'MB' ? 0.1 : 100"
:disabled="!enabled || !loaded"
style="width: 180px"
placeholder="请输入数值"
/>
<Select
v-model:value="thresholdUnit"
:disabled="!enabled || !loaded"
style="width: 100px"
:options="[
{ label: 'MB', value: 'MB' },
{ label: 'KB', value: 'KB' },
]"
/>
<span class="ic-cfg-eq">
<template v-if="enabled">
约 {{ formatFileSize(thresholdBytes) }}({{ thresholdBytes }} 字节)
</template>
<template v-else>已关闭,PC 上传不再弹压缩窗</template>
</span>
</div>
<div class="mt-3 text-sm text-[hsl(var(--muted-foreground))]">
关闭开关后,PC 上传图片不再弹压缩窗,直接上传原图。
</div>
</div>
<div class="cfg-panel-footer">
<Button type="primary" :loading="saving" :disabled="!loaded" @click="handleSave">
保存本模块配置
</Button>
</div>
</div>
</template>
<style lang="less" scoped>
.ic-cfg-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.ic-cfg-eq {
font-size: 13px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,184 @@
<script lang="ts" setup>
/**
* 系统配置 - 患者端小程序 / Short Link 次数
* 统计长/短期已用次数;测试环境在别处生成后可手动增加已用(扣减剩余)
*/
import { onMounted, reactive, ref } from 'vue';
import { Button, InputNumber, message } from 'ant-design-vue';
import {
adjustMpShortLinkQuota,
getMpShortLinkQuota,
} from '../api';
defineOptions({ name: 'MpShortLinkQuotaPanel' });
const loading = ref(false);
const adjusting = ref(false);
const stats = reactive({
permanent_used: 0,
permanent_limit: 100000,
permanent_remain: 100000,
temporary_used: 0,
});
/** 手动扣减数量(增加已用) */
const permanentDelta = ref<number>(1);
const temporaryDelta = ref<number>(1);
async function load() {
loading.value = true;
try {
const res = await getMpShortLinkQuota();
const data = (res as any)?.data ?? res ?? {};
stats.permanent_used = Number(data.permanent_used || 0);
stats.permanent_limit = Number(data.permanent_limit || 100000);
stats.permanent_remain = Number(
data.permanent_remain ??
Math.max(0, stats.permanent_limit - stats.permanent_used),
);
stats.temporary_used = Number(data.temporary_used || 0);
} finally {
loading.value = false;
}
}
/**
* 手动增加已用次数(对账测试环境消耗)
*/
async function handleAdjust(
type: 'permanent' | 'temporary',
delta: number,
) {
const n = Math.trunc(Number(delta) || 0);
if (n === 0) {
message.warning('请输入非 0 的数量');
return;
}
adjusting.value = true;
try {
const res = await adjustMpShortLinkQuota({ type, delta: n });
const data = (res as any)?.data ?? res ?? {};
stats.permanent_used = Number(data.permanent_used || 0);
stats.permanent_limit = Number(data.permanent_limit || 100000);
stats.permanent_remain = Number(
data.permanent_remain ??
Math.max(0, stats.permanent_limit - stats.permanent_used),
);
stats.temporary_used = Number(data.temporary_used || 0);
message.success(n > 0 ? '已增加已用次数' : '已回退已用次数');
} finally {
adjusting.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body space-y-8">
<div class="text-sm text-[hsl(var(--muted-foreground))]">
微信 Short Link:长期累计上限约 10 万;短期约 30
天有效、累计不设上限。本页为本地统计,便于对账;测试环境生成后请在此手动增加已用次数。
</div>
<div class="quota-card">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
长期短链
</div>
<div class="quota-nums">
<span>已用 {{ stats.permanent_used }}</span>
<span class="sep">/</span>
<span>上限 {{ stats.permanent_limit }}</span>
<span class="sep">·</span>
<span>剩余 {{ stats.permanent_remain }}</span>
</div>
<div class="quota-actions">
<InputNumber
v-model:value="permanentDelta"
:min="1"
:max="9999"
class="w-28"
/>
<Button
type="primary"
:loading="adjusting"
@click="handleAdjust('permanent', permanentDelta)"
>
手动扣减(+已用)
</Button>
<Button
:loading="adjusting"
@click="handleAdjust('permanent', -Math.abs(permanentDelta || 1))"
>
回退已用
</Button>
</div>
</div>
<div class="quota-card">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
短期短链
</div>
<div class="quota-nums">
<span>已用 {{ stats.temporary_used }}</span>
<span class="sep">·</span>
<span>无累计上限(约 30 天有效)</span>
</div>
<div class="quota-actions">
<InputNumber
v-model:value="temporaryDelta"
:min="1"
:max="9999"
class="w-28"
/>
<Button
type="primary"
:loading="adjusting"
@click="handleAdjust('temporary', temporaryDelta)"
>
手动扣减(+已用)
</Button>
<Button
:loading="adjusting"
@click="handleAdjust('temporary', -Math.abs(temporaryDelta || 1))"
>
回退已用
</Button>
</div>
</div>
</div>
<div class="cfg-panel-footer">
<Button @click="load">刷新统计</Button>
</div>
</div>
</template>
<style scoped>
.quota-card {
padding: 14px 16px;
border-radius: 8px;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.2);
box-shadow: 0 0 6px hsl(var(--primary) / 0.08);
}
.quota-nums {
font-size: 14px;
color: hsl(var(--foreground));
margin-bottom: 12px;
}
.quota-nums .sep {
margin: 0 6px;
color: hsl(var(--muted-foreground));
}
.quota-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
/**
* 系统配置 - 挂号凭证页 UI 风格
* 仅影响患者端 register-voucher:模拟小票 / 苹果液态玻璃
*/
import { onMounted, ref } from 'vue';
import { Button, Radio, message } from 'ant-design-vue';
import { getSystemConfigByKeys, saveSystemConfig } from '../api';
defineOptions({ name: 'RegisterVoucherUiConfigPanel' });
const KEY = 'register_voucher_ui_style';
const STYLE_RECEIPT = 'receipt';
const STYLE_GLASS = 'glass';
const loading = ref(false);
const saving = ref(false);
const loaded = ref(false);
const uiStyle = ref<string>(STYLE_RECEIPT);
async function load() {
loading.value = true;
try {
const res = await getSystemConfigByKeys([KEY]);
const raw = String(res?.[KEY] ?? '').trim();
uiStyle.value = raw === STYLE_GLASS ? STYLE_GLASS : STYLE_RECEIPT;
loaded.value = true;
} catch {
loaded.value = false;
} finally {
loading.value = false;
}
}
async function handleSave() {
const value = uiStyle.value === STYLE_GLASS ? STYLE_GLASS : STYLE_RECEIPT;
saving.value = true;
try {
await saveSystemConfig([
{
config_group: 'register',
config_key: KEY,
config_value: value,
value_type: 'string',
description: '挂号凭证页UI:receipt模拟小票 / glass苹果液态玻璃',
sort: 213,
},
]);
message.success('保存成功');
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
患者端挂号凭证页风格
</div>
<div class="mb-4 text-sm text-[hsl(var(--muted-foreground))]">
仅影响小程序「挂号凭证」页(扫码 / 详情入口);管理端打印小票版式不受影响。
</div>
<Radio.Group v-model:value="uiStyle">
<Radio :value="STYLE_RECEIPT">模拟小票</Radio>
<Radio :value="STYLE_GLASS">苹果液态玻璃</Radio>
</Radio.Group>
</div>
<div class="cfg-panel-footer">
<Button
type="primary"
:loading="saving"
:disabled="!loaded"
@click="handleSave"
>
保存本模块配置
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,148 @@
<script lang="ts" setup>
/**
* 系统配置 - 挂号排队预估
* 患者端挂号凭证:前面人数 × [min,max] 分钟;上限超过 120 分钟展示自定义文案
*/
import { onMounted, ref } from 'vue';
import { Button, Input, InputNumber, message } from 'ant-design-vue';
import { getSystemConfigByKeys, saveSystemConfig } from '../api';
defineOptions({ name: 'RegisterWaitConfigPanel' });
const KEY_MIN = 'register_wait_min_minutes';
const KEY_MAX = 'register_wait_max_minutes';
const KEY_OVER = 'register_wait_over_2h_text';
const loading = ref(false);
const saving = ref(false);
const loaded = ref(false);
const waitMin = ref<number>(10);
const waitMax = ref<number>(15);
const overText = ref('预计等待超过2小时,请留意叫号');
async function load() {
loading.value = true;
try {
const res = await getSystemConfigByKeys([KEY_MIN, KEY_MAX, KEY_OVER]);
const min = Number(res?.[KEY_MIN]);
const max = Number(res?.[KEY_MAX]);
waitMin.value = Number.isFinite(min) && min > 0 ? min : 10;
waitMax.value = Number.isFinite(max) && max > 0 ? max : 15;
if (waitMax.value < waitMin.value) {
waitMax.value = waitMin.value;
}
const text = String(res?.[KEY_OVER] ?? '').trim();
overText.value = text || '预计等待超过2小时,请留意叫号';
loaded.value = true;
} catch {
loaded.value = false;
} finally {
loading.value = false;
}
}
async function handleSave() {
const min = Number(waitMin.value);
const max = Number(waitMax.value);
if (!Number.isFinite(min) || min < 1) {
message.warning('请填写有效的预估下限(至少 1 分钟)');
return;
}
if (!Number.isFinite(max) || max < min) {
message.warning('预估上限不能小于下限');
return;
}
const text = String(overText.value || '').trim();
if (!text) {
message.warning('请填写超过 2 小时时的提示文案');
return;
}
saving.value = true;
try {
await saveSystemConfig([
{
config_group: 'register',
config_key: KEY_MIN,
config_value: String(Math.round(min)),
value_type: 'int',
description: '挂号排队:单人预估等待下限(分钟)',
sort: 210,
},
{
config_group: 'register',
config_key: KEY_MAX,
config_value: String(Math.round(max)),
value_type: 'int',
description: '挂号排队:单人预估等待上限(分钟)',
sort: 211,
},
{
config_group: 'register',
config_key: KEY_OVER,
config_value: text,
value_type: 'string',
description: '挂号排队:预估上限超过120分钟时展示文案',
sort: 212,
},
]);
message.success('保存成功');
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
单人预估等待区间(分钟)
</div>
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
凭证页按「前面待接诊人数 × 区间」估算等待;人数为 0 显示「即将就诊」。
当上限估算超过 120 分钟时,改用下方自定义文案。
</div>
<div class="mb-4 flex flex-wrap items-center gap-2">
<InputNumber
v-model:value="waitMin"
:min="1"
:precision="0"
style="width: 120px"
/>
<span class="text-[hsl(var(--muted-foreground))]">至</span>
<InputNumber
v-model:value="waitMax"
:min="1"
:precision="0"
style="width: 120px"
/>
<span class="text-[hsl(var(--muted-foreground))]">分钟/人</span>
</div>
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
超过 2 小时提示文案
</div>
<Input
v-model:value="overText"
allow-clear
maxlength="100"
show-count
placeholder="预计等待超过2小时,请留意叫号"
style="max-width: 480px"
/>
</div>
<div class="cfg-panel-footer">
<Button
type="primary"
:loading="saving"
:disabled="!loaded"
@click="handleSave"
>
保存本模块配置
</Button>
</div>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
/**
* 系统配置 - 工作台:AI 今日简报总开关
* 关闭后 PC / 医生小程序工作台都隐藏该模块,后端也不生成
* 瓷片风格已挪到「医生端小程序」Tab,本面板只管简报
*/
import { onMounted, ref } from 'vue';
@@ -20,7 +20,6 @@ function parseBoolConfig(val: unknown, defaultVal: boolean): boolean {
return val === '1' || val === 1 || val === true || val === 'true';
}
/** 读取工作台简报开关,未落库时按开启回落,与后端 getValue 默认一致 */
async function load() {
loading.value = true;
try {
@@ -36,7 +35,6 @@ async function load() {
}
}
/** 写入系统配置;关闭后工作台立刻不再生成简报 */
async function handleSave() {
saving.value = true;
try {

View File

@@ -0,0 +1,298 @@
<script lang="ts" setup>
/**
* 系统配置 - 医生端小程序 / 工作台瓷片风格
* Tab 切换各风格;每个风格按槽位上传多张入口卡背景(空则小程序用默认色)
*/
import { onMounted, reactive, ref } from 'vue';
import { Button, Tabs, message } from 'ant-design-vue';
import FormAvatar from '#/components/form/components/avatar.vue';
import { getSystemConfigList, saveSystemConfig } from '../api';
defineOptions({ name: 'WxWorkbenchCardStylePanel' });
const TAB_STORAGE_KEY = 'system_config_wx_tile_style_edit_tab';
const TILE_STYLE_OPTIONS = [
{
value: '02',
label: '02 长方双卡',
slots: 3,
slotLabels: ['入口卡 1', '入口卡 2', '入口卡 3'],
},
{
value: '03',
label: '03 外卡内嵌',
slots: 3,
slotLabels: ['主卡', '右侧上', '右侧下'],
},
{
value: '04',
label: '04 四格活动',
slots: 4,
slotLabels: ['格子 1', '格子 2', '格子 3', '格子 4'],
},
{
value: '08',
label: '08 紫渐变',
slots: 3,
slotLabels: ['左侧主入口', '右侧卡 1', '右侧卡 2'],
},
{
value: '17',
label: '17 缎带分栏',
slots: 2,
slotLabels: ['左栏', '右栏'],
},
{
value: '19',
label: '19 双格白卡',
slots: 2,
slotLabels: ['左卡', '右卡'],
},
] as const;
type StyleCode = (typeof TILE_STYLE_OPTIONS)[number]['value'];
const loading = ref(false);
const saving = ref(false);
/** 全平台当前启用的风格编号 */
const enabledStyle = ref<StyleCode>('02');
/** 正在编辑的 Tab(可与启用风格不同) */
const editTab = ref<StyleCode>('02');
/** 各风格背景:styleCode → URL[](长度=槽位数) */
const styleBgs = reactive<Record<StyleCode, string[]>>({
'02': emptySlots(3),
'03': emptySlots(3),
'04': emptySlots(4),
'08': emptySlots(3),
'17': emptySlots(2),
'19': emptySlots(2),
});
function emptySlots(n: number): string[] {
return Array.from({ length: n }, () => '');
}
function normalizeTileStyle(val: unknown): StyleCode {
const v = String(val || '').trim();
return TILE_STYLE_OPTIONS.some((o) => o.value === v)
? (v as StyleCode)
: '02';
}
function bgConfigKey(code: StyleCode) {
return `wx_tile_style_bg_${code}`;
}
function slotCount(code: StyleCode): number {
const opt = TILE_STYLE_OPTIONS.find((o) => o.value === code);
return opt ? opt.slots : 3;
}
/**
* 把配置值归一成固定长度 URL 数组
* 兼容旧数据:单字符串 URL → [url, '', ...];json 数组按槽位截断/补空
*/
function parseBgList(raw: unknown, code: StyleCode): string[] {
const n = slotCount(code);
const out = emptySlots(n);
if (raw == null) return out;
let list: unknown = raw;
if (typeof raw === 'string') {
const text = raw.trim();
if (!text) return out;
try {
list = JSON.parse(text);
} catch {
out[0] = text;
return out;
}
}
if (!Array.isArray(list)) return out;
for (let i = 0; i < n; i++) {
out[i] = String(list[i] || '').trim();
}
return out;
}
function onTabChange(key: string | number) {
const code = normalizeTileStyle(key);
editTab.value = code;
localStorage.setItem(TAB_STORAGE_KEY, code);
}
/** 把当前编辑的 Tab 设为全平台启用风格(仍要点保存才入库) */
function enableCurrentTab() {
enabledStyle.value = editTab.value;
message.success(`已标记启用 ${editTab.value},请点击下方保存生效`);
}
async function load() {
loading.value = true;
try {
const list = await getSystemConfigList();
const rows = Array.isArray(list) ? list : list?.data || [];
for (const row of rows) {
if (row.config_key === 'wx_workbench_card_style') {
enabledStyle.value = normalizeTileStyle(row.config_value);
}
for (const opt of TILE_STYLE_OPTIONS) {
if (row.config_key === bgConfigKey(opt.value)) {
styleBgs[opt.value] = parseBgList(row.config_value, opt.value);
}
}
}
const stored = localStorage.getItem(TAB_STORAGE_KEY);
editTab.value = stored
? normalizeTileStyle(stored)
: enabledStyle.value;
} finally {
loading.value = false;
}
}
async function handleSave() {
saving.value = true;
try {
const style = normalizeTileStyle(enabledStyle.value);
const payload = [
{
config_key: 'wx_workbench_card_style',
config_value: style,
value_type: 'string',
config_group: 'wx_workbench',
description:
'小程序工作台瓷片风格编号:02/03/04/08/17/19,全平台一套',
sort: 10,
},
...TILE_STYLE_OPTIONS.map((opt, idx) => ({
config_key: bgConfigKey(opt.value),
config_value: JSON.stringify(
parseBgList(styleBgs[opt.value], opt.value),
),
value_type: 'json',
config_group: 'wx_workbench',
description: `工作台瓷片风格 ${opt.value} 各入口卡背景图 JSON 数组(空串=默认色,长度=${opt.slots})`,
sort: 30 + idx,
})),
];
await saveSystemConfig(payload);
message.success('保存成功');
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
工作台瓷片风格
</div>
<div class="mb-4 text-sm text-[hsl(var(--muted-foreground))]">
用 Tab 编辑各风格的入口卡背景;槽位按风格最大入口数(02=3、03=3、04=4、08=3、17=2、19=2)。未上传的槽位小程序用默认背景色。当前启用:
<span class="enabled-tag">{{ enabledStyle }}</span>
</div>
<Tabs :active-key="editTab" type="card" @change="onTabChange">
<Tabs.TabPane
v-for="opt in TILE_STYLE_OPTIONS"
:key="opt.value"
:tab="
enabledStyle === opt.value ? `${opt.label} · 启用中` : opt.label
"
>
<div class="tab-toolbar">
<Button
v-if="enabledStyle !== opt.value"
size="small"
type="primary"
ghost
@click="enableCurrentTab"
>
设为当前启用风格
</Button>
<span v-else class="enabled-hint">
此风格已启用(保存后全平台生效)
</span>
</div>
<div class="slot-grid">
<div
v-for="(label, idx) in opt.slotLabels"
:key="`${opt.value}-${idx}`"
class="slot-card"
>
<div class="slot-card__label">{{ label }}</div>
<div class="slot-card__hint">背景图(可选)</div>
<FormAvatar v-model:value="styleBgs[opt.value][idx]" />
</div>
</div>
</Tabs.TabPane>
</Tabs>
</div>
<div class="cfg-panel-footer">
<Button type="primary" :loading="saving" @click="handleSave">
保存本模块配置
</Button>
</div>
</div>
</template>
<style scoped>
.enabled-tag {
display: inline-block;
margin-left: 4px;
padding: 0 8px;
font-weight: 600;
color: hsl(var(--primary));
background: hsl(var(--primary) / 10%);
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 999px;
}
.tab-toolbar {
display: flex;
align-items: center;
min-height: 32px;
margin-bottom: 14px;
}
.enabled-hint {
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.slot-grid {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.slot-card {
width: 160px;
padding: 12px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 10px;
box-shadow: 0 0 6px hsl(var(--border) / 18%);
}
.slot-card__label {
margin-bottom: 4px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.slot-card__hint {
margin-bottom: 10px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -1,19 +1,27 @@
<script lang="ts" setup>
/**
* 系统配置 - 医生端小程序 / 默认常用入口
* 按角色勾选首次访问自动写入「我的常用」的入口,只能勾选该角色已分配项
* 按角色勾选首次访问自动写入「我的常用」的入口,只能勾选该角色已分配项;
* 同页再选该角色入口版式:旧版金刚区/长卡,或跟随全平台瓷片风格(传方员不在本面板,后端强制瓷片)
*/
import { computed, onMounted, ref, watch } from 'vue';
import { Button, Checkbox, Empty, message, Tabs } from 'ant-design-vue';
import { Button, Checkbox, Empty, Radio, message, Tabs } from 'ant-design-vue';
import { getWxWorkbenchEntryByRoleId, saveWxWorkbenchDefaultFav } from '../api';
import {
getSystemConfigByKeys,
getWxWorkbenchEntryByRoleId,
saveSystemConfig,
saveWxWorkbenchDefaultFav,
} from '../api';
defineOptions({ name: 'WxWorkbenchDefaultFavPanel' });
const TAB_STORAGE_KEY = 'wx_workbench_default_fav_role';
/** 与后端 resolveCardMode / myUserData 共用的配置键 */
const CARD_MODE_CONFIG_KEY = 'wx_workbench_card_mode_by_role';
/** 有工作台入口的角色,value 与 xk_role.id 一致 */
/** 有工作台入口的角色,value 与 xk_role.id 一致(不含传方员 17) */
const ROLE_TABS = [
{ label: '诊所管理员', value: 8 },
{ label: '超级管理员', value: 1 },
@@ -24,6 +32,8 @@ const ROLE_TABS = [
{ label: '诊所推广员', value: 14 },
];
type CardMode = 'legacy' | 'tile';
type AssignedEntry = {
code: string;
entry_id: number;
@@ -35,6 +45,10 @@ type AssignedEntry = {
const activeRole = ref(String(ROLE_TABS[0]?.value || 8));
const assigned = ref<AssignedEntry[]>([]);
const selectedIds = ref<number[]>([]);
/** 全量角色 → 版式;缺省 key 表示 legacy(与后端一致) */
const cardModeByRole = ref<Record<string, CardMode>>({});
/** 当前 Tab 选中的版式(未写入 map 的角色回显为 legacy) */
const cardMode = ref<CardMode>('legacy');
const loading = ref(false);
const saving = ref(false);
@@ -53,6 +67,37 @@ const assignedGroups = computed(() => {
return groups;
});
/** 把接口/配置值收成 roleId 字符串 → legacy|tile */
function parseCardModeMap(raw: unknown): Record<string, CardMode> {
let obj: unknown = raw;
if (typeof raw === 'string') {
const text = raw.trim();
if (!text) return {};
try {
obj = JSON.parse(text);
} catch {
return {};
}
}
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return {};
const out: Record<string, CardMode> = {};
Object.keys(obj as Record<string, unknown>).forEach((k) => {
const mode = String((obj as Record<string, unknown>)[k] || '')
.trim()
.toLowerCase();
if (mode === 'tile' || mode === 'legacy') {
out[String(k)] = mode;
}
});
return out;
}
/** 当前角色版式:map 无 key 时按产品约定回显旧版 */
function modeForRole(roleId: number | string): CardMode {
const key = String(roleId || '');
return cardModeByRole.value[key] === 'tile' ? 'tile' : 'legacy';
}
/** 拉取当前角色已分配入口,并用 is_default_fav 回显勾选 */
async function loadAssigned() {
const roleId = Number(activeRole.value || 0);
@@ -65,16 +110,36 @@ async function loadAssigned() {
selectedIds.value = list
.filter((item) => Number(item.is_default_fav) === 1)
.map((item) => Number(item.entry_id));
cardMode.value = modeForRole(roleId);
} catch {
message.error('获取已分配入口失败');
assigned.value = [];
selectedIds.value = [];
cardMode.value = 'legacy';
} finally {
loading.value = false;
}
}
/** 保存当前角色的默认常用(空数组表示该角色不自动写入) */
/** 加载全角色版式 map(与入口勾选分开拉,避免切 Tab 重复打配置接口也能本地改) */
async function loadCardModeMap() {
try {
const res = await getSystemConfigByKeys([CARD_MODE_CONFIG_KEY]);
const raw =
res && typeof res === 'object'
? (res[CARD_MODE_CONFIG_KEY] ?? res?.data?.[CARD_MODE_CONFIG_KEY])
: {};
cardModeByRole.value = parseCardModeMap(raw);
} catch {
cardModeByRole.value = {};
}
cardMode.value = modeForRole(activeRole.value);
}
/**
* 保存当前角色:默认常用入口 + 版式 map(读-改-写整份 JSON)
* 为什么和入口一起保存:同属「该角色工作台怎么开箱」的配置,少一次点保存
*/
async function handleSave() {
const roleId = Number(activeRole.value || 0);
if (!roleId) {
@@ -83,10 +148,28 @@ async function handleSave() {
}
saving.value = true;
try {
await saveWxWorkbenchDefaultFav({
role_id: roleId,
entry_ids: selectedIds.value,
});
const nextMap: Record<string, CardMode> = {
...cardModeByRole.value,
[String(roleId)]: cardMode.value === 'tile' ? 'tile' : 'legacy',
};
await Promise.all([
saveWxWorkbenchDefaultFav({
role_id: roleId,
entry_ids: selectedIds.value,
}),
saveSystemConfig([
{
config_key: CARD_MODE_CONFIG_KEY,
config_value: JSON.stringify(nextMap),
value_type: 'json',
config_group: 'wx_workbench',
description:
'各角色工作台入口版式:role_id→legacy|tile;空 map 时非传方员默认 legacy,传方员后端强制 tile',
sort: 11,
},
]),
]);
cardModeByRole.value = nextMap;
message.success('保存成功');
await loadAssigned();
} catch {
@@ -107,18 +190,20 @@ function toggleDefault(id: number, checked: boolean) {
selectedIds.value = selectedIds.value.filter((item) => item !== id);
}
onMounted(() => {
onMounted(async () => {
const cached = localStorage.getItem(TAB_STORAGE_KEY);
const valid = ROLE_TABS.some((item) => String(item.value) === cached);
if (valid && cached) {
activeRole.value = cached;
}
await loadCardModeMap();
});
watch(
activeRole,
(key) => {
localStorage.setItem(TAB_STORAGE_KEY, key);
cardMode.value = modeForRole(key);
loadAssigned();
},
{ immediate: true },
@@ -135,6 +220,17 @@ watch(
:tab="item.label"
/>
</Tabs>
<div class="mode-block">
<div class="mode-block__title">入口版式</div>
<p class="mode-block__hint">
旧版本走金刚区/长卡(我的页可切);跟随各种风格则使用「工作台瓷片风格」里当前启用的
02~19。传方员不在此配置,小程序端固定瓷片。未保存过时默认旧版本。
</p>
<Radio.Group v-model:value="cardMode">
<Radio value="legacy">旧版本(金刚区 / 长卡)</Radio>
<Radio value="tile">跟随各种风格(瓷片)</Radio>
</Radio.Group>
</div>
<p class="fav-hint">
勾选后,该角色用户第一次进入医生端小程序时,会自动写入「我的常用」。已加过再清空的用户不会被覆盖。仅能勾选该角色已分配的入口。
</p>
@@ -178,6 +274,29 @@ watch(
</template>
<style scoped>
.mode-block {
margin-bottom: 16px;
padding: 12px 14px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 8px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.mode-block__title {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.mode-block__hint {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.6;
color: hsl(var(--muted-foreground));
}
.fav-hint {
margin: 0 0 16px;
font-size: 13px;

View File

@@ -0,0 +1,99 @@
<script lang="ts" setup>
/**
* 系统配置 - 医生端小程序 / 传方瓷片图标
* 后台上传云端图,小程序工作台与识图页优先用 URL,未配置时回落本地默认图
*/
import { onMounted, ref } from 'vue';
import { Button, message } from 'ant-design-vue';
import FormAvatar from '#/components/form/components/avatar.vue';
import { getSystemConfigList, saveSystemConfig } from '../api';
defineOptions({ name: 'WxWorkbenchTileIconPanel' });
const loading = ref(false);
const saving = ref(false);
/** 识图传方入口瓷片图(云端 URL) */
const uploadTile = ref('');
/** 传方订单入口瓷片图(云端 URL) */
const ordersTile = ref('');
async function load() {
loading.value = true;
try {
const list = await getSystemConfigList();
const rows = Array.isArray(list) ? list : list?.data || [];
for (const row of rows) {
if (row.config_key === 'wx_tile_image_transfer_upload') {
uploadTile.value = String(row.config_value || '');
}
if (row.config_key === 'wx_tile_image_transfer_orders') {
ordersTile.value = String(row.config_value || '');
}
}
} finally {
loading.value = false;
}
}
async function handleSave() {
saving.value = true;
try {
await saveSystemConfig([
{
config_key: 'wx_tile_image_transfer_upload',
config_value: String(uploadTile.value || '').trim(),
value_type: 'string',
config_group: 'wx_workbench',
description:
'工作台瓷片图:识图传方(云端 URL,空则用小程序本地默认)',
sort: 20,
},
{
config_key: 'wx_tile_image_transfer_orders',
config_value: String(ordersTile.value || '').trim(),
value_type: 'string',
config_group: 'wx_workbench',
description:
'工作台瓷片图:传方订单(云端 URL,空则用小程序本地默认)',
sort: 21,
},
]);
message.success('保存成功');
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
识图传方瓷片图
</div>
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
用于传方员首页、诊所管理员常用入口(识图传方)及识图页加载态。建议
192×192,方图自带渐变底。清空后小程序回落本地默认图。
</div>
<FormAvatar v-model:value="uploadTile" />
<div class="mb-2 mt-8 font-medium text-[hsl(var(--foreground))]">
传方订单瓷片图
</div>
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
用于传方员首页与诊所管理员常用入口(传方订单)。建议 192×192。清空后回落本地默认图。
</div>
<FormAvatar v-model:value="ordersTile" />
</div>
<div class="cfg-panel-footer">
<Button type="primary" :loading="saving" @click="handleSave">
保存本模块配置
</Button>
</div>
</div>
</template>

View File

@@ -17,23 +17,29 @@ import AiModelConfigPanel from './components/ai-model-config-panel.vue';
import DictSearchConfigPanel from './components/dict-search-config-panel.vue';
import DispenseSignConfigPanel from './components/dispense-sign-config-panel.vue';
import DoctorLoginConfigPanel from './components/doctor-login-config-panel.vue';
import ExcipientPriceConfigPanel from './components/excipient-price-config-panel.vue';
import FreeShippingConfigPanel from './components/free-shipping-config-panel.vue';
import HomeDisplayConfigPanel from './components/home-display-config-panel.vue';
import InputAuditConfigPanel from './components/input-audit-config-panel.vue';
import ImageCompressConfigPanel from './components/image-compress-config-panel.vue';
import ImageTransferConfigPanel from './components/image-transfer-config-panel.vue';
import InputAuditConfigPanel from './components/input-audit-config-panel.vue';
import InvoiceNoticeConfigPanel from './components/invoice-notice-config-panel.vue';
import LogisticsConfigPanel from './components/logistics-config-panel.vue';
import MedicalRecordConfigPanel from './components/medical-record-config-panel.vue';
import MiniprogramConfigPanel from './components/miniprogram-config-panel.vue';
import ModuleTabsPanel from './components/module-tabs-panel.vue';
import MpShortLinkQuotaPanel from './components/mp-short-link-quota-panel.vue';
import OaNotifyConfigPanel from './components/oa-notify-config-panel.vue';
import PriceAdjustConfigPanel from './components/price-adjust-config-panel.vue';
import ExcipientPriceConfigPanel from './components/excipient-price-config-panel.vue';
import PublicAccountPayConfigPanel from './components/public-account-pay-config-panel.vue';
import RegisterVoucherUiConfigPanel from './components/register-voucher-ui-config-panel.vue';
import RegisterWaitConfigPanel from './components/register-wait-config-panel.vue';
import SalespersonConfigPanel from './components/salesperson-config-panel.vue';
import WorkbenchConfigPanel from './components/workbench-config-panel.vue';
import WithdrawalRulesConfigPanel from './components/withdrawal-rules-config-panel.vue';
import WorkbenchConfigPanel from './components/workbench-config-panel.vue';
import WxWorkbenchCardStylePanel from './components/wx-workbench-card-style-panel.vue';
import WxWorkbenchDefaultFavPanel from './components/wx-workbench-default-fav-panel.vue';
import WxWorkbenchTileIconPanel from './components/wx-workbench-tile-icon-panel.vue';
defineOptions({ name: 'SystemConfig' });
@@ -54,6 +60,11 @@ const menuItems: MenuItem[] = [
tabs: [
{ key: 'empty', title: '空状态图', component: MiniprogramConfigPanel },
{ key: 'home', title: '首页展示', component: HomeDisplayConfigPanel },
{
key: 'short_link',
title: 'Short Link 次数',
component: MpShortLinkQuotaPanel,
},
],
},
{
@@ -65,6 +76,16 @@ const menuItems: MenuItem[] = [
title: '默认常用入口',
component: WxWorkbenchDefaultFavPanel,
},
{
key: 'card_style',
title: '工作台瓷片风格',
component: WxWorkbenchCardStylePanel,
},
{
key: 'tile_icons',
title: '传方瓷片图标',
component: WxWorkbenchTileIconPanel,
},
],
},
{
@@ -104,6 +125,27 @@ const menuItems: MenuItem[] = [
},
],
},
{
key: 'register',
title: '挂号',
tabs: [
{
key: 'wait',
title: '排队预估',
component: RegisterWaitConfigPanel,
},
{
key: 'voucher_ui',
title: '凭证页风格',
component: RegisterVoucherUiConfigPanel,
},
],
},
{
key: 'upload',
title: '上传',
component: ImageCompressConfigPanel,
},
{
key: 'security',
title: '权限安全',
@@ -175,6 +217,8 @@ const LEGACY_MENU_MAP: Record<string, { menu: string; tab?: string }> = {
miniprogram: { menu: 'mp_patient' },
logistics: { menu: 'order', tab: 'logistics' },
public_account_pay: { menu: 'order', tab: 'public_account_pay' },
/** 原挂在订单下的「挂号排队」→ 挂号 / 排队预估 */
register_wait: { menu: 'register', tab: 'wait' },
salesperson: { menu: 'security', tab: 'salesperson' },
doctor_login: { menu: 'security', tab: 'login' },
dict_search: { menu: 'medical', tab: 'dict_search' },
@@ -192,6 +236,17 @@ const currentMenu = computed(() => {
function readStoredMenu() {
const stored = localStorage.getItem(MENU_STORAGE_KEY);
// 订单下原「挂号排队」Tab → 挂号 / 排队预估
const orderSub = localStorage.getItem('system_config_order_sub_tab');
if (orderSub === 'register_wait') {
localStorage.setItem('system_config_register_sub_tab', 'wait');
localStorage.removeItem('system_config_order_sub_tab');
if (!stored || stored === 'order') {
activeKey.value = 'register';
localStorage.setItem(MENU_STORAGE_KEY, 'register');
return;
}
}
if (!stored) {
return;
}