Compare commits
19 Commits
main
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a504433da | ||
|
|
33dcd3f5d8 | ||
|
|
efd7e8ffc8 | ||
|
|
5b9f3ec3ed | ||
|
|
e434aa9534 | ||
|
|
32eb96b4ad | ||
|
|
9f37802ba7 | ||
|
|
cfc8390573 | ||
|
|
174693f6db | ||
|
|
c1bc234509 | ||
|
|
dc9f9c5c13 | ||
|
|
0331325929 | ||
|
|
9c629ff8ee | ||
|
|
bd5b0b2de1 | ||
|
|
029b45d0bb | ||
|
|
212b16a8ec | ||
|
|
feebf4b14e | ||
|
|
3bc22d02e6 | ||
|
|
371594bfda |
@@ -145,6 +145,7 @@ alwaysApply: true
|
||||
|
||||
- 表单必须用 `useVbenForm`,禁止直接 `<form>` 或 antd `Form`
|
||||
- 动态修改 schema 必须用 `formApi.updateSchema([...])`,**vben form 没有 `setComponentProps` 这个方法**(这是已踩过的坑)
|
||||
- 重置表单用 `formApi.resetForm()`,**严禁 `formApi.resetFields()`**(那是 antd Form 的 API,Vben Form 没有,会报 `resetFields is not a function`;已踩过坑)
|
||||
- 取值/赋值用 `formApi.getValues()` / `formApi.setValues(obj)`,禁止自己 `v-model` 收集
|
||||
- 校验用 `formApi.validate().then(e => { if (e.valid) {...} })` 或 `formApi.validateAndSubmitForm()`
|
||||
- 必填规则用字符串 `'required'` / `'selectRequired'`,规则在 `#/adapter/form.ts` 的 `defineRules` 注册
|
||||
@@ -152,82 +153,3 @@ alwaysApply: true
|
||||
- 密码/敏感字段用 `VbenInputPassword` 组件,禁止用 `VbenInput` 配 `type="password"`
|
||||
- 单选/多选选项来自接口时,必须在弹窗/页面 `onMounted` 或 `onOpenChange` 里拉取后通过 `updateSchema` 注入,禁止写死常量
|
||||
|
||||
# 专项模块规范
|
||||
|
||||
## OA 通知模块前端规则
|
||||
|
||||
### 组件与封装复用
|
||||
|
||||
- 所有 OA 配置入口(系统配置 tab、CRUD 页面、员工 OA 账号弹窗)必须复用 `Tabs.TabPane`、`useVbenVxeGrid`、`useVbenModal`、`useVbenForm`,禁止用原生 antd 表格/表单
|
||||
|
||||
### 平台动态化(禁止写死)
|
||||
|
||||
- **平台列表必须从接口 `oa-platform/list` 动态拉取**,禁止在前端写死 `['work_wechat', 'dingtalk', 'feishu']` 常量数组
|
||||
- 系统配置 tab 中的平台开关行、机器人表单中的平台下拉、场景表单中的机器人分组,都要走接口动态渲染
|
||||
- 平台编码统一为字符串 snake_case(如 `work_wechat`、`dingtalk`、`feishu`),与后端 `xk_oa_platform.platform_code` 字段对齐
|
||||
|
||||
### 系统配置 tab 交互
|
||||
|
||||
- 系统配置 tab 中的「总开关关闭」必须禁用下方所有平台子开关(视觉上置灰),禁止总开关关闭时仍能切换子开关
|
||||
- 平台子开关切换必须立即调 `oa-platform/update-enabled` 接口,**禁止依赖保存按钮批量提交**(避免总开关与子开关状态不一致)
|
||||
- 系统配置页的总开关走现有 `getSystemConfigList` / `saveSystemConfig` 接口(config_key = `oa_notify_enabled`),**禁止新建专用接口**
|
||||
|
||||
### 表单字段约定
|
||||
|
||||
- @ 人配置必须按平台分组(用 Tabs 或 Collapse 分组),员工必须从 `xk_admin` 表选择(复用员工 picker 组件)
|
||||
- OA 机器人表单中的 webhook、secret 字段必须用密码型输入框(`InputPassword`),禁止明文展示
|
||||
- OA 场景表单中的机器人多选必须按平台分组展示(用 `Tabs` 或 `Collapse` 分组)
|
||||
|
||||
### 消息类型全量支持(数据库驱动 schema)
|
||||
|
||||
- 场景弹窗每个平台 Tab 内的消息类型选项**必须**通过 `GET /oa-scene/message-types` 接口动态拉取,
|
||||
按 `platform_code` 过滤当前平台支持的所有 `message_type`
|
||||
- 每个消息类型对应的 payload 表单**必须**按后端返回的 `payload_schema`(JSON 字符串)动态渲染,
|
||||
禁止前端写死 schema(数据库可启停类型,前端硬编码会不同步)
|
||||
- payload 字段渲染规则(与后端 `payload_schema` 约定):
|
||||
- `Textarea`:多行文本
|
||||
- `Input` / `InputNumber`:单行文本/数字
|
||||
- `RadioGroup` / `Select`:选项组
|
||||
- `GalleryPickLink`:素材库选择器,透传 `props.acceptTypes: number[]` 过滤文件类型
|
||||
- `TemplateCardEditor`:企微模板卡片专用编辑器(用 `views/system/oa-scene/components/template-card-form.vue`)
|
||||
- 提交结构:`message_config: { platform_code: { message_type, payload } }`
|
||||
|
||||
### image-gallery-picker 文件类型过滤
|
||||
|
||||
- `image-gallery-picker.vue` 和 `gallery-pick-link.vue` 都支持 `acceptTypes?: number[]` prop
|
||||
- 取值对应 `xk_file_type.value`:1=图片、2=视频、3=音频、6=文件 等
|
||||
- 不传 `acceptTypes` 时显示全部类型(默认行为,保持向后兼容)
|
||||
- 传数组时只展示匹配类型 tab,且自动锁定到第一个匹配类型
|
||||
- OA 场景中图片字段必须传 `[1]`、语音字段传 `[3]`、文件字段传 `[6]`,避免用户选错类型
|
||||
|
||||
### 通用拖拽可视化编辑器(`components/oa-template-card-editor/`)
|
||||
|
||||
- 任何「左侧组件库 → 右侧画布拖拽组装 → 自动生成 JSON」的需求,**必须复用此通用组件**,禁止重复造轮子
|
||||
- schema 驱动:新增块类型只需扩展 `BlockSchema[]` 配置,禁止改组件内部
|
||||
- SortableJS 用法参照 `views/system/role/components/quick-nav-transfer.vue`(动态 import `sortablejs/modular/sortable.complete.esm.js`)
|
||||
- 双向绑定走 `v-model`(`modelValue` + `update:modelValue`),禁止内部直接修改 props
|
||||
- OA 场景中的企微模板卡片必须用 `views/system/oa-scene/components/template-card-form.vue`(在通用编辑器之上做 OA 封装)
|
||||
|
||||
### 测试发送(全类型循环)
|
||||
|
||||
- OA 机器人 modal 的「测试发送」必须循环跑该平台支持的所有消息类型(单平台最多 8 种)
|
||||
- 后端返回 `results` 数组,前端必须遍历分类型展示成功/失败(不要只展示第一条)
|
||||
- 测试按钮 loading 用独立状态(不要复用 modal 的 `confirmLoading`)
|
||||
- 测试发送结果以列表形式展示在表单下方,每种类型显示状态/耗时/错误信息
|
||||
- 测试发送支持两套入口(共用 `testSendOaRobot` API):
|
||||
- **表单弹窗内测试**(modal.vue):用于「保存前验证密钥」,直接传 webhook_url/secret(不带 id)
|
||||
- **行级测试**(独立组件 `test-send-modal.vue`):用于列表中已有机器人,仅传 `id`,后端自动取已加密密钥
|
||||
- 行级测试弹窗入参:`test_content` + `test_at_all` 开关 + `test_mobiles` 手机号多选
|
||||
- 飞书 webhook 不支持手机号 @,前端必须在飞书平台下隐藏手机号字段并提示用户(仅 @all 有效)
|
||||
|
||||
### 群聊绑定(按平台过滤的 N:N 多选)
|
||||
|
||||
- 「OA群聊管理」是独立菜单(pid=232 OA 通知父级下,菜单 id=233,路由 `/system/oa-chat`)
|
||||
- 群聊管理页结构必须与 `oa-robot` 一致:`index.vue + api/index.ts + config/{table,search,form}.ts + components/modal.vue`
|
||||
- 机器人表单中的「绑定群聊」字段必须用 `Checkbox.Group`:
|
||||
- 群聊列表通过 `getOaChatListByPlatform()` 一次性拉取全量(按平台分组的对象)
|
||||
- 前端按当前 `platform_code` 过滤展示(平台切换时自动过滤掉不属于当前平台的勾选)
|
||||
- 平台切换通过 `componentProps.onChange` 监听(vben form 字段值变化无现成 watch,必须通过 schema 注入 onChange 回调)
|
||||
- 编辑场景下,机器人详情接口返回 `chat_ids: number[]`,前端 `selectedChatIds` 直接回显
|
||||
- 提交时把 `chat_ids` 数组合并到 payload(后端 `OaRobotController::notRequest` 已包含该字段)
|
||||
- 列表展示用后端拼接好的 `chat_names` 字符串(按「、」拼接),详情接口才返回 `chat_ids` 数组
|
||||
|
||||
@@ -54,12 +54,15 @@ export namespace AuthApi {
|
||||
* 登录
|
||||
*/
|
||||
export async function loginApi(data: AuthApi.LoginParams) {
|
||||
// PC 管理端固定 web,供单机登录按端区分会话
|
||||
const clientType = 'web';
|
||||
if (data.account) {
|
||||
return requestClient.post<AuthApi.LoginResult>('login', {
|
||||
account: data.account,
|
||||
password: data.password,
|
||||
code: data.code,
|
||||
admin_id: data.admin_id,
|
||||
client_type: clientType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,6 +70,7 @@ export async function loginApi(data: AuthApi.LoginParams) {
|
||||
login_method: data.login_method || 'phone',
|
||||
password: data.password,
|
||||
code: data.code,
|
||||
client_type: clientType,
|
||||
};
|
||||
|
||||
if (data.login_method === 'phone') {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@vben/request';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
// eslint-disable-next-line n/no-extraneous-import
|
||||
import { Base64 } from 'js-base64';
|
||||
|
||||
@@ -37,6 +37,8 @@ function createRequestClient(baseURL: string) {
|
||||
/**
|
||||
* 重新认证逻辑
|
||||
*/
|
||||
/** 防重复:并发 401 只弹一次重新登录提示 */
|
||||
let authModalShowing = false;
|
||||
async function doReAuthenticate() {
|
||||
const accessStore = useAccessStore();
|
||||
const authStore = useAuthStore();
|
||||
@@ -47,6 +49,21 @@ function createRequestClient(baseURL: string) {
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { App, ConfigProvider, theme } from 'ant-design-vue';
|
||||
|
||||
import { antdLocale } from '#/locales';
|
||||
import { GlobalContextMenu } from '#/components/context-menu';
|
||||
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
|
||||
import { useWebSocket } from '#/views/business/chat/composables/useWebSocket';
|
||||
import { useChatStore } from '#/views/business/chat/stores/chat';
|
||||
import { useUserStore } from '#/views/business/chat/stores/user';
|
||||
@@ -76,13 +75,6 @@ watch(userInfoLoaded, (loaded) => {
|
||||
initWebsocket();
|
||||
}
|
||||
});
|
||||
|
||||
/** 诊所医生账号显示传方悬浮窗(有 doctor_id 且绑定门店) */
|
||||
const showDoctorTransferFloat = computed(() => {
|
||||
const info = vbenUserStore.userInfo as Record<string, any> | null;
|
||||
if (!info) return false;
|
||||
return !!(info.doctor_id && info.store_id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -90,7 +82,6 @@ const showDoctorTransferFloat = computed(() => {
|
||||
<App>
|
||||
<RouterView />
|
||||
<GlobalContextMenu />
|
||||
<DoctorTransferFloat v-if="showDoctorTransferFloat" />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 中药药名气泡选择器
|
||||
* - 对齐诊断/医嘱 EntryKeywordBubble:Input 聚焦弹出 Teleport 浮层
|
||||
* - 远程搜索药品(getProductListDoctorReception),支持药名/别名/首拼/全拼/数字
|
||||
* - 列表展示拼音列;命中别名时展示匹配别名及其拼音
|
||||
* - 选中后回传完整商品行,供接诊/复诊/特色方/常用方复用现有落库逻辑
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { Empty, Input, Spin } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
rankByMatchScore,
|
||||
splitHighlight,
|
||||
type HighlightPart,
|
||||
} from '#/utils/dictSearchRank';
|
||||
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
|
||||
|
||||
export type ChineseDrugBubbleItem = {
|
||||
id: number;
|
||||
drug_id: number;
|
||||
price: number;
|
||||
buy_price?: number;
|
||||
drug: Record<string, any>;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 已选药名(展示回填;打开搜索时仍可编辑关键词) */
|
||||
displayName?: string;
|
||||
/** 药品类型:1 中药,颗粒等由调用方传入 */
|
||||
type?: number;
|
||||
storeId?: number;
|
||||
registerId?: number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/** 卡片内紧凑宽度 */
|
||||
compact?: boolean;
|
||||
/** 额外 class,便于焦点跳转 querySelector */
|
||||
inputClass?: string;
|
||||
}>(),
|
||||
{
|
||||
displayName: '',
|
||||
type: 1,
|
||||
storeId: 0,
|
||||
registerId: 0,
|
||||
placeholder: '药名',
|
||||
disabled: false,
|
||||
compact: false,
|
||||
inputClass: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [item: ChineseDrugBubbleItem];
|
||||
}>();
|
||||
|
||||
const ROW_HEIGHT = 48;
|
||||
const HINT_HEIGHT = 34;
|
||||
const HEAD_HEIGHT = 32;
|
||||
const PANEL_PAD = 8;
|
||||
const PREFERRED_PANEL = 360;
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const panelRef = ref<HTMLElement | null>(null);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
const inputRef = ref<{ focus?: () => void } | null>(null);
|
||||
const keyword = ref('');
|
||||
const loading = ref(false);
|
||||
const open = ref(false);
|
||||
/** 是否处于「正在搜」:true 时不把 displayName 强写回 keyword */
|
||||
const searching = ref(false);
|
||||
const highlightIndex = ref(0);
|
||||
const options = ref<ChineseDrugBubbleItem[]>([]);
|
||||
const scrollTop = ref(0);
|
||||
const listViewportH = ref(200);
|
||||
const panelStyle = ref<Record<string, string>>({});
|
||||
|
||||
const totalHeight = computed(() => options.value.length * ROW_HEIGHT);
|
||||
const startIndex = computed(() =>
|
||||
Math.max(0, Math.floor(scrollTop.value / ROW_HEIGHT) - 2),
|
||||
);
|
||||
const endIndex = computed(() => {
|
||||
const visible = Math.ceil(listViewportH.value / ROW_HEIGHT) + 4;
|
||||
return Math.min(options.value.length - 1, startIndex.value + visible);
|
||||
});
|
||||
const visibleRows = computed(() => {
|
||||
const list = options.value;
|
||||
const start = startIndex.value;
|
||||
const end = endIndex.value;
|
||||
if (list.length === 0 || end < start) return [];
|
||||
return list.slice(start, end + 1).map((item, i) => ({
|
||||
item,
|
||||
index: start + i,
|
||||
}));
|
||||
});
|
||||
const offsetY = computed(() => startIndex.value * ROW_HEIGHT);
|
||||
|
||||
/**
|
||||
* 同步展示名:未打开搜索时用 displayName 回填输入框
|
||||
*/
|
||||
watch(
|
||||
() => props.displayName,
|
||||
(name) => {
|
||||
if (!searching.value && !open.value) {
|
||||
keyword.value = String(name || '');
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function partsOf(text: string): HighlightPart[] {
|
||||
return splitHighlight(text, keyword.value.trim());
|
||||
}
|
||||
|
||||
function drugNameOf(item: ChineseDrugBubbleItem): string {
|
||||
return String(item?.drug?.drug_name || item?.drug_name || '').trim();
|
||||
}
|
||||
|
||||
/** 关键词命中的别名(后端 matched_alias) */
|
||||
function matchedAliasOf(item: ChineseDrugBubbleItem): string {
|
||||
return String(
|
||||
item?.matched_alias || item?.drug?.matched_alias || '',
|
||||
).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼音展示:命中别名时用别名拼音,否则用药名首拼(全拼)
|
||||
*/
|
||||
function pinyinLabelOf(item: ChineseDrugBubbleItem): string {
|
||||
const aliasPy = String(
|
||||
item?.matched_alias_pinyin || item?.drug?.matched_alias_pinyin || '',
|
||||
).trim();
|
||||
if (matchedAliasOf(item) && aliasPy) return aliasPy;
|
||||
const display = String(item?.drug?.pinyin_display || '').trim();
|
||||
if (display) return display;
|
||||
const simple = String(item?.drug?.pinyin_simple || '').trim();
|
||||
const full = String(item?.drug?.pinyin_full || '').trim();
|
||||
if (simple && full && simple.toLowerCase() !== full.toLowerCase()) {
|
||||
return `${simple}(${full})`;
|
||||
}
|
||||
return simple || full || '-';
|
||||
}
|
||||
|
||||
function unitLabelOf(item: ChineseDrugBubbleItem): string {
|
||||
const unit = item?.drug?.unit || item?.unit;
|
||||
if (typeof unit === 'object' && unit?.name) return String(unit.name);
|
||||
return String(item?.drug?.specification || item?.specification || '').trim() || '-';
|
||||
}
|
||||
|
||||
function priceLabelOf(item: ChineseDrugBubbleItem): string {
|
||||
const p = Number(item?.price ?? 0);
|
||||
return Number.isFinite(p) ? `¥${p.toFixed(2)}` : '-';
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板 Teleport + fixed,避免接诊/Modal overflow 裁切
|
||||
*/
|
||||
function updatePanelPlacement() {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const gap = 4;
|
||||
const spaceBelow = vh - rect.bottom - PANEL_PAD;
|
||||
const spaceAbove = rect.top - PANEL_PAD;
|
||||
const placeTop = spaceBelow < 180 && spaceAbove > spaceBelow;
|
||||
const avail = Math.max(140, placeTop ? spaceAbove : spaceBelow);
|
||||
const panelMax = Math.min(PREFERRED_PANEL, avail);
|
||||
listViewportH.value = Math.max(80, panelMax - HINT_HEIGHT - HEAD_HEIGHT);
|
||||
|
||||
// 含拼音列后加宽,避免别名/全拼被裁切
|
||||
const PANEL_MIN_WIDTH = 560;
|
||||
const width = Math.min(
|
||||
Math.max(rect.width, PANEL_MIN_WIDTH),
|
||||
vw - PANEL_PAD * 2,
|
||||
);
|
||||
let left = rect.left;
|
||||
if (left + width > vw - PANEL_PAD) {
|
||||
left = vw - PANEL_PAD - width;
|
||||
}
|
||||
if (left < PANEL_PAD) left = PANEL_PAD;
|
||||
|
||||
const base: Record<string, string> = {
|
||||
position: 'fixed',
|
||||
zIndex: '4000',
|
||||
width: `${width}px`,
|
||||
maxHeight: `${panelMax}px`,
|
||||
left: `${left}px`,
|
||||
right: 'auto',
|
||||
};
|
||||
if (placeTop) {
|
||||
panelStyle.value = {
|
||||
...base,
|
||||
top: 'auto',
|
||||
bottom: `${Math.max(PANEL_PAD, vh - rect.top + gap)}px`,
|
||||
};
|
||||
} else {
|
||||
panelStyle.value = {
|
||||
...base,
|
||||
top: `${rect.bottom + gap}px`,
|
||||
bottom: 'auto',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程搜药;空关键词不请求(与原中药 Select 一致)
|
||||
*/
|
||||
async function fetchDrugs(kw: string) {
|
||||
const name = String(kw || '').trim();
|
||||
if (!name) {
|
||||
options.value = [];
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getProductListDoctorReception({
|
||||
name,
|
||||
type: props.type,
|
||||
store_id: props.storeId || 0,
|
||||
...(props.registerId > 0 ? { register_id: props.registerId } : {}),
|
||||
});
|
||||
const list = Array.isArray(res) ? res : [];
|
||||
const mapped: ChineseDrugBubbleItem[] = list.map((item: any) => {
|
||||
const drug = item.drug || {};
|
||||
return {
|
||||
...item,
|
||||
id: Number(item.id || 0),
|
||||
drug_id: Number(item.drug_id || drug.id || 0),
|
||||
price: Number(item.price ?? 0),
|
||||
buy_price: item.buy_price,
|
||||
drug: {
|
||||
...drug,
|
||||
drug_name: drug.drug_name || item.drug_name || '',
|
||||
},
|
||||
};
|
||||
});
|
||||
options.value = rankByMatchScore(
|
||||
mapped,
|
||||
name,
|
||||
(it) => drugNameOf(it),
|
||||
(it) =>
|
||||
[
|
||||
it.drug?.pinyin_simple,
|
||||
it.drug?.pinyin_full,
|
||||
it.drug?.pinyin_display,
|
||||
it.drug?.drug_alias,
|
||||
it.matched_alias || it.drug?.matched_alias,
|
||||
it.matched_alias_pinyin || it.drug?.matched_alias_pinyin,
|
||||
it.drug?.search_digits,
|
||||
it.drug?.specification,
|
||||
]
|
||||
.map((v) => String(v || ''))
|
||||
.join(' '),
|
||||
);
|
||||
highlightIndex.value = options.value.length > 0 ? 0 : -1;
|
||||
scrollTop.value = 0;
|
||||
if (listRef.value) listRef.value.scrollTop = 0;
|
||||
} catch (e) {
|
||||
console.error('中药药名搜索失败:', e);
|
||||
options.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
nextTick(() => updatePanelPlacement());
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedFetch = useDebounceFn((kw: string) => {
|
||||
void fetchDrugs(kw);
|
||||
}, 280);
|
||||
|
||||
function openPanel() {
|
||||
if (props.disabled) return;
|
||||
open.value = true;
|
||||
searching.value = true;
|
||||
nextTick(() => {
|
||||
updatePanelPlacement();
|
||||
if (keyword.value.trim()) {
|
||||
debouncedFetch(keyword.value);
|
||||
} else {
|
||||
options.value = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closePanel(restoreDisplay = true) {
|
||||
open.value = false;
|
||||
searching.value = false;
|
||||
options.value = [];
|
||||
highlightIndex.value = 0;
|
||||
if (restoreDisplay) {
|
||||
keyword.value = String(props.displayName || '');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (props.disabled) return;
|
||||
// 聚焦时清空为搜索态,便于直接输入新药名
|
||||
if (props.displayName && keyword.value === props.displayName) {
|
||||
keyword.value = '';
|
||||
}
|
||||
openPanel();
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
searching.value = true;
|
||||
if (!open.value) openPanel();
|
||||
else updatePanelPlacement();
|
||||
debouncedFetch(keyword.value);
|
||||
}
|
||||
|
||||
function selectItem(item: ChineseDrugBubbleItem) {
|
||||
const name = drugNameOf(item);
|
||||
keyword.value = name;
|
||||
searching.value = false;
|
||||
open.value = false;
|
||||
options.value = [];
|
||||
emit('select', item);
|
||||
}
|
||||
|
||||
/**
|
||||
* 键盘高亮循环移动(与诊断 EntryKeywordBubble 一致:第一项再上键 → 最后一项)
|
||||
*/
|
||||
function moveHighlight(delta: number) {
|
||||
if (options.value.length === 0) return;
|
||||
const len = options.value.length;
|
||||
let next = highlightIndex.value;
|
||||
if (next < 0) next = 0;
|
||||
else next = (next + delta + len) % len;
|
||||
highlightIndex.value = next;
|
||||
ensureRowVisible(highlightIndex.value);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!open.value) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
openPanel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closePanel(true);
|
||||
return;
|
||||
}
|
||||
if (options.value.length === 0) return;
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
moveHighlight(1);
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
moveHighlight(-1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const hit = options.value[highlightIndex.value];
|
||||
if (hit) selectItem(hit);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureRowVisible(index: number) {
|
||||
const el = listRef.value;
|
||||
if (!el || index < 0) return;
|
||||
const top = index * ROW_HEIGHT;
|
||||
const bottom = top + ROW_HEIGHT;
|
||||
if (top < el.scrollTop) el.scrollTop = top;
|
||||
else if (bottom > el.scrollTop + el.clientHeight) {
|
||||
el.scrollTop = bottom - el.clientHeight;
|
||||
}
|
||||
// 同步虚拟列表切片(循环跳到首/尾时必须刷新可见区)
|
||||
scrollTop.value = el.scrollTop;
|
||||
}
|
||||
|
||||
function onListScroll() {
|
||||
scrollTop.value = listRef.value?.scrollTop || 0;
|
||||
}
|
||||
|
||||
function onDocPointerDown(e: MouseEvent) {
|
||||
if (!open.value) return;
|
||||
const t = e.target as Node;
|
||||
if (containerRef.value?.contains(t)) return;
|
||||
if (panelRef.value?.contains(t)) return;
|
||||
closePanel(true);
|
||||
}
|
||||
|
||||
function onWinChange() {
|
||||
if (open.value) updatePanelPlacement();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onDocPointerDown, true);
|
||||
window.addEventListener('resize', onWinChange);
|
||||
window.addEventListener('scroll', onWinChange, true);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', onDocPointerDown, true);
|
||||
window.removeEventListener('resize', onWinChange);
|
||||
window.removeEventListener('scroll', onWinChange, true);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
focus() {
|
||||
nextTick(() => {
|
||||
const el = inputRef.value as any;
|
||||
el?.focus?.();
|
||||
const native = containerRef.value?.querySelector(
|
||||
'input',
|
||||
) as HTMLInputElement | null;
|
||||
native?.focus();
|
||||
});
|
||||
},
|
||||
close() {
|
||||
closePanel(true);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="chinese-drug-name-bubble"
|
||||
:class="[{ 'chinese-drug-name-bubble--compact': compact }, inputClass]"
|
||||
>
|
||||
<Input
|
||||
ref="inputRef"
|
||||
v-model:value="keyword"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
allow-clear
|
||||
size="small"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<template #prefix>
|
||||
<LoadingOutlined v-if="loading" class="text-muted-foreground" />
|
||||
<SearchOutlined v-else class="text-muted-foreground" />
|
||||
</template>
|
||||
</Input>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="panelRef"
|
||||
class="chinese-drug-name-bubble__panel"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<div class="chinese-drug-name-bubble__hint">
|
||||
<span>↑↓←→ 循环切换 · Enter 选用 · Esc 关闭</span>
|
||||
<span class="chinese-drug-name-bubble__count">
|
||||
<template v-if="keyword.trim()">搜到 {{ options.length }} 条</template>
|
||||
<template v-else>输入药名搜索</template>
|
||||
</span>
|
||||
</div>
|
||||
<Spin :spinning="loading">
|
||||
<Empty
|
||||
v-if="!loading && options.length === 0"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
:description="
|
||||
keyword.trim()
|
||||
? '未找到匹配药材'
|
||||
: '请输入药名/别名/拼音/数字搜索'
|
||||
"
|
||||
class="py-4"
|
||||
/>
|
||||
<template v-else>
|
||||
<div
|
||||
class="chinese-drug-name-bubble__head"
|
||||
:class="{ 'has-score': !!keyword.trim() }"
|
||||
>
|
||||
<span class="col-name">药名</span>
|
||||
<span class="col-py">拼音</span>
|
||||
<span class="col-spec">规格/单位</span>
|
||||
<span class="col-price">价格</span>
|
||||
<span v-if="keyword.trim()" class="col-score">匹配度</span>
|
||||
</div>
|
||||
<div
|
||||
ref="listRef"
|
||||
class="chinese-drug-name-bubble__list"
|
||||
:style="{ height: `${listViewportH}px` }"
|
||||
@scroll="onListScroll"
|
||||
>
|
||||
<div
|
||||
class="chinese-drug-name-bubble__phantom"
|
||||
:style="{ height: `${totalHeight}px` }"
|
||||
/>
|
||||
<div
|
||||
class="chinese-drug-name-bubble__virtual"
|
||||
:style="{ transform: `translateY(${offsetY}px)` }"
|
||||
>
|
||||
<div
|
||||
v-for="{ item, index } in visibleRows"
|
||||
:key="`${item.drug_id}_${index}`"
|
||||
class="chinese-drug-name-bubble__row"
|
||||
:class="{
|
||||
'is-active': index === highlightIndex,
|
||||
'has-score': !!keyword.trim(),
|
||||
}"
|
||||
:style="{ height: `${ROW_HEIGHT}px` }"
|
||||
@mousedown.prevent="selectItem(item)"
|
||||
@mouseenter="highlightIndex = index"
|
||||
>
|
||||
<span
|
||||
class="col-name"
|
||||
:title="
|
||||
matchedAliasOf(item)
|
||||
? `${drugNameOf(item)}(别名:${matchedAliasOf(item)})`
|
||||
: drugNameOf(item)
|
||||
"
|
||||
>
|
||||
<span class="col-name__main">
|
||||
<template
|
||||
v-for="(p, pi) in partsOf(drugNameOf(item))"
|
||||
:key="`n_${pi}`"
|
||||
>
|
||||
<em v-if="p.hit" class="hit">{{ p.text }}</em>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="matchedAliasOf(item)"
|
||||
class="col-name__alias"
|
||||
>
|
||||
别名:
|
||||
<template
|
||||
v-for="(p, pi) in partsOf(matchedAliasOf(item))"
|
||||
:key="`a_${pi}`"
|
||||
>
|
||||
<em v-if="p.hit" class="hit">{{ p.text }}</em>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
<span class="col-py" :title="pinyinLabelOf(item)">
|
||||
<template v-if="pinyinLabelOf(item) === '-'">-</template>
|
||||
<template v-else>
|
||||
<template
|
||||
v-for="(p, pi) in partsOf(pinyinLabelOf(item))"
|
||||
:key="`py_${pi}`"
|
||||
>
|
||||
<em v-if="p.hit" class="hit">{{ p.text }}</em>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</template>
|
||||
</span>
|
||||
<span class="col-spec" :title="unitLabelOf(item)">
|
||||
{{ unitLabelOf(item) }}
|
||||
</span>
|
||||
<span class="col-price">{{ priceLabelOf(item) }}</span>
|
||||
<span v-if="keyword.trim()" class="col-score">
|
||||
{{ Number(item.match_score || 0) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Spin>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chinese-drug-name-bubble {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
box-sizing: border-box;
|
||||
/* 锁死宽度:避免接诊/复诊/特色方/常用方 flex 布局把药名框撑满整行 */
|
||||
width: 100px !important;
|
||||
min-width: 88px !important;
|
||||
max-width: 110px !important;
|
||||
flex: 0 0 100px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chinese-drug-name-bubble--compact {
|
||||
width: 100px !important;
|
||||
min-width: 88px !important;
|
||||
max-width: 110px !important;
|
||||
flex: 0 0 100px !important;
|
||||
}
|
||||
.chinese-drug-name-bubble :deep(.ant-input-affix-wrapper) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
padding-inline: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chinese-drug-name-bubble :deep(.ant-input-prefix) {
|
||||
margin-inline-end: 2px;
|
||||
}
|
||||
.chinese-drug-name-bubble :deep(.ant-input) {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chinese-drug-name-bubble__panel {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--popover, var(--background)));
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.chinese-drug-name-bubble__hint {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
}
|
||||
.chinese-drug-name-bubble__count {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.chinese-drug-name-bubble__list {
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.chinese-drug-name-bubble__phantom {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chinese-drug-name-bubble__virtual {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.chinese-drug-name-bubble__head,
|
||||
.chinese-drug-name-bubble__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 1.1fr) minmax(110px, 1.3fr) 90px 64px;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chinese-drug-name-bubble__head.has-score,
|
||||
.chinese-drug-name-bubble__row.has-score {
|
||||
grid-template-columns: minmax(90px, 1.1fr) minmax(100px, 1.2fr) 80px 56px 48px;
|
||||
}
|
||||
.chinese-drug-name-bubble__head {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--popover, var(--background)));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
.chinese-drug-name-bubble__row {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid hsl(var(--border) / 0.6);
|
||||
}
|
||||
.chinese-drug-name-bubble__row:hover,
|
||||
.chinese-drug-name-bubble__row.is-active {
|
||||
background: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
.col-name,
|
||||
.col-py,
|
||||
.col-spec {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
white-space: normal;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.col-name__main {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-name__alias {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.col-py {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.col-spec {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.col-price {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-score {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: #d4380d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chinese-drug-name-bubble__row.is-active .col-spec,
|
||||
.chinese-drug-name-bubble__row.is-active .col-py,
|
||||
.chinese-drug-name-bubble__row.is-active .col-name__alias {
|
||||
color: inherit;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.chinese-drug-name-bubble__row.is-active .col-score {
|
||||
color: inherit;
|
||||
}
|
||||
.hit {
|
||||
font-style: normal;
|
||||
color: #cf1322;
|
||||
background: rgba(255, 214, 102, 0.55);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 中药药名气泡选择器
|
||||
*/
|
||||
export { default as ChineseDrugNameBubble } from './chinese-drug-name-bubble.vue';
|
||||
export type { ChineseDrugBubbleItem } from './chinese-drug-name-bubble.vue';
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 医生端 PC 传方悬浮窗:悬浮按钮 + 子窗口列表 + 状态操作
|
||||
* 医生端 PC 传方入口:顶栏按钮 + 子窗口列表 + 状态操作
|
||||
* 权限:有 doctor_id 且绑定 store_id 时由外层 v-if 控制,本组件只负责展示与操作
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
FileTextOutlined,
|
||||
ReloadOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
@@ -19,6 +21,7 @@ import { usePreferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dropdown,
|
||||
Image,
|
||||
@@ -29,10 +32,10 @@ import {
|
||||
Spin,
|
||||
Tag,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import FloatFab from '#/components/float-fab/FloatFab.vue';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import {
|
||||
DEFAULT_SUB_WINDOW_Z_INDEX,
|
||||
@@ -355,12 +358,20 @@ defineExpose({ loadList });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatFab
|
||||
storage-key="pc_doctor_transfer_fab"
|
||||
label="传方"
|
||||
:badge="pendingCount"
|
||||
@click="openWindow"
|
||||
/>
|
||||
<div class="doctor-transfer-header-entry">
|
||||
<Tooltip title="门店传方">
|
||||
<Badge :count="pendingCount" :overflow-count="99" size="small">
|
||||
<Button
|
||||
type="text"
|
||||
class="transfer-header-btn"
|
||||
@click="openWindow"
|
||||
>
|
||||
<FileTextOutlined />
|
||||
<span class="transfer-header-btn__text">传方</span>
|
||||
</Button>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SubWindow
|
||||
v-model:open="windowOpen"
|
||||
title="门店传方"
|
||||
@@ -848,4 +859,25 @@ defineExpose({ loadList });
|
||||
margin: 0 auto;
|
||||
user-select: none;
|
||||
}
|
||||
.doctor-transfer-header-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.transfer-header-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.transfer-header-btn__text {
|
||||
font-size: 13px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.transfer-header-btn__text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
196
apps/web-antd/src/components/file-preview/FilePreviewModal.vue
Normal file
196
apps/web-antd/src/components/file-preview/FilePreviewModal.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 通用文件预览:图片用 Ant Image;PDF fetch→blob→iframe;支持多文件切换
|
||||
* 开票中心与素材库复用,避免两套预览逻辑
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Image, Modal, Spin, message } from 'ant-design-vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 是否显示 */
|
||||
open: boolean;
|
||||
/** 文件 URL 列表 */
|
||||
files?: string[];
|
||||
/** 弹窗标题前缀 */
|
||||
title?: string;
|
||||
}>(),
|
||||
{
|
||||
files: () => [],
|
||||
title: '文件预览',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', v: boolean): void;
|
||||
}>();
|
||||
|
||||
const currentIndex = ref(0);
|
||||
const previewIsImage = ref(false);
|
||||
const previewImageUrl = ref('');
|
||||
const previewPdfUrl = ref('');
|
||||
const previewLoading = ref(false);
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.open,
|
||||
set: (v: boolean) => emit('update:open', v),
|
||||
});
|
||||
|
||||
const currentUrl = computed(() => props.files?.[currentIndex.value] || '');
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
const name = (currentUrl.value || '').split('/').pop() || '';
|
||||
const total = props.files?.length || 0;
|
||||
if (total > 1) {
|
||||
return `${props.title}(${currentIndex.value + 1}/${total})${name ? ' - ' + name : ''}`;
|
||||
}
|
||||
return name ? `${props.title} - ${name}` : props.title;
|
||||
});
|
||||
|
||||
function isImageUrl(url: string) {
|
||||
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
|
||||
}
|
||||
|
||||
function revokePdfBlob() {
|
||||
if (previewPdfUrl.value && previewPdfUrl.value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewPdfUrl.value);
|
||||
}
|
||||
previewPdfUrl.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前文件到预览区
|
||||
*/
|
||||
async function loadCurrent() {
|
||||
const url = currentUrl.value;
|
||||
revokePdfBlob();
|
||||
if (!url) {
|
||||
message.info('暂无文件');
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
if (isImageUrl(url)) {
|
||||
previewIsImage.value = true;
|
||||
previewImageUrl.value = url;
|
||||
previewLoading.value = false;
|
||||
return;
|
||||
}
|
||||
previewIsImage.value = false;
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('文件加载失败');
|
||||
const blob = await resp.blob();
|
||||
const pdfBlob =
|
||||
blob.type && blob.type !== 'application/octet-stream'
|
||||
? blob
|
||||
: new Blob([blob], { type: 'application/pdf' });
|
||||
previewPdfUrl.value = URL.createObjectURL(pdfBlob);
|
||||
} catch {
|
||||
message.error('预览失败,请尝试下载后查看');
|
||||
visible.value = false;
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function prevFile() {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function nextFile() {
|
||||
if (props.files && currentIndex.value < props.files.length - 1) {
|
||||
currentIndex.value += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.open, props.files] as const,
|
||||
([open]) => {
|
||||
if (open) {
|
||||
currentIndex.value = 0;
|
||||
loadCurrent();
|
||||
} else {
|
||||
revokePdfBlob();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(currentIndex, () => {
|
||||
if (props.open) {
|
||||
loadCurrent();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokePdfBlob();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-model:open="visible"
|
||||
:title="displayTitle"
|
||||
:footer="null"
|
||||
width="860px"
|
||||
destroy-on-close
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div class="preview-wrap">
|
||||
<div v-if="(files?.length || 0) > 1" class="nav">
|
||||
<Button size="small" :disabled="currentIndex <= 0" @click="prevFile">上一份</Button>
|
||||
<Button
|
||||
size="small"
|
||||
:disabled="currentIndex >= (files?.length || 0) - 1"
|
||||
@click="nextFile"
|
||||
>
|
||||
下一份
|
||||
</Button>
|
||||
</div>
|
||||
<Spin :spinning="previewLoading">
|
||||
<div v-if="previewIsImage" class="img-box">
|
||||
<Image :src="previewImageUrl" :preview="true" style="max-width: 100%" />
|
||||
</div>
|
||||
<iframe
|
||||
v-else-if="previewPdfUrl"
|
||||
:src="previewPdfUrl"
|
||||
class="pdf-frame"
|
||||
title="pdf-preview"
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preview-wrap {
|
||||
min-height: 240px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.img-box {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--muted) / 0.25);
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
.pdf-frame {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
</style>
|
||||
@@ -79,7 +79,7 @@ const handleRemove = (e: Event) => {
|
||||
</div>
|
||||
<Icon v-else icon="ant-design:plus-outlined" />
|
||||
</Upload>
|
||||
<GalleryPickLink @select="onGallerySelect" />
|
||||
<GalleryPickLink :accept-types="[0]" @select="onGallerySelect" />
|
||||
</div>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
|
||||
@@ -9,8 +9,8 @@ const props = withDefaults(
|
||||
maxCount?: number;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 限制可选择的文件类型(透传给 image-gallery-picker,按 xk_file_type.value 过滤)
|
||||
* 例如图片:[1];文件:[6];语音:[3]
|
||||
* 限制可选择的文件类型(透传给 image-gallery-picker,按 xk_file.type / xk_file_type.value 过滤)
|
||||
* 例如图片:[0];视频:[1];文件:[6];语音:[3]
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}>(),
|
||||
|
||||
@@ -18,7 +18,7 @@ const props = withDefaults(
|
||||
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
|
||||
* - 不传:显示全部类型(默认行为,保持向后兼容)
|
||||
* - 传数组:只展示指定类型 tab,且自动锁定到第一个匹配的类型
|
||||
* 例如图片:[1];图片+视频:[1, 2];文件:[6]
|
||||
* 例如图片:[0];图片+视频:[0, 1];文件:[6]
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}>(),
|
||||
@@ -217,9 +217,9 @@ function itemIcon(item: FileGalleryItem) {
|
||||
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
|
||||
}
|
||||
|
||||
/** 图片类型展示缩略图(xk_file_type:1=图片) */
|
||||
/** 图片类型展示缩略图(xk_file.type:0=图片;兼容历史错误写入的 1) */
|
||||
function isImageItem(item: FileGalleryItem) {
|
||||
return Number(item.type) === 1 || Number(item.type) === 0;
|
||||
return Number(item.type) === 0 || Number(item.type) === 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ onBeforeUnmount(() => {
|
||||
<Icon icon="ant-design:inbox-outlined" class="text-4xl text-blue-500" />
|
||||
</p>
|
||||
<p class="ant-upload-text">{{ tip }}</p>
|
||||
<p class="ant-upload-hint text-gray-400">
|
||||
<p class="ant-upload-hint text-muted-foreground">
|
||||
支持拖拽或点击选择,单文件不超过 {{ maxSizeMb }}MB
|
||||
</p>
|
||||
</UploadDragger>
|
||||
@@ -296,7 +296,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
v-for="item in fileList"
|
||||
:key="item.uid"
|
||||
class="flex items-center gap-2 rounded border border-gray-100 px-3 py-2 text-sm"
|
||||
class="flex items-center gap-2 rounded border border-border bg-muted/20 px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<Icon
|
||||
:icon="
|
||||
@@ -311,12 +311,12 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
v-if="item.url"
|
||||
type="button"
|
||||
class="min-w-0 flex-1 truncate text-left text-blue-600"
|
||||
class="min-w-0 flex-1 truncate text-left text-primary"
|
||||
@click="previewItem(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
<span v-else class="min-w-0 flex-1 truncate text-gray-500">{{ item.name }}</span>
|
||||
<span v-else class="min-w-0 flex-1 truncate text-muted-foreground">{{ item.name }}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -343,7 +343,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
|
||||
</Button>
|
||||
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500">
|
||||
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500 dark:text-orange-400">
|
||||
监听中:请将文件或截图 Ctrl+V 粘贴
|
||||
</span>
|
||||
</div>
|
||||
@@ -356,14 +356,14 @@ onBeforeUnmount(() => {
|
||||
destroy-on-close
|
||||
@cancel="onPreviewClose"
|
||||
>
|
||||
<div v-if="previewLoading" class="py-16 text-center text-gray-500">加载中…</div>
|
||||
<div v-else-if="previewIsImage" class="flex justify-center">
|
||||
<div v-if="previewLoading" class="py-16 text-center text-muted-foreground">加载中…</div>
|
||||
<div v-else-if="previewIsImage" class="flex justify-center rounded border border-border bg-muted/25 p-2">
|
||||
<Image :src="previewImageUrl" :preview="true" style="max-height: 70vh" />
|
||||
</div>
|
||||
<iframe
|
||||
v-else-if="previewPdfUrl"
|
||||
:src="previewPdfUrl"
|
||||
class="h-[70vh] w-full border-0"
|
||||
class="h-[70vh] w-full rounded border border-border bg-background"
|
||||
title="文件预览"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -24,12 +24,18 @@ interface Props {
|
||||
modelValue: string[];
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 素材库可选类型(透传 GalleryPickLink)
|
||||
* xk_file.type:0=图片 1=视频;默认仅图片。不传会展示全部类型
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
maxCount: 9,
|
||||
acceptTypes: () => [0],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -345,6 +351,7 @@ onMounted(() => {
|
||||
v-if="showUploadButton"
|
||||
:multiple="multiple"
|
||||
:max-count="galleryRemainCount"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onGallerySelect"
|
||||
/>
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ interface Props {
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 素材库可选类型(透传 GalleryPickLink),默认仅图片
|
||||
* 素材库可选类型(透传 GalleryPickLink)
|
||||
* xk_file.type:0=图片 1=视频,默认仅图片
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}
|
||||
@@ -40,7 +41,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
maxCount: 9,
|
||||
acceptTypes: () => [1],
|
||||
acceptTypes: () => [0],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
714
apps/web-antd/src/components/loading/PrescriptionLoading.vue
Normal file
714
apps/web-antd/src/components/loading/PrescriptionLoading.vue
Normal file
@@ -0,0 +1,714 @@
|
||||
<template>
|
||||
<!--
|
||||
AI 加载动画:处方=药丸;病历=病历本
|
||||
type + text 由调用方传入;嵌入抽屉时默认不显示主题切换
|
||||
-->
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="loading-wrapper"
|
||||
:class="{ 'loading-wrapper--dark': isDark }"
|
||||
:style="{ width: '100%', height: '100%' }"
|
||||
>
|
||||
<canvas ref="canvasRef" class="loading-canvas"></canvas>
|
||||
<button
|
||||
v-if="showToggle"
|
||||
class="theme-toggle-btn"
|
||||
:title="isDark ? '切换到亮色模式' : '切换到暗色模式'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<span class="theme-icon">
|
||||
<svg
|
||||
v-if="!isDark"
|
||||
class="sun"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="moon"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</span>
|
||||
<!-- <span class="theme-label">{{ isDark ? '亮色' : '暗色' }}</span>-->
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI 生成加载动画
|
||||
* - type=prescription:中心画胶囊药丸
|
||||
* - type=medical_record:中心画病历本
|
||||
* - text:底部文案(可动态传)
|
||||
*/
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
export type AiLoadingType = 'prescription' | 'medical_record';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 动画类型:处方 / 病历 */
|
||||
type?: AiLoadingType;
|
||||
/** 底部文案 */
|
||||
text?: string;
|
||||
/** 是否显示主题切换(嵌入业务抽屉建议关闭) */
|
||||
showToggle?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: 'prescription',
|
||||
text: '',
|
||||
showToggle: false,
|
||||
},
|
||||
);
|
||||
|
||||
/** 未传文案时按 type 给默认值 */
|
||||
const displayText = computed(() => {
|
||||
const t = String(props.text || '').trim();
|
||||
if (t) return t;
|
||||
return props.type === 'medical_record'
|
||||
? 'AI正在生成病历'
|
||||
: 'AI正在生成处方';
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const isDark = ref(false);
|
||||
/** 画布循环里读取最新文案/类型,避免闭包过期 */
|
||||
const liveText = ref(displayText.value);
|
||||
const liveType = ref<AiLoadingType>(props.type);
|
||||
|
||||
watch(
|
||||
displayText,
|
||||
(v) => {
|
||||
liveText.value = v;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(
|
||||
() => props.type,
|
||||
(v) => {
|
||||
liveType.value = v || 'prescription';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function detectAppDark(): boolean {
|
||||
return (
|
||||
document.documentElement.classList.contains('dark') ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
document.documentElement.getAttribute('class')?.includes('dark') === true
|
||||
);
|
||||
}
|
||||
|
||||
function syncThemeFromApp() {
|
||||
if (localStorage.getItem('prescription-loading-theme')) {
|
||||
isDark.value = localStorage.getItem('prescription-loading-theme') === 'dark';
|
||||
return;
|
||||
}
|
||||
isDark.value = detectAppDark();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value;
|
||||
localStorage.setItem(
|
||||
'prescription-loading-theme',
|
||||
isDark.value ? 'dark' : 'light',
|
||||
);
|
||||
resetParticles?.();
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
size: number;
|
||||
color: { r: number; g: number; b: number };
|
||||
type: 'circle' | 'cross';
|
||||
}
|
||||
|
||||
interface ThemeColors {
|
||||
textColor: string;
|
||||
ringColor: string;
|
||||
glowInner: string;
|
||||
glowMiddle: string;
|
||||
glowOuter: string;
|
||||
particleBright: number;
|
||||
iconShadow: string;
|
||||
}
|
||||
|
||||
let animationId = 0;
|
||||
let resetParticles: (() => void) | null = null;
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let removeWindowResize: (() => void) | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
syncThemeFromApp();
|
||||
if (!containerRef.value || !canvasRef.value) return;
|
||||
|
||||
const canvas = canvasRef.value;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const container = containerRef.value;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let cssWidth = 0;
|
||||
let cssHeight = 0;
|
||||
let prevW = 0;
|
||||
let prevH = 0;
|
||||
let startTime: number | null = null;
|
||||
let lastTime: number | null = null;
|
||||
let particles: Particle[] = [];
|
||||
const MAX_PARTICLES = 28;
|
||||
|
||||
resetParticles = () => {
|
||||
particles = [];
|
||||
};
|
||||
|
||||
function updateSize() {
|
||||
const rect = container.getBoundingClientRect();
|
||||
cssWidth = rect.width || 300;
|
||||
cssHeight = rect.height || 280;
|
||||
const pw = Math.round(cssWidth * dpr);
|
||||
const ph = Math.round(cssHeight * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
if (
|
||||
prevW &&
|
||||
(Math.abs(cssWidth - prevW) > 2 || Math.abs(cssHeight - prevH) > 2)
|
||||
) {
|
||||
particles = [];
|
||||
}
|
||||
prevW = cssWidth;
|
||||
prevH = cssHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function createParticle(cx: number, cy: number, s: number): Particle {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = s * (0.15 + Math.random() * 0.45);
|
||||
const speed = s * (0.03 + Math.random() * 0.08);
|
||||
const colors = [
|
||||
{ r: 61, g: 214, b: 208 },
|
||||
{ r: 74, g: 195, b: 190 },
|
||||
{ r: 93, g: 173, b: 226 },
|
||||
{ r: 46, g: 180, b: 170 },
|
||||
{ r: 100, g: 200, b: 215 },
|
||||
];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)]!;
|
||||
return {
|
||||
x: cx + Math.cos(angle) * distance,
|
||||
y: cy + Math.sin(angle) * distance,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
maxLife: 1.8 + Math.random() * 2.5,
|
||||
size: 1.2 + Math.random() * 2.8,
|
||||
color,
|
||||
type: Math.random() < 0.25 ? 'cross' : 'circle',
|
||||
};
|
||||
}
|
||||
|
||||
function updateParticles(dt: number, cx: number, cy: number, s: number) {
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const p = particles[i]!;
|
||||
p.life -= dt / p.maxLife;
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.vx *= 0.997;
|
||||
p.vy *= 0.997;
|
||||
if (p.life <= 0) particles.splice(i, 1);
|
||||
}
|
||||
while (particles.length < MAX_PARTICLES) {
|
||||
const p = createParticle(cx, cy, s);
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
p.x = cx + Math.cos(ang) * s * 0.12 * Math.random();
|
||||
p.y = cy + Math.sin(ang) * s * 0.12 * Math.random();
|
||||
particles.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
function drawParticle(
|
||||
c: CanvasRenderingContext2D,
|
||||
p: Particle,
|
||||
bright: number,
|
||||
) {
|
||||
const alpha = p.life;
|
||||
const { r, g, b } = p.color;
|
||||
const size = p.size * (0.7 + 0.3 * p.life);
|
||||
const br = Math.min(255, Math.round(r * bright));
|
||||
const bg = Math.min(255, Math.round(g * bright));
|
||||
const bb = Math.min(255, Math.round(b * bright));
|
||||
c.save();
|
||||
c.globalAlpha = alpha;
|
||||
if (p.type === 'cross') {
|
||||
const len = size * 1.8;
|
||||
const w = size * 0.55;
|
||||
c.strokeStyle = `rgba(${br},${bg},${bb},${alpha})`;
|
||||
c.lineWidth = w;
|
||||
c.lineCap = 'round';
|
||||
c.beginPath();
|
||||
c.moveTo(p.x - len, p.y);
|
||||
c.lineTo(p.x + len, p.y);
|
||||
c.stroke();
|
||||
c.beginPath();
|
||||
c.moveTo(p.x, p.y - len);
|
||||
c.lineTo(p.x, p.y + len);
|
||||
c.stroke();
|
||||
c.fillStyle = `rgba(255,255,255,${alpha * 0.9})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size * 0.35, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
} else {
|
||||
c.shadowColor = `rgba(${br},${bg},${bb},${alpha * 0.7})`;
|
||||
c.shadowBlur = size * 2.5;
|
||||
c.fillStyle = `rgba(${br},${bg},${bb},${alpha})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
c.shadowBlur = 0;
|
||||
c.fillStyle = `rgba(255,255,255,${alpha * 0.6})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size * 0.4, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** 处方药丸(处方) */
|
||||
function drawPill(
|
||||
c: CanvasRenderingContext2D,
|
||||
px: number,
|
||||
py: number,
|
||||
pw: number,
|
||||
ph: number,
|
||||
floatOff: number,
|
||||
pulse: number,
|
||||
shadowCol: string,
|
||||
) {
|
||||
c.save();
|
||||
const cx = px + pw / 2;
|
||||
const cy = py + ph / 2;
|
||||
const hw = (pw / 2) * pulse;
|
||||
const hh = (ph / 2) * pulse;
|
||||
const adjY = cy + floatOff;
|
||||
const dx = cx - hw;
|
||||
const dy = adjY - hh;
|
||||
const dw = hw * 2;
|
||||
const dh = hh * 2;
|
||||
const rad = hh;
|
||||
const path = new Path2D();
|
||||
path.roundRect(dx, dy, dw, dh, rad);
|
||||
|
||||
c.shadowColor = shadowCol;
|
||||
c.shadowBlur = hh * 0.7;
|
||||
c.shadowOffsetY = hh * 0.25;
|
||||
c.fillStyle = '#ffffff';
|
||||
c.fill(path);
|
||||
c.shadowColor = 'transparent';
|
||||
c.shadowBlur = 0;
|
||||
c.shadowOffsetY = 0;
|
||||
|
||||
c.save();
|
||||
c.clip(path);
|
||||
const leftGrad = c.createLinearGradient(dx, 0, dx + dw * 0.5, 0);
|
||||
leftGrad.addColorStop(0, '#FDFBF7');
|
||||
leftGrad.addColorStop(0.7, '#F5F1EA');
|
||||
leftGrad.addColorStop(1, '#EDE8DF');
|
||||
c.fillStyle = leftGrad;
|
||||
c.fillRect(dx, dy, dw * 0.5, dh);
|
||||
|
||||
const rightGrad = c.createLinearGradient(dx + dw * 0.5, 0, dx + dw, 0);
|
||||
rightGrad.addColorStop(0, '#5DD9D1');
|
||||
rightGrad.addColorStop(0.5, '#3DD6D0');
|
||||
rightGrad.addColorStop(1, '#2CB8B0');
|
||||
c.fillStyle = rightGrad;
|
||||
c.fillRect(dx + dw * 0.5, dy, dw * 0.5, dh);
|
||||
|
||||
c.strokeStyle = 'rgba(180,180,180,0.5)';
|
||||
c.lineWidth = Math.max(0.6, hh * 0.06);
|
||||
c.beginPath();
|
||||
c.moveTo(dx + dw * 0.5, dy + 1);
|
||||
c.lineTo(dx + dw * 0.5, dy + dh - 1);
|
||||
c.stroke();
|
||||
|
||||
const hy = dy + dh * 0.15;
|
||||
const hh2 = dh * 0.38;
|
||||
const hp = new Path2D();
|
||||
hp.roundRect(dx + dw * 0.06, hy, dw * 0.88, hh2, hh2 / 2);
|
||||
c.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
c.fill(hp);
|
||||
c.restore();
|
||||
|
||||
c.strokeStyle = 'rgba(200,200,200,0.5)';
|
||||
c.lineWidth = Math.max(0.8, hh * 0.08);
|
||||
c.stroke(path);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** 病历本(病历) */
|
||||
function drawMedicalRecord(
|
||||
c: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
scale: number,
|
||||
floatOff: number,
|
||||
pulse: number,
|
||||
shadowCol: string,
|
||||
) {
|
||||
c.save();
|
||||
const w = scale * 0.28 * pulse;
|
||||
const h = scale * 0.34 * pulse;
|
||||
const x = cx - w / 2;
|
||||
const y = cy - h / 2 + floatOff;
|
||||
const r = Math.max(4, scale * 0.018);
|
||||
const path = new Path2D();
|
||||
path.roundRect(x, y, w, h, r);
|
||||
|
||||
c.shadowColor = shadowCol;
|
||||
c.shadowBlur = scale * 0.035;
|
||||
c.shadowOffsetY = scale * 0.012;
|
||||
c.fillStyle = '#ffffff';
|
||||
c.fill(path);
|
||||
c.shadowColor = 'transparent';
|
||||
c.shadowBlur = 0;
|
||||
c.shadowOffsetY = 0;
|
||||
|
||||
// 左侧装订条
|
||||
c.fillStyle = '#3DD6D0';
|
||||
c.fillRect(x, y + r * 0.2, w * 0.12, h - r * 0.4);
|
||||
|
||||
// 横线(像病历行)
|
||||
c.strokeStyle = 'rgba(61,214,208,0.35)';
|
||||
c.lineWidth = Math.max(1, scale * 0.004);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const ly = y + h * (0.32 + i * 0.14);
|
||||
c.beginPath();
|
||||
c.moveTo(x + w * 0.22, ly);
|
||||
c.lineTo(x + w * 0.88, ly);
|
||||
c.stroke();
|
||||
}
|
||||
// 顶部标题条
|
||||
c.fillStyle = 'rgba(61,214,208,0.2)';
|
||||
c.beginPath();
|
||||
c.roundRect(x + w * 0.22, y + h * 0.12, w * 0.5, h * 0.08, 2);
|
||||
c.fill();
|
||||
|
||||
c.strokeStyle = 'rgba(180,200,200,0.55)';
|
||||
c.lineWidth = Math.max(0.8, scale * 0.005);
|
||||
c.stroke(path);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function drawCross(
|
||||
c: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
alpha: number,
|
||||
rot: number,
|
||||
) {
|
||||
c.save();
|
||||
c.translate(x, y);
|
||||
c.rotate(rot);
|
||||
c.globalAlpha = alpha;
|
||||
const len = size;
|
||||
const w = size * 0.3;
|
||||
c.strokeStyle = '#3DD6D0';
|
||||
c.lineWidth = w;
|
||||
c.lineCap = 'round';
|
||||
c.beginPath();
|
||||
c.moveTo(-len, 0);
|
||||
c.lineTo(len, 0);
|
||||
c.stroke();
|
||||
c.beginPath();
|
||||
c.moveTo(0, -len);
|
||||
c.lineTo(0, len);
|
||||
c.stroke();
|
||||
c.fillStyle = '#ffffff';
|
||||
c.beginPath();
|
||||
c.arc(0, 0, size * 0.25, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function getTheme(): ThemeColors {
|
||||
const dark = isDark.value;
|
||||
return {
|
||||
textColor: dark ? '#d1d5db' : '#4a5568',
|
||||
ringColor: dark ? 'rgba(255,255,255,0.25)' : 'rgba(180,195,200,0.45)',
|
||||
glowInner: dark ? 'rgba(61,214,208,0.15)' : 'rgba(61,214,208,0.13)',
|
||||
glowMiddle: dark ? 'rgba(61,214,208,0.05)' : 'rgba(61,214,208,0.04)',
|
||||
glowOuter: 'rgba(61,214,208,0)',
|
||||
particleBright: dark ? 1.4 : 1.0,
|
||||
iconShadow: dark ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.12)',
|
||||
};
|
||||
}
|
||||
|
||||
function draw(timestamp: number) {
|
||||
updateSize();
|
||||
if (cssWidth <= 0 || cssHeight <= 0) {
|
||||
animationId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
if (startTime === null) {
|
||||
startTime = timestamp;
|
||||
lastTime = timestamp;
|
||||
}
|
||||
const elapsed = (timestamp - startTime) / 1000;
|
||||
let dt = (timestamp - (lastTime ?? timestamp)) / 1000;
|
||||
if (dt <= 0) dt = 0.016;
|
||||
if (dt > 0.2) dt = 0.2;
|
||||
lastTime = timestamp;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const cx = cssWidth / 2;
|
||||
const cy = cssHeight / 2 - cssHeight * 0.04;
|
||||
const scale = Math.min(cssWidth, cssHeight);
|
||||
const orbitR = scale * 0.26;
|
||||
const fontSize = Math.max(12, scale * 0.055);
|
||||
const textY = cy + orbitR + fontSize * 2.4;
|
||||
const text = liveText.value;
|
||||
const animType = liveType.value;
|
||||
|
||||
const dashOff = elapsed * scale * 0.025;
|
||||
const arcRot = elapsed * 3.5;
|
||||
const arcLen = (100 * Math.PI) / 180;
|
||||
const floatOff = Math.sin(elapsed * 2.5) * scale * 0.025;
|
||||
const pulseS = 1 + Math.sin(elapsed * 3.7) * 0.04;
|
||||
const textAlpha = 0.55 + 0.45 * Math.sin(elapsed * 1.6);
|
||||
const theme = getTheme();
|
||||
|
||||
const grad = ctx.createRadialGradient(
|
||||
cx,
|
||||
cy,
|
||||
orbitR * 0.15,
|
||||
cx,
|
||||
cy,
|
||||
orbitR * 1.6,
|
||||
);
|
||||
grad.addColorStop(0, theme.glowInner);
|
||||
grad.addColorStop(0.5, theme.glowMiddle);
|
||||
grad.addColorStop(1, theme.glowOuter);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash([scale * 0.018, scale * 0.04]);
|
||||
ctx.lineDashOffset = -dashOff;
|
||||
ctx.strokeStyle = theme.ringColor;
|
||||
ctx.lineWidth = Math.max(1, scale * 0.006);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, orbitR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
|
||||
const arcS = arcRot;
|
||||
const arcE = arcRot + arcLen;
|
||||
const fx = cx + orbitR * Math.cos(arcE);
|
||||
const fy = cy + orbitR * Math.sin(arcE);
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(61,214,208,0.8)';
|
||||
ctx.shadowBlur = scale * 0.04;
|
||||
ctx.strokeStyle = '#3DD6D0';
|
||||
ctx.lineWidth = Math.max(2, scale * 0.014);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, orbitR, arcS, arcE);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(255,255,255,0.9)';
|
||||
ctx.shadowBlur = scale * 0.03;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(fx, fy, Math.max(2.5, scale * 0.014), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// 中心图标:按 type 切换
|
||||
if (animType === 'medical_record') {
|
||||
drawMedicalRecord(ctx, cx, cy, scale, floatOff, pulseS, theme.iconShadow);
|
||||
} else {
|
||||
const pillW = scale * 0.34;
|
||||
const pillH = scale * 0.1;
|
||||
drawPill(
|
||||
ctx,
|
||||
cx - pillW / 2,
|
||||
cy - pillH / 2,
|
||||
pillW,
|
||||
pillH,
|
||||
floatOff,
|
||||
pulseS,
|
||||
theme.iconShadow,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ang = elapsed * 1.8 + (i * Math.PI * 2) / 3;
|
||||
const crossOrbitR = orbitR * 1.02;
|
||||
const rx = cx + crossOrbitR * Math.cos(ang);
|
||||
const ry = cy + crossOrbitR * Math.sin(ang);
|
||||
const rs = Math.max(4, scale * 0.022);
|
||||
const ra = 0.4 + 0.35 * Math.sin(elapsed * 3.2 + i * 1.7);
|
||||
drawCross(ctx, rx, ry, rs, ra, elapsed * 0.9 + i);
|
||||
}
|
||||
|
||||
updateParticles(dt, cx, cy, scale);
|
||||
for (const p of particles) {
|
||||
drawParticle(ctx, p, theme.particleBright);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = textAlpha;
|
||||
ctx.fillStyle = theme.textColor;
|
||||
ctx.font = `${fontSize}px "PingFang SC","Microsoft YaHei","Noto Sans SC","Helvetica Neue",sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.shadowColor = 'rgba(61,214,208,0.25)';
|
||||
ctx.shadowBlur = fontSize * 0.6;
|
||||
ctx.fillText(text, cx, textY);
|
||||
ctx.restore();
|
||||
|
||||
const charW = fontSize * 0.55;
|
||||
const dotsStartX = cx + (text.length / 2) * charW + fontSize * 0.5;
|
||||
const dotR = Math.max(2, fontSize * 0.16);
|
||||
const dotSpacing = fontSize * 0.7;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const phase = (elapsed * 2.8 + i * 1.0) % 3;
|
||||
let brightness = phase < 1 ? phase : phase < 2 ? 2 - phase : 0;
|
||||
brightness = brightness * brightness * (3 - 2 * brightness);
|
||||
const alpha = 0.2 + brightness * 0.75;
|
||||
const dx = dotsStartX + i * dotSpacing * 2.2;
|
||||
ctx.save();
|
||||
ctx.shadowColor = `rgba(61,214,208,${alpha * 0.8})`;
|
||||
ctx.shadowBlur = dotR * 3;
|
||||
ctx.fillStyle = `rgba(61,214,208,${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, textY, dotR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
animationId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
updateSize();
|
||||
animationId = requestAnimationFrame(draw);
|
||||
|
||||
if (window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => updateSize());
|
||||
resizeObserver.observe(container);
|
||||
} else {
|
||||
const onResize = () => updateSize();
|
||||
window.addEventListener('resize', onResize);
|
||||
removeWindowResize = () => window.removeEventListener('resize', onResize);
|
||||
}
|
||||
|
||||
// 跟随应用 dark class(未手动锁定主题时)
|
||||
themeObserver = new MutationObserver(() => {
|
||||
if (!localStorage.getItem('prescription-loading-theme')) {
|
||||
syncThemeFromApp();
|
||||
}
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(animationId);
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
removeWindowResize?.();
|
||||
removeWindowResize = null;
|
||||
themeObserver?.disconnect();
|
||||
themeObserver = null;
|
||||
resetParticles = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
//background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.loading-wrapper--dark {
|
||||
//background: #1e2227;
|
||||
}
|
||||
.loading-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.theme-toggle-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(128, 128, 128, 0.3);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.25s;
|
||||
}
|
||||
.loading-wrapper--dark .theme-toggle-btn {
|
||||
background: rgba(30, 34, 39, 0.9);
|
||||
color: #d1d5db;
|
||||
}
|
||||
.theme-toggle-btn:hover {
|
||||
border-color: #3dd6d0;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
.theme-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -105,7 +105,9 @@ const downloadQRCode = async (name: string, isQr = false) => {
|
||||
{{ doctorInfo.doctor.name }} {{ doctorInfo.doctor.title.name }}
|
||||
</div>
|
||||
<div class="qrAddress">
|
||||
<span class="addressTitle">诊所地址:</span>{{ previewAddress }}
|
||||
<span class="addressTitle">{{
|
||||
Number(doctorInfo?.store?.type) === 1 ? '药店地址:' : '诊所地址:'
|
||||
}}</span>{{ previewAddress }}
|
||||
</div>
|
||||
<Image :preview="false" :src="doctorInfo.qr_code" class="mt-3" height="30" width="30" />
|
||||
<h3 class="border-l-4 border-blue-500 pl-3 text-lg font-semibold">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
/**
|
||||
* 诊所/药店二维码预览弹窗
|
||||
* 按门店 type(0 诊所 / 1 药店)切换「诊所|药店」文案,列表页与门店卡片共用
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
@@ -11,6 +15,13 @@ const htmlToImage = ref('');
|
||||
const url = ref('');
|
||||
const previewTitle = ref('二维码');
|
||||
const previewAddress = ref('');
|
||||
/** 门店类型:0=诊所 1=药店,未传时按诊所示 */
|
||||
const storeType = ref(0);
|
||||
|
||||
const isPharmacy = computed(() => Number(storeType.value) === 1);
|
||||
const kindLabel = computed(() => (isPharmacy.value ? '药店' : '诊所'));
|
||||
const addressLabel = computed(() => `${kindLabel.value}地址:`);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
footer: false,
|
||||
@@ -28,21 +39,28 @@ const [Modal, modalApi] = useVbenModal({
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const { values, title, address } =
|
||||
modalApi.getData<Record<string, any>>();
|
||||
const { values, title, address, type, store_type } =
|
||||
modalApi.getData<Record<string, any>>() || {};
|
||||
if (values) {
|
||||
url.value = values;
|
||||
qrCodeUrl.value = values;
|
||||
previewTitle.value = title;
|
||||
previewAddress.value = address;
|
||||
// 兼容 type / store_type 两种传参
|
||||
storeType.value = Number(type ?? store_type ?? 0);
|
||||
modalApi.setState({
|
||||
confirmText: `保存${kindLabel.value}卡片图片`,
|
||||
cancelText: `保存${kindLabel.value}二维码图片`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 下载二维码
|
||||
* @param name
|
||||
* @param isQr
|
||||
* 下载二维码卡片或纯二维码图片
|
||||
* @param name 文件名后缀
|
||||
* @param isQr true=只截二维码区域,false=截整张卡片
|
||||
*/
|
||||
const downloadQRCode = async (name: string, isQr = false) => {
|
||||
const element = isQr ? qrCodeUrl.value : htmlToImage.value;
|
||||
@@ -51,25 +69,24 @@ const downloadQRCode = async (name: string, isQr = false) => {
|
||||
allowTaint: true,
|
||||
logging: false,
|
||||
}).then((canvas) => {
|
||||
// 创建a标签下载
|
||||
const link = document.createElement('a'); // 创建a标签
|
||||
link.href = canvas.toDataURL(); // 是canvas对象的一种方法,用于将canvas对象转换为base64位编码
|
||||
link.setAttribute('download', `${previewTitle.value}${name}.png`); // 利用了a标签的download 来下载 canvas图片
|
||||
link.style.display = 'none'; // 将图片隐藏起来
|
||||
document.body.append(link); // 插入到其中
|
||||
const link = document.createElement('a');
|
||||
link.href = canvas.toDataURL();
|
||||
link.setAttribute('download', `${previewTitle.value}${name}.png`);
|
||||
link.style.display = 'none';
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`${previewTitle}二维码`" class="w-[50%]">
|
||||
<Modal :title="`${previewTitle}二维码`">
|
||||
<div class="qrBox" style="text-align: center">
|
||||
<div ref="htmlToImage" class="qrModal">
|
||||
<div id="qrcode" ref="qrCodeUrl" class="qrcode">
|
||||
<div class="qrTitle">{{ previewTitle }}</div>
|
||||
<Image :preview="false" :src="url" height="30" width="30" />
|
||||
<div class="qrAddress">
|
||||
<span class="addressTitle">诊所地址:</span>{{ previewAddress }}
|
||||
<span class="addressTitle">{{ addressLabel }}</span>{{ previewAddress }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface FormFieldSchema {
|
||||
required?: boolean;
|
||||
/** select / radio 类型的选项列表 */
|
||||
options?: { label: string; value: any }[];
|
||||
/** 图片字段:限定素材库文件类型(如 [1] 只显示图片) */
|
||||
/** 图片字段:限定素材库文件类型(如 [0] 只显示图片) */
|
||||
acceptTypes?: number[];
|
||||
/** 最大长度 */
|
||||
maxLength?: number;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Dayjs } from 'dayjs';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
@@ -35,9 +36,15 @@ import {
|
||||
} from '#/views/system/store/api';
|
||||
import { getSalespersonList } from '#/views/system/store/api/salesperson';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
import AddDoctorFromStoreModal from '#/views/system/store/components/AddDoctorFromStoreModal.vue';
|
||||
import { canAddStoreDoctor } from '#/views/system/admin/_shared/platform-admin-role';
|
||||
|
||||
const RangePicker = DatePicker.RangePicker;
|
||||
|
||||
const userStore = useUserStore();
|
||||
// 是否允许「添加医生」:超管/管理员/省经理/市经理/业务员
|
||||
const canAddDoctor = computed(() => canAddStoreDoctor(userStore.userInfo));
|
||||
|
||||
const storeId = ref(0);
|
||||
const loading = ref(false);
|
||||
const activeTab = ref('basic');
|
||||
@@ -64,10 +71,13 @@ const stats = computed(() => cardData.value?.stats_summary ?? {});
|
||||
const doctorList = computed(() => cardData.value?.doctor_list ?? []);
|
||||
const isClinic = computed(() => Number(store.value?.type) === 0);
|
||||
const isPharmacy = computed(() => Number(store.value?.type) === 1);
|
||||
/** 按门店类型区分诊所/药店文案,避免药店仍显示「诊所」 */
|
||||
const storeKindLabel = computed(() => (isPharmacy.value ? '药店' : '诊所'));
|
||||
const qrTabLabel = computed(() => `${storeKindLabel.value}二维码`);
|
||||
const modalTitle = computed(() => {
|
||||
const name = store.value?.name;
|
||||
if (!name) return '门店详情';
|
||||
return isPharmacy.value ? `药店详情 · ${name}` : `诊所详情 · ${name}`;
|
||||
return `${storeKindLabel.value}详情 · ${name}`;
|
||||
});
|
||||
|
||||
const drugTypeOptions = [
|
||||
@@ -121,6 +131,25 @@ const [DoctorModal, DoctorModalApi] = useVbenModal({
|
||||
connectedComponent: DoctorCardModal,
|
||||
});
|
||||
|
||||
/** 添加医生弹窗:诊所详情医生团队 Tab 内入口 */
|
||||
const [AddDoctorModal, AddDoctorModalApi] = useVbenModal({
|
||||
connectedComponent: AddDoctorFromStoreModal,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开「添加医生」弹窗:默认绑定当前门店,提交成功后刷新医生团队列表
|
||||
*/
|
||||
const openAddDoctorModal = () => {
|
||||
AddDoctorModalApi.setData({
|
||||
storeId: storeId.value,
|
||||
storeName: (store.value as any)?.name || '',
|
||||
onSuccess: () => {
|
||||
loadCard();
|
||||
},
|
||||
});
|
||||
AddDoctorModalApi.open();
|
||||
};
|
||||
|
||||
function searchTimeParam(): [string, string] {
|
||||
return [
|
||||
searchTime.value[0].format('YYYY-MM-DD 00:00:00'),
|
||||
@@ -256,6 +285,7 @@ function clinicTypeText(v: number) {
|
||||
|
||||
<template>
|
||||
<Modal :title="modalTitle">
|
||||
<AddDoctorModal />
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="cardData" class="mb-4 flex flex-wrap items-center justify-between gap-3 px-1">
|
||||
<div class="text-sm text-muted-foreground">统计时间范围</div>
|
||||
@@ -399,6 +429,15 @@ function clinicTypeText(v: number) {
|
||||
|
||||
<Tabs.TabPane v-if="isClinic" key="doctors" tab="诊所医生团队">
|
||||
<div class="pl-4">
|
||||
<div v-if="canAddDoctor" class="mb-3">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openAddDoctorModal"
|
||||
>
|
||||
添加医生
|
||||
</Button>
|
||||
</div>
|
||||
<Empty v-if="doctorList.length === 0" description="暂无医生" />
|
||||
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
@@ -443,7 +482,7 @@ function clinicTypeText(v: number) {
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="qr" tab="门店二维码">
|
||||
<Tabs.TabPane key="qr" :tab="qrTabLabel">
|
||||
<div class="flex flex-col items-center gap-4 py-6 pl-4">
|
||||
<template v-if="cardData?.qr_code || store.qr_code">
|
||||
<Image
|
||||
@@ -452,13 +491,13 @@ function clinicTypeText(v: number) {
|
||||
:preview="true"
|
||||
/>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ store.name }} 门店二维码
|
||||
{{ store.name }} {{ qrTabLabel }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Empty description="尚未生成二维码" />
|
||||
<Empty :description="`尚未生成${storeKindLabel}二维码`" />
|
||||
<Button type="primary" :loading="generatingQr" @click="ensureQrCode">
|
||||
生成二维码
|
||||
生成{{ storeKindLabel }}二维码
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
30
apps/web-antd/src/components/vip/HeaderVipBadge.vue
Normal file
30
apps/web-antd/src/components/vip/HeaderVipBadge.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 顶栏 VIP 徽标:等级徽标 + 期限丝带叠加(丝带透明底绑在徽标上),放大显示
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import VipBadgeCombo from './VipBadgeCombo.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const vip = computed(() => (userStore.userInfo as any)?.vip || null);
|
||||
|
||||
const show = computed(() => {
|
||||
if (!vip.value) return false;
|
||||
return !!(vip.value.duration_badge_url || vip.value.badge_url);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="show" class="mr-2 flex items-center">
|
||||
<VipBadgeCombo
|
||||
size="sm"
|
||||
:badge-url="vip.badge_url"
|
||||
:duration-badge-url="vip.duration_badge_url"
|
||||
:level-name="vip.level_name"
|
||||
:duration-label="vip.duration_label"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
83
apps/web-antd/src/components/vip/StoreVipCell.vue
Normal file
83
apps/web-antd/src/components/vip/StoreVipCell.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 诊所列表 VIP 单元格:徽标+会员名,点击打开升级弹窗
|
||||
*/
|
||||
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||
|
||||
defineProps<{
|
||||
vip?: Record<string, any> | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="store-vip-cell"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click.stop="emit('click')"
|
||||
@keydown.enter.prevent="emit('click')"
|
||||
>
|
||||
<VipBadgeCombo
|
||||
size="sm"
|
||||
:badge-url="vip?.badge_url"
|
||||
:duration-badge-url="vip?.duration_badge_url"
|
||||
:level-name="vip?.level_name"
|
||||
:duration-label="vip?.duration_label"
|
||||
/>
|
||||
<div class="store-vip-cell__text">
|
||||
<div class="store-vip-cell__name">{{ vip?.level_name || '普通会员' }}</div>
|
||||
<div class="store-vip-cell__sub">
|
||||
{{ vip?.duration_label || '点击升级' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.store-vip-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px 4px 4px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s ease;
|
||||
max-width: 100%;
|
||||
}
|
||||
.store-vip-cell:hover {
|
||||
background: rgba(106, 205, 187, 0.12);
|
||||
}
|
||||
.store-vip-cell__text {
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.store-vip-cell__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground, #1f1f1f);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.store-vip-cell__sub {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
html.dark .store-vip-cell:hover,
|
||||
.dark .store-vip-cell:hover {
|
||||
background: rgba(106, 205, 187, 0.2);
|
||||
}
|
||||
html.dark .store-vip-cell__name,
|
||||
.dark .store-vip-cell__name {
|
||||
color: #f4f4f5;
|
||||
}
|
||||
html.dark .store-vip-cell__sub,
|
||||
.dark .store-vip-cell__sub {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
</style>
|
||||
107
apps/web-antd/src/components/vip/StoreVipMemberCard.vue
Normal file
107
apps/web-antd/src/components/vip/StoreVipMemberCard.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 诊所设置「基础资料」会员卡片
|
||||
* 大尺寸等级徽标 + 期限丝带叠加,展示等级名与到期时间
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import VipBadgeCombo from './VipBadgeCombo.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
vip?: Record<string, any> | null;
|
||||
}>();
|
||||
|
||||
const hasVipVisual = computed(() => {
|
||||
const v = props.vip;
|
||||
return !!(v && (v.badge_url || v.duration_badge_url));
|
||||
});
|
||||
|
||||
const expireText = computed(() => {
|
||||
const v = props.vip;
|
||||
if (!v) return '默认会员';
|
||||
if (v.is_lifetime) return '永久有效';
|
||||
const expireAt = Number(v.expire_at || 0);
|
||||
if (!expireAt) {
|
||||
if ((v.level_code || 'V0') === 'V0') return '默认会员';
|
||||
return '永久有效';
|
||||
}
|
||||
if (typeof v.expire_at === 'string' && String(v.expire_at).includes('-')) {
|
||||
return `有效期至 ${v.expire_at}`;
|
||||
}
|
||||
const d = new Date(expireAt * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `有效期至 ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="store-vip-card">
|
||||
<VipBadgeCombo
|
||||
v-if="hasVipVisual"
|
||||
size="xl"
|
||||
:badge-url="vip?.badge_url"
|
||||
:duration-badge-url="vip?.duration_badge_url"
|
||||
:level-name="vip?.level_name"
|
||||
:duration-label="vip?.duration_label"
|
||||
/>
|
||||
<div v-else class="store-vip-card__placeholder">V</div>
|
||||
<div class="store-vip-card__info">
|
||||
<div class="store-vip-card__title">
|
||||
{{ vip?.level_name || '普通会员' }}
|
||||
</div>
|
||||
<div class="store-vip-card__sub">
|
||||
{{ vip?.duration_label || '普通会员' }}
|
||||
· {{ vip?.level_code || 'V0' }}
|
||||
</div>
|
||||
<div class="store-vip-card__expire">{{ expireText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.store-vip-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 20px 28px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
|
||||
box-shadow: 0 8px 24px rgba(44, 62, 80, 0.16);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.store-vip-card__placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.store-vip-card__info {
|
||||
min-width: 0;
|
||||
color: #fff;
|
||||
}
|
||||
.store-vip-card__title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.store-vip-card__sub {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.store-vip-card__expire {
|
||||
font-size: 13px;
|
||||
opacity: 0.78;
|
||||
}
|
||||
html.dark .store-vip-card,
|
||||
.dark .store-vip-card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
</style>
|
||||
117
apps/web-antd/src/components/vip/VipBadgeCombo.vue
Normal file
117
apps/web-antd/src/components/vip/VipBadgeCombo.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* VIP 组合徽标:等级徽标在下,期限丝带(透明底)叠在上方绑定
|
||||
* size: sm 顶栏 / md 表格 / lg 详情 / xl 诊所设置会员卡
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
badgeUrl?: string;
|
||||
durationBadgeUrl?: string;
|
||||
levelName?: string;
|
||||
durationLabel?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}>(),
|
||||
{
|
||||
badgeUrl: '',
|
||||
durationBadgeUrl: '',
|
||||
levelName: '',
|
||||
durationLabel: '',
|
||||
size: 'md',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="badgeUrl || durationBadgeUrl"
|
||||
class="vip-badge-combo"
|
||||
:class="[`vip-badge-combo--${size}`]"
|
||||
:title="[durationLabel, levelName].filter(Boolean).join(' · ')"
|
||||
>
|
||||
<img
|
||||
v-if="badgeUrl"
|
||||
class="vip-badge-combo__level"
|
||||
:src="badgeUrl"
|
||||
:alt="levelName || '会员等级'"
|
||||
/>
|
||||
<img
|
||||
v-if="durationBadgeUrl"
|
||||
class="vip-badge-combo__ribbon"
|
||||
:src="durationBadgeUrl"
|
||||
:alt="durationLabel || '会员类型'"
|
||||
/>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<style scoped>
|
||||
.vip-badge-combo {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.vip-badge-combo__level {
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.vip-badge-combo__ribbon {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 8%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vip-badge-combo--sm {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
.vip-badge-combo--sm .vip-badge-combo__level {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.vip-badge-combo--sm .vip-badge-combo__ribbon {
|
||||
width: 72px;
|
||||
height: 28px;
|
||||
}
|
||||
.vip-badge-combo--md {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.vip-badge-combo--md .vip-badge-combo__level {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
.vip-badge-combo--md .vip-badge-combo__ribbon {
|
||||
width: 84px;
|
||||
height: 30px;
|
||||
}
|
||||
.vip-badge-combo--lg {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
.vip-badge-combo--lg .vip-badge-combo__level {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
}
|
||||
.vip-badge-combo--lg .vip-badge-combo__ribbon {
|
||||
width: 120px;
|
||||
height: 42px;
|
||||
}
|
||||
.vip-badge-combo--xl {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
.vip-badge-combo--xl .vip-badge-combo__level {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
.vip-badge-combo--xl .vip-badge-combo__ribbon {
|
||||
width: 168px;
|
||||
height: 58px;
|
||||
}
|
||||
</style>
|
||||
41
apps/web-antd/src/components/vip/VipGate.vue
Normal file
41
apps/web-antd/src/components/vip/VipGate.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* VIP 权限显隐口子组件
|
||||
* 用法:<VipGate code="xxx" :min-level="'V5'">内容</VipGate>
|
||||
* 后续业务权益接入时直接包一层即可;当前从 userInfo.vip 判断
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { hasVipPermission, levelAtLeast } from '#/utils/vip';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 权限码(与等级 permissions 对齐) */
|
||||
code?: string;
|
||||
/** 最低等级编码 */
|
||||
minLevel?: string;
|
||||
/** 无权限时是否渲染占位(默认不渲染) */
|
||||
fallback?: boolean;
|
||||
}>(),
|
||||
{
|
||||
code: '',
|
||||
minLevel: '',
|
||||
fallback: false,
|
||||
},
|
||||
);
|
||||
|
||||
const allowed = computed(() => {
|
||||
if (props.code) {
|
||||
return hasVipPermission(props.code);
|
||||
}
|
||||
if (props.minLevel) {
|
||||
return levelAtLeast(props.minLevel);
|
||||
}
|
||||
// 未指定条件时默认展示,方便后续逐步接入
|
||||
return true;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<slot v-if="allowed" />
|
||||
<slot v-else-if="fallback" name="fallback" />
|
||||
</template>
|
||||
440
apps/web-antd/src/components/vip/VipUpgradeModal.vue
Normal file
440
apps/web-antd/src/components/vip/VipUpgradeModal.vue
Normal file
@@ -0,0 +1,440 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* VIP 升级/开通弹窗
|
||||
* 等级/时长用两行横向滑动单选卡片,预览丝带随所选时长自动切换
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Input, InputNumber, message } from 'ant-design-vue';
|
||||
|
||||
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||
import { getVipDurationOption } from '#/views/system/vip/duration/api';
|
||||
import { getVipLevelOption } from '#/views/system/vip/level/api';
|
||||
import {
|
||||
getVipDurationPresets,
|
||||
openVipStore,
|
||||
} from '#/views/system/vip/store/api';
|
||||
|
||||
/** 时长预设 → 时效类型 code(与后端 VipDurationHelper 对齐) */
|
||||
const PRESET_TO_DURATION: Record<string, string> = {
|
||||
'7d': 'trial',
|
||||
'1w': 'week',
|
||||
'1m': 'month',
|
||||
'3m': 'month',
|
||||
'6m': 'month',
|
||||
'1y': 'year',
|
||||
xy: 'year',
|
||||
lifetime: 'lifetime',
|
||||
};
|
||||
|
||||
const gridApi = ref<any>();
|
||||
const onSuccess = ref<null | (() => void)>(null);
|
||||
const storeId = ref(0);
|
||||
const storeName = ref('');
|
||||
const levelId = ref<number | undefined>();
|
||||
const durationPreset = ref<string | undefined>();
|
||||
const customYears = ref<number | undefined>();
|
||||
const remark = ref('');
|
||||
|
||||
const levels = ref<any[]>([]);
|
||||
const presets = ref<{ label: string; value: string }[]>([]);
|
||||
const durationTypes = ref<any[]>([]);
|
||||
|
||||
const selectedLevel = computed(() =>
|
||||
levels.value.find((i) => i.id === levelId.value),
|
||||
);
|
||||
|
||||
/** 按时长预设解析对应丝带资源 */
|
||||
function ribbonByPreset(preset?: string) {
|
||||
const code = PRESET_TO_DURATION[preset || ''] || '';
|
||||
if (!code) return null;
|
||||
return durationTypes.value.find((i) => i.code === code) || null;
|
||||
}
|
||||
|
||||
const selectedDurationType = computed(() =>
|
||||
ribbonByPreset(durationPreset.value),
|
||||
);
|
||||
|
||||
/** 时长卡片数据:附带丝带图,避免模板重复查找 */
|
||||
const durationCards = computed(() =>
|
||||
presets.value.map((p) => {
|
||||
const ribbon = ribbonByPreset(p.value);
|
||||
return {
|
||||
...p,
|
||||
ribbonUrl: ribbon?.badge_url || '',
|
||||
ribbonName: ribbon?.name || '',
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const previewBadgeUrl = computed(() => selectedLevel.value?.badge_url || '');
|
||||
const previewRibbonUrl = computed(
|
||||
() => selectedDurationType.value?.badge_url || '',
|
||||
);
|
||||
const previewLevelName = computed(
|
||||
() => selectedLevel.value?.name || '请选择等级',
|
||||
);
|
||||
const previewDurationLabel = computed(() => {
|
||||
if (!durationPreset.value) return '请选择时长';
|
||||
const preset = presets.value.find((p) => p.value === durationPreset.value);
|
||||
return preset?.label || selectedDurationType.value?.name || '请选择时长';
|
||||
});
|
||||
|
||||
const title = computed(() => `升级VIP - ${storeName.value || storeId.value}`);
|
||||
|
||||
/** 选择会员等级 */
|
||||
function selectLevel(id: number) {
|
||||
levelId.value = id;
|
||||
}
|
||||
|
||||
/** 选择开通时长(同步驱动预览丝带) */
|
||||
function selectDuration(value: string) {
|
||||
durationPreset.value = value;
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
if (!levelId.value) {
|
||||
message.warning('请选择会员等级');
|
||||
return;
|
||||
}
|
||||
if (!durationPreset.value) {
|
||||
message.warning('请选择开通时长');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
durationPreset.value === 'xy' &&
|
||||
!(Number(customYears.value) > 0)
|
||||
) {
|
||||
message.warning('请填写有效年数');
|
||||
return;
|
||||
}
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
await openVipStore({
|
||||
store_id: storeId.value,
|
||||
level_id: levelId.value,
|
||||
duration_preset: durationPreset.value,
|
||||
custom_years: customYears.value || 0,
|
||||
remark: remark.value || '',
|
||||
});
|
||||
message.success('开通成功');
|
||||
gridApi.value?.query?.();
|
||||
onSuccess.value?.();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
levelId.value = undefined;
|
||||
durationPreset.value = undefined;
|
||||
customYears.value = undefined;
|
||||
remark.value = '';
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<Record<string, any>>() || {};
|
||||
gridApi.value = data.gridApi;
|
||||
onSuccess.value = data.onSuccess || null;
|
||||
storeId.value = Number(data.store_id || 0);
|
||||
storeName.value = data.store_name || '';
|
||||
const [levelList, presetList, durationList] = await Promise.all([
|
||||
getVipLevelOption(),
|
||||
getVipDurationPresets(),
|
||||
getVipDurationOption(),
|
||||
]);
|
||||
levels.value = levelList || [];
|
||||
presets.value = (presetList || []).map((i: any) => ({
|
||||
label: i.label,
|
||||
value: i.value,
|
||||
}));
|
||||
durationTypes.value = durationList || [];
|
||||
// 回填当前 VIP 等级(便于续期)
|
||||
if (data.vip?.level_id) {
|
||||
levelId.value = Number(data.vip.level_id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
watch(durationPreset, (v) => {
|
||||
if (v !== 'xy') customYears.value = undefined;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="title" class="w-[50%]">
|
||||
<!-- 预览:徽标卡片 + 丝带(随时长自动适配) -->
|
||||
<div class="vip-upgrade-preview mb-5">
|
||||
<VipBadgeCombo
|
||||
size="xl"
|
||||
:badge-url="previewBadgeUrl"
|
||||
:duration-badge-url="previewRibbonUrl"
|
||||
:level-name="previewLevelName"
|
||||
:duration-label="previewDurationLabel"
|
||||
/>
|
||||
<div class="vip-upgrade-preview__text">
|
||||
<div class="vip-upgrade-preview__title">{{ previewLevelName }}</div>
|
||||
<div class="vip-upgrade-preview__sub">{{ previewDurationLabel }}</div>
|
||||
<div v-if="selectedLevel" class="vip-upgrade-preview__price">
|
||||
原价 ¥{{ selectedLevel.price }} · 管理员开通实付 ¥0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
会员等级
|
||||
</div>
|
||||
<div class="vip-pick-scroll" role="listbox" aria-label="会员等级">
|
||||
<button
|
||||
v-for="item in levels"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="vip-pick-card"
|
||||
:class="{ 'vip-pick-card--active': levelId === item.id }"
|
||||
role="option"
|
||||
:aria-selected="levelId === item.id"
|
||||
@click="selectLevel(item.id)"
|
||||
>
|
||||
<img
|
||||
v-if="item.badge_url"
|
||||
:src="item.badge_url"
|
||||
alt=""
|
||||
class="vip-pick-card__badge"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="vip-pick-card__badge vip-pick-card__badge--empty"
|
||||
>
|
||||
{{ item.code }}
|
||||
</div>
|
||||
<div class="vip-pick-card__name">{{ item.name }}</div>
|
||||
<div class="vip-pick-card__meta">
|
||||
{{ item.code }} · ¥{{ item.price }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
开通时长
|
||||
</div>
|
||||
<div class="vip-pick-scroll" role="listbox" aria-label="开通时长">
|
||||
<button
|
||||
v-for="item in durationCards"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="vip-pick-card vip-pick-card--duration"
|
||||
:class="{ 'vip-pick-card--active': durationPreset === item.value }"
|
||||
role="option"
|
||||
:aria-selected="durationPreset === item.value"
|
||||
@click="selectDuration(item.value)"
|
||||
>
|
||||
<img
|
||||
v-if="item.ribbonUrl"
|
||||
:src="item.ribbonUrl"
|
||||
alt=""
|
||||
class="vip-pick-card__ribbon"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="vip-pick-card__ribbon vip-pick-card__ribbon--empty"
|
||||
>
|
||||
丝带
|
||||
</div>
|
||||
<div class="vip-pick-card__name">{{ item.label }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="durationPreset === 'xy'">
|
||||
<div class="mb-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
自定义年数
|
||||
</div>
|
||||
<InputNumber
|
||||
v-model:value="customYears"
|
||||
class="w-full"
|
||||
:min="1"
|
||||
:max="99"
|
||||
placeholder="请输入年数"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-sm text-gray-600 dark:text-gray-300">备注</div>
|
||||
<Input.TextArea v-model:value="remark" :rows="2" placeholder="选填" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
<style scoped>
|
||||
.vip-upgrade-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 20px 24px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.vip-upgrade-preview__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.vip-upgrade-preview__sub {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.vip-upgrade-preview__price {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
/* 两行 + 横向滑动的单选卡片轨道 */
|
||||
.vip-pick-scroll {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(2, minmax(0, auto));
|
||||
grid-auto-columns: 112px;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 4px 2px 10px;
|
||||
scroll-snap-type: x proximity;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.vip-pick-scroll::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
.vip-pick-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.vip-pick-card {
|
||||
scroll-snap-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 108px;
|
||||
padding: 10px 8px;
|
||||
border: 1.5px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
background 0.15s ease;
|
||||
text-align: center;
|
||||
}
|
||||
.vip-pick-card:hover {
|
||||
border-color: #6acdbb;
|
||||
}
|
||||
.vip-pick-card--active {
|
||||
border-color: #6acdbb;
|
||||
background: rgba(106, 205, 187, 0.1);
|
||||
box-shadow: 0 0 0 2px rgba(106, 205, 187, 0.25);
|
||||
}
|
||||
.vip-pick-card__badge {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.vip-pick-card__badge--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vip-pick-card__ribbon {
|
||||
width: 88px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.vip-pick-card__ribbon--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 88px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: #f5f5f5;
|
||||
color: #bbb;
|
||||
font-size: 11px;
|
||||
}
|
||||
.vip-pick-card--duration {
|
||||
min-height: 88px;
|
||||
}
|
||||
.vip-pick-card__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vip-pick-card__meta {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* ========== 暗色适配(html.dark / .dark)========== */
|
||||
html.dark .vip-upgrade-preview,
|
||||
.dark .vip-upgrade-preview {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(106, 205, 187, 0.18);
|
||||
}
|
||||
html.dark .vip-pick-scroll::-webkit-scrollbar-thumb,
|
||||
.dark .vip-pick-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
html.dark .vip-pick-card,
|
||||
.dark .vip-pick-card {
|
||||
border-color: #3f3f46;
|
||||
background: #1f1f23;
|
||||
}
|
||||
html.dark .vip-pick-card:hover,
|
||||
.dark .vip-pick-card:hover {
|
||||
border-color: #6acdbb;
|
||||
background: #27272a;
|
||||
}
|
||||
html.dark .vip-pick-card--active,
|
||||
.dark .vip-pick-card--active {
|
||||
border-color: #6acdbb;
|
||||
background: rgba(106, 205, 187, 0.16);
|
||||
box-shadow: 0 0 0 2px rgba(106, 205, 187, 0.28);
|
||||
}
|
||||
html.dark .vip-pick-card__badge--empty,
|
||||
.dark .vip-pick-card__badge--empty,
|
||||
html.dark .vip-pick-card__ribbon--empty,
|
||||
.dark .vip-pick-card__ribbon--empty {
|
||||
background: #2a2a2e;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
html.dark .vip-pick-card__name,
|
||||
.dark .vip-pick-card__name {
|
||||
color: #f4f4f5;
|
||||
}
|
||||
html.dark .vip-pick-card__meta,
|
||||
.dark .vip-pick-card__meta {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
</style>
|
||||
@@ -43,6 +43,10 @@ export interface WarehouseDrugItem {
|
||||
id: number;
|
||||
drug_name: string;
|
||||
pinyin_simple?: string;
|
||||
/** 关键词命中的别名(后端 matched_alias) */
|
||||
matched_alias?: string;
|
||||
/** 命中别名对应拼音展示 */
|
||||
matched_alias_pinyin?: string;
|
||||
specification?: string;
|
||||
image?: string;
|
||||
type?: number;
|
||||
@@ -212,8 +216,27 @@ watch(
|
||||
<div v-else class="warehouse-drug-item__img-placeholder">无图</div>
|
||||
</div>
|
||||
<div class="warehouse-drug-item__info">
|
||||
<div class="warehouse-drug-item__name">{{ item.drug_name }}</div>
|
||||
<div
|
||||
class="warehouse-drug-item__name"
|
||||
:title="
|
||||
item.matched_alias
|
||||
? `${item.drug_name}(别名:${item.matched_alias})`
|
||||
: item.drug_name
|
||||
"
|
||||
>
|
||||
{{ item.drug_name }}
|
||||
</div>
|
||||
<!-- 与开方气泡一致:命中别名时展示别名及拼音 -->
|
||||
<div v-if="item.matched_alias" class="warehouse-drug-item__alias">
|
||||
别名:{{ item.matched_alias }}
|
||||
<span v-if="item.matched_alias_pinyin">
|
||||
({{ item.matched_alias_pinyin }})
|
||||
</span>
|
||||
</div>
|
||||
<div class="warehouse-drug-item__meta">
|
||||
<span v-if="item.pinyin_simple && !item.matched_alias" class="warehouse-drug-item__py">
|
||||
拼音:{{ item.pinyin_simple }}
|
||||
</span>
|
||||
<span v-if="item.specification" class="warehouse-drug-item__spec">
|
||||
规格:{{ item.specification }}
|
||||
</span>
|
||||
@@ -321,7 +344,17 @@ watch(
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__alias {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
@@ -332,6 +365,10 @@ watch(
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&__py {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
&__spec {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export const ENCRYPT_FIELDS = new Set([
|
||||
'password',
|
||||
'patient_mobile',
|
||||
'phone',
|
||||
'open_api_key',
|
||||
'api_key',
|
||||
]);
|
||||
|
||||
/** 需要生成 _tm_text 并默认脱敏展示的字段(encrypt_only) */
|
||||
@@ -96,7 +98,7 @@ export function maskSensitiveField(field: string, plainText: unknown): string {
|
||||
return '';
|
||||
}
|
||||
const str = String(plainText);
|
||||
if (field === 'password') {
|
||||
if (field === 'password' || field === 'api_key' || field === 'open_api_key') {
|
||||
return '*'.repeat(Math.max(str.length, 6));
|
||||
}
|
||||
if (PHONE_FIELDS.has(field)) {
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
// import { $t } from '#/locales';
|
||||
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
|
||||
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
|
||||
import { useAuthStore } from '#/store';
|
||||
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
|
||||
@@ -179,6 +181,13 @@ const showDot = computed(() =>
|
||||
notifications.value.some((item) => !item.isRead),
|
||||
);
|
||||
|
||||
/** 与原先悬浮窗一致:有 doctor_id 且绑定门店才显示传方入口 */
|
||||
const showDoctorTransfer = computed(() => {
|
||||
const info = userStore.userInfo as Record<string, any> | null | undefined;
|
||||
if (!info) return false;
|
||||
return !!(info.doctor_id && info.store_id);
|
||||
});
|
||||
|
||||
const menus = computed(() => [
|
||||
{
|
||||
handler: () => {
|
||||
@@ -233,15 +242,22 @@ watch(
|
||||
|
||||
<template>
|
||||
<BasicLayout @clear-preferences-and-logout="handleLogout">
|
||||
<!-- index=55:紧挨全局搜索(50)右侧 -->
|
||||
<template #header-right-55>
|
||||
<DoctorTransferFloat v-if="showDoctorTransfer" />
|
||||
</template>
|
||||
<template #user-dropdown>
|
||||
<UserDropdown
|
||||
:avatar
|
||||
:description="userStore.userInfo?.email"
|
||||
:menus
|
||||
:tag-text="userStore.userInfo?.roles?.name"
|
||||
:text="userStore.userInfo?.nick_name"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
<HeaderVipBadge />
|
||||
<UserDropdown
|
||||
:avatar
|
||||
:description="userStore.userInfo?.email"
|
||||
:menus
|
||||
:tag-text="userStore.userInfo?.roles?.name"
|
||||
:text="userStore.userInfo?.nick_name"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #notification>
|
||||
<Notification
|
||||
|
||||
@@ -65,6 +65,11 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
{ label: '医疗器械', value: 7, icon: '', icon_text: '' },
|
||||
]);
|
||||
const prescriptionTypeDefault = ref(2);
|
||||
/** 开方种类权限校验中(进入开方页先 loading) */
|
||||
const prescriptionTypeLoading = ref(false);
|
||||
/** 当前门店是否有可开方种类 */
|
||||
const prescriptionTypeAllowed = ref(true);
|
||||
const prescriptionTypeDenyMessage = ref('');
|
||||
const diagnosis = ref('');
|
||||
const medicalAdvice = ref('');
|
||||
const treatmentPrice = ref(0);
|
||||
@@ -89,6 +94,8 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
const newDrugInfo = ref<any>({});
|
||||
const selectChineseIndex = ref(-1);
|
||||
const selectChineseId = ref(0);
|
||||
/** 中药气泡选中的完整商品行(添加时优先使用) */
|
||||
const pendingNewChineseProduct = ref<any>(null);
|
||||
|
||||
// 二次签名相关
|
||||
const doctorSecondSign = ref(0);
|
||||
@@ -204,9 +211,10 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 拉取后端处方类型列表与默认选中
|
||||
* 拉取后端处方类型列表与默认选中;无绑定时清空 list 并返回 false
|
||||
*/
|
||||
const loadPrescriptionTypeOptions = async (registerId?: number | string) => {
|
||||
prescriptionTypeLoading.value = true;
|
||||
try {
|
||||
const params: { register_id?: number; store_id?: number } = {};
|
||||
const rid = Number(registerId || currentRegisterId.value);
|
||||
@@ -214,19 +222,34 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
if (myStoreId.value) params.store_id = myStoreId.value;
|
||||
const res = await getPrescriptionTypeOptionsApi(params);
|
||||
const list = Array.isArray(res?.list) ? res.list : [];
|
||||
if (list.length) {
|
||||
categories.value = list.map((item) => ({
|
||||
value: Number(item.value),
|
||||
label: item.label || '',
|
||||
icon: item.icon || '',
|
||||
icon_text: item.icon_text || '',
|
||||
}));
|
||||
categories.value = list.map((item) => ({
|
||||
value: Number(item.value),
|
||||
label: item.label || '',
|
||||
icon: item.icon || '',
|
||||
icon_text: item.icon_text || '',
|
||||
}));
|
||||
const def = Number(res?.default) || 0;
|
||||
prescriptionTypeDefault.value = def;
|
||||
const allowed =
|
||||
res?.allowed !== false && list.length > 0;
|
||||
prescriptionTypeAllowed.value = allowed;
|
||||
prescriptionTypeDenyMessage.value = allowed
|
||||
? ''
|
||||
: (res?.message || '当前门店未开通开方权限');
|
||||
// 当前 Tab 不在 allowed 内时强制切到 default
|
||||
if (allowed && def && !categories.value.some((c) => c.value === activeCategory.value)) {
|
||||
activeCategory.value = def;
|
||||
}
|
||||
const def = Number(res?.default);
|
||||
if (def) prescriptionTypeDefault.value = def;
|
||||
return allowed;
|
||||
} catch (error) {
|
||||
console.error('加载处方类型失败:', error);
|
||||
categories.value = [];
|
||||
prescriptionTypeAllowed.value = false;
|
||||
prescriptionTypeDenyMessage.value = '处方类型加载失败,请稍后重试';
|
||||
message.warning('处方类型加载失败,请稍后重试');
|
||||
return false;
|
||||
} finally {
|
||||
prescriptionTypeLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -705,7 +728,91 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
updateCurrentDrugs(newDrugs);
|
||||
};
|
||||
|
||||
// 新药品操作
|
||||
/**
|
||||
* 中药气泡:选中新增行药名
|
||||
* @param item 商品检索完整行
|
||||
*/
|
||||
const onSelectNewChineseDrug = (item: any) => {
|
||||
const drugId = Number(item?.drug_id || item?.drug?.id || 0);
|
||||
if (!drugId) return;
|
||||
const check = currentDrugs.value.find((v) => Number(v.id) === drugId);
|
||||
if (check) {
|
||||
newDrugInfo.value.id = '';
|
||||
newDrugInfo.value.name = '';
|
||||
pendingNewChineseProduct.value = null;
|
||||
message.error('该药品已经在处方中了!');
|
||||
return;
|
||||
}
|
||||
pendingNewChineseProduct.value = item;
|
||||
newDrugInfo.value.id = drugId;
|
||||
newDrugInfo.value.price = item.price;
|
||||
newDrugInfo.value.name = item.drug?.drug_name || '';
|
||||
newDrugInfo.value.unit =
|
||||
item.drug?.unit ||
|
||||
drugUnit.value.find((u) => u.id === item.drug?.unit_id);
|
||||
newDrugInfo.value.unit_id = item.drug?.unit_id;
|
||||
nextTick(() => {
|
||||
const input = document.querySelector(
|
||||
'.new-number-input input',
|
||||
) as HTMLElement;
|
||||
input?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 中药气泡:替换已选行药名
|
||||
* @param index 行下标
|
||||
* @param item 商品检索完整行
|
||||
*/
|
||||
const onSelectOldChineseDrug = (index: number, item: any) => {
|
||||
const drugId = Number(item?.drug?.id || item?.drug_id || 0);
|
||||
if (!drugId) return;
|
||||
const check = currentDrugs.value.find(
|
||||
(v, i) => i !== index && Number(v.id) === drugId,
|
||||
);
|
||||
if (check) {
|
||||
message.error('该药品已经在处方中了!');
|
||||
return;
|
||||
}
|
||||
const data = item;
|
||||
const newProduct = {
|
||||
index_id: data.id,
|
||||
id: data.drug.id,
|
||||
drug_name: data.drug.drug_name,
|
||||
number: data.drug.number,
|
||||
use_num: drugTime.value.find((u) => u.id === data.drug.time_id),
|
||||
use_type: drugUseType.value.find((u) => u.id === data.drug.type_id),
|
||||
use_frequency: drugUseFrequency.value.find(
|
||||
(u) => u.id === data.drug.frequency_id,
|
||||
),
|
||||
unit:
|
||||
data.drug?.unit ||
|
||||
drugUnit.value.find((u) => u.id === data.drug.unit_id),
|
||||
price: data.price,
|
||||
buy_price: data.buy_price,
|
||||
way_id: data.drug?.way_id,
|
||||
use_ways: drugUseWay.value.find((u) => u.id === data.drug.way_id),
|
||||
time_id: data.drug.time_id,
|
||||
type_id: data.drug.type_id,
|
||||
frequency_id: data.drug.frequency_id,
|
||||
unit_id: data.drug.unit_id,
|
||||
image: data.drug.image,
|
||||
specification: data.drug?.specification || data.specification || '',
|
||||
instruction: data.drug.instruction,
|
||||
type: data.drug.type,
|
||||
};
|
||||
const newDrugs = [...currentDrugs.value];
|
||||
newDrugs[index] = newProduct;
|
||||
updateCurrentDrugs(newDrugs);
|
||||
nextTick(() => {
|
||||
const input = document.querySelector(
|
||||
`.old-number-input-${index} input`,
|
||||
) as HTMLElement;
|
||||
input?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
// 新药品操作(Select 兼容)
|
||||
const selectNewDrugInfo = () => {
|
||||
const check = currentDrugs.value.find((v) => v.id === newDrugInfo.value.id);
|
||||
if (check) {
|
||||
@@ -716,6 +823,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
|
||||
const data = drugList.value.find((v) => v.drug_id === newDrugInfo.value.id);
|
||||
if (data) {
|
||||
pendingNewChineseProduct.value = data;
|
||||
newDrugInfo.value.price = data.price;
|
||||
newDrugInfo.value.name = data.drug.drug_name;
|
||||
// 选药后立刻带上真实单位,供新行数量后缀展示
|
||||
@@ -801,7 +909,9 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = drugList.value.find((v) => v.drug_id === newDrugInfo.value.id);
|
||||
const data =
|
||||
pendingNewChineseProduct.value ||
|
||||
drugList.value.find((v) => v.drug_id === newDrugInfo.value.id);
|
||||
if (!data) {
|
||||
message.error('请选择药品');
|
||||
return false;
|
||||
@@ -812,6 +922,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
const success = addProducts(data);
|
||||
if (success) {
|
||||
newDrugInfo.value = {};
|
||||
pendingNewChineseProduct.value = null;
|
||||
}
|
||||
return success;
|
||||
};
|
||||
@@ -1249,6 +1360,9 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
activeCategory,
|
||||
categories,
|
||||
prescriptionTypeDefault,
|
||||
prescriptionTypeLoading,
|
||||
prescriptionTypeAllowed,
|
||||
prescriptionTypeDenyMessage,
|
||||
diagnosis,
|
||||
medicalAdvice,
|
||||
treatmentPrice,
|
||||
@@ -1299,6 +1413,8 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
updateDrugUsage,
|
||||
selectNewDrugInfo,
|
||||
selectOldDrugInfo,
|
||||
onSelectNewChineseDrug,
|
||||
onSelectOldChineseDrug,
|
||||
setSelectChineseIndex,
|
||||
addDrugByChinese,
|
||||
selectDrugByNewDrugInfo,
|
||||
|
||||
165
apps/web-antd/src/utils/dictSearchRank.ts
Normal file
165
apps/web-antd/src/utils/dictSearchRank.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 字典/词条搜索:匹配度打分、排序、关键字高亮拆分
|
||||
* 与后端 DictSearchRankService 规则对齐;mode=frontend 时由本工具排序
|
||||
*/
|
||||
|
||||
export type DictSearchRankMode = 'backend' | 'frontend';
|
||||
|
||||
export type HighlightPart = { text: string; hit: boolean };
|
||||
|
||||
/** 与 PHP DictSearchRankService::score 对齐 */
|
||||
export function scoreDictMatch(
|
||||
text: string,
|
||||
keyword: string,
|
||||
extra = '',
|
||||
): number {
|
||||
const kw = String(keyword || '').trim();
|
||||
if (!kw) return 0;
|
||||
const main = String(text || '').trim();
|
||||
if (!main) return 0;
|
||||
let s = scoreOne(main, kw);
|
||||
if (extra) {
|
||||
s = Math.max(s, Math.floor(scoreOne(String(extra), kw) * 0.9));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function scoreOne(haystack: string, needle: string): number {
|
||||
if (haystack === needle) return 100;
|
||||
const lowerH = haystack.toLowerCase();
|
||||
const lowerN = needle.toLowerCase();
|
||||
const pos = lowerH.indexOf(lowerN);
|
||||
if (pos < 0) return 0;
|
||||
if (pos === 0) {
|
||||
const lenBonus = Math.max(0, 10 - Math.abs(haystack.length - needle.length));
|
||||
return 80 + Math.min(15, lenBonus);
|
||||
}
|
||||
return Math.max(20, 60 - Math.min(40, pos));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按匹配度降序;无关键字时保持原序
|
||||
*/
|
||||
export function rankByMatchScore<T>(
|
||||
items: T[],
|
||||
keyword: string,
|
||||
getText: (item: T) => string,
|
||||
getExtra?: (item: T) => string,
|
||||
): Array<T & { match_score: number }> {
|
||||
const kw = String(keyword || '').trim();
|
||||
const scored = items.map((item) => {
|
||||
const score = kw
|
||||
? scoreDictMatch(getText(item), kw, getExtra?.(item) || '')
|
||||
: 0;
|
||||
return Object.assign({}, item, { match_score: score }) as T & {
|
||||
match_score: number;
|
||||
};
|
||||
});
|
||||
if (!kw) return scored;
|
||||
return scored.sort((a, b) => {
|
||||
if (b.match_score !== a.match_score) return b.match_score - a.match_score;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文本按关键字拆成高亮片段(不做 HTML,便于 PC/小程序安全渲染)
|
||||
*/
|
||||
export function splitHighlight(
|
||||
text: string,
|
||||
keyword: string,
|
||||
): HighlightPart[] {
|
||||
const raw = String(text ?? '');
|
||||
const kw = String(keyword || '').trim();
|
||||
if (!raw || !kw) return [{ text: raw, hit: false }];
|
||||
const lower = raw.toLowerCase();
|
||||
const needle = kw.toLowerCase();
|
||||
const parts: HighlightPart[] = [];
|
||||
let start = 0;
|
||||
let idx = lower.indexOf(needle, start);
|
||||
while (idx >= 0) {
|
||||
if (idx > start) {
|
||||
parts.push({ text: raw.slice(start, idx), hit: false });
|
||||
}
|
||||
parts.push({ text: raw.slice(idx, idx + kw.length), hit: true });
|
||||
start = idx + kw.length;
|
||||
idx = lower.indexOf(needle, start);
|
||||
}
|
||||
if (start < raw.length) {
|
||||
parts.push({ text: raw.slice(start), hit: false });
|
||||
}
|
||||
return parts.length ? parts : [{ text: raw, hit: false }];
|
||||
}
|
||||
|
||||
let cachedRankMode: DictSearchRankMode | null = null;
|
||||
/** 多气泡实例共享同一请求,避免进页 N 次 get-by-keys */
|
||||
let rankModeFetchPromise: Promise<DictSearchRankMode> | null = null;
|
||||
|
||||
/** 写入缓存(从系统配置或接口响应读取后调用) */
|
||||
export function setDictSearchRankMode(mode: string | undefined | null) {
|
||||
cachedRankMode = mode === 'frontend' ? 'frontend' : 'backend';
|
||||
}
|
||||
|
||||
export function getDictSearchRankMode(): DictSearchRankMode {
|
||||
return cachedRankMode || 'backend';
|
||||
}
|
||||
|
||||
export function isDictSearchRankModeLoaded(): boolean {
|
||||
return cachedRankMode != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保已加载匹配度模式:已缓存则跳过;并发调用共享同一 Promise
|
||||
* @param loader 真正打网取配置的函数,返回 frontend|backend
|
||||
*/
|
||||
export async function ensureDictSearchRankMode(
|
||||
loader: () => Promise<string | undefined | null>,
|
||||
): Promise<DictSearchRankMode> {
|
||||
if (cachedRankMode != null) return cachedRankMode;
|
||||
if (!rankModeFetchPromise) {
|
||||
rankModeFetchPromise = (async () => {
|
||||
try {
|
||||
const mode = await loader();
|
||||
setDictSearchRankMode(mode);
|
||||
} catch {
|
||||
setDictSearchRankMode('backend');
|
||||
}
|
||||
return cachedRankMode || 'backend';
|
||||
})();
|
||||
}
|
||||
return rankModeFetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前模式决定是否前端重排;有关键字时保证按匹配度降序,并尽量带上可展示的 match_score
|
||||
*/
|
||||
export function applyDictRankIfNeeded<T>(
|
||||
items: T[],
|
||||
keyword: string,
|
||||
getText: (item: T) => string,
|
||||
getExtra?: (item: T) => string,
|
||||
modeFromApi?: string,
|
||||
): Array<T & { match_score: number }> {
|
||||
if (modeFromApi === 'frontend' || modeFromApi === 'backend') {
|
||||
setDictSearchRankMode(modeFromApi);
|
||||
}
|
||||
const mode = getDictSearchRankMode();
|
||||
const kw = String(keyword || '').trim();
|
||||
if (mode === 'frontend') {
|
||||
return rankByMatchScore(items, keyword, getText, getExtra);
|
||||
}
|
||||
// backend:保留接口分数;若有关键字却全是 0(漏打分/合并未打分),前端补算
|
||||
const mapped = items.map((item: any) => ({
|
||||
...item,
|
||||
match_score: Number(item?.match_score || 0),
|
||||
})) as Array<T & { match_score: number }>;
|
||||
if (!kw) return mapped;
|
||||
const hasPositive = mapped.some((i) => Number(i.match_score) > 0);
|
||||
if (!hasPositive) {
|
||||
return rankByMatchScore(items, keyword, getText, getExtra);
|
||||
}
|
||||
return mapped.sort((a, b) => {
|
||||
if (b.match_score !== a.match_score) return b.match_score - a.match_score;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
80
apps/web-antd/src/utils/vip.ts
Normal file
80
apps/web-antd/src/utils/vip.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* VIP 权限判断工具(前端口子)
|
||||
* 默认从登录态 userInfo.vip 读取;在线复诊等场景可传入挂号履约诊所的 vip
|
||||
*/
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
export interface StoreVipInfo {
|
||||
level_code?: string;
|
||||
level_name?: string;
|
||||
level_weight?: number;
|
||||
badge_url?: string;
|
||||
duration_type?: string;
|
||||
duration_label?: string;
|
||||
duration_badge_url?: string;
|
||||
permissions?: string[];
|
||||
expire_at?: number;
|
||||
is_lifetime?: boolean;
|
||||
}
|
||||
|
||||
/** 读取当前登录用户 VIP 信息 */
|
||||
export function getCurrentVip(): StoreVipInfo | null {
|
||||
const userStore = useUserStore();
|
||||
return ((userStore.userInfo as any)?.vip as StoreVipInfo) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否拥有指定权限码
|
||||
* @param code 权限码
|
||||
* @param vipOrPermissions 可选:挂号侧 vip 对象,或 permissions 字符串数组;不传则用登录态
|
||||
*/
|
||||
export function hasVipPermission(
|
||||
code: string,
|
||||
vipOrPermissions?: StoreVipInfo | string[] | null,
|
||||
): boolean {
|
||||
if (!code) return false;
|
||||
let list: string[] | undefined;
|
||||
if (Array.isArray(vipOrPermissions)) {
|
||||
list = vipOrPermissions;
|
||||
} else if (vipOrPermissions && typeof vipOrPermissions === 'object') {
|
||||
list = vipOrPermissions.permissions;
|
||||
} else {
|
||||
list = getCurrentVip()?.permissions;
|
||||
}
|
||||
return Array.isArray(list) && list.includes(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 等级是否不低于目标(按 level_weight;若无 weight 则仅精确匹配编码时返回 true)
|
||||
* 简化:仅当当前 level_code 与 minCode 相同,或 weight 足够时通过
|
||||
*/
|
||||
export function levelAtLeast(minCode: string, vip?: StoreVipInfo | null): boolean {
|
||||
const current = vip ?? getCurrentVip();
|
||||
if (!current?.level_code) return false;
|
||||
if (current.level_code === minCode) return true;
|
||||
const weight = Number(current.level_weight ?? 0);
|
||||
const minWeight = parseLevelWeight(minCode);
|
||||
if (minWeight >= 0) {
|
||||
return weight >= minWeight;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 粗略解析 V0-V11 / BLACK_GOLD / CUSTOM 权重 */
|
||||
function parseLevelWeight(code: string): number {
|
||||
if (/^V\d+$/i.test(code)) {
|
||||
return Number(code.slice(1));
|
||||
}
|
||||
if (code === 'BLACK_GOLD') return 12;
|
||||
if (code === 'CUSTOM') return 13;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 组合式:供组件调用 */
|
||||
export function useVipGate() {
|
||||
return {
|
||||
vip: getCurrentVip(),
|
||||
hasVipPermission,
|
||||
levelAtLeast,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 病历门店词条-按字段拆分页(从路由 path 末段解析 field_code)
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import EntryListPage from '#/views/doctor/medical-record/components/EntryListPage.vue';
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS } from '#/views/doctor/medical-record/config/constants';
|
||||
|
||||
defineOptions({ name: 'MedicalRecordEntryStoreField' });
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const fieldCode = computed(() => {
|
||||
const seg = (route.path.split('/').filter(Boolean).pop() || '').trim();
|
||||
return seg.replace(/-/g, '_');
|
||||
});
|
||||
|
||||
const fieldLabel = computed(() => {
|
||||
const hit = MEDICAL_RECORD_FIELD_OPTIONS.find((o) => o.value === fieldCode.value);
|
||||
return hit?.label || fieldCode.value;
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => `门店词条·${fieldLabel.value}`);
|
||||
</script>
|
||||
<template>
|
||||
<EntryListPage
|
||||
:key="fieldCode"
|
||||
scope="store"
|
||||
:fixed-field-code="fieldCode"
|
||||
:page-title="pageTitle"
|
||||
:page-name="String(route.name || 'MedicalRecordEntryStoreField')"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 病历门店词条管理(当前登录门店)
|
||||
*/
|
||||
import EntryListPage from '#/views/doctor/medical-record/components/EntryListPage.vue';
|
||||
|
||||
defineOptions({ name: 'MedicalRecordEntryStore' });
|
||||
</script>
|
||||
<template>
|
||||
<EntryListPage
|
||||
scope="store"
|
||||
page-title="病历门店词条"
|
||||
page-name="MedicalRecordEntryStore"
|
||||
/>
|
||||
</template>
|
||||
@@ -9,6 +9,20 @@ export async function simulatePayApi(data: {
|
||||
return requestClient.post<any>(`${prefix}simulate-pay`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品订单重新结算:作废残缺分账后按规则重建并入账(防重复加账)
|
||||
*/
|
||||
export async function retrySettleApi(data: { order_id: number }) {
|
||||
return requestClient.post<any>(`${prefix}retry-settle`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管手动确认收货 / 重试解冻(自动确认收货队列失败后的人工补偿)
|
||||
*/
|
||||
export async function retryConfirmReceiveApi(data: { order_id: number }) {
|
||||
return requestClient.post<any>(`${prefix}retry-confirm-receive`, data);
|
||||
}
|
||||
|
||||
export async function getOrderTraceApi(params: {
|
||||
order_id: number;
|
||||
scene: 'product' | 'register';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { getOrderPrescriptionTypeOption } from '#/views/business/order/product-order/api';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
// 默认展开
|
||||
@@ -16,29 +17,19 @@ export const formOptions: VbenFormProps = {
|
||||
label: '订单号',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
// 与商品订单共用后端 ProductTypeEnum 开方类型,保证筛选项完整
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
filterOption: true,
|
||||
showSearch: true,
|
||||
options: [
|
||||
{
|
||||
label: '中药',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: '西药',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
label: '中成药',
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
label: '产品服务包',
|
||||
value: 5,
|
||||
},
|
||||
],
|
||||
afterFetch: (data: { id: number; name: string }[]) => {
|
||||
return data.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
},
|
||||
api: getOrderPrescriptionTypeOption,
|
||||
placeholder: '请选择',
|
||||
},
|
||||
fieldName: 'prescription_type',
|
||||
|
||||
@@ -15,6 +15,16 @@ export async function getOrderList(data: any) {
|
||||
export async function getOrderStatusOption(data: any) {
|
||||
return requestClient.get<any>(`${prefix}status-option`, { params: data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单/处方类型筛选项(与后端 ProductTypeEnum 开方类型对齐)
|
||||
*/
|
||||
export async function getOrderPrescriptionTypeOption(data?: any) {
|
||||
return requestClient.get<Array<{ id: number; name: string }>>(
|
||||
`${prefix}prescription-type-option`,
|
||||
{ params: data },
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 统计金额
|
||||
* @param data
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
deleteOrderExportScheme,
|
||||
getOrderExportDataApi,
|
||||
getOrderExportSchemeList,
|
||||
getOrderPrescriptionTypeOption,
|
||||
getOrderStatusOption,
|
||||
type OrderExportDataParams,
|
||||
type OrderExportSchemeItem,
|
||||
@@ -69,13 +70,6 @@ const DELIVERY_OPTIONS = [
|
||||
{ label: '诊所自提', value: 1 },
|
||||
];
|
||||
|
||||
const PRESCRIPTION_TYPE_OPTIONS = [
|
||||
{ label: '中药', value: 1 },
|
||||
{ label: '西药', value: 2 },
|
||||
{ label: '中成药', value: 3 },
|
||||
{ label: '产品服务包', value: 5 },
|
||||
];
|
||||
|
||||
const searchTime = ref<[Dayjs, Dayjs]>([dayjs().startOf('month'), dayjs()]);
|
||||
const orderNo = ref('');
|
||||
const storeId = ref<number | undefined>(undefined);
|
||||
@@ -94,6 +88,8 @@ const loadingStep = ref('');
|
||||
|
||||
const storeOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const statusOptions = ref<{ label: string; value: number }[]>([]);
|
||||
/** 订单类型选项:打开弹窗时从后端 ProductTypeEnum 拉取 */
|
||||
const prescriptionTypeOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const fetchingStores = ref(false);
|
||||
const storeExportLocked = ref(false);
|
||||
const lockedStoreName = ref('');
|
||||
@@ -331,6 +327,18 @@ const loadStatusOptions = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/** 拉取完整订单类型(含非药品、医疗器械等) */
|
||||
const loadPrescriptionTypeOptions = () => {
|
||||
getOrderPrescriptionTypeOption({}).then(
|
||||
(res: { id: number; name: string }[]) => {
|
||||
prescriptionTypeOptions.value = (res || []).map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
function onSelectedKeysChange() {
|
||||
markSchemeModified();
|
||||
}
|
||||
@@ -572,7 +580,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
status_text: labelOf(statusOptions.value, status.value),
|
||||
delivery_method_text: labelOf(DELIVERY_OPTIONS, deliveryMethod.value),
|
||||
prescription_type_text: labelOf(
|
||||
PRESCRIPTION_TYPE_OPTIONS,
|
||||
prescriptionTypeOptions.value,
|
||||
prescriptionType.value,
|
||||
),
|
||||
export_fields_text: selectedFieldLabels(),
|
||||
@@ -620,6 +628,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
loadStores();
|
||||
}
|
||||
loadStatusOptions();
|
||||
loadPrescriptionTypeOptions();
|
||||
void loadSchemes(true);
|
||||
}
|
||||
},
|
||||
@@ -749,7 +758,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
v-model:value="prescriptionType"
|
||||
allow-clear
|
||||
class="w-full"
|
||||
:options="PRESCRIPTION_TYPE_OPTIONS"
|
||||
:options="prescriptionTypeOptions"
|
||||
placeholder="全部"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,10 @@ import { computed, ref } from 'vue';
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { Button, Card, Descriptions, Image, Space, Tabs, Tag, message } from 'ant-design-vue';
|
||||
import { Button, Card, Descriptions, Image, Modal as AntdModal, Space, Tabs, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
|
||||
import { retrySettleApi } from '#/views/business/order/api/order-ops';
|
||||
|
||||
import OrderPricePercentAdjustDrawer from '#/views/business/order/components/OrderPricePercentAdjustDrawer.vue';
|
||||
import { getOrderPriceAdjustConfig, adjustOrderPercent } from '#/api/order/priceAdjust';
|
||||
@@ -30,6 +31,26 @@ const expressDetail = ref({});
|
||||
// 订单发货方式
|
||||
const deliveryMethod = ref(-1);
|
||||
const syncingLegacy = ref(false);
|
||||
/** 重新结算请求中,防止重复点击 */
|
||||
const retrySettling = ref(false);
|
||||
|
||||
/**
|
||||
* 超管可见:已支付且未取消/未退款时可重新结算(补齐残缺分账)
|
||||
* 与后端一致:仅 role_id=1(SUPPER_ADMIN)
|
||||
*/
|
||||
const canRetrySettle = computed(() => {
|
||||
const roleId = Number(
|
||||
userStore.userInfo?.role_id ?? userStore.userInfo?.roles?.id,
|
||||
);
|
||||
if (roleId !== 1) return false;
|
||||
const row = data.value;
|
||||
if (!row?.id) return false;
|
||||
return (
|
||||
Number(row.is_pay) === 1 &&
|
||||
Number(row.cancel_status) === 0 &&
|
||||
Number(row.refund_status) === 0
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 超管可见:上门快递且订单有运单时,可手动把历史运单同步到 shipment
|
||||
@@ -123,6 +144,31 @@ function openOrderTrace() {
|
||||
traceDrawerApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认后触发后端重新结算:作废残缺分账并按规则重建入账,避免重复加钱
|
||||
*/
|
||||
function handleRetrySettle() {
|
||||
if (!data.value?.id || retrySettling.value) {
|
||||
return;
|
||||
}
|
||||
AntdModal.confirm({
|
||||
title: '重新结算',
|
||||
content: `确认为订单 ${data.value.order_no} 重新结算分账?将作废旧分账后按当前规则重建,已完整的订单不会重复加账。`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
retrySettling.value = true;
|
||||
try {
|
||||
const res = await retrySettleApi({ order_id: Number(data.value.id) });
|
||||
message.success(res?.message || '重新结算成功');
|
||||
await reloadOrder();
|
||||
} finally {
|
||||
retrySettling.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
@@ -269,6 +315,14 @@ function prescriptionStatusColor() {
|
||||
<Button type="primary" size="small" @click="openOrderTrace">
|
||||
查看溯源
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canRetrySettle"
|
||||
size="small"
|
||||
:loading="retrySettling"
|
||||
@click="handleRetrySettle"
|
||||
>
|
||||
重新结算
|
||||
</Button>
|
||||
</Space>
|
||||
<h3 class="mt-4">订单信息</h3>
|
||||
<Descriptions
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { getOrderStatusOption } from '#/views/business/order/product-order/api';
|
||||
import {
|
||||
getOrderPrescriptionTypeOption,
|
||||
getOrderStatusOption,
|
||||
} from '#/views/business/order/product-order/api';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
// 默认展开
|
||||
@@ -65,29 +68,19 @@ export const formOptions: VbenFormProps = {
|
||||
label: '发货方式',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
// 类型从后端枚举拉取,避免前端写死漏掉非药品/医疗器械等
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
filterOption: true,
|
||||
showSearch: true,
|
||||
options: [
|
||||
{
|
||||
label: '中药',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: '西药',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
label: '中成药',
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
label: '产品服务包',
|
||||
value: 5,
|
||||
},
|
||||
],
|
||||
afterFetch: (data: { id: number; name: string }[]) => {
|
||||
return data.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
},
|
||||
api: getOrderPrescriptionTypeOption,
|
||||
placeholder: '请选择',
|
||||
},
|
||||
fieldName: 'prescription_type',
|
||||
|
||||
@@ -43,7 +43,12 @@ import {
|
||||
updateFreeShipping,
|
||||
} from '#/views/business/order/product-order/api';
|
||||
import { getDeliveryWarehouseOptionsByDrugs } from '#/views/doctor/doctor-reception/api';
|
||||
import { simulatePayApi, accrueSalespersonCommissionApi, reverseSalespersonCommissionApi } from '#/views/business/order/api/order-ops';
|
||||
import {
|
||||
accrueSalespersonCommissionApi,
|
||||
retryConfirmReceiveApi,
|
||||
reverseSalespersonCommissionApi,
|
||||
simulatePayApi,
|
||||
} from '#/views/business/order/api/order-ops';
|
||||
import ChinaErpSyncLogDrawer from '#/views/business/order/components/china-erp-sync-log-drawer.vue';
|
||||
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
|
||||
import OrderPricePercentAdjustDrawer from '#/views/business/order/components/OrderPricePercentAdjustDrawer.vue';
|
||||
@@ -446,6 +451,29 @@ function handleSimulatePay(row: Record<string, any>) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管手动确认收货 / 重试解冻(自动确认队列失败后的人工补偿)
|
||||
* 仅 Super Admin 可见;待收货走完整签收,已确认收货仅重试解冻
|
||||
*/
|
||||
function handleRetryConfirmReceive(row: Record<string, any>) {
|
||||
const isWaitReceive = Number(row.status) === 2;
|
||||
AntdModal.confirm({
|
||||
title: isWaitReceive ? '确认收货' : '重试解冻',
|
||||
content: isWaitReceive
|
||||
? `确认为订单 ${row.order_no} 手动确认收货?将更新为已确认收货并解冻供应商/配送仓分账冻结。`
|
||||
: `订单 ${row.order_no} 已确认收货,是否重试解冻分账冻结?(幂等,不会重复加账)`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const res = await retryConfirmReceiveApi({ order_id: Number(row.id) });
|
||||
message.success(
|
||||
res?.message || (isWaitReceive ? '确认收货成功' : '解冻重试成功'),
|
||||
);
|
||||
await gridApi.query();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const accruingOrderIds = ref<number[]>([]);
|
||||
const reversingOrderIds = ref<number[]>([]);
|
||||
|
||||
@@ -1303,6 +1331,19 @@ const openOrderAmountVerify = () => {
|
||||
ifShow: row.is_pay === 0,
|
||||
onClick: handleSimulatePay.bind(null, row),
|
||||
},
|
||||
{
|
||||
// 仅超管:待收货确认收货 / 已确认收货重试解冻
|
||||
label: Number(row.status) === 2 ? '确认收货' : '重试解冻',
|
||||
type: 'link',
|
||||
icon: 'mdi:package-check',
|
||||
auth: ['Super Admin'],
|
||||
ifShow:
|
||||
Number(row.is_pay) === 1 &&
|
||||
Number(row.cancel_status) === 0 &&
|
||||
Number(row.refund_status) === 0 &&
|
||||
(Number(row.status) === 2 || Number(row.status) === 7),
|
||||
onClick: handleRetryConfirmReceive.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '发货',
|
||||
type: 'link',
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'drug-alias/';
|
||||
|
||||
/**
|
||||
* 药品详情 + 别名(药名点击弹窗)
|
||||
*/
|
||||
export async function getDrugAliasDetail(drugId: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, {
|
||||
params: { drug_id: drugId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 整表覆盖保存别名(管理员/超管)
|
||||
*/
|
||||
export async function saveDrugAliases(
|
||||
drugId: number,
|
||||
aliases: string | string[],
|
||||
) {
|
||||
return requestClient.post<any>(`${prefix}save-aliases`, {
|
||||
drug_id: drugId,
|
||||
aliases,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增一条别名
|
||||
*/
|
||||
export async function addDrugAlias(drugId: number, alias: string) {
|
||||
return requestClient.post<any>(`${prefix}add-alias`, {
|
||||
drug_id: drugId,
|
||||
alias,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一条别名
|
||||
*/
|
||||
export async function deleteDrugAlias(drugId: number, aliasId: number) {
|
||||
return requestClient.post<any>(`${prefix}delete-alias`, {
|
||||
drug_id: drugId,
|
||||
alias_id: aliasId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 药品详情弹窗:展示基础信息;管理员/超管可逐条添加/删除别名
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
DescriptionsItem,
|
||||
Empty,
|
||||
Image,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Spin,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
addDrugAlias,
|
||||
deleteDrugAlias,
|
||||
getDrugAliasDetail,
|
||||
} from './drug-alias-api';
|
||||
|
||||
type AliasRow = {
|
||||
id?: number;
|
||||
alias: string;
|
||||
pinyin_simple?: string;
|
||||
pinyin_full?: string;
|
||||
pinyin_display?: string;
|
||||
search_digits?: string;
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const adding = ref(false);
|
||||
const deletingId = ref(0);
|
||||
const drug = ref<Record<string, any>>({});
|
||||
const aliases = ref<AliasRow[]>([]);
|
||||
const canManage = ref(false);
|
||||
/** 新增输入框 */
|
||||
const newAlias = ref('');
|
||||
const gridApiRef = ref<any>(null);
|
||||
|
||||
const titleText = computed(
|
||||
() => `药品详情${drug.value?.drug_name ? ` · ${drug.value.drug_name}` : ''}`,
|
||||
);
|
||||
|
||||
const aliasCountText = computed(() => `共 ${aliases.value.length} 条`);
|
||||
|
||||
/**
|
||||
* 用接口结果回填本地状态
|
||||
*/
|
||||
function applyDetail(res: any) {
|
||||
drug.value = res?.drug || {};
|
||||
aliases.value = Array.isArray(res?.aliases) ? res.aliases : [];
|
||||
canManage.value = !!res?.can_manage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载详情
|
||||
*/
|
||||
async function loadDetail(drugId: number) {
|
||||
loading.value = true;
|
||||
newAlias.value = '';
|
||||
try {
|
||||
const res = await getDrugAliasDetail(drugId);
|
||||
applyDetail(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
drug.value = {};
|
||||
aliases.value = [];
|
||||
canManage.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
class: 'w-[720px]',
|
||||
draggable: true,
|
||||
fullscreenButton: false,
|
||||
showConfirmButton: false,
|
||||
cancelText: '关闭',
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
newAlias.value = '';
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{
|
||||
drug_id?: number;
|
||||
id?: number;
|
||||
gridApi?: any;
|
||||
}>();
|
||||
gridApiRef.value = data?.gridApi || null;
|
||||
const id = Number(data?.drug_id || data?.id || 0);
|
||||
if (id > 0) {
|
||||
void loadDetail(id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 添加一条别名(支持一次输入多个,按分隔符拆)
|
||||
*/
|
||||
async function handleAddAlias() {
|
||||
const id = Number(drug.value?.id || 0);
|
||||
if (!id || !canManage.value) return;
|
||||
const parts = String(newAlias.value || '')
|
||||
.split(/[\n,,、;;|]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
message.warning('请输入别名');
|
||||
return;
|
||||
}
|
||||
adding.value = true;
|
||||
try {
|
||||
let lastRes: any = null;
|
||||
for (const part of parts) {
|
||||
lastRes = await addDrugAlias(id, part);
|
||||
}
|
||||
if (lastRes) applyDetail(lastRes);
|
||||
newAlias.value = '';
|
||||
message.success(parts.length > 1 ? `已添加 ${parts.length} 条别名` : '已添加');
|
||||
gridApiRef.value?.query?.();
|
||||
} finally {
|
||||
adding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一条别名
|
||||
*/
|
||||
async function handleDeleteAlias(row: AliasRow) {
|
||||
const drugId = Number(drug.value?.id || 0);
|
||||
const aliasId = Number(row?.id || 0);
|
||||
if (!drugId || !aliasId || !canManage.value) return;
|
||||
deletingId.value = aliasId;
|
||||
try {
|
||||
const res = await deleteDrugAlias(drugId, aliasId);
|
||||
applyDetail(res);
|
||||
message.success('已删除');
|
||||
gridApiRef.value?.query?.();
|
||||
} finally {
|
||||
deletingId.value = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="titleText">
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="drug?.id" class="drug-detail">
|
||||
<div class="drug-detail__head">
|
||||
<Image
|
||||
v-if="drug.image"
|
||||
:src="drug.image"
|
||||
:width="64"
|
||||
:height="64"
|
||||
class="drug-detail__img"
|
||||
/>
|
||||
<div v-else class="drug-detail__img drug-detail__img--empty">无图</div>
|
||||
<div class="drug-detail__head-text">
|
||||
<div class="drug-detail__name">{{ drug.drug_name || '-' }}</div>
|
||||
<div class="drug-detail__meta">
|
||||
ID:{{ drug.id }}
|
||||
<span v-if="drug.drug_number"> · 编号:{{ drug.drug_number }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Descriptions bordered size="small" :column="2" class="mt-3">
|
||||
<DescriptionsItem label="拼音首拼">
|
||||
{{ drug.pinyin_simple || '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="拼音全拼">
|
||||
{{ drug.pinyin_full || '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="检索数字">
|
||||
{{ drug.search_digits || '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="规格">
|
||||
{{ drug.specification || '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="单位">
|
||||
{{ drug.unit_name || '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="供应商">
|
||||
{{ drug.supplier_name || '-' }}
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
|
||||
<div class="drug-detail__alias-head">
|
||||
<div class="drug-detail__alias-title">
|
||||
别名
|
||||
<Tag v-if="canManage" color="processing" class="ml-1">可维护</Tag>
|
||||
<Tag v-else class="ml-1">只读</Tag>
|
||||
<span class="drug-detail__alias-count">{{ aliasCountText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理员:顶部添加区 -->
|
||||
<div v-if="canManage" class="drug-detail__alias-add">
|
||||
<Input
|
||||
v-model:value="newAlias"
|
||||
allow-clear
|
||||
placeholder="输入别名后回车添加;多个可用顿号/逗号分隔"
|
||||
:disabled="adding"
|
||||
@press-enter="handleAddAlias"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
class="ml-2"
|
||||
:loading="adding"
|
||||
@click="handleAddAlias"
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="drug-detail__alias-list">
|
||||
<Empty
|
||||
v-if="aliases.length === 0"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
description="暂无别名"
|
||||
/>
|
||||
<div
|
||||
v-for="(a, idx) in aliases"
|
||||
:key="`${a.id || idx}_${a.alias}`"
|
||||
class="drug-detail__alias-row"
|
||||
>
|
||||
<div class="drug-detail__alias-main">
|
||||
<span class="drug-detail__alias-name">{{ a.alias }}</span>
|
||||
<span class="drug-detail__alias-py">
|
||||
{{ a.pinyin_display || a.pinyin_simple || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<Popconfirm
|
||||
v-if="canManage"
|
||||
title="确认删除该别名?"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDeleteAlias(a)"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
:loading="deletingId === Number(a.id || 0)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Empty v-else-if="!loading" description="未找到药品" />
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drug-detail__head {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.drug-detail__img {
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.drug-detail__img--empty {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
.drug-detail__name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.drug-detail__meta {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.drug-detail__alias-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.drug-detail__alias-title {
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.drug-detail__alias-count {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.drug-detail__alias-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.drug-detail__alias-list {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
min-height: 80px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.drug-detail__alias-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid hsl(var(--border) / 0.7);
|
||||
font-size: 13px;
|
||||
}
|
||||
.drug-detail__alias-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.drug-detail__alias-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.drug-detail__alias-name {
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
.drug-detail__alias-py {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 商品信息合并列:图片 + 名称/ID/拼音/发布时间 + 说明书预览链接
|
||||
* 五类商品列表复用,避免每页各写一套 slot
|
||||
* 点击药名打开详情弹窗(由父级监听 name-click)
|
||||
* 搜索命中别名时展示匹配别名(对齐开方/仓库列表)
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Image } from 'ant-design-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
row: Record<string, any>;
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
row: Record<string, any>;
|
||||
/** 药名是否可点击打开详情 */
|
||||
nameClickable?: boolean;
|
||||
}>(),
|
||||
{ nameClickable: true },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'name-click': [row: Record<string, any>];
|
||||
}>();
|
||||
|
||||
const previewVisible = ref(false);
|
||||
@@ -18,10 +28,27 @@ const instructionUrl = computed(() => {
|
||||
return typeof url === 'string' && url.trim() ? url.trim() : '';
|
||||
});
|
||||
|
||||
/** 当前搜索命中的别名 */
|
||||
const matchedAlias = computed(() =>
|
||||
String(props.row?.matched_alias || '').trim(),
|
||||
);
|
||||
|
||||
/** 拼音:命中别名时用别名拼音,否则用药名首拼 */
|
||||
const pinyinDisplay = computed(() => {
|
||||
const aliasPy = String(props.row?.matched_alias_pinyin || '').trim();
|
||||
if (matchedAlias.value && aliasPy) return aliasPy;
|
||||
return String(props.row?.pinyin_simple || '').trim();
|
||||
});
|
||||
|
||||
function openInstruction() {
|
||||
if (!instructionUrl.value) return;
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
function onNameClick() {
|
||||
if (!props.nameClickable) return;
|
||||
emit('name-click', props.row);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="product-info-cell">
|
||||
@@ -36,10 +63,25 @@ function openInstruction() {
|
||||
无图
|
||||
</div>
|
||||
<div class="product-info-cell__body">
|
||||
<div class="product-info-cell__name">{{ row.drug_name || '-' }}</div>
|
||||
<div
|
||||
class="product-info-cell__name"
|
||||
:class="{ 'product-info-cell__name--link': nameClickable }"
|
||||
:title="
|
||||
matchedAlias
|
||||
? `${row.drug_name || '-'}(别名:${matchedAlias})`
|
||||
: row.drug_name || '-'
|
||||
"
|
||||
@click.stop="onNameClick"
|
||||
>
|
||||
{{ row.drug_name || '-' }}
|
||||
</div>
|
||||
<div v-if="matchedAlias" class="product-info-cell__alias">
|
||||
别名:{{ matchedAlias }}
|
||||
<span v-if="pinyinDisplay">({{ pinyinDisplay }})</span>
|
||||
</div>
|
||||
<div class="product-info-cell__meta">
|
||||
<span>ID:{{ row.id }}</span>
|
||||
<span v-if="row.pinyin_simple">拼音:{{ row.pinyin_simple }}</span>
|
||||
<span v-if="pinyinDisplay && !matchedAlias">拼音:{{ pinyinDisplay }}</span>
|
||||
<span v-if="row.created_at">发布时间:{{ row.created_at }}</span>
|
||||
</div>
|
||||
<a
|
||||
@@ -49,7 +91,6 @@ function openInstruction() {
|
||||
>
|
||||
说明书
|
||||
</a>
|
||||
<!-- 隐藏 Image 仅用于说明书预览 -->
|
||||
<Image
|
||||
v-if="instructionUrl"
|
||||
:src="instructionUrl"
|
||||
@@ -96,6 +137,20 @@ function openInstruction() {
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
.product-info-cell__name--link {
|
||||
color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
.product-info-cell__name--link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.product-info-cell__alias {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
}
|
||||
.product-info-cell__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -96,3 +96,10 @@ export async function applyBatchDrugNumberApi(data: {
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}apply-batch-drug-number`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步空拼音(首拼/全拼/数字串)
|
||||
*/
|
||||
export async function syncChinaMedicinePinyin() {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, {});
|
||||
}
|
||||
|
||||
@@ -53,6 +53,33 @@ export const modalFormProps: VbenFormProps = {
|
||||
label: '药品别名',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '可留空,保存时按药名自动生成',
|
||||
},
|
||||
fieldName: 'pinyin_simple',
|
||||
formItemClass: 'col-span-6',
|
||||
label: '拼音首拼',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '可留空,保存时按药名自动生成',
|
||||
},
|
||||
fieldName: 'pinyin_full',
|
||||
formItemClass: 'col-span-6',
|
||||
label: '拼音全拼',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '可留空,保存时从药名提取数字',
|
||||
},
|
||||
fieldName: 'search_digits',
|
||||
formItemClass: 'col-span-6',
|
||||
label: '检索数字',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
|
||||
@@ -24,9 +24,11 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'drug_name', align: 'left', title: '药品名称' },
|
||||
{ field: 'drug_name', align: 'left', title: '药品名称', slots: { default: 'drug_name' } },
|
||||
{ field: 'drug_number', align: 'left', title: '药品编码(erp)' },
|
||||
{ field: 'pinyin_simple', title: '拼音' },
|
||||
{ field: 'pinyin_simple', title: '首拼', minWidth: 100, slots: { default: 'pinyin_simple' } },
|
||||
{ field: 'pinyin_full', title: '全拼', minWidth: 140 },
|
||||
{ field: 'search_digits', title: '数字', width: 90 },
|
||||
{ field: 'status', title: '状态', slots: { default: 'status' } },
|
||||
{ field: 'created_at', title: '发布时间' },
|
||||
{ type: 'html', title: '操作', slots: { default: 'action' } },
|
||||
|
||||
@@ -5,20 +5,22 @@ import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Image, message, Tag } from 'ant-design-vue';
|
||||
import { Button, Image, Modal as AntModal, message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { downloadByData } from '#/util/tool';
|
||||
|
||||
import { deleteChinaMedicine, exportChinaMedicineApi } from './api';
|
||||
import { deleteChinaMedicine, exportChinaMedicineApi, syncChinaMedicinePinyin } from './api';
|
||||
import DrugNumberBatchModal from './components/DrugNumberBatchModal.vue';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import ExcelUpload from './components/ExcelUpload.vue';
|
||||
import DrugDetailModal from '#/views/business/product/_shared/drug-detail-modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
@@ -51,6 +53,10 @@ const [DrugNumberBatchModalComp, drugNumberBatchModalApi] = useVbenModal({
|
||||
connectedComponent: DrugNumberBatchModal,
|
||||
});
|
||||
|
||||
const [DrugDetailModalComp, drugDetailModalApi] = useVbenModal({
|
||||
connectedComponent: DrugDetailModal,
|
||||
});
|
||||
|
||||
const showModal = (data = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
// 表单值
|
||||
@@ -61,6 +67,36 @@ const showModal = (data = {}, isUpdate = false) => {
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
/** 点击药名:打开详情 / 别名维护弹窗 */
|
||||
function openDrugDetail(row: any) {
|
||||
drugDetailModalApi.setData({
|
||||
drug_id: row?.id,
|
||||
gridApi,
|
||||
});
|
||||
drugDetailModalApi.open();
|
||||
}
|
||||
|
||||
/** 同步本类型空拼音(首拼/全拼/数字) */
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音',
|
||||
content:
|
||||
'将扫描本类型药品中首拼/全拼/数字串为空的记录并自动生成,已有值不会覆盖。确认执行?',
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncChinaMedicinePinyin();
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const deleteApi = (row: any) => {
|
||||
let ids = [];
|
||||
if (row) {
|
||||
@@ -104,6 +140,7 @@ const openDrugNumberBatchModal = () => {
|
||||
<Page auto-content-height title="中药管理">
|
||||
<ExcelUploadModal />
|
||||
<DrugNumberBatchModalComp />
|
||||
<DrugDetailModalComp />
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
@@ -157,6 +194,30 @@ const openDrugNumberBatchModal = () => {
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音
|
||||
</Button>
|
||||
</template>
|
||||
<template #drug_name="{ row }">
|
||||
<div class="cm-drug-name-cell">
|
||||
<a class="text-primary cursor-pointer" @click="openDrugDetail(row)">
|
||||
{{ row.drug_name || '-' }}
|
||||
</a>
|
||||
<!-- 搜索命中别名时展示(对齐开方/仓库) -->
|
||||
<div v-if="row.matched_alias" class="cm-drug-name-cell__alias">
|
||||
别名:{{ row.matched_alias }}
|
||||
<span v-if="row.matched_alias_pinyin">
|
||||
({{ row.matched_alias_pinyin }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #pinyin_simple="{ row }">
|
||||
<span>
|
||||
{{
|
||||
row.matched_alias_pinyin || row.pinyin_simple || '-'
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||
@@ -191,3 +252,12 @@ const openDrugNumberBatchModal = () => {
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
<style scoped>
|
||||
.cm-drug-name-cell__alias {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,4 +85,11 @@ export async function importHealthFoodApi(data: Record<string, any>) {
|
||||
*/
|
||||
export async function updateZoneCategory(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update-zone-category`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步空拼音(首拼/全拼/数字串)
|
||||
*/
|
||||
export async function syncHealthFoodPinyin() {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, {});
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { ref } from 'vue';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
// 导入Ant Design Vue组件
|
||||
import { Button, message, Tag } from 'ant-design-vue';
|
||||
import { Button, Modal as AntModal, message, Tag } from 'ant-design-vue';
|
||||
|
||||
// 导入Vben VxeGrid适配器
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
@@ -20,6 +20,7 @@ import { downloadByData } from '#/util/tool';
|
||||
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
|
||||
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
|
||||
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
|
||||
import DrugDetailModal from '#/views/business/product/_shared/drug-detail-modal.vue';
|
||||
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
|
||||
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
|
||||
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
|
||||
@@ -27,7 +28,11 @@ import BindListModal from '#/views/system/delivery-warehouse-drug/components/bin
|
||||
import { resolveCentralPrice } from '#/views/system/delivery-warehouse-drug/utils/resolve-central-price';
|
||||
|
||||
// 导入保健食品管理相关API
|
||||
import { deleteHealthFood, exportHealthFoodApi } from './api';
|
||||
import {
|
||||
deleteHealthFood,
|
||||
exportHealthFoodApi,
|
||||
syncHealthFoodPinyin,
|
||||
} from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import ExcelUpload from './components/ExcelUpload.vue';
|
||||
import CategoryModal from './components/CategoryModal.vue';
|
||||
@@ -41,6 +46,7 @@ const PRODUCT_TYPE = 3;
|
||||
|
||||
// 控制顶部表格下拉操作按钮的显示状态
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
|
||||
// 定义表格事件监听器
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
@@ -94,6 +100,40 @@ const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
|
||||
connectedComponent: WarehouseDrugPriceModal,
|
||||
});
|
||||
|
||||
const [DrugDetailModalComp, drugDetailModalApi] = useVbenModal({
|
||||
connectedComponent: DrugDetailModal,
|
||||
});
|
||||
|
||||
/** 点击药名:打开详情 / 别名维护弹窗 */
|
||||
function openDrugDetail(row: any) {
|
||||
drugDetailModalApi.setData({
|
||||
drug_id: row?.id,
|
||||
gridApi,
|
||||
});
|
||||
drugDetailModalApi.open();
|
||||
}
|
||||
|
||||
/** 同步本类型空拼音(首拼/全拼/数字) */
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音',
|
||||
content:
|
||||
'将扫描本类型药品中首拼/全拼/数字串为空的记录并自动生成,已有值不会覆盖。确认执行?',
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncHealthFoodPinyin();
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开某产品的配送仓绑定列表(带总仓售价供费用封顶/默认平台费)
|
||||
* @param autoCreate 为 true 时打开后立刻弹出新增绑定表单
|
||||
@@ -234,6 +274,7 @@ const openExcelUploadModal = () => {
|
||||
<Page auto-content-height title="保健食品管理">
|
||||
<ExcelUploadModal />
|
||||
<FormModal />
|
||||
<DrugDetailModalComp />
|
||||
<CategoryModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
@@ -281,9 +322,12 @@ const openExcelUploadModal = () => {
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音
|
||||
</Button>
|
||||
</template>
|
||||
<template #product_info="{ row }">
|
||||
<ProductInfoCell :row="row" />
|
||||
<ProductInfoCell :row="row" @name-click="openDrugDetail" />
|
||||
</template>
|
||||
<template #category_warehouse="{ row }">
|
||||
<CategoryWarehouseCell
|
||||
|
||||
@@ -73,6 +73,13 @@ export async function updateZoneCategory(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update-zone-category`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步空拼音(首拼/全拼/数字串)
|
||||
*/
|
||||
export async function syncMedicalDevicePinyin() {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, {});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Tag } from 'ant-design-vue';
|
||||
import { Button, Modal as AntModal, message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
@@ -13,13 +13,18 @@ import { downloadByData } from '#/util/tool';
|
||||
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
|
||||
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
|
||||
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
|
||||
import DrugDetailModal from '#/views/business/product/_shared/drug-detail-modal.vue';
|
||||
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
|
||||
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
|
||||
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
|
||||
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
|
||||
import { resolveCentralPrice } from '#/views/system/delivery-warehouse-drug/utils/resolve-central-price';
|
||||
|
||||
import { deleteMedicalDevice, exportMedicalDeviceApi } from './api';
|
||||
import {
|
||||
deleteMedicalDevice,
|
||||
exportMedicalDeviceApi,
|
||||
syncMedicalDevicePinyin,
|
||||
} from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import ExcelUpload from './components/ExcelUpload.vue';
|
||||
import CategoryModal from './components/CategoryModal.vue';
|
||||
@@ -30,6 +35,7 @@ import { gridOptions } from './config/table';
|
||||
const PRODUCT_TYPE = 7;
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
@@ -72,6 +78,40 @@ const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
|
||||
connectedComponent: WarehouseDrugPriceModal,
|
||||
});
|
||||
|
||||
const [DrugDetailModalComp, drugDetailModalApi] = useVbenModal({
|
||||
connectedComponent: DrugDetailModal,
|
||||
});
|
||||
|
||||
/** 点击药名:打开详情 / 别名维护弹窗 */
|
||||
function openDrugDetail(row: any) {
|
||||
drugDetailModalApi.setData({
|
||||
drug_id: row?.id,
|
||||
gridApi,
|
||||
});
|
||||
drugDetailModalApi.open();
|
||||
}
|
||||
|
||||
/** 同步本类型空拼音(首拼/全拼/数字) */
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音',
|
||||
content:
|
||||
'将扫描本类型药品中首拼/全拼/数字串为空的记录并自动生成,已有值不会覆盖。确认执行?',
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncMedicalDevicePinyin();
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开某产品的配送仓绑定列表(带总仓售价供费用封顶/默认平台费)
|
||||
* @param autoCreate 为 true 时打开后立刻弹出新增绑定表单
|
||||
@@ -181,6 +221,7 @@ const openExcelUploadModal = () => {
|
||||
<Page auto-content-height title="医疗器械管理">
|
||||
<ExcelUploadModal />
|
||||
<FormModal />
|
||||
<DrugDetailModalComp />
|
||||
<CategoryModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
@@ -228,9 +269,12 @@ const openExcelUploadModal = () => {
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音
|
||||
</Button>
|
||||
</template>
|
||||
<template #product_info="{ row }">
|
||||
<ProductInfoCell :row="row" />
|
||||
<ProductInfoCell :row="row" @name-click="openDrugDetail" />
|
||||
</template>
|
||||
<template #category_warehouse="{ row }">
|
||||
<CategoryWarehouseCell
|
||||
|
||||
@@ -73,6 +73,13 @@ export async function updateZoneCategory(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update-zone-category`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步空拼音(首拼/全拼/数字串)
|
||||
*/
|
||||
export async function syncNonDrugPinyin() {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, {});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Tag } from 'ant-design-vue';
|
||||
import { Button, Modal as AntModal, message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
@@ -13,13 +13,14 @@ import { downloadByData } from '#/util/tool';
|
||||
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
|
||||
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
|
||||
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
|
||||
import DrugDetailModal from '#/views/business/product/_shared/drug-detail-modal.vue';
|
||||
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
|
||||
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
|
||||
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
|
||||
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
|
||||
import { resolveCentralPrice } from '#/views/system/delivery-warehouse-drug/utils/resolve-central-price';
|
||||
|
||||
import { deleteNonDrug, exportNonDrugApi } from './api';
|
||||
import { deleteNonDrug, exportNonDrugApi, syncNonDrugPinyin } from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import ExcelUpload from './components/ExcelUpload.vue';
|
||||
import CategoryModal from './components/CategoryModal.vue';
|
||||
@@ -30,6 +31,7 @@ import { gridOptions } from './config/table';
|
||||
const PRODUCT_TYPE = 6;
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
@@ -72,6 +74,40 @@ const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
|
||||
connectedComponent: WarehouseDrugPriceModal,
|
||||
});
|
||||
|
||||
const [DrugDetailModalComp, drugDetailModalApi] = useVbenModal({
|
||||
connectedComponent: DrugDetailModal,
|
||||
});
|
||||
|
||||
/** 点击药名:打开详情 / 别名维护弹窗 */
|
||||
function openDrugDetail(row: any) {
|
||||
drugDetailModalApi.setData({
|
||||
drug_id: row?.id,
|
||||
gridApi,
|
||||
});
|
||||
drugDetailModalApi.open();
|
||||
}
|
||||
|
||||
/** 同步本类型空拼音(首拼/全拼/数字) */
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音',
|
||||
content:
|
||||
'将扫描本类型药品中首拼/全拼/数字串为空的记录并自动生成,已有值不会覆盖。确认执行?',
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncNonDrugPinyin();
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开某产品的配送仓绑定列表(带总仓售价供费用封顶/默认平台费)
|
||||
* @param autoCreate 为 true 时打开后立刻弹出新增绑定表单
|
||||
@@ -181,6 +217,7 @@ const openExcelUploadModal = () => {
|
||||
<Page auto-content-height title="非药品管理">
|
||||
<ExcelUploadModal />
|
||||
<FormModal />
|
||||
<DrugDetailModalComp />
|
||||
<CategoryModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
@@ -228,9 +265,12 @@ const openExcelUploadModal = () => {
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音
|
||||
</Button>
|
||||
</template>
|
||||
<template #product_info="{ row }">
|
||||
<ProductInfoCell :row="row" />
|
||||
<ProductInfoCell :row="row" @name-click="openDrugDetail" />
|
||||
</template>
|
||||
<template #category_warehouse="{ row }">
|
||||
<CategoryWarehouseCell
|
||||
|
||||
@@ -55,3 +55,10 @@ export async function deleteServicePack(data: Record<string, any>) {
|
||||
export async function updateZoneCategory(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update-zone-category`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步空拼音(首拼/全拼/数字串)
|
||||
*/
|
||||
export async function syncServicePackPinyin() {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, {});
|
||||
}
|
||||
|
||||
@@ -5,20 +5,21 @@ import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Tag } from 'ant-design-vue';
|
||||
import { Button, Modal as AntModal, message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
|
||||
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
|
||||
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
|
||||
import DrugDetailModal from '#/views/business/product/_shared/drug-detail-modal.vue';
|
||||
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
|
||||
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
|
||||
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
|
||||
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
|
||||
import { resolveCentralPrice } from '#/views/system/delivery-warehouse-drug/utils/resolve-central-price';
|
||||
|
||||
import { deleteServicePack } from './api';
|
||||
import { deleteServicePack, syncServicePackPinyin } from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import ZoneCategoryModal from './components/ZoneCategoryModal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
@@ -28,6 +29,7 @@ import { gridOptions } from './config/table';
|
||||
const PRODUCT_TYPE = 5;
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
@@ -68,6 +70,40 @@ const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
|
||||
connectedComponent: WarehouseDrugPriceModal,
|
||||
});
|
||||
|
||||
const [DrugDetailModalComp, drugDetailModalApi] = useVbenModal({
|
||||
connectedComponent: DrugDetailModal,
|
||||
});
|
||||
|
||||
/** 点击药名:打开详情 / 别名维护弹窗 */
|
||||
function openDrugDetail(row: any) {
|
||||
drugDetailModalApi.setData({
|
||||
drug_id: row?.id,
|
||||
gridApi,
|
||||
});
|
||||
drugDetailModalApi.open();
|
||||
}
|
||||
|
||||
/** 同步本类型空拼音(首拼/全拼/数字) */
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音',
|
||||
content:
|
||||
'将扫描本类型药品中首拼/全拼/数字串为空的记录并自动生成,已有值不会覆盖。确认执行?',
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncServicePackPinyin();
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开某产品的配送仓绑定列表(带总仓售价供费用封顶/默认平台费)
|
||||
* @param autoCreate 为 true 时打开后立刻弹出新增绑定表单
|
||||
@@ -164,6 +200,7 @@ const deleteApi = (row: any) => {
|
||||
<template>
|
||||
<Page auto-content-height title="产品服务包管理">
|
||||
<FormModal />
|
||||
<DrugDetailModalComp />
|
||||
<ZoneCategoryModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
@@ -200,9 +237,12 @@ const deleteApi = (row: any) => {
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音
|
||||
</Button>
|
||||
</template>
|
||||
<template #product_info="{ row }">
|
||||
<ProductInfoCell :row="row" />
|
||||
<ProductInfoCell :row="row" @name-click="openDrugDetail" />
|
||||
</template>
|
||||
<template #category_warehouse="{ row }">
|
||||
<CategoryWarehouseCell
|
||||
|
||||
@@ -77,7 +77,11 @@ export async function updateZoneCategory(data: Record<string, any>) {
|
||||
* 更新最低调整金额
|
||||
* @param data
|
||||
*/
|
||||
export async function updateMinAdjustPrice(data: { id: number; min_adjust_price: number | null }) {
|
||||
export async function updateMinAdjustPrice(data: {
|
||||
id: number;
|
||||
min_adjust_price: number | null;
|
||||
max_adjust_price?: number | null;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}update-min-adjust-price`, data);
|
||||
}
|
||||
|
||||
@@ -88,3 +92,10 @@ export async function updateMinAdjustPrice(data: { id: number; min_adjust_price:
|
||||
export async function updateErpQtyFactor(data: { id: number; erp_qty_factor: number }) {
|
||||
return requestClient.post<any>(`${prefix}update-erp-qty-factor`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步空拼音(首拼/全拼/数字串)
|
||||
*/
|
||||
export async function syncWesternMedicinePinyin() {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, {});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Form, InputNumber, message } from 'ant-design-vue';
|
||||
/**
|
||||
* 设置药品改价底价与最高价
|
||||
* 商户改价、订单调价须落在该区间;可按总仓售价快捷填入上下 30%
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Form, InputNumber, message } from 'ant-design-vue';
|
||||
|
||||
import { updateMinAdjustPrice } from '../api';
|
||||
|
||||
defineOptions({
|
||||
name: 'MinPriceModal',
|
||||
});
|
||||
|
||||
/** 相对总仓售价的浮动比例(上下各 30%) */
|
||||
const CENTRAL_PRICE_RANGE_RATIO = 0.3;
|
||||
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -15,9 +25,44 @@ const formData = ref<{
|
||||
id?: number;
|
||||
drugName?: string;
|
||||
currentMinPrice?: number | null;
|
||||
currentMaxPrice?: number | null;
|
||||
/** 总仓售价,用于快捷填区间 */
|
||||
centralPrice?: number | null;
|
||||
}>({});
|
||||
|
||||
const minPrice = ref<number | null>(null);
|
||||
const maxPrice = ref<number | null>(null);
|
||||
|
||||
/** 是否有可用总仓售价 */
|
||||
const hasCentralPrice = computed(() => {
|
||||
const p = Number(formData.value.centralPrice);
|
||||
return Number.isFinite(p) && p > 0;
|
||||
});
|
||||
|
||||
/** 预览:底价 = 售价×0.7,最高价 = 售价×1.3 */
|
||||
const previewRange = computed(() => {
|
||||
if (!hasCentralPrice.value) return null;
|
||||
const base = Number(formData.value.centralPrice);
|
||||
const min = Number((base * (1 - CENTRAL_PRICE_RANGE_RATIO)).toFixed(2));
|
||||
const max = Number((base * (1 + CENTRAL_PRICE_RANGE_RATIO)).toFixed(2));
|
||||
return { min, max, base };
|
||||
});
|
||||
|
||||
/**
|
||||
* 快捷填入总仓售价上下 30% 区间
|
||||
* 为什么:运营批量设价时以总仓建议售价为基准,避免手工算错
|
||||
*/
|
||||
function fillByCentralPriceRange() {
|
||||
if (!previewRange.value) {
|
||||
message.warning('暂无总仓售价,无法快捷填入');
|
||||
return;
|
||||
}
|
||||
minPrice.value = previewRange.value.min;
|
||||
maxPrice.value = previewRange.value.max;
|
||||
message.success(
|
||||
`已填入:¥${previewRange.value.min.toFixed(2)} ~ ¥${previewRange.value.max.toFixed(2)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [ModalComponent, modalApi] = useVbenModal({
|
||||
class: 'w-[500px]',
|
||||
@@ -30,19 +75,30 @@ const [ModalComponent, modalApi] = useVbenModal({
|
||||
}
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!formData.value.id || minPrice.value === null) {
|
||||
message.error('参数不完整');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
maxPrice.value !== null &&
|
||||
maxPrice.value > 0 &&
|
||||
minPrice.value > maxPrice.value
|
||||
) {
|
||||
message.error('最高价不能低于底价');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await updateMinAdjustPrice({
|
||||
id: formData.value.id,
|
||||
min_adjust_price: minPrice.value,
|
||||
// 始终提交最高价字段:null/0 表示清空上限
|
||||
max_adjust_price:
|
||||
maxPrice.value && maxPrice.value > 0 ? maxPrice.value : 0,
|
||||
});
|
||||
message.success('设置成功');
|
||||
modalApi.close();
|
||||
@@ -59,45 +115,76 @@ const [ModalComponent, modalApi] = useVbenModal({
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<typeof formData.value & { gridApi?: any }>();
|
||||
const data = modalApi.getData<
|
||||
typeof formData.value & {
|
||||
gridApi?: any;
|
||||
min_adjust_price?: number | null;
|
||||
max_adjust_price?: number | null;
|
||||
central_price?: number | null;
|
||||
drug_name?: string;
|
||||
}
|
||||
>();
|
||||
if (data) {
|
||||
const central = Number(
|
||||
data.centralPrice ?? data.central_price ?? 0,
|
||||
);
|
||||
formData.value = {
|
||||
id: data.id,
|
||||
drugName: data.drugName || data.drug_name,
|
||||
currentMinPrice: data.currentMinPrice || data.min_adjust_price || null,
|
||||
currentMaxPrice: data.currentMaxPrice || data.max_adjust_price || null,
|
||||
centralPrice: central > 0 ? central : null,
|
||||
};
|
||||
minPrice.value = formData.value.currentMinPrice;
|
||||
maxPrice.value = formData.value.currentMaxPrice;
|
||||
}
|
||||
} else {
|
||||
formData.value = {};
|
||||
minPrice.value = null;
|
||||
maxPrice.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalComponent
|
||||
title="设置最低调整金额"
|
||||
:loading="loading"
|
||||
>
|
||||
<ModalComponent title="设置改价区间(底价 / 最高价)" :loading="loading">
|
||||
<div v-if="formData.drugName" class="min-price-modal">
|
||||
<div class="mb-4">
|
||||
<p><strong>药品名称:</strong>{{ formData.drugName }}</p>
|
||||
<p v-if="formData.currentMinPrice" class="mt-2">
|
||||
<strong>当前最低调整金额:</strong>¥{{ Number(formData.currentMinPrice).toFixed(2) }}
|
||||
<p v-if="hasCentralPrice" class="mt-1 text-sm text-gray-600">
|
||||
总仓售价:¥{{ Number(formData.centralPrice).toFixed(2) }}
|
||||
</p>
|
||||
<p v-else class="mt-2" style="color: #999">
|
||||
<strong>当前最低调整金额:</strong>未设置
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
商户改价与订单调价须落在底价~最高价之间;最高价留空表示不限制上限。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form ref="formRef" :model="{ minPrice }" layout="vertical">
|
||||
<div class="mb-4">
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
:disabled="!hasCentralPrice"
|
||||
@click="fillByCentralPriceRange"
|
||||
>
|
||||
快捷填入总仓售价 ±30%
|
||||
</Button>
|
||||
<p v-if="previewRange" class="mt-2 text-xs text-gray-500">
|
||||
将填入 ¥{{ previewRange.min.toFixed(2) }} ~
|
||||
¥{{ previewRange.max.toFixed(2) }}
|
||||
(售价 ¥{{ previewRange.base.toFixed(2) }} × 0.7 / 1.3)
|
||||
</p>
|
||||
<p v-else class="mt-2 text-xs text-orange-500">
|
||||
该药品暂无总仓售价,请先入库总仓后再使用快捷填入。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form ref="formRef" :model="{ minPrice, maxPrice }" layout="vertical">
|
||||
<Form.Item
|
||||
label="最低调整金额"
|
||||
label="底价(最低调整金额)"
|
||||
name="minPrice"
|
||||
:rules="[
|
||||
{ required: true, message: '请输入最低调整金额' },
|
||||
{ required: true, message: '请输入底价' },
|
||||
{ type: 'number', min: 0, message: '金额不能小于0' },
|
||||
]"
|
||||
>
|
||||
@@ -107,12 +194,19 @@ const [ModalComponent, modalApi] = useVbenModal({
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
style="width: 100%"
|
||||
placeholder="请输入最低调整金额(设置为0或留空表示不允许调整)"
|
||||
placeholder="请输入底价(0 表示不允许调价)"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="最高价" name="maxPrice">
|
||||
<InputNumber
|
||||
v-model:value="maxPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
style="width: 100%"
|
||||
placeholder="请输入最高价(留空或0表示不限制)"
|
||||
/>
|
||||
</Form.Item>
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
提示:设置为0或留空表示该商品不允许调整价格
|
||||
</p>
|
||||
</Form>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
|
||||
@@ -134,6 +134,33 @@ export const modalFormProps: VbenFormProps = {
|
||||
label: '药品别名',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '可留空,保存时按药名自动生成',
|
||||
},
|
||||
fieldName: 'pinyin_simple',
|
||||
formItemClass: 'col-span-6',
|
||||
label: '拼音首拼',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '可留空,保存时按药名自动生成',
|
||||
},
|
||||
fieldName: 'pinyin_full',
|
||||
formItemClass: 'col-span-6',
|
||||
label: '拼音全拼',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '可留空,保存时从药名提取数字',
|
||||
},
|
||||
fieldName: 'search_digits',
|
||||
formItemClass: 'col-span-6',
|
||||
label: '检索数字',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
|
||||
@@ -64,8 +64,8 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{
|
||||
field: 'min_adjust_price',
|
||||
title: '最低调整金额',
|
||||
width: 120,
|
||||
title: '改价区间',
|
||||
width: 140,
|
||||
slots: { default: 'min_adjust_price' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Tag } from 'ant-design-vue';
|
||||
import { Button, Modal as AntModal, message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
@@ -13,13 +13,18 @@ import { downloadByData } from '#/util/tool';
|
||||
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
|
||||
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
|
||||
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
|
||||
import DrugDetailModal from '#/views/business/product/_shared/drug-detail-modal.vue';
|
||||
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
|
||||
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
|
||||
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
|
||||
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
|
||||
import { resolveCentralPrice } from '#/views/system/delivery-warehouse-drug/utils/resolve-central-price';
|
||||
|
||||
import { deleteWesternMedicine, exportWesternMedicineApi } from './api';
|
||||
import {
|
||||
deleteWesternMedicine,
|
||||
exportWesternMedicineApi,
|
||||
syncWesternMedicinePinyin,
|
||||
} from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import ExcelUpload from './components/ExcelUpload.vue';
|
||||
import CategoryModal from './components/CategoryModal.vue';
|
||||
@@ -32,6 +37,7 @@ import { gridOptions } from './config/table';
|
||||
const PRODUCT_TYPE = 2;
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
@@ -84,6 +90,40 @@ const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
|
||||
connectedComponent: WarehouseDrugPriceModal,
|
||||
});
|
||||
|
||||
const [DrugDetailModalComp, drugDetailModalApi] = useVbenModal({
|
||||
connectedComponent: DrugDetailModal,
|
||||
});
|
||||
|
||||
/** 点击药名:打开详情 / 别名维护弹窗 */
|
||||
function openDrugDetail(row: any) {
|
||||
drugDetailModalApi.setData({
|
||||
drug_id: row?.id,
|
||||
gridApi,
|
||||
});
|
||||
drugDetailModalApi.open();
|
||||
}
|
||||
|
||||
/** 同步本类型空拼音(首拼/全拼/数字) */
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音',
|
||||
content:
|
||||
'将扫描本类型药品中首拼/全拼/数字串为空的记录并自动生成,已有值不会覆盖。确认执行?',
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncWesternMedicinePinyin();
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开某药的配送仓绑定列表(带总仓售价供费用封顶/默认平台费)
|
||||
* @param autoCreate 为 true 时打开后立刻弹出新增绑定表单
|
||||
@@ -157,6 +197,9 @@ const openMinPriceModal = (row: any) => {
|
||||
id: row.id,
|
||||
drugName: row.drug_name,
|
||||
min_adjust_price: row.min_adjust_price,
|
||||
max_adjust_price: row.max_adjust_price,
|
||||
// 总仓售价:供弹窗「±30%」快捷填入
|
||||
central_price: resolveCentralPrice(row),
|
||||
gridApi,
|
||||
});
|
||||
minPriceModalApi.open();
|
||||
@@ -218,6 +261,7 @@ const openExcelUploadModal = () => {
|
||||
<Page auto-content-height title="西(中成)药管理">
|
||||
<ExcelUploadModal />
|
||||
<FormModal />
|
||||
<DrugDetailModalComp />
|
||||
<CategoryModalComp />
|
||||
<MinPriceModalComp />
|
||||
<ErpQtyFactorModalComp />
|
||||
@@ -271,9 +315,12 @@ const openExcelUploadModal = () => {
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音
|
||||
</Button>
|
||||
</template>
|
||||
<template #product_info="{ row }">
|
||||
<ProductInfoCell :row="row" />
|
||||
<ProductInfoCell :row="row" @name-click="openDrugDetail" />
|
||||
</template>
|
||||
<template #category_warehouse="{ row }">
|
||||
<CategoryWarehouseCell
|
||||
@@ -311,9 +358,13 @@ const openExcelUploadModal = () => {
|
||||
class="text-primary cursor-pointer hover:underline"
|
||||
@click="openMinPriceModal(row)"
|
||||
>
|
||||
<span v-if="row.min_adjust_price && row.min_adjust_price > 0">
|
||||
<template v-if="row.min_adjust_price && row.min_adjust_price > 0">
|
||||
¥{{ Number(row.min_adjust_price).toFixed(2) }}
|
||||
</span>
|
||||
<span v-if="row.max_adjust_price && row.max_adjust_price > 0">
|
||||
~ ¥{{ Number(row.max_adjust_price).toFixed(2) }}
|
||||
</span>
|
||||
<span v-else> ~ 不限</span>
|
||||
</template>
|
||||
<span v-else style="color: #999">未设置</span>
|
||||
</a>
|
||||
</template>
|
||||
@@ -344,7 +395,7 @@ const openExcelUploadModal = () => {
|
||||
onClick: openBindList.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '设置底价',
|
||||
label: '设置改价区间',
|
||||
type: 'link',
|
||||
icon: 'ant-design:dollar-outlined',
|
||||
size: 'small',
|
||||
|
||||
@@ -15,12 +15,9 @@ import {
|
||||
Select,
|
||||
SelectOption,
|
||||
} from 'ant-design-vue';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
import {
|
||||
getDrugUseList,
|
||||
getProductListDoctorReception,
|
||||
} from '#/views/doctor/doctor-reception/api';
|
||||
import { ChineseDrugNameBubble } from '#/components/chinese-drug-name-bubble';
|
||||
import { getDrugUseList } from '#/views/doctor/doctor-reception/api';
|
||||
|
||||
import PrescriptionDiagnosisOrderCards from './PrescriptionDiagnosisOrderCards.vue';
|
||||
|
||||
@@ -118,7 +115,6 @@ const doctorOrderModel = computed({
|
||||
});
|
||||
|
||||
const drugUseWay = ref<any[]>([]);
|
||||
const chineseSearchResults = ref<any[]>([]);
|
||||
const newDrugInfo = ref<{
|
||||
id?: number;
|
||||
name?: string;
|
||||
@@ -151,48 +147,36 @@ function syncToParent() {
|
||||
emit('update:dayDosage', dayDosageLocal.value);
|
||||
}
|
||||
|
||||
const searchChineseDrugs = debounce(async (keyword: string) => {
|
||||
if (!keyword || keyword.length < 1) {
|
||||
chineseSearchResults.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await getProductListDoctorReception({
|
||||
name: keyword,
|
||||
type: 1,
|
||||
store_id: userStore.userInfo?.store_id || 2,
|
||||
});
|
||||
chineseSearchResults.value = Array.isArray(res) ? res : [];
|
||||
} catch {
|
||||
chineseSearchResults.value = [];
|
||||
}
|
||||
}, 300);
|
||||
|
||||
function selectNewDrug(drugId: number) {
|
||||
/**
|
||||
* 中药气泡:选中新增行
|
||||
* @param selectedItem 商品检索完整行
|
||||
*/
|
||||
function selectNewDrug(selectedItem: any) {
|
||||
const drugId = Number(selectedItem?.drug?.id || selectedItem?.drug_id || 0);
|
||||
if (!drugId) return;
|
||||
const exists = drugList.value.some(
|
||||
(item) => item.id === drugId || item.drug_id === drugId,
|
||||
);
|
||||
if (exists) {
|
||||
message.warning('该药品已在列表中');
|
||||
newDrugInfo.value.id = undefined;
|
||||
newDrugInfo.value.name = '';
|
||||
return;
|
||||
}
|
||||
const selectedItem = chineseSearchResults.value.find(
|
||||
(item) => item.drug?.id === drugId || item.drug_id === drugId,
|
||||
);
|
||||
if (selectedItem) {
|
||||
newDrugInfo.value.name = selectedItem.drug?.drug_name || '';
|
||||
newDrugInfo.value.price = selectedItem.price || 0;
|
||||
newDrugInfo.value.way_id = 0;
|
||||
newDrugInfo.value.unit =
|
||||
selectedItem.drug?.unit || selectedItem.unit || null;
|
||||
newDrugInfo.value.unit_id =
|
||||
selectedItem.drug?.unit_id || selectedItem.unit_id || 0;
|
||||
nextTick(() => {
|
||||
const numberInput = document.querySelector('.new-chinese-number input') as HTMLInputElement;
|
||||
numberInput?.focus();
|
||||
});
|
||||
}
|
||||
newDrugInfo.value.id = drugId;
|
||||
newDrugInfo.value.name = selectedItem.drug?.drug_name || '';
|
||||
newDrugInfo.value.price = selectedItem.price || 0;
|
||||
newDrugInfo.value.way_id = 0;
|
||||
newDrugInfo.value.unit =
|
||||
selectedItem.drug?.unit || selectedItem.unit || null;
|
||||
newDrugInfo.value.unit_id =
|
||||
selectedItem.drug?.unit_id || selectedItem.unit_id || 0;
|
||||
nextTick(() => {
|
||||
const numberInput = document.querySelector(
|
||||
'.new-chinese-number input',
|
||||
) as HTMLInputElement;
|
||||
numberInput?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function addChineseDrug() {
|
||||
@@ -216,11 +200,12 @@ function addChineseDrug() {
|
||||
unit: newDrugInfo.value.unit || null,
|
||||
});
|
||||
newDrugInfo.value = {};
|
||||
chineseSearchResults.value = [];
|
||||
syncToParent();
|
||||
message.success('已添加药品');
|
||||
nextTick(() => {
|
||||
const nameInput = document.querySelector('.new-chinese-name input') as HTMLInputElement;
|
||||
const nameInput = document.querySelector(
|
||||
'.new-chinese-name input',
|
||||
) as HTMLInputElement;
|
||||
nameInput?.focus();
|
||||
});
|
||||
}
|
||||
@@ -234,34 +219,36 @@ function handleChineseKeydown(event: KeyboardEvent, isNew: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function changeChineseDrug(index: number, drugId: number) {
|
||||
/**
|
||||
* 中药气泡:替换已有行
|
||||
* @param index 行下标
|
||||
* @param selectedItem 商品检索完整行
|
||||
*/
|
||||
function changeChineseDrug(index: number, selectedItem: any) {
|
||||
const drugId = Number(selectedItem?.drug?.id || selectedItem?.drug_id || 0);
|
||||
if (!drugId) return;
|
||||
const exists = drugList.value.some(
|
||||
(item, i) => i !== index && (item.id === drugId || item.drug_id === drugId),
|
||||
);
|
||||
if (exists) {
|
||||
message.warning('该药品已在列表中');
|
||||
const drug = drugList.value[index];
|
||||
if (drug) drug.id = drug.drug_id;
|
||||
return;
|
||||
}
|
||||
const selectedItem = chineseSearchResults.value.find(
|
||||
(item) => item.drug?.id === drugId || item.drug_id === drugId,
|
||||
);
|
||||
if (selectedItem) {
|
||||
const drug = drugList.value[index];
|
||||
if (drug) {
|
||||
drug.id = drugId;
|
||||
drug.drug_id = drugId;
|
||||
drug.drug_name = selectedItem.drug?.drug_name || '';
|
||||
drug.price = selectedItem.price || 0;
|
||||
drug.unit = selectedItem.drug?.unit || selectedItem.unit || null;
|
||||
drug.unit_id = selectedItem.drug?.unit_id || selectedItem.unit_id || 0;
|
||||
syncToParent();
|
||||
nextTick(() => {
|
||||
const numberInput = document.querySelector(`.chinese-number-${index} input`) as HTMLInputElement;
|
||||
numberInput?.focus();
|
||||
});
|
||||
}
|
||||
const drug = drugList.value[index];
|
||||
if (drug) {
|
||||
drug.id = drugId;
|
||||
drug.drug_id = drugId;
|
||||
drug.drug_name = selectedItem.drug?.drug_name || '';
|
||||
drug.price = selectedItem.price || 0;
|
||||
drug.unit = selectedItem.drug?.unit || selectedItem.unit || null;
|
||||
drug.unit_id = selectedItem.drug?.unit_id || selectedItem.unit_id || 0;
|
||||
syncToParent();
|
||||
nextTick(() => {
|
||||
const numberInput = document.querySelector(
|
||||
`.chinese-number-${index} input`,
|
||||
) as HTMLInputElement;
|
||||
numberInput?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,27 +410,15 @@ defineExpose({
|
||||
<span>{{ getWayName(drug.way_id) }}</span>
|
||||
</div>
|
||||
<div v-else class="chinese-drug-form">
|
||||
<Select
|
||||
v-model:value="drug.id"
|
||||
show-search
|
||||
:filter-option="false"
|
||||
<ChineseDrugNameBubble
|
||||
:display-name="drug.drug_name"
|
||||
:store-id="userStore.userInfo?.store_id || 0"
|
||||
:type="1"
|
||||
compact
|
||||
input-class="chinese-drug-name"
|
||||
placeholder="药名"
|
||||
class="chinese-drug-name"
|
||||
@search="searchChineseDrugs"
|
||||
@change="(val) => changeChineseDrug(index, val as number)"
|
||||
>
|
||||
<SelectOption v-if="chineseSearchResults.length === 0" :value="drug.id">
|
||||
{{ drug.drug_name }}
|
||||
</SelectOption>
|
||||
<SelectOption
|
||||
v-for="item in chineseSearchResults"
|
||||
v-else
|
||||
:key="item.drug?.id || item.id"
|
||||
:value="item.drug?.id || item.id"
|
||||
>
|
||||
{{ item.drug?.drug_name || item.drug_name }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
@select="(item) => changeChineseDrug(index, item)"
|
||||
/>
|
||||
<span class="chinese-drug-comma">,</span>
|
||||
<InputNumber
|
||||
v-model:value="drug.number"
|
||||
@@ -523,23 +498,15 @@ defineExpose({
|
||||
<div class="chinese-drug-content">
|
||||
<span class="chinese-drug-index">{{ drugList.length + 1 }}、</span>
|
||||
<div class="chinese-drug-form">
|
||||
<Select
|
||||
v-model:value="newDrugInfo.id"
|
||||
show-search
|
||||
:filter-option="false"
|
||||
<ChineseDrugNameBubble
|
||||
:display-name="newDrugInfo.name || ''"
|
||||
:store-id="userStore.userInfo?.store_id || 0"
|
||||
:type="1"
|
||||
compact
|
||||
input-class="chinese-drug-name new-chinese-name"
|
||||
placeholder="药名"
|
||||
class="chinese-drug-name new-chinese-name"
|
||||
@search="searchChineseDrugs"
|
||||
@change="selectNewDrug"
|
||||
>
|
||||
<SelectOption
|
||||
v-for="item in chineseSearchResults"
|
||||
:key="item.drug?.id || item.id"
|
||||
:value="item.drug?.id || item.id"
|
||||
>
|
||||
{{ item.drug?.drug_name || item.drug_name }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
@select="selectNewDrug"
|
||||
/>
|
||||
<span class="chinese-drug-comma">,</span>
|
||||
<InputNumber
|
||||
v-model:value="newDrugInfo.number"
|
||||
@@ -607,15 +574,18 @@ defineExpose({
|
||||
|
||||
.chinese-drug-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-left: 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chinese-drug-name {
|
||||
min-width: 80px;
|
||||
max-width: 120px;
|
||||
/* 药名气泡锁宽,与克数/用法保持同一行 */
|
||||
.chinese-drug-form :deep(.chinese-drug-name-bubble) {
|
||||
width: 100px !important;
|
||||
min-width: 88px !important;
|
||||
max-width: 110px !important;
|
||||
flex: 0 0 100px !important;
|
||||
}
|
||||
|
||||
.chinese-drug-readonly {
|
||||
|
||||
@@ -100,7 +100,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="编辑药方" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
|
||||
<Modal title="编辑药方" class="w-[80%]">
|
||||
<div v-if="prescriptionType === 'chinese'">
|
||||
<ChineseDrugEditor
|
||||
ref="chineseDrugEditorRef"
|
||||
|
||||
@@ -67,7 +67,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="modalTitle" class="w-[420px]">
|
||||
<Modal :title="modalTitle" class="w-[80%]">
|
||||
<div class="free-shipping-dose-modal">
|
||||
<p v-if="spName" class="sp-name">特色方:{{ spName }}</p>
|
||||
<div class="form-row">
|
||||
|
||||
@@ -178,7 +178,7 @@ function bindPrescriptionTypeChange() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}特色方`" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}特色方`" class="w-[80%]">
|
||||
<Form />
|
||||
<div v-if="prescriptionType === 'chinese'" class="mt-4 border-t pt-4">
|
||||
<ChineseDrugEditor
|
||||
|
||||
@@ -91,7 +91,9 @@ const downloadQRCode = async (name: string, isQr = false) => {
|
||||
{{ data.qr_code.store.name }}
|
||||
</div>
|
||||
<div class="qrAddress">
|
||||
<span class="addressTitle">诊所地址:</span>{{ data.qr_code.store.position }}
|
||||
<span class="addressTitle">{{
|
||||
Number(data?.qr_code?.store?.type) === 1 ? '药店地址:' : '诊所地址:'
|
||||
}}</span>{{ data.qr_code.store.position }}
|
||||
</div>
|
||||
<Image :preview="false" :src="data.qr_code.qr_code" class="mt-3" height="30" width="30" />
|
||||
<div class="qrAddress">
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
import DoctorQrCodePreview from '#/components/modal/DoctorQrCodePreview.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';
|
||||
import {
|
||||
deleteNavApi,
|
||||
@@ -31,7 +33,6 @@ import {
|
||||
updateNavSortApi,
|
||||
updateClinicTypeApi,
|
||||
} from '#/views/business/store/settings/api';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
|
||||
import UploadModal from './components/uploadModal.vue';
|
||||
import SalespersonCard from "#/views/business/store/settings/components/SalespersonCard.vue";
|
||||
@@ -63,6 +64,8 @@ interface StoreInfo {
|
||||
qr_code: string;
|
||||
doctor_list: any[];
|
||||
nav: { id: number; pic: string }[];
|
||||
/** 门店 VIP:等级徽标 + 期限丝带 */
|
||||
vip?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
const [DoctorQrCodePreviewModal, DoctorQrCodePreviewModalApi] = useVbenModal({
|
||||
@@ -375,6 +378,8 @@ const handleSwitchClinicType = () => {
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<Card v-if="activeTabBar === 1" :title="isPharmacy ? '药店资料' : '诊所资料'">
|
||||
<!-- 门店 VIP 会员卡:大尺寸徽标 + 期限丝带 -->
|
||||
<StoreVipMemberCard :vip="data.vip" />
|
||||
<!-- 头部信息 -->
|
||||
<div class="mb-8 flex flex-col gap-6 md:flex-row md:gap-8">
|
||||
<div class="flex-1">
|
||||
@@ -576,16 +581,15 @@ const handleSwitchClinicType = () => {
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
专业:
|
||||
|
||||
<span
|
||||
:class="
|
||||
doctor.identity === 1
|
||||
doctor.doctor?.identity === 1
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
"
|
||||
class="rounded px-2 py-1"
|
||||
>
|
||||
{{ doctor.identity === 1 ? '中医' : '西医' }}
|
||||
{{ doctor.doctor?.identity === 1 ? '中医' : '西医' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -44,11 +44,21 @@ export async function getWarehouseDrugManagementInfo(id: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出仓库药品药品
|
||||
* 导出仓库药品(旧:后端二进制,保留兼容)
|
||||
*/
|
||||
export async function exportWarehouseDrugManagementApi(params?: { type?: number }) {
|
||||
return requestClient.download(`${prefix}export`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 JSON 数据(供前端 ExcelJS,对齐订单 export-data)
|
||||
*/
|
||||
export async function getWarehouseExportDataApi(params?: { type?: number }) {
|
||||
return requestClient.get<{
|
||||
total: number;
|
||||
rows: Array<Record<string, unknown>>;
|
||||
}>(`${prefix}export-data`, { params });
|
||||
}
|
||||
/**
|
||||
* 导出仓库药品药品 - 模板
|
||||
*/
|
||||
@@ -110,22 +120,25 @@ export interface WarehouseBatchPriceItem {
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
pinyin_simple: string;
|
||||
unit_id?: number;
|
||||
unit_name?: string;
|
||||
market_price: number | string;
|
||||
price: number | string;
|
||||
}
|
||||
|
||||
/** 批量改价预览:全量仓库药品 */
|
||||
export async function getAllForBatchPriceApi(params: { type: number }) {
|
||||
return requestClient.get<{ items: WarehouseBatchPriceItem[] }>(
|
||||
`${prefix}list-all-for-batch-price`,
|
||||
{ params },
|
||||
);
|
||||
return requestClient.get<{
|
||||
items: WarehouseBatchPriceItem[];
|
||||
unit_options?: { value: number; label: string }[];
|
||||
}>(`${prefix}list-all-for-batch-price`, { params });
|
||||
}
|
||||
|
||||
export interface BatchPriceUpdateItem {
|
||||
id: number;
|
||||
market_price: number;
|
||||
price: number;
|
||||
unit_name?: string;
|
||||
}
|
||||
|
||||
export interface BatchPriceCreateItem {
|
||||
@@ -133,6 +146,7 @@ export interface BatchPriceCreateItem {
|
||||
drug_number: string;
|
||||
market_price: number;
|
||||
price: number;
|
||||
unit_name?: string;
|
||||
}
|
||||
|
||||
/** 批量改价预览:应用更新与新增 */
|
||||
|
||||
@@ -19,17 +19,23 @@ import { downloadByData } from '#/util/tool';
|
||||
import {
|
||||
applyBatchPriceApi,
|
||||
getAllForBatchPriceApi,
|
||||
getWarehouseExportDataApi,
|
||||
type WarehouseBatchPriceItem,
|
||||
} from '../api';
|
||||
import { exportWarehousePriceExcel } from '../utils/exportWarehousePriceExcel';
|
||||
import {
|
||||
exportWarehouseListExcel,
|
||||
type WarehouseExportRow,
|
||||
} from '../utils/exportWarehousePriceExcel';
|
||||
import { parseWarehousePriceExcelBuffer } from '../utils/parseWarehousePriceExcel';
|
||||
import {
|
||||
getPriceDelta,
|
||||
isFieldChanged,
|
||||
isMarketGtPrice,
|
||||
isPriceChanged,
|
||||
isUnitChanged,
|
||||
normalizePricePair,
|
||||
parsePriceValue,
|
||||
reconcileExcelPrice,
|
||||
type PricePair,
|
||||
} from '../utils/priceCompare';
|
||||
import PriceCompareCardGrid, {
|
||||
@@ -60,10 +66,13 @@ const newRows = ref<
|
||||
drug_number: string;
|
||||
market_price: number;
|
||||
price: number;
|
||||
unit_name: string;
|
||||
}>
|
||||
>([]);
|
||||
const unmatchedRows = ref<UnmatchedRow[]>([]);
|
||||
const excelTouchedIds = ref<Set<number>>(new Set());
|
||||
/** 已知单位名称集合,用于标记「待建」 */
|
||||
const knownUnitNames = ref<Set<string>>(new Set());
|
||||
let tempIdCounter = -1;
|
||||
let loadAllPromise: Promise<void> | null = null;
|
||||
|
||||
@@ -74,6 +83,7 @@ function buildSnapshot(items: WarehouseBatchPriceItem[]) {
|
||||
const pair = normalizePricePair({
|
||||
market_price: parsePriceValue(item.market_price),
|
||||
price: parsePriceValue(item.price),
|
||||
unit_name: item.unit_name ?? '',
|
||||
});
|
||||
init[item.id] = { ...pair };
|
||||
loc[item.id] = { ...pair };
|
||||
@@ -91,6 +101,7 @@ function resetState() {
|
||||
newRows.value = [];
|
||||
unmatchedRows.value = [];
|
||||
excelTouchedIds.value = new Set();
|
||||
knownUnitNames.value = new Set();
|
||||
parseHint.value = '';
|
||||
parsedReady.value = false;
|
||||
filterTab.value = 'all';
|
||||
@@ -109,22 +120,44 @@ function buildCompareRow(
|
||||
orig: PricePair,
|
||||
isNew: boolean,
|
||||
): PriceCompareRow {
|
||||
const normalizedOrig = normalizePricePair(orig);
|
||||
const normalizedCurrent = normalizePricePair(current);
|
||||
const unitChanged = isUnitChanged(
|
||||
normalizedOrig.unit_name,
|
||||
normalizedCurrent.unit_name,
|
||||
);
|
||||
const unitName = (normalizedCurrent.unit_name ?? '').trim();
|
||||
const pendingCreate =
|
||||
unitName !== '' && !knownUnitNames.value.has(unitName);
|
||||
return {
|
||||
id: item.id,
|
||||
drug_id: item.drug_id,
|
||||
drug_name: item.drug_name,
|
||||
drug_number: item.drug_number,
|
||||
pinyin_simple: item.pinyin_simple,
|
||||
orig_market_price: orig.market_price,
|
||||
orig_price: orig.price,
|
||||
market_price: current.market_price,
|
||||
price: current.price,
|
||||
_marketDelta: getPriceDelta(orig.market_price, current.market_price),
|
||||
_priceDelta: getPriceDelta(orig.price, current.price),
|
||||
_marketChanged: isFieldChanged(orig.market_price, current.market_price),
|
||||
_priceChanged: isFieldChanged(orig.price, current.price),
|
||||
_changed: isNew || isPriceChanged(orig, current),
|
||||
_marketGtPrice: isMarketGtPrice(current),
|
||||
orig_market_price: normalizedOrig.market_price,
|
||||
orig_price: normalizedOrig.price,
|
||||
orig_unit_name: normalizedOrig.unit_name ?? '',
|
||||
market_price: normalizedCurrent.market_price,
|
||||
price: normalizedCurrent.price,
|
||||
unit_name: normalizedCurrent.unit_name ?? '',
|
||||
_marketDelta: getPriceDelta(
|
||||
normalizedOrig.market_price,
|
||||
normalizedCurrent.market_price,
|
||||
),
|
||||
_priceDelta: getPriceDelta(normalizedOrig.price, normalizedCurrent.price),
|
||||
_marketChanged: isFieldChanged(
|
||||
normalizedOrig.market_price,
|
||||
normalizedCurrent.market_price,
|
||||
),
|
||||
_priceChanged: isFieldChanged(
|
||||
normalizedOrig.price,
|
||||
normalizedCurrent.price,
|
||||
),
|
||||
_unitChanged: unitChanged,
|
||||
_unitPendingCreate: pendingCreate,
|
||||
_changed: isNew || isPriceChanged(normalizedOrig, normalizedCurrent),
|
||||
_marketGtPrice: isMarketGtPrice(normalizedCurrent),
|
||||
_isNew: isNew,
|
||||
_fromExcel: isNew ? true : undefined,
|
||||
};
|
||||
@@ -134,11 +167,13 @@ const compareRows = computed<PriceCompareRow[]>(() => {
|
||||
const existingRows: PriceCompareRow[] = warehouseItems.value
|
||||
.filter((item) => excelTouchedIds.value.has(item.id))
|
||||
.map((item) => {
|
||||
const current = localPrices.value[item.id] ?? normalizePricePair({
|
||||
const fallback = normalizePricePair({
|
||||
market_price: parsePriceValue(item.market_price),
|
||||
price: parsePriceValue(item.price),
|
||||
unit_name: item.unit_name ?? '',
|
||||
});
|
||||
const orig = initialSnapshot.value[item.id] ?? current;
|
||||
const current = localPrices.value[item.id] ?? fallback;
|
||||
const orig = initialSnapshot.value[item.id] ?? fallback;
|
||||
return buildCompareRow(item, current, orig, false);
|
||||
});
|
||||
|
||||
@@ -149,8 +184,12 @@ const compareRows = computed<PriceCompareRow[]>(() => {
|
||||
drug_name: row.drug_name,
|
||||
drug_number: row.drug_number,
|
||||
},
|
||||
{ market_price: row.market_price, price: row.price },
|
||||
{ market_price: 0, price: 0 },
|
||||
{
|
||||
market_price: row.market_price,
|
||||
price: row.price,
|
||||
unit_name: row.unit_name,
|
||||
},
|
||||
{ market_price: 0, price: 0, unit_name: '' },
|
||||
true,
|
||||
),
|
||||
);
|
||||
@@ -225,6 +264,9 @@ async function loadAllDrugs() {
|
||||
try {
|
||||
const res = await getAllForBatchPriceApi({ type: productType.value });
|
||||
warehouseItems.value = res?.items ?? [];
|
||||
knownUnitNames.value = new Set(
|
||||
(res?.unit_options ?? []).map((u) => String(u.label).trim()).filter(Boolean),
|
||||
);
|
||||
buildSnapshot(warehouseItems.value);
|
||||
} catch {
|
||||
message.error('加载仓库药品失败');
|
||||
@@ -255,24 +297,37 @@ async function handleFileChange(info: { file: UploadFile }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载标准模板:与列表页「导出」同一套 export-data + 列结构,
|
||||
* 避免改价模板列与导出不一致导致解析误报变更
|
||||
*/
|
||||
async function downloadTemplate() {
|
||||
if (warehouseItems.value.length === 0) {
|
||||
message.warning('暂无药品数据');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buffer = await exportWarehousePriceExcel(warehouseItems.value);
|
||||
const res = await getWarehouseExportDataApi({ type: productType.value });
|
||||
const rows = (res?.rows ?? []) as WarehouseExportRow[];
|
||||
if (rows.length === 0) {
|
||||
message.warning('暂无药品数据');
|
||||
return;
|
||||
}
|
||||
const buffer = await exportWarehouseListExcel(rows, '仓库商品');
|
||||
downloadByData(
|
||||
buffer,
|
||||
'萧康云医-中药仓库改价模板.xlsx',
|
||||
'萧康云医-仓库商品导出.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
message.success('模板已下载');
|
||||
message.success(`模板已下载(${res?.total ?? rows.length} 条,与导出一致)`);
|
||||
} catch {
|
||||
message.error('模板下载失败');
|
||||
}
|
||||
}
|
||||
|
||||
function findWarehouseByDrugId(drugId: number) {
|
||||
if (drugId <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return warehouseItems.value.find((d) => d.drug_id === drugId);
|
||||
}
|
||||
|
||||
function findWarehouseByNumber(number: string) {
|
||||
const n = number.trim();
|
||||
if (!n) {
|
||||
@@ -300,7 +355,7 @@ async function runParse() {
|
||||
syncModalFooterState();
|
||||
try {
|
||||
const buf = await selectedFile.value.arrayBuffer();
|
||||
const { items, invalidCount, rowCount } =
|
||||
const { items, invalidCount, rowCount, hasUnitColumn } =
|
||||
await parseWarehousePriceExcelBuffer(buf);
|
||||
|
||||
if (items.length === 0) {
|
||||
@@ -312,6 +367,9 @@ async function runParse() {
|
||||
rowCount > 5000
|
||||
? `共 ${rowCount} 行,已按上限处理前 5000 行。`
|
||||
: `共解析 ${items.length} 条有效行。`;
|
||||
if (!hasUnitColumn) {
|
||||
parseHint.value += '(未识别到单位列,单位保持原值)';
|
||||
}
|
||||
|
||||
const nextUnmatched: UnmatchedRow[] = [];
|
||||
const nextNew: typeof newRows.value = [];
|
||||
@@ -320,31 +378,41 @@ async function runParse() {
|
||||
let changedExistingCount = 0;
|
||||
|
||||
for (const row of items) {
|
||||
let matched = findWarehouseByNumber(row.drug_number);
|
||||
// 优先药品ID,避免同名/编码漂移误匹配
|
||||
let matched =
|
||||
findWarehouseByDrugId(row.drug_id) ||
|
||||
findWarehouseByNumber(row.drug_number);
|
||||
if (!matched) {
|
||||
matched = findWarehouseByName(row.drug_name);
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
nextTouched.add(matched.id);
|
||||
const orig = initialSnapshot.value[matched.id];
|
||||
nextLocal[matched.id] = {
|
||||
market_price: row.market_price,
|
||||
price: row.price,
|
||||
const orig = initialSnapshot.value[matched.id] ?? {
|
||||
market_price: parsePriceValue(matched.market_price),
|
||||
price: parsePriceValue(matched.price),
|
||||
unit_name: matched.unit_name ?? '',
|
||||
};
|
||||
if (
|
||||
orig &&
|
||||
isPriceChanged(orig, {
|
||||
market_price: row.market_price,
|
||||
price: row.price,
|
||||
})
|
||||
) {
|
||||
// 无单位列或单元格为空:保留原单位,避免误报单位变更
|
||||
const excelUnit = (row.unit_name ?? '').trim();
|
||||
const unitName =
|
||||
hasUnitColumn && excelUnit !== ''
|
||||
? excelUnit
|
||||
: (orig.unit_name ?? matched.unit_name ?? '');
|
||||
// 价格与快照对账,消化 Excel 一分钱浮点误差
|
||||
const nextPair = normalizePricePair({
|
||||
market_price: reconcileExcelPrice(row.market_price, orig.market_price),
|
||||
price: reconcileExcelPrice(row.price, orig.price),
|
||||
unit_name: unitName,
|
||||
});
|
||||
nextLocal[matched.id] = nextPair;
|
||||
if (isPriceChanged(orig, nextPair)) {
|
||||
changedExistingCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.drug_name || row.drug_number) {
|
||||
if (row.drug_name || row.drug_number || row.drug_id > 0) {
|
||||
tempIdCounter -= 1;
|
||||
nextNew.push({
|
||||
tempId: tempIdCounter,
|
||||
@@ -352,6 +420,7 @@ async function runParse() {
|
||||
drug_number: row.drug_number,
|
||||
market_price: row.market_price,
|
||||
price: row.price,
|
||||
unit_name: (row.unit_name ?? '').trim(),
|
||||
});
|
||||
} else {
|
||||
nextUnmatched.push(row);
|
||||
@@ -368,7 +437,7 @@ async function runParse() {
|
||||
} else if (nextNew.length > 0) {
|
||||
filterTab.value = 'new';
|
||||
} else {
|
||||
filterTab.value = 'all';
|
||||
filterTab.value = 'unchanged';
|
||||
}
|
||||
|
||||
if (invalidCount > 0) {
|
||||
@@ -377,6 +446,9 @@ async function runParse() {
|
||||
if (nextNew.length > 0) {
|
||||
message.info(`有 ${nextNew.length} 条药品将自动新增入库`);
|
||||
}
|
||||
if (changedExistingCount === 0 && nextNew.length === 0) {
|
||||
message.info('未检测到价格或单位变更');
|
||||
}
|
||||
|
||||
parsedReady.value = true;
|
||||
} catch {
|
||||
@@ -391,6 +463,7 @@ function syncRowFromGrid(row: PriceCompareRow) {
|
||||
const normalized = normalizePricePair({
|
||||
market_price: parsePriceValue(row.market_price),
|
||||
price: parsePriceValue(row.price),
|
||||
unit_name: row.unit_name,
|
||||
});
|
||||
if (row._isNew) {
|
||||
newRows.value = newRows.value.map((item) =>
|
||||
@@ -399,6 +472,7 @@ function syncRowFromGrid(row: PriceCompareRow) {
|
||||
...item,
|
||||
market_price: normalized.market_price,
|
||||
price: normalized.price,
|
||||
unit_name: normalized.unit_name ?? '',
|
||||
}
|
||||
: item,
|
||||
);
|
||||
@@ -435,6 +509,7 @@ async function handleRowSave(row: PriceCompareRow) {
|
||||
const saved = normalizePricePair({
|
||||
market_price: parsePriceValue(row.market_price),
|
||||
price: parsePriceValue(row.price),
|
||||
unit_name: row.unit_name,
|
||||
});
|
||||
|
||||
savingRowId.value = row.id;
|
||||
@@ -449,6 +524,7 @@ async function handleRowSave(row: PriceCompareRow) {
|
||||
drug_number: row.drug_number,
|
||||
market_price: saved.market_price,
|
||||
price: saved.price,
|
||||
unit_name: saved.unit_name,
|
||||
},
|
||||
],
|
||||
notify: false,
|
||||
@@ -462,6 +538,7 @@ async function handleRowSave(row: PriceCompareRow) {
|
||||
id: row.id,
|
||||
market_price: saved.market_price,
|
||||
price: saved.price,
|
||||
unit_name: saved.unit_name,
|
||||
},
|
||||
],
|
||||
creates: [],
|
||||
@@ -475,9 +552,17 @@ async function handleRowSave(row: PriceCompareRow) {
|
||||
...localPrices.value,
|
||||
[row.id]: { ...saved },
|
||||
};
|
||||
if (saved.unit_name) {
|
||||
knownUnitNames.value.add(saved.unit_name);
|
||||
}
|
||||
warehouseItems.value = warehouseItems.value.map((item) =>
|
||||
item.id === row.id
|
||||
? { ...item, market_price: saved.market_price, price: saved.price }
|
||||
? {
|
||||
...item,
|
||||
market_price: saved.market_price,
|
||||
price: saved.price,
|
||||
unit_name: saved.unit_name,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
@@ -499,12 +584,14 @@ async function runBatchApply() {
|
||||
const prices = normalizePricePair({
|
||||
market_price: row.market_price,
|
||||
price: row.price,
|
||||
unit_name: row.unit_name,
|
||||
});
|
||||
return {
|
||||
drug_name: row.drug_name,
|
||||
drug_number: row.drug_number,
|
||||
market_price: prices.market_price,
|
||||
price: prices.price,
|
||||
unit_name: prices.unit_name,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -522,8 +609,12 @@ async function runBatchApply() {
|
||||
drug_name: c.drug_name,
|
||||
drug_number: c.drug_number,
|
||||
},
|
||||
{ market_price: c.market_price, price: c.price },
|
||||
{ market_price: 0, price: 0 },
|
||||
{
|
||||
market_price: c.market_price,
|
||||
price: c.price,
|
||||
unit_name: c.unit_name,
|
||||
},
|
||||
{ market_price: 0, price: 0, unit_name: '' },
|
||||
true,
|
||||
),
|
||||
),
|
||||
@@ -541,11 +632,13 @@ async function runBatchApply() {
|
||||
const prices = normalizePricePair({
|
||||
market_price: parsePriceValue(row.market_price),
|
||||
price: parsePriceValue(row.price),
|
||||
unit_name: row.unit_name,
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
market_price: prices.market_price,
|
||||
price: prices.price,
|
||||
unit_name: prices.unit_name,
|
||||
};
|
||||
}),
|
||||
creates,
|
||||
@@ -568,7 +661,7 @@ async function runBatchApply() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalComp title="批量改价预览(中药总仓库)">
|
||||
<ModalComp title="批量改价预览(总仓库)">
|
||||
<!-- Wrap main content in transition for smooth View swapping -->
|
||||
<Transition name="fade-slide" mode="out-in">
|
||||
|
||||
@@ -699,6 +792,7 @@ async function runBatchApply() {
|
||||
:rows="compareRows"
|
||||
:saving-row-id="savingRowId"
|
||||
:unmatched-rows="unmatchedRows"
|
||||
:show-unit="true"
|
||||
@cell-change="syncRowFromGrid"
|
||||
@row-save="handleRowSave"
|
||||
/>
|
||||
|
||||
@@ -25,8 +25,8 @@ export function createGridOptions(productType: number): VxeGridProps<RowType> {
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'drug.drug_name', align: 'left', title: '药品名称' },
|
||||
{ field: 'drug.pinyin_simple', title: '拼音' },
|
||||
{ field: 'drug.drug_name', align: 'left', title: '药品名称', minWidth: 160, slots: { default: 'drug_name' } },
|
||||
{ field: 'drug.pinyin_simple', title: '拼音', minWidth: 120, slots: { default: 'drug_pinyin' } },
|
||||
{
|
||||
field: 'image',
|
||||
align: 'left',
|
||||
|
||||
@@ -13,9 +13,10 @@ import { TableAction } from '#/components/table-action';
|
||||
import { downloadByData } from '#/util/tool';
|
||||
|
||||
import { useWarehouseDrugTypeRoute } from '../shared/useWarehouseDrugTypeRoute';
|
||||
import WarehouseDrugNameCell from '../shared/components/WarehouseDrugNameCell.vue';
|
||||
import {
|
||||
deleteWarehouseDrugManagement,
|
||||
exportWarehouseDrugManagementApi,
|
||||
getWarehouseExportDataApi,
|
||||
updateWarehouseDrugManagementStatusApi
|
||||
} from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
@@ -26,6 +27,10 @@ import ViewModeFloatButton from './components/ViewModeFloatButton.vue';
|
||||
import WarehouseDrugExcelView from './components/WarehouseDrugExcelView.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { createGridOptions } from './config/table';
|
||||
import {
|
||||
exportWarehouseListExcel,
|
||||
type WarehouseExportRow,
|
||||
} from './utils/exportWarehousePriceExcel';
|
||||
import {
|
||||
readWarehouseViewMode,
|
||||
writeWarehouseViewMode,
|
||||
@@ -129,13 +134,26 @@ const deleteApi = (row: any) => {
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 导出
|
||||
* 导出:后端 export-data 出 JSON,前端 ExcelJS 生成文件(对齐订单导出)
|
||||
*/
|
||||
const passApplication = () => {
|
||||
exportWarehouseDrugManagementApi({ type: productType.value }).then((res) => {
|
||||
downloadByData(res.data, '萧康云医-仓库商品导出.xlsx');
|
||||
message.success('导出成功!');
|
||||
});
|
||||
const passApplication = async () => {
|
||||
try {
|
||||
const res = await getWarehouseExportDataApi({ type: productType.value });
|
||||
const rows = (res?.rows ?? []) as WarehouseExportRow[];
|
||||
if (rows.length === 0) {
|
||||
message.warning('暂无药品数据');
|
||||
return;
|
||||
}
|
||||
const buffer = await exportWarehouseListExcel(rows, '仓库商品');
|
||||
downloadByData(
|
||||
buffer,
|
||||
'萧康云医-仓库商品导出.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
message.success(`导出成功(${res?.total ?? rows.length} 条)`);
|
||||
} catch {
|
||||
message.error('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openExcelUploadModal = (type = 1) => {
|
||||
@@ -215,13 +233,14 @@ function onSpreadsheetConfigSaved() {
|
||||
// auth: ['超级西(中成)药', 'sys:user:save'],
|
||||
onClick: openExcelUploadModal.bind(null, 1),
|
||||
},
|
||||
{
|
||||
label: '批量修改价格',
|
||||
type: 'primary',
|
||||
icon: 'ix:export-check',
|
||||
// auth: ['超级西(中成)药', 'sys:user:save'],
|
||||
onClick: openExcelUploadModal.bind(null, 2),
|
||||
},
|
||||
// 旧「批量修改价格」直传入口已停用,改走「批量改价预览」
|
||||
// {
|
||||
// label: '批量修改价格',
|
||||
// type: 'primary',
|
||||
// icon: 'ix:export-check',
|
||||
// // auth: ['超级西(中成)药', 'sys:user:save'],
|
||||
// onClick: openExcelUploadModal.bind(null, 2),
|
||||
// },
|
||||
{
|
||||
label: '批量改价预览',
|
||||
type: 'primary',
|
||||
@@ -268,6 +287,20 @@ function onSpreadsheetConfigSaved() {
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<!-- 药名/拼音:搜索命中别名时展示匹配别名(对齐开方) -->
|
||||
<template #drug_name="{ row }">
|
||||
<WarehouseDrugNameCell :row="row" />
|
||||
</template>
|
||||
<template #drug_pinyin="{ row }">
|
||||
<span>
|
||||
{{
|
||||
row.matched_alias_pinyin ||
|
||||
row.drug?.matched_alias_pinyin ||
|
||||
row.drug?.pinyin_simple ||
|
||||
'-'
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<Image
|
||||
v-if="rowProductImageSrc(row)"
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* 总仓 ExcelJS 导出
|
||||
* - list:列表「导出」完整列(来自 export-data)
|
||||
* - price:改价模板列(预览弹窗下载模板)
|
||||
* 价格/单位一律文本原样写出,避免浮点改写
|
||||
*/
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
import type { WarehouseBatchPriceItem } from '../api';
|
||||
@@ -9,33 +15,73 @@ const THIN_BORDER: Partial<ExcelJS.Borders> = {
|
||||
right: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
};
|
||||
|
||||
const HEADERS = [
|
||||
/** export-data 行结构 */
|
||||
export type WarehouseExportRow = {
|
||||
drug_id?: number | string;
|
||||
drug_number?: string;
|
||||
drug_name?: string;
|
||||
pinyin_simple?: string;
|
||||
unit_name?: string;
|
||||
type_text?: string;
|
||||
status_text?: string;
|
||||
market_price?: string | number;
|
||||
price?: string | number;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
const LIST_HEADERS = [
|
||||
'药品ID',
|
||||
'商品编码',
|
||||
'通用名',
|
||||
'拼音首拼',
|
||||
'单位',
|
||||
'类型',
|
||||
'状态',
|
||||
'每克供货价',
|
||||
'每克零售价',
|
||||
'创建时间',
|
||||
] as const;
|
||||
|
||||
const PRICE_HEADERS = [
|
||||
'药品ID',
|
||||
'商品编码',
|
||||
'通用名',
|
||||
'拼音首拼',
|
||||
'单位',
|
||||
'每克供货价',
|
||||
'每克零售价',
|
||||
] as const;
|
||||
|
||||
export async function exportWarehousePriceExcel(
|
||||
items: WarehouseBatchPriceItem[],
|
||||
): Promise<ArrayBuffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('中药价格');
|
||||
function priceToCellText(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
sheet.columns = [
|
||||
{ width: 10 },
|
||||
{ width: 14 },
|
||||
{ width: 18 },
|
||||
{ width: 12 },
|
||||
{ width: 14 },
|
||||
{ width: 14 },
|
||||
];
|
||||
function resolveUnitName(item: { unit_name?: string; drug?: { unit?: { name?: string } } }): string {
|
||||
const direct = (item.unit_name ?? '').trim();
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const nested = item.drug?.unit?.name;
|
||||
return nested != null ? String(nested).trim() : '';
|
||||
}
|
||||
|
||||
const headerRow = sheet.addRow([...HEADERS]);
|
||||
headerRow.height = 28;
|
||||
headerRow.eachCell((cell) => {
|
||||
/**
|
||||
* 强制单元格为文本:ExcelJS addRow 遇「0.14」可能自动转 number,
|
||||
* 再经 IEEE 回读会漂成 0.139999… / 0.90,造成假变更
|
||||
*/
|
||||
function forceTextCell(cell: ExcelJS.Cell, text: string) {
|
||||
cell.value = text;
|
||||
cell.numFmt = '@';
|
||||
cell.border = THIN_BORDER;
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
}
|
||||
|
||||
function styleHeader(row: ExcelJS.Row) {
|
||||
row.height = 28;
|
||||
row.eachCell((cell) => {
|
||||
cell.font = { bold: true };
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
@@ -45,38 +91,99 @@ export async function exportWarehousePriceExcel(
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
cell.border = THIN_BORDER;
|
||||
});
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const row = sheet.addRow([
|
||||
item.drug_id,
|
||||
item.drug_number ?? '',
|
||||
item.drug_name ?? '',
|
||||
item.pinyin_simple ?? '',
|
||||
item.market_price ?? 0,
|
||||
item.price ?? 0,
|
||||
]);
|
||||
row.eachCell((cell, colNumber) => {
|
||||
cell.border = THIN_BORDER;
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
if (colNumber === 2) {
|
||||
cell.numFmt = '@';
|
||||
} else if (colNumber >= 5) {
|
||||
cell.numFmt = '#,##0.00';
|
||||
}
|
||||
});
|
||||
if (index % 2 === 1) {
|
||||
row.eachCell((cell) => {
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFF2F2F2' },
|
||||
};
|
||||
});
|
||||
}
|
||||
function styleDataRowFill(row: ExcelJS.Row, index: number) {
|
||||
if (index % 2 !== 1) {
|
||||
return;
|
||||
}
|
||||
row.eachCell((cell) => {
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFF2F2F2' },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表导出:export-data → ExcelJS
|
||||
*/
|
||||
export async function exportWarehouseListExcel(
|
||||
rows: WarehouseExportRow[],
|
||||
sheetName = '仓库商品',
|
||||
): Promise<ArrayBuffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet(sheetName);
|
||||
sheet.columns = [
|
||||
{ width: 10 },
|
||||
{ width: 14 },
|
||||
{ width: 18 },
|
||||
{ width: 12 },
|
||||
{ width: 10 },
|
||||
{ width: 12 },
|
||||
{ width: 10 },
|
||||
{ width: 14 },
|
||||
{ width: 14 },
|
||||
{ width: 18 },
|
||||
];
|
||||
styleHeader(sheet.addRow([...LIST_HEADERS]));
|
||||
rows.forEach((item, index) => {
|
||||
const row = sheet.addRow([]);
|
||||
const values = [
|
||||
String(item.drug_id ?? ''),
|
||||
String(item.drug_number ?? ''),
|
||||
String(item.drug_name ?? ''),
|
||||
String(item.pinyin_simple ?? ''),
|
||||
resolveUnitName(item),
|
||||
String(item.type_text ?? ''),
|
||||
String(item.status_text ?? ''),
|
||||
priceToCellText(item.market_price),
|
||||
priceToCellText(item.price),
|
||||
String(item.created_at ?? ''),
|
||||
];
|
||||
values.forEach((text, i) => forceTextCell(row.getCell(i + 1), text));
|
||||
styleDataRowFill(row, index);
|
||||
});
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 改价模板导出(预览弹窗)
|
||||
*/
|
||||
export async function exportWarehousePriceExcel(
|
||||
items: WarehouseBatchPriceItem[] | WarehouseExportRow[],
|
||||
sheetName = '仓库改价',
|
||||
): Promise<ArrayBuffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet(sheetName);
|
||||
sheet.columns = [
|
||||
{ width: 10 },
|
||||
{ width: 14 },
|
||||
{ width: 18 },
|
||||
{ width: 12 },
|
||||
{ width: 10 },
|
||||
{ width: 14 },
|
||||
{ width: 14 },
|
||||
];
|
||||
styleHeader(sheet.addRow([...PRICE_HEADERS]));
|
||||
items.forEach((item, index) => {
|
||||
const row = sheet.addRow([]);
|
||||
const values = [
|
||||
String(item.drug_id ?? ''),
|
||||
String(item.drug_number ?? ''),
|
||||
String(item.drug_name ?? ''),
|
||||
String(item.pinyin_simple ?? ''),
|
||||
resolveUnitName(item),
|
||||
priceToCellText(item.market_price),
|
||||
priceToCellText(item.price),
|
||||
];
|
||||
values.forEach((text, i) => forceTextCell(row.getCell(i + 1), text));
|
||||
styleDataRowFill(row, index);
|
||||
});
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,71 @@
|
||||
/**
|
||||
* 总仓改价 Excel 解析
|
||||
* - 解析药品ID,便于精确匹配
|
||||
* - 价格优先读单元格「原始字符串 / 显示文本」,避免 IEEE 浮点
|
||||
*/
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
import { formatPriceLikeBackend, parsePriceValue } from './priceCompare';
|
||||
import { roundPrice4 } from './priceCompare';
|
||||
|
||||
const MAX_ROWS = 5000;
|
||||
|
||||
export type ParsedWarehousePriceRow = {
|
||||
drug_id: number;
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
market_price: number;
|
||||
price: number;
|
||||
unit_name: string;
|
||||
};
|
||||
|
||||
export type ParseWarehousePriceResult = {
|
||||
items: ParsedWarehousePriceRow[];
|
||||
invalidCount: number;
|
||||
rowCount: number;
|
||||
hasUnitColumn: boolean;
|
||||
};
|
||||
|
||||
function normalizeHeader(value: string): string {
|
||||
return value.replace(/\s+/g, '').replace(/\u3000/g, '');
|
||||
}
|
||||
|
||||
function cellRawString(cell: ExcelJS.Cell): string {
|
||||
const v = cell.value;
|
||||
if (v === null || v === undefined) {
|
||||
return '';
|
||||
}
|
||||
// 导出时我们写的是字符串,打开后若仍是字符串则最可信
|
||||
if (typeof v === 'string') {
|
||||
return v.trim().replace(/,/g, '').replace(/¥/g, '');
|
||||
}
|
||||
if (typeof v === 'number') {
|
||||
// 不用直接 Number 展示;用足量有效位再交给 roundPrice4
|
||||
return String(v);
|
||||
}
|
||||
if (typeof v === 'object') {
|
||||
if ('result' in v && v.result !== null && v.result !== undefined) {
|
||||
return String((v as { result: unknown }).result).trim();
|
||||
}
|
||||
if ('text' in v && (v as { text?: string }).text != null) {
|
||||
return String((v as { text?: string }).text).trim();
|
||||
}
|
||||
if ('richText' in v && Array.isArray((v as { richText: { text: string }[] }).richText)) {
|
||||
return (v as { richText: { text: string }[] }).richText
|
||||
.map((p) => p.text)
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
const display = String(cell.text ?? '').trim().replace(/,/g, '');
|
||||
return display;
|
||||
}
|
||||
|
||||
function normalizeCell(value: ExcelJS.CellValue): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'object' && 'text' in value) {
|
||||
return String(value.text ?? '').trim();
|
||||
if (typeof value === 'object' && value && 'text' in value) {
|
||||
return String((value as { text?: string }).text ?? '').trim();
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? String(value) : String(value).trim();
|
||||
@@ -30,14 +73,12 @@ function normalizeCell(value: ExcelJS.CellValue): string {
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string): string {
|
||||
return value.replace(/\s+/g, '').replace(/\u3000/g, '');
|
||||
}
|
||||
|
||||
const NUMBER_ALIASES = ['商品编码', '药品编码', '编码', 'drug_number'];
|
||||
const DRUG_ID_ALIASES = ['药品ID', '药品Id', 'drug_id', 'DrugId'];
|
||||
const NUMBER_ALIASES = ['商品编码', '药品编码', 'ErpCode', 'drug_number'];
|
||||
const NAME_ALIASES = ['通用名', '药品名称', '药名', 'drug_name'];
|
||||
const MARKET_ALIASES = ['每克供货价', '供货价', 'market_price'];
|
||||
const PRICE_ALIASES = ['每克零售价', '建议售价', '零售价', 'price'];
|
||||
const PRICE_ALIASES = ['每克零售价', '建议售价', '零售价', '当前零售价', 'price'];
|
||||
const UNIT_ALIASES = ['单位', '计量单位', 'unit', 'unit_name'];
|
||||
|
||||
function buildHeaderIndexMap(headerRow: ExcelJS.Row): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
@@ -67,14 +108,23 @@ function getCellNumber(row: ExcelJS.Row, col?: number): number {
|
||||
if (!col) {
|
||||
return 0;
|
||||
}
|
||||
return parsePriceValue(row.getCell(col).value);
|
||||
const raw = cellRawString(row.getCell(col));
|
||||
if (raw === '' || !Number.isFinite(Number(raw))) {
|
||||
return 0;
|
||||
}
|
||||
return roundPrice4(raw);
|
||||
}
|
||||
|
||||
function getCellText(row: ExcelJS.Row, col?: number): string {
|
||||
if (!col) {
|
||||
return '';
|
||||
}
|
||||
return normalizeCell(row.getCell(col).value);
|
||||
const cell = row.getCell(col);
|
||||
const fromValue = normalizeCell(cell.value);
|
||||
if (fromValue) {
|
||||
return fromValue;
|
||||
}
|
||||
return String(cell.text ?? '').trim();
|
||||
}
|
||||
|
||||
function rowIsEmpty(row: ExcelJS.Row): boolean {
|
||||
@@ -95,16 +145,19 @@ export async function parseWarehousePriceExcelBuffer(
|
||||
|
||||
const sheet = workbook.worksheets[0];
|
||||
if (!sheet) {
|
||||
return { items: [], invalidCount: 0, rowCount: 0 };
|
||||
return { items: [], invalidCount: 0, rowCount: 0, hasUnitColumn: false };
|
||||
}
|
||||
|
||||
const headerRow = sheet.getRow(1);
|
||||
const headerMap = buildHeaderIndexMap(headerRow);
|
||||
|
||||
const drugIdCol = pickColumn(headerMap, DRUG_ID_ALIASES);
|
||||
const numberCol = pickColumn(headerMap, NUMBER_ALIASES);
|
||||
const nameCol = pickColumn(headerMap, NAME_ALIASES);
|
||||
const marketCol = pickColumn(headerMap, MARKET_ALIASES);
|
||||
const priceCol = pickColumn(headerMap, PRICE_ALIASES);
|
||||
const unitCol = pickColumn(headerMap, UNIT_ALIASES);
|
||||
const hasUnitColumn = !!unitCol;
|
||||
|
||||
const items: ParsedWarehousePriceRow[] = [];
|
||||
let invalidCount = 0;
|
||||
@@ -116,25 +169,37 @@ export async function parseWarehousePriceExcelBuffer(
|
||||
continue;
|
||||
}
|
||||
|
||||
const drugIdRaw = getCellText(row, drugIdCol);
|
||||
const drugId = Number.parseInt(drugIdRaw, 10) || 0;
|
||||
const drugNumber = getCellText(row, numberCol);
|
||||
const drugName = getCellText(row, nameCol);
|
||||
const marketPrice = getCellNumber(row, marketCol);
|
||||
const price = getCellNumber(row, priceCol);
|
||||
const unitName = getCellText(row, unitCol);
|
||||
|
||||
if (!drugName && !drugNumber && marketPrice === 0 && price === 0) {
|
||||
if (
|
||||
drugId <= 0 &&
|
||||
!drugName &&
|
||||
!drugNumber &&
|
||||
marketPrice === 0 &&
|
||||
price === 0 &&
|
||||
!unitName
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!drugName && !drugNumber) {
|
||||
if (drugId <= 0 && !drugName && !drugNumber) {
|
||||
invalidCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
drug_id: drugId,
|
||||
drug_name: drugName,
|
||||
drug_number: drugNumber,
|
||||
market_price: formatPriceLikeBackend(marketPrice),
|
||||
price: formatPriceLikeBackend(price),
|
||||
market_price: marketPrice,
|
||||
price,
|
||||
unit_name: unitName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,5 +207,6 @@ export async function parseWarehousePriceExcelBuffer(
|
||||
items,
|
||||
invalidCount,
|
||||
rowCount: Math.max(0, rowCount - 1),
|
||||
hasUnitColumn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,60 @@
|
||||
/**
|
||||
* 仓库改价对比工具
|
||||
* 库内价格 decimal(10,4)。预览对比禁止 format_price。
|
||||
* Excel IEEE 浮点常见把 0.91 读成 ≈0.8999→0.90,需在合并时与快照对账。
|
||||
*/
|
||||
export type PricePair = {
|
||||
market_price: number;
|
||||
price: number;
|
||||
unit_name?: string;
|
||||
};
|
||||
|
||||
export function parsePriceValue(value: unknown): number {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const cleaned = value.trim().replace(/,/g, '').replace(/¥/g, '');
|
||||
if (cleaned === '') {
|
||||
return 0;
|
||||
}
|
||||
const num = Number(cleaned);
|
||||
return Number.isFinite(num) ? num : 0;
|
||||
}
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : 0;
|
||||
}
|
||||
|
||||
/** 对齐库字段 decimal(10,4) */
|
||||
export function roundPrice4(value: unknown): number {
|
||||
return Math.round(parsePriceValue(value) * 10000) / 10000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与后端 format_price 一致:第三位小数非 0 则分位进 1,再保留两位小数。
|
||||
* Excel 读入价与快照对账:未改到「分」或仅 1 分浮点漂移时,强制用快照价
|
||||
* 避免 0.91→0.90、0.09→0.08 这类误报
|
||||
*/
|
||||
export function reconcileExcelPrice(excel: unknown, snapshot: unknown): number {
|
||||
const e = roundPrice4(excel);
|
||||
const s = roundPrice4(snapshot);
|
||||
if (e === s) {
|
||||
return s;
|
||||
}
|
||||
const fenE = Math.round(e * 100);
|
||||
const fenS = Math.round(s * 100);
|
||||
// 分位相同 → 未改价
|
||||
if (fenE === fenS) {
|
||||
return s;
|
||||
}
|
||||
// 仅差 1 分且更像浮点下漂/上漂,视为未改(用户真改 1 分极少见于每克价误触)
|
||||
if (Math.abs(fenE - fenS) === 1 && Math.abs(e - s) <= 0.011) {
|
||||
return s;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与后端 format_price 一致:仅确认落库时用
|
||||
*/
|
||||
export function formatPriceLikeBackend(value: unknown): number {
|
||||
const price = parsePriceValue(value);
|
||||
@@ -26,17 +68,20 @@ export function formatPriceLikeBackend(value: unknown): number {
|
||||
|
||||
export function normalizePricePair(pair: PricePair): PricePair {
|
||||
return {
|
||||
market_price: formatPriceLikeBackend(pair.market_price),
|
||||
price: formatPriceLikeBackend(pair.price),
|
||||
market_price: roundPrice4(pair.market_price),
|
||||
price: roundPrice4(pair.price),
|
||||
unit_name: (pair.unit_name ?? '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toPriceUnits(value: unknown): number {
|
||||
return Math.round(formatPriceLikeBackend(value) * 100);
|
||||
return Math.round(roundPrice4(value) * 10000);
|
||||
}
|
||||
|
||||
export function formatPriceDisplay(value: unknown): string {
|
||||
return formatPriceLikeBackend(value).toFixed(2);
|
||||
const n = roundPrice4(value);
|
||||
const fixed = n.toFixed(4);
|
||||
return fixed.replace(/\.?0+$/, '') || '0';
|
||||
}
|
||||
|
||||
export type PriceDelta = 'up' | 'down' | 'same';
|
||||
@@ -57,6 +102,13 @@ export function isFieldChanged(orig: number, next: number): boolean {
|
||||
return getPriceDelta(orig, next) !== 'same';
|
||||
}
|
||||
|
||||
export function isUnitChanged(
|
||||
orig: string | undefined,
|
||||
next: string | undefined,
|
||||
): boolean {
|
||||
return (orig ?? '').trim() !== (next ?? '').trim();
|
||||
}
|
||||
|
||||
export function isPriceChanged(
|
||||
initial: PricePair | undefined,
|
||||
current: PricePair,
|
||||
@@ -66,7 +118,8 @@ export function isPriceChanged(
|
||||
}
|
||||
return (
|
||||
isFieldChanged(initial.market_price, current.market_price) ||
|
||||
isFieldChanged(initial.price, current.price)
|
||||
isFieldChanged(initial.price, current.price) ||
|
||||
isUnitChanged(initial.unit_name, current.unit_name)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isFieldChanged,
|
||||
isMarketGtPrice,
|
||||
isPriceChanged,
|
||||
isUnitChanged,
|
||||
type PriceDelta,
|
||||
type PricePair,
|
||||
} from '../../admin/utils/priceCompare';
|
||||
@@ -18,6 +19,8 @@ export type PriceCompareFilterTab =
|
||||
| 'changed'
|
||||
| 'unchanged'
|
||||
| 'marketGtPrice'
|
||||
| 'unitChanged'
|
||||
| 'priceLocked'
|
||||
| 'new'
|
||||
| 'unmatched';
|
||||
|
||||
@@ -29,12 +32,19 @@ export type PriceCompareRow = {
|
||||
pinyin_simple?: string;
|
||||
orig_market_price: number;
|
||||
orig_price: number;
|
||||
orig_unit_name?: string;
|
||||
market_price: number;
|
||||
price: number;
|
||||
unit_name?: string;
|
||||
_changed: boolean;
|
||||
_marketGtPrice: boolean;
|
||||
_unitChanged?: boolean;
|
||||
/** 单位名不在字典中,确认时将自动新建 */
|
||||
_unitPendingCreate?: boolean;
|
||||
_isNew: boolean;
|
||||
_fromExcel?: boolean;
|
||||
/** 配送仓锁定,不可改价(门店) */
|
||||
_priceLocked?: boolean;
|
||||
_marketDelta: PriceDelta;
|
||||
_priceDelta: PriceDelta;
|
||||
_marketChanged: boolean;
|
||||
@@ -46,6 +56,7 @@ export type UnmatchedRow = {
|
||||
drug_number: string;
|
||||
market_price: number;
|
||||
price: number;
|
||||
unit_name?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -54,11 +65,26 @@ const props = withDefaults(
|
||||
unmatchedRows?: UnmatchedRow[];
|
||||
filterTab?: PriceCompareFilterTab;
|
||||
savingRowId?: number | null;
|
||||
/** 是否展示单位编辑(平台 true,门店 false) */
|
||||
showUnit?: boolean;
|
||||
/** 是否展示供货价(门店仅零售价时为 false) */
|
||||
showMarketPrice?: boolean;
|
||||
/** 售价列文案 */
|
||||
priceLabel?: string;
|
||||
/** 是否显示单行保存按钮 */
|
||||
showRowSave?: boolean;
|
||||
/** 是否展示「不可改价」筛选 Tab */
|
||||
showPriceLockedTab?: boolean;
|
||||
}>(),
|
||||
{
|
||||
unmatchedRows: () => [],
|
||||
filterTab: 'all',
|
||||
savingRowId: null,
|
||||
showUnit: false,
|
||||
showMarketPrice: true,
|
||||
priceLabel: '售价',
|
||||
showRowSave: true,
|
||||
showPriceLockedTab: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -84,12 +110,56 @@ watch(innerFilterTab, (v) => {
|
||||
|
||||
const tabCounts = computed(() => {
|
||||
const all = props.rows.length;
|
||||
const changed = props.rows.filter((r) => r._changed).length;
|
||||
const unchanged = all - changed;
|
||||
const changed = props.rows.filter((r) => r._changed && !r._priceLocked).length;
|
||||
const unchanged = props.rows.filter(
|
||||
(r) => !r._changed && !r._priceLocked && !r._isNew,
|
||||
).length;
|
||||
const marketGtPrice = props.rows.filter((r) => r._marketGtPrice).length;
|
||||
const unitChanged = props.rows.filter((r) => r._unitChanged).length;
|
||||
const priceLocked = props.rows.filter((r) => r._priceLocked).length;
|
||||
const newCount = props.rows.filter((r) => r._isNew).length;
|
||||
const unmatched = props.unmatchedRows.length;
|
||||
return { all, changed, unchanged, marketGtPrice, new: newCount, unmatched };
|
||||
return {
|
||||
all,
|
||||
changed,
|
||||
unchanged,
|
||||
marketGtPrice,
|
||||
unitChanged,
|
||||
priceLocked,
|
||||
new: newCount,
|
||||
unmatched,
|
||||
};
|
||||
});
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const opts: { label: string; value: PriceCompareFilterTab }[] = [
|
||||
{ label: `全部 ${tabCounts.value.all}`, value: 'all' },
|
||||
{ label: `有修改 ${tabCounts.value.changed}`, value: 'changed' },
|
||||
{ label: `无修改 ${tabCounts.value.unchanged}`, value: 'unchanged' },
|
||||
];
|
||||
if (props.showMarketPrice) {
|
||||
opts.push({
|
||||
label: `供价超售价 ${tabCounts.value.marketGtPrice}`,
|
||||
value: 'marketGtPrice',
|
||||
});
|
||||
}
|
||||
if (props.showUnit) {
|
||||
opts.push({
|
||||
label: `单位变更 ${tabCounts.value.unitChanged}`,
|
||||
value: 'unitChanged',
|
||||
});
|
||||
}
|
||||
if (props.showPriceLockedTab) {
|
||||
opts.push({
|
||||
label: `不可改价 ${tabCounts.value.priceLocked}`,
|
||||
value: 'priceLocked',
|
||||
});
|
||||
}
|
||||
opts.push(
|
||||
{ label: `待新增 ${tabCounts.value.new}`, value: 'new' },
|
||||
{ label: `未匹配 ${tabCounts.value.unmatched}`, value: 'unmatched' },
|
||||
);
|
||||
return opts;
|
||||
});
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
@@ -97,11 +167,15 @@ const filteredRows = computed(() => {
|
||||
let list = props.rows;
|
||||
|
||||
if (innerFilterTab.value === 'changed') {
|
||||
list = list.filter((r) => r._changed);
|
||||
list = list.filter((r) => r._changed && !r._priceLocked);
|
||||
} else if (innerFilterTab.value === 'unchanged') {
|
||||
list = list.filter((r) => !r._changed);
|
||||
list = list.filter((r) => !r._changed && !r._priceLocked && !r._isNew);
|
||||
} else if (innerFilterTab.value === 'marketGtPrice') {
|
||||
list = list.filter((r) => r._marketGtPrice);
|
||||
} else if (innerFilterTab.value === 'unitChanged') {
|
||||
list = list.filter((r) => r._unitChanged);
|
||||
} else if (innerFilterTab.value === 'priceLocked') {
|
||||
list = list.filter((r) => r._priceLocked);
|
||||
} else if (innerFilterTab.value === 'new') {
|
||||
list = list.filter((r) => r._isNew);
|
||||
}
|
||||
@@ -139,7 +213,11 @@ function cardClass(row: PriceCompareRow): string[] {
|
||||
const classes = [
|
||||
'relative group rounded-xl border p-3 shadow-sm transition-all duration-300 ease-out hover:-translate-y-1 hover:shadow-md dark:bg-gray-800/80',
|
||||
];
|
||||
if (row._isNew) {
|
||||
if (row._priceLocked) {
|
||||
classes.push(
|
||||
'border-orange-200 bg-orange-50/40 dark:border-orange-500/30 dark:bg-orange-900/10',
|
||||
);
|
||||
} else if (row._isNew) {
|
||||
classes.push('border-blue-200 bg-blue-50/30 dark:border-blue-500/30 dark:bg-blue-900/10');
|
||||
} else if (row._marketGtPrice) {
|
||||
classes.push(
|
||||
@@ -159,10 +237,12 @@ function refreshRowFlags(row: PriceCompareRow) {
|
||||
const snapshot: PricePair = {
|
||||
market_price: row.orig_market_price,
|
||||
price: row.orig_price,
|
||||
unit_name: row.orig_unit_name,
|
||||
};
|
||||
const current: PricePair = {
|
||||
market_price: row.market_price,
|
||||
price: row.price,
|
||||
unit_name: row.unit_name,
|
||||
};
|
||||
row._marketDelta = getPriceDelta(snapshot.market_price, current.market_price);
|
||||
row._priceDelta = getPriceDelta(snapshot.price, current.price);
|
||||
@@ -171,6 +251,7 @@ function refreshRowFlags(row: PriceCompareRow) {
|
||||
current.market_price,
|
||||
);
|
||||
row._priceChanged = isFieldChanged(snapshot.price, current.price);
|
||||
row._unitChanged = isUnitChanged(snapshot.unit_name, current.unit_name);
|
||||
row._changed = row._isNew || isPriceChanged(snapshot, current);
|
||||
row._marketGtPrice = isMarketGtPrice(current);
|
||||
}
|
||||
@@ -185,6 +266,12 @@ function onPriceChange(
|
||||
emit('cellChange', { ...row });
|
||||
}
|
||||
|
||||
function onUnitChange(row: PriceCompareRow, value: string) {
|
||||
row.unit_name = value;
|
||||
refreshRowFlags(row);
|
||||
emit('cellChange', { ...row });
|
||||
}
|
||||
|
||||
function handleRowSave(row: PriceCompareRow) {
|
||||
emit('rowSave', row);
|
||||
}
|
||||
@@ -209,17 +296,7 @@ function handleRowSave(row: PriceCompareRow) {
|
||||
<Segmented
|
||||
v-model:value="innerFilterTab"
|
||||
class="mb-4 flex-shrink-0 flex-wrap !rounded-lg !p-1 shadow-sm"
|
||||
:options="[
|
||||
{ label: `全部 ${tabCounts.all}`, value: 'all' },
|
||||
{ label: `有修改 ${tabCounts.changed}`, value: 'changed' },
|
||||
{ label: `无修改 ${tabCounts.unchanged}`, value: 'unchanged' },
|
||||
{
|
||||
label: `供价超售价 ${tabCounts.marketGtPrice}`,
|
||||
value: 'marketGtPrice',
|
||||
},
|
||||
{ label: `待新增 ${tabCounts.new}`, value: 'new' },
|
||||
{ label: `未匹配 ${tabCounts.unmatched}`, value: 'unmatched' },
|
||||
]"
|
||||
:options="filterOptions"
|
||||
/>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
@@ -264,14 +341,30 @@ function handleRowSave(row: PriceCompareRow) {
|
||||
</div>
|
||||
|
||||
<!-- Status Tags -->
|
||||
<div v-if="row._isNew" class="mb-2">
|
||||
<Tag color="blue" class="!mr-0 !border-blue-200 !text-[11px] shadow-sm">待新增</Tag>
|
||||
<div v-if="row._isNew || row._priceLocked" class="mb-2 flex flex-wrap gap-1">
|
||||
<Tag
|
||||
v-if="row._isNew"
|
||||
color="blue"
|
||||
class="!mr-0 !border-blue-200 !text-[11px] shadow-sm"
|
||||
>
|
||||
待新增
|
||||
</Tag>
|
||||
<Tag
|
||||
v-if="row._priceLocked"
|
||||
color="orange"
|
||||
class="!mr-0 !text-[11px] shadow-sm"
|
||||
>
|
||||
不可改价
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<!-- Price Inputs Area -->
|
||||
<div class="space-y-2.5 text-xs">
|
||||
<!-- Market Price Row -->
|
||||
<div class="flex items-center justify-between group/row">
|
||||
<div
|
||||
v-if="showMarketPrice"
|
||||
class="flex items-center justify-between group/row"
|
||||
>
|
||||
<span class="w-12 flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">
|
||||
供货价
|
||||
</span>
|
||||
@@ -288,6 +381,7 @@ function handleRowSave(row: PriceCompareRow) {
|
||||
:precision="4"
|
||||
:step="0.0001"
|
||||
size="small"
|
||||
:disabled="!!row._priceLocked"
|
||||
:value="row.market_price"
|
||||
@update:value="
|
||||
(v) => onPriceChange(row, 'market_price', v as number)
|
||||
@@ -307,7 +401,7 @@ function handleRowSave(row: PriceCompareRow) {
|
||||
<!-- Sell Price Row -->
|
||||
<div class="flex items-center justify-between group/row">
|
||||
<span class="w-12 flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">
|
||||
售价
|
||||
{{ priceLabel }}
|
||||
</span>
|
||||
<div class="flex flex-1 items-center justify-end gap-2">
|
||||
<template v-if="!row._isNew">
|
||||
@@ -322,6 +416,7 @@ function handleRowSave(row: PriceCompareRow) {
|
||||
:precision="4"
|
||||
:step="0.0001"
|
||||
size="small"
|
||||
:disabled="!!row._priceLocked"
|
||||
:value="row.price"
|
||||
@update:value="
|
||||
(v) => onPriceChange(row, 'price', v as number)
|
||||
@@ -337,6 +432,47 @@ function handleRowSave(row: PriceCompareRow) {
|
||||
<span v-else class="w-4"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unit Row(平台) -->
|
||||
<div
|
||||
v-if="showUnit"
|
||||
class="flex items-center justify-between group/row"
|
||||
>
|
||||
<span class="w-12 flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">
|
||||
单位
|
||||
</span>
|
||||
<div class="flex flex-1 items-center justify-end gap-2">
|
||||
<template v-if="!row._isNew">
|
||||
<span class="text-gray-400 line-through decoration-gray-300 dark:text-gray-500">
|
||||
{{ row.orig_unit_name || '—' }}
|
||||
</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">→</span>
|
||||
</template>
|
||||
<Input
|
||||
class="!w-[85px] !rounded-md shadow-sm"
|
||||
size="small"
|
||||
:value="row.unit_name"
|
||||
placeholder="单位"
|
||||
@update:value="(v) => onUnitChange(row, String(v ?? ''))"
|
||||
/>
|
||||
<Tag
|
||||
v-if="row._unitPendingCreate"
|
||||
color="orange"
|
||||
class="!mr-0 !text-[10px]"
|
||||
>
|
||||
待建
|
||||
</Tag>
|
||||
<span v-else class="w-4"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 门店只读单位对照 -->
|
||||
<div
|
||||
v-else-if="row.unit_name || row.orig_unit_name"
|
||||
class="flex items-center justify-between text-gray-400"
|
||||
>
|
||||
<span class="w-12 flex-shrink-0">单位</span>
|
||||
<span>{{ row.unit_name || row.orig_unit_name || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Area -->
|
||||
@@ -345,7 +481,7 @@ function handleRowSave(row: PriceCompareRow) {
|
||||
>
|
||||
<Transition name="slide-up">
|
||||
<Button
|
||||
v-if="row._changed"
|
||||
v-if="showRowSave && row._changed && !row._priceLocked"
|
||||
:loading="savingRowId === row.id"
|
||||
size="small"
|
||||
type="primary"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 仓库/药品列表药名单元格:搜索命中别名时展示「别名 + 拼音」(对齐开方气泡)
|
||||
*/
|
||||
defineProps<{
|
||||
row: Record<string, any>;
|
||||
}>();
|
||||
|
||||
function drugNameOf(row: Record<string, any>): string {
|
||||
return String(row?.drug?.drug_name || row?.drug_name || '').trim() || '-';
|
||||
}
|
||||
|
||||
function matchedAliasOf(row: Record<string, any>): string {
|
||||
return String(
|
||||
row?.matched_alias || row?.drug?.matched_alias || '',
|
||||
).trim();
|
||||
}
|
||||
|
||||
function pinyinOf(row: Record<string, any>): string {
|
||||
const aliasPy = String(
|
||||
row?.matched_alias_pinyin || row?.drug?.matched_alias_pinyin || '',
|
||||
).trim();
|
||||
if (matchedAliasOf(row) && aliasPy) return aliasPy;
|
||||
return String(row?.drug?.pinyin_simple || row?.pinyin_simple || '').trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wh-drug-name-cell">
|
||||
<div class="wh-drug-name-cell__name" :title="drugNameOf(row)">
|
||||
{{ drugNameOf(row) }}
|
||||
</div>
|
||||
<div v-if="matchedAliasOf(row)" class="wh-drug-name-cell__alias">
|
||||
别名:{{ matchedAliasOf(row) }}
|
||||
<span v-if="pinyinOf(row)" class="wh-drug-name-cell__py">
|
||||
({{ pinyinOf(row) }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wh-drug-name-cell__name {
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
}
|
||||
.wh-drug-name-cell__alias {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
}
|
||||
.wh-drug-name-cell__py {
|
||||
color: hsl(var(--muted-foreground) / 0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
/**
|
||||
* 门店仓库药品编辑弹窗
|
||||
* 绑定配送仓不可改价;有底价/最高价时提示改价区间
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { Alert, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
@@ -13,10 +16,7 @@ import {
|
||||
updateWarehouseDrugManagementStore,
|
||||
} from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
import {getDrugUseList} from "#/views/doctor/doctor-reception/api";
|
||||
|
||||
|
||||
const userStore = useUserStore();
|
||||
import { getDrugUseList } from '#/views/doctor/doctor-reception/api';
|
||||
|
||||
const drugTime = ref([]);
|
||||
const drugType = ref([]);
|
||||
@@ -52,10 +52,13 @@ getDrugUseList().then((res) => {
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
/** 是否绑定配送仓(不可改价) */
|
||||
const priceLocked = ref(false);
|
||||
/** 改价区间展示文案 */
|
||||
const priceRangeTip = ref('');
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
@@ -63,6 +66,10 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
if (priceLocked.value) {
|
||||
message.warning('该商品不可改价');
|
||||
return;
|
||||
}
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (e.valid) {
|
||||
const values = await formApi.getValues();
|
||||
@@ -86,7 +93,6 @@ const [Modal, modalApi] = useVbenModal({
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (isOpen) {
|
||||
|
||||
formApi.updateSchema([
|
||||
{
|
||||
componentProps: {
|
||||
@@ -115,19 +121,61 @@ const [Modal, modalApi] = useVbenModal({
|
||||
]);
|
||||
const { values, update } = modalApi.getData<Record<string, any>>();
|
||||
if (values) {
|
||||
console.log(values, 'sssssssssssss')
|
||||
isUpdate.value = update;
|
||||
formApi.setValues(values);
|
||||
// 从列表行带入配送仓标记与价格区间(drug 嵌套字段展平到表单)
|
||||
const hasDelivery =
|
||||
Number(values.has_delivery_warehouse) === 1 ||
|
||||
Number(values.drug?.has_delivery_warehouse) === 1;
|
||||
priceLocked.value = hasDelivery;
|
||||
const min = Number(
|
||||
values.min_adjust_price ?? values.drug?.min_adjust_price ?? 0,
|
||||
);
|
||||
const max = Number(
|
||||
values.max_adjust_price ?? values.drug?.max_adjust_price ?? 0,
|
||||
);
|
||||
if (min > 0 || max > 0) {
|
||||
const minTxt = min > 0 ? `¥${min.toFixed(2)}` : '不限';
|
||||
const maxTxt = max > 0 ? `¥${max.toFixed(2)}` : '不限';
|
||||
priceRangeTip.value = `改价区间:${minTxt} ~ ${maxTxt}`;
|
||||
} else {
|
||||
priceRangeTip.value = '';
|
||||
}
|
||||
formApi.setValues({
|
||||
...values,
|
||||
has_delivery_warehouse: hasDelivery ? 1 : 0,
|
||||
min_adjust_price: min || null,
|
||||
max_adjust_price: max || null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
priceLocked.value = false;
|
||||
priceRangeTip.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const modalTitle = computed(() =>
|
||||
isUpdate.value === true ? '编辑仓库药品' : '新增仓库药品',
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}仓库药品`"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Modal :title="modalTitle" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
|
||||
<Alert
|
||||
v-if="priceLocked"
|
||||
type="warning"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
message="该商品不可改价"
|
||||
description="已绑定配送仓库,商家侧不允许修改售价。"
|
||||
/>
|
||||
<Alert
|
||||
v-else-if="priceRangeTip"
|
||||
type="info"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
:message="priceRangeTip"
|
||||
description="售价必须落在平台设置的底价~最高价区间内。"
|
||||
/>
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -65,6 +65,40 @@ export const modalFormProps: VbenFormProps = {
|
||||
formItemClass: 'col-span-12',
|
||||
label: '价格',
|
||||
rules: 'required',
|
||||
// 绑定配送仓时禁用改价;区间提示由弹窗文案补充
|
||||
dependencies: {
|
||||
disabled(values: any) {
|
||||
return Number(values?.has_delivery_warehouse) === 1;
|
||||
},
|
||||
triggerFields: ['has_delivery_warehouse'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'has_delivery_warehouse',
|
||||
label: '配送仓',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['has_delivery_warehouse'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'min_adjust_price',
|
||||
label: '底价',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['min_adjust_price'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'max_adjust_price',
|
||||
label: '最高价',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['max_adjust_price'],
|
||||
},
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
|
||||
@@ -25,8 +25,8 @@ export function createGridOptions(productType: number): VxeGridProps<RowType> {
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'drug.drug_name', align: 'left', title: '药品名称' },
|
||||
{ field: 'drug.pinyin_simple', title: '拼音' },
|
||||
{ field: 'drug.drug_name', align: 'left', title: '药品名称', minWidth: 160, slots: { default: 'drug_name' } },
|
||||
{ field: 'drug.pinyin_simple', title: '拼音', minWidth: 120, slots: { default: 'drug_pinyin' } },
|
||||
{
|
||||
field: 'image',
|
||||
align: 'left',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TableAction } from '#/components/table-action';
|
||||
import { downloadByData } from '#/util/tool';
|
||||
|
||||
import { useWarehouseDrugTypeRoute } from '../shared/useWarehouseDrugTypeRoute';
|
||||
import WarehouseDrugNameCell from '../shared/components/WarehouseDrugNameCell.vue';
|
||||
import {
|
||||
checkSubscribeApi,
|
||||
deleteWarehouseDrugManagementStore,
|
||||
@@ -62,9 +63,12 @@ const [ExcelUploadModal, ExcelUploadModalApi] = useVbenModal({
|
||||
connectedComponent: ExcelUpload,
|
||||
});
|
||||
|
||||
const showModal = (data = {}, isUpdate = false) => {
|
||||
const showModal = (data: any = {}, isUpdate = false) => {
|
||||
if (isUpdate && Number(data?.has_delivery_warehouse) === 1) {
|
||||
message.warning('该商品不可改价');
|
||||
return;
|
||||
}
|
||||
formModalApi.setData({
|
||||
// 表单值
|
||||
values: data,
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
@@ -212,6 +216,20 @@ checkSubscribe();
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<!-- 药名/拼音:搜索命中别名时展示匹配别名(对齐开方) -->
|
||||
<template #drug_name="{ row }">
|
||||
<WarehouseDrugNameCell :row="row" />
|
||||
</template>
|
||||
<template #drug_pinyin="{ row }">
|
||||
<span>
|
||||
{{
|
||||
row.matched_alias_pinyin ||
|
||||
row.drug?.matched_alias_pinyin ||
|
||||
row.drug?.pinyin_simple ||
|
||||
'-'
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<Image
|
||||
:src="
|
||||
@@ -254,31 +272,15 @@ checkSubscribe();
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
// {
|
||||
// label: '上架/下架(仓库)',
|
||||
// type: 'link',
|
||||
// icon: 'uil:edit',
|
||||
// size: 'small',
|
||||
// // auth: ['western-medicine', 'sys:role:detail'],
|
||||
// onClick: updateStatus.bind(null, row.id),
|
||||
// },
|
||||
// {
|
||||
// label: '上架/下架(商城)',
|
||||
// type: 'link',
|
||||
// icon: 'uil:edit',
|
||||
// size: 'small',
|
||||
// // auth: ['western-medicine', 'sys:role:detail'],
|
||||
// onClick: updateWarehouseDrugManagementShopUpdateStatus.bind(
|
||||
// null,
|
||||
// row.id,
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
label: '编辑',
|
||||
label:
|
||||
Number(row.has_delivery_warehouse) === 1
|
||||
? '不可改价'
|
||||
: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
// auth: ['western-medicine', 'sys:role:detail'],
|
||||
disabled: Number(row.has_delivery_warehouse) === 1,
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
]"
|
||||
|
||||
@@ -50,11 +50,21 @@ export async function updateWarehouseDrugManagementShopUpdateStatusApi(id: numbe
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出仓库药品药品
|
||||
* 导出仓库药品(旧:后端二进制,保留兼容)
|
||||
*/
|
||||
export async function exportWarehouseDrugManagementStoreApi(params?: { type?: number }) {
|
||||
return requestClient.download(`${prefix}export`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 JSON 数据(供前端 ExcelJS)
|
||||
*/
|
||||
export async function getStoreExportDataApi(params?: { type?: number }) {
|
||||
return requestClient.get<{
|
||||
total: number;
|
||||
rows: Array<Record<string, unknown>>;
|
||||
}>(`${prefix}export-data`, { params });
|
||||
}
|
||||
/**
|
||||
* 导出仓库药品药品 - 模板
|
||||
*/
|
||||
@@ -95,13 +105,65 @@ export async function importWarehouseDrugManagementStoreApi(data: Record<string,
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入仓库药品修改价格
|
||||
* 导入仓库药品修改价格(兼容旧直传入口)
|
||||
* @param data
|
||||
*/
|
||||
export async function importUpdatePriceApi(data: Record<string, any>) {
|
||||
return requestClient.upload(`${prefix}import-update-price`, data);
|
||||
}
|
||||
|
||||
export interface StoreBatchPriceItem {
|
||||
id: number;
|
||||
drug_id: number;
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
pinyin_simple: string;
|
||||
unit_name?: string;
|
||||
price: number | string;
|
||||
buy_price?: number | string;
|
||||
has_delivery_warehouse?: number;
|
||||
}
|
||||
|
||||
export interface StoreCentralAvailableItem {
|
||||
drug_id: number;
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
pinyin_simple: string;
|
||||
unit_name?: string;
|
||||
price: number | string;
|
||||
buy_price?: number | string;
|
||||
}
|
||||
|
||||
/** 门店批量改价预览:全量快照 */
|
||||
export async function getStoreAllForBatchPriceApi(params: { type: number }) {
|
||||
return requestClient.get<{
|
||||
items: StoreBatchPriceItem[];
|
||||
central_available?: StoreCentralAvailableItem[];
|
||||
}>(`${prefix}list-all-for-batch-price`, { params });
|
||||
}
|
||||
|
||||
export interface StoreBatchPriceUpdateItem {
|
||||
id: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface StoreBatchPriceCreateItem {
|
||||
drug_id: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
/** 门店批量改价预览:确认落库(仅零售价) */
|
||||
export async function applyStoreBatchPriceApi(data: {
|
||||
type: number;
|
||||
updates?: StoreBatchPriceUpdateItem[];
|
||||
creates?: StoreBatchPriceCreateItem[];
|
||||
}) {
|
||||
return requestClient.post<{
|
||||
update_count: number;
|
||||
new_count: number;
|
||||
}>(`${prefix}apply-batch-price`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步总仓库api
|
||||
* @param data
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 门店仓库批量改价预览:ExcelJS 本地解析 → 分类对比 → 确认落库
|
||||
* 仅改零售价;Excel 含单位列时忽略并提示;配送仓锁定行不可提交
|
||||
*/
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Spin, Tag, UploadDragger } from 'ant-design-vue';
|
||||
|
||||
import { downloadByData } from '#/util/tool';
|
||||
|
||||
import {
|
||||
applyStoreBatchPriceApi,
|
||||
getStoreAllForBatchPriceApi,
|
||||
getStoreExportDataApi,
|
||||
type StoreBatchPriceItem,
|
||||
type StoreCentralAvailableItem,
|
||||
} from '../api';
|
||||
import {
|
||||
exportStoreListExcel,
|
||||
type StoreExportRow,
|
||||
} from '../utils/exportStorePriceExcel';
|
||||
import { parseStorePriceExcelBuffer } from '../utils/parseStorePriceExcel';
|
||||
import {
|
||||
getPriceDelta,
|
||||
isFieldChanged,
|
||||
parsePriceValue,
|
||||
reconcileExcelPrice,
|
||||
roundPrice4,
|
||||
} from '../../admin/utils/priceCompare';
|
||||
import PriceCompareCardGrid, {
|
||||
type PriceCompareFilterTab,
|
||||
type PriceCompareRow,
|
||||
type UnmatchedRow,
|
||||
} from '../../shared/components/PriceCompareCardGrid.vue';
|
||||
|
||||
const gridApi = ref();
|
||||
const productType = ref(1);
|
||||
const fileList = ref<UploadFile[]>([]);
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const loadingAll = ref(false);
|
||||
const parseLoading = ref(false);
|
||||
const applyLoading = ref(false);
|
||||
const parseHint = ref('');
|
||||
const parsedReady = ref(false);
|
||||
const filterTab = ref<PriceCompareFilterTab>('all');
|
||||
|
||||
const storeItems = ref<StoreBatchPriceItem[]>([]);
|
||||
const centralAvailable = ref<StoreCentralAvailableItem[]>([]);
|
||||
/** relation.id → 原始零售价 */
|
||||
const initialPriceMap = ref<Record<number, number>>({});
|
||||
/** relation.id → 当前编辑零售价 */
|
||||
const localPriceMap = ref<Record<number, number>>({});
|
||||
const newRows = ref<
|
||||
Array<{
|
||||
tempId: number;
|
||||
drug_id: number;
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
unit_name: string;
|
||||
price: number;
|
||||
}>
|
||||
>([]);
|
||||
const unmatchedRows = ref<UnmatchedRow[]>([]);
|
||||
const excelTouchedIds = ref<Set<number>>(new Set());
|
||||
let tempIdCounter = -1;
|
||||
let loadAllPromise: Promise<void> | null = null;
|
||||
|
||||
function resetState() {
|
||||
selectedFile.value = null;
|
||||
fileList.value = [];
|
||||
storeItems.value = [];
|
||||
centralAvailable.value = [];
|
||||
initialPriceMap.value = {};
|
||||
localPriceMap.value = {};
|
||||
newRows.value = [];
|
||||
unmatchedRows.value = [];
|
||||
excelTouchedIds.value = new Set();
|
||||
parseHint.value = '';
|
||||
parsedReady.value = false;
|
||||
filterTab.value = 'all';
|
||||
tempIdCounter = -1;
|
||||
}
|
||||
|
||||
function clearParseResult() {
|
||||
newRows.value = [];
|
||||
unmatchedRows.value = [];
|
||||
excelTouchedIds.value = new Set();
|
||||
parseHint.value = '';
|
||||
parsedReady.value = false;
|
||||
const next: Record<number, number> = {};
|
||||
for (const [id, price] of Object.entries(initialPriceMap.value)) {
|
||||
next[Number(id)] = price;
|
||||
}
|
||||
localPriceMap.value = next;
|
||||
}
|
||||
|
||||
function buildSnapshot(items: StoreBatchPriceItem[]) {
|
||||
const init: Record<number, number> = {};
|
||||
const loc: Record<number, number> = {};
|
||||
for (const item of items) {
|
||||
// 与 Excel 解析同一套 4 位小数,避免 format_price 把浮点误差打成「降一分」
|
||||
const price = roundPrice4(item.price);
|
||||
init[item.id] = price;
|
||||
loc[item.id] = price;
|
||||
}
|
||||
initialPriceMap.value = init;
|
||||
localPriceMap.value = loc;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装对比行:门店仅零售价;配送仓锁定标记 _priceLocked
|
||||
*/
|
||||
function buildCompareRow(
|
||||
item: {
|
||||
id: number;
|
||||
drug_id?: number;
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
pinyin_simple?: string;
|
||||
unit_name?: string;
|
||||
has_delivery_warehouse?: number;
|
||||
},
|
||||
currentPrice: number,
|
||||
origPrice: number,
|
||||
isNew: boolean,
|
||||
): PriceCompareRow {
|
||||
const priceLocked = !isNew && Number(item.has_delivery_warehouse) === 1;
|
||||
const priceChanged = isFieldChanged(origPrice, currentPrice);
|
||||
// 锁定行即使 Excel 改了价也不算「有修改」,避免误提交
|
||||
const changed = isNew || (!priceLocked && priceChanged);
|
||||
return {
|
||||
id: item.id,
|
||||
drug_id: item.drug_id,
|
||||
drug_name: item.drug_name,
|
||||
drug_number: item.drug_number,
|
||||
pinyin_simple: item.pinyin_simple ?? '',
|
||||
orig_market_price: 0,
|
||||
orig_price: origPrice,
|
||||
orig_unit_name: item.unit_name ?? '',
|
||||
market_price: 0,
|
||||
price: currentPrice,
|
||||
unit_name: item.unit_name ?? '',
|
||||
_changed: changed,
|
||||
_marketGtPrice: false,
|
||||
_unitChanged: false,
|
||||
_isNew: isNew,
|
||||
_fromExcel: excelTouchedIds.value.has(item.id) || isNew,
|
||||
_priceLocked: priceLocked,
|
||||
_marketDelta: 'same',
|
||||
_priceDelta: getPriceDelta(origPrice, currentPrice),
|
||||
_marketChanged: false,
|
||||
_priceChanged: priceChanged,
|
||||
};
|
||||
}
|
||||
|
||||
const compareRows = computed<PriceCompareRow[]>(() => {
|
||||
const existing = storeItems.value.map((item) => {
|
||||
const orig = initialPriceMap.value[item.id] ?? parsePriceValue(item.price);
|
||||
const current = localPriceMap.value[item.id] ?? orig;
|
||||
return buildCompareRow(item, current, orig, false);
|
||||
});
|
||||
const news = newRows.value.map((row) =>
|
||||
buildCompareRow(
|
||||
{
|
||||
id: row.tempId,
|
||||
drug_id: row.drug_id,
|
||||
drug_name: row.drug_name,
|
||||
drug_number: row.drug_number,
|
||||
unit_name: row.unit_name,
|
||||
},
|
||||
row.price,
|
||||
0,
|
||||
true,
|
||||
),
|
||||
);
|
||||
return [...existing, ...news];
|
||||
});
|
||||
|
||||
const changedCount = computed(
|
||||
() =>
|
||||
compareRows.value.filter((r) => r._changed && !r._isNew && !r._priceLocked)
|
||||
.length,
|
||||
);
|
||||
const newCount = computed(
|
||||
() => compareRows.value.filter((r) => r._isNew).length,
|
||||
);
|
||||
|
||||
const [ModalComp, modalApi] = useVbenModal({
|
||||
fullscreen: true,
|
||||
fullscreenButton: true,
|
||||
draggable: true,
|
||||
confirmText: '批量确认更新',
|
||||
cancelText: '关闭',
|
||||
showConfirmButton: true,
|
||||
onConfirm: runBatchApply,
|
||||
onCancel() {
|
||||
resetState();
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
productType.value = isOpen ? modalApi.getData()?.productType ?? 1 : 1;
|
||||
if (isOpen) {
|
||||
modalApi.setState({ fullscreen: true });
|
||||
syncModalFooterState();
|
||||
loadAllDrugs();
|
||||
} else {
|
||||
resetState();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function syncModalFooterState() {
|
||||
modalApi.setState({
|
||||
confirmDisabled:
|
||||
!parsedReady.value ||
|
||||
(changedCount.value === 0 && newCount.value === 0),
|
||||
confirmLoading: applyLoading.value,
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
[changedCount, newCount, applyLoading, parseLoading, parsedReady],
|
||||
syncModalFooterState,
|
||||
);
|
||||
|
||||
async function loadAllDrugs() {
|
||||
loadingAll.value = true;
|
||||
const task = (async () => {
|
||||
try {
|
||||
const res = await getStoreAllForBatchPriceApi({ type: productType.value });
|
||||
storeItems.value = res?.items ?? [];
|
||||
centralAvailable.value = res?.central_available ?? [];
|
||||
buildSnapshot(storeItems.value);
|
||||
} catch {
|
||||
message.error('加载门店药品失败');
|
||||
storeItems.value = [];
|
||||
centralAvailable.value = [];
|
||||
initialPriceMap.value = {};
|
||||
localPriceMap.value = {};
|
||||
} finally {
|
||||
loadingAll.value = false;
|
||||
loadAllPromise = null;
|
||||
}
|
||||
})();
|
||||
loadAllPromise = task;
|
||||
await task;
|
||||
}
|
||||
|
||||
async function handleFileChange(info: { file: UploadFile }) {
|
||||
const { file } = info;
|
||||
if (file.status === 'removed') {
|
||||
selectedFile.value = null;
|
||||
clearParseResult();
|
||||
filterTab.value = 'all';
|
||||
return;
|
||||
}
|
||||
const raw = file.originFileObj ?? file;
|
||||
if (raw instanceof File) {
|
||||
selectedFile.value = raw;
|
||||
await runParse();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载标准模板:与列表页「导出」同一套 export-data + 列结构
|
||||
*/
|
||||
async function downloadTemplate() {
|
||||
try {
|
||||
const res = await getStoreExportDataApi({ type: productType.value });
|
||||
const rows = (res?.rows ?? []) as StoreExportRow[];
|
||||
if (rows.length === 0) {
|
||||
message.warning('暂无药品数据');
|
||||
return;
|
||||
}
|
||||
const buffer = await exportStoreListExcel(rows, '门店仓库');
|
||||
downloadByData(
|
||||
buffer,
|
||||
'萧康云医-门店仓库商品导出.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
message.success(`模板已下载(${res?.total ?? rows.length} 条,与导出一致)`);
|
||||
} catch {
|
||||
message.error('模板下载失败');
|
||||
}
|
||||
}
|
||||
|
||||
function findStoreByDrugId(drugId: number) {
|
||||
return storeItems.value.find((d) => d.drug_id === drugId);
|
||||
}
|
||||
|
||||
function findCentralByDrugId(drugId: number) {
|
||||
return centralAvailable.value.find((d) => d.drug_id === drugId);
|
||||
}
|
||||
|
||||
async function runParse() {
|
||||
if (!selectedFile.value) {
|
||||
return;
|
||||
}
|
||||
if (loadingAll.value && loadAllPromise) {
|
||||
await loadAllPromise;
|
||||
}
|
||||
parseLoading.value = true;
|
||||
syncModalFooterState();
|
||||
try {
|
||||
const buf = await selectedFile.value.arrayBuffer();
|
||||
const { items, invalidCount, rowCount, hasUnitColumn } =
|
||||
await parseStorePriceExcelBuffer(buf);
|
||||
|
||||
if (hasUnitColumn) {
|
||||
message.info('门店导入已忽略单位列(单位仅平台总仓可改)');
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
message.warning(
|
||||
invalidCount > 0
|
||||
? `未解析到有效行(${invalidCount} 行缺少合法药品ID)`
|
||||
: '未解析到有效的价格行',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const hints: string[] = [
|
||||
rowCount > 5000
|
||||
? `共 ${rowCount} 行,已按上限处理前 5000 行。`
|
||||
: `共解析 ${items.length} 条有效行。`,
|
||||
];
|
||||
if (hasUnitColumn) {
|
||||
hints.push('已忽略单位列。');
|
||||
}
|
||||
if (invalidCount > 0) {
|
||||
hints.push(`${invalidCount} 行因药品ID无效已跳过。`);
|
||||
}
|
||||
parseHint.value = hints.join(' ');
|
||||
|
||||
const nextUnmatched: UnmatchedRow[] = [];
|
||||
const nextNew: typeof newRows.value = [];
|
||||
const nextLocal = { ...localPriceMap.value };
|
||||
const nextTouched = new Set<number>();
|
||||
|
||||
for (const row of items) {
|
||||
const matched = findStoreByDrugId(row.drug_id);
|
||||
if (matched) {
|
||||
nextTouched.add(matched.id);
|
||||
const orig = initialPriceMap.value[matched.id] ?? parsePriceValue(matched.price);
|
||||
// 与快照对账,消化 Excel 一分钱浮点误差
|
||||
nextLocal[matched.id] = reconcileExcelPrice(row.price, orig);
|
||||
continue;
|
||||
}
|
||||
const central = findCentralByDrugId(row.drug_id);
|
||||
if (central) {
|
||||
nextNew.push({
|
||||
tempId: tempIdCounter--,
|
||||
drug_id: central.drug_id,
|
||||
drug_name: central.drug_name || row.drug_name,
|
||||
drug_number: central.drug_number || row.drug_number,
|
||||
unit_name: central.unit_name ?? '',
|
||||
price: row.price,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
nextUnmatched.push({
|
||||
drug_name: row.drug_name || `ID:${row.drug_id}`,
|
||||
drug_number: row.drug_number || String(row.drug_id),
|
||||
market_price: 0,
|
||||
price: row.price,
|
||||
unit_name: '',
|
||||
});
|
||||
}
|
||||
|
||||
localPriceMap.value = nextLocal;
|
||||
excelTouchedIds.value = nextTouched;
|
||||
newRows.value = nextNew;
|
||||
unmatchedRows.value = nextUnmatched;
|
||||
parsedReady.value = true;
|
||||
|
||||
const changedN = storeItems.value.filter((item) => {
|
||||
if (!nextTouched.has(item.id)) {
|
||||
return false;
|
||||
}
|
||||
const orig = initialPriceMap.value[item.id];
|
||||
const cur = nextLocal[item.id];
|
||||
return (
|
||||
Number(item.has_delivery_warehouse) !== 1 &&
|
||||
isFieldChanged(orig, cur)
|
||||
);
|
||||
}).length;
|
||||
|
||||
if (changedN > 0) {
|
||||
filterTab.value = 'changed';
|
||||
} else if (nextNew.length > 0) {
|
||||
filterTab.value = 'new';
|
||||
} else {
|
||||
filterTab.value = 'unchanged';
|
||||
message.info('未检测到零售价变更');
|
||||
}
|
||||
} catch {
|
||||
message.error('解析失败,请检查 Excel 格式');
|
||||
} finally {
|
||||
parseLoading.value = false;
|
||||
syncModalFooterState();
|
||||
}
|
||||
}
|
||||
|
||||
function syncRowFromGrid(row: PriceCompareRow) {
|
||||
if (row._isNew) {
|
||||
newRows.value = newRows.value.map((item) =>
|
||||
item.tempId === row.id ? { ...item, price: row.price } : item,
|
||||
);
|
||||
return;
|
||||
}
|
||||
localPriceMap.value = {
|
||||
...localPriceMap.value,
|
||||
[row.id]: row.price,
|
||||
};
|
||||
}
|
||||
|
||||
async function runBatchApply() {
|
||||
const changedExisting = compareRows.value.filter(
|
||||
(r) => r._changed && !r._isNew && !r._priceLocked,
|
||||
);
|
||||
const creates = newRows.value.map((row) => ({
|
||||
drug_id: row.drug_id,
|
||||
price: row.price,
|
||||
}));
|
||||
|
||||
if (changedExisting.length === 0 && creates.length === 0) {
|
||||
message.info('没有需要保存的变更');
|
||||
return;
|
||||
}
|
||||
|
||||
applyLoading.value = true;
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
try {
|
||||
const res = await applyStoreBatchPriceApi({
|
||||
type: productType.value,
|
||||
updates: changedExisting.map((row) => ({
|
||||
id: row.id,
|
||||
price: parsePriceValue(row.price),
|
||||
})),
|
||||
creates,
|
||||
});
|
||||
const updateN = res?.update_count ?? 0;
|
||||
const newN = res?.new_count ?? 0;
|
||||
message.success(`已更新 ${updateN} 条,新增 ${newN} 条`);
|
||||
gridApi.value?.reload?.();
|
||||
resetState();
|
||||
modalApi.close();
|
||||
} catch {
|
||||
message.error('批量更新失败');
|
||||
} finally {
|
||||
applyLoading.value = false;
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
syncModalFooterState();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalComp title="批量改价预览(门店仓库)">
|
||||
<Transition name="fade-slide" mode="out-in">
|
||||
<div
|
||||
v-if="!parsedReady"
|
||||
class="flex h-[calc(100vh-220px)] w-full flex-col items-center justify-center px-4 sm:px-12 md:px-24"
|
||||
>
|
||||
<div class="relative w-full max-w-[85vw] xl:max-w-screen-xl">
|
||||
<div
|
||||
v-if="loadingAll || parseLoading"
|
||||
class="absolute inset-0 z-50 flex items-center justify-center rounded-3xl bg-white/70 backdrop-blur-[2px] dark:bg-gray-900/70"
|
||||
>
|
||||
<Spin size="large" tip="解析数据中..." />
|
||||
</div>
|
||||
<div class="flex w-full flex-col items-center gap-8">
|
||||
<div class="text-center">
|
||||
<h2 class="mb-3 text-3xl font-medium text-gray-800 dark:text-gray-100">
|
||||
导入门店零售价
|
||||
</h2>
|
||||
<p class="text-lg text-gray-500">
|
||||
上传 Excel 预览有变动 / 无变动 / 待新增后再确认;单位列会被忽略
|
||||
</p>
|
||||
</div>
|
||||
<div class="batch-price-upload batch-price-upload--hero w-full transition-transform duration-300 hover:scale-[1.01]">
|
||||
<UploadDragger
|
||||
v-model:file-list="fileList"
|
||||
:before-upload="() => false"
|
||||
:max-count="1"
|
||||
:show-upload-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
name="file"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<div class="flex min-h-[420px] w-full flex-col items-center justify-center py-16">
|
||||
<div class="mb-8 rounded-full bg-blue-50 p-6 text-blue-500 ring-8 ring-blue-50/50 dark:bg-blue-900/30 dark:ring-blue-900/20">
|
||||
<svg class="h-14 w-14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-2xl font-medium text-gray-700 dark:text-gray-200">
|
||||
点击或将 Excel 文件拖拽至此
|
||||
</p>
|
||||
<p class="mt-4 text-lg text-gray-400">
|
||||
纯前端本地解析,确认后才提交服务器
|
||||
</p>
|
||||
</div>
|
||||
</UploadDragger>
|
||||
</div>
|
||||
<div class="flex min-h-[28px] items-center gap-2">
|
||||
<p
|
||||
v-if="selectedFile"
|
||||
class="text-lg font-medium text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
已选文件:{{ selectedFile.name }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
:disabled="loadingAll || storeItems.length === 0"
|
||||
size="large"
|
||||
type="dashed"
|
||||
class="!rounded-full px-10 py-2 shadow-sm"
|
||||
@click="downloadTemplate"
|
||||
>
|
||||
下载标准模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex w-full flex-col gap-4"
|
||||
style="height: calc(100vh - 200px)"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 flex-col gap-4 rounded-xl border border-gray-100 bg-gray-50/50 p-4 shadow-sm backdrop-blur-sm dark:border-gray-800 dark:bg-gray-900/30 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="batch-price-upload batch-price-upload--mini">
|
||||
<UploadDragger
|
||||
v-model:file-list="fileList"
|
||||
:before-upload="() => false"
|
||||
:max-count="1"
|
||||
:show-upload-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
name="file"
|
||||
class="!rounded-lg"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
重新上传
|
||||
</div>
|
||||
</UploadDragger>
|
||||
</div>
|
||||
<div v-if="selectedFile" class="text-sm text-gray-500">
|
||||
当前:
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ selectedFile.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button size="small" type="link" @click="downloadTemplate">
|
||||
下载模板
|
||||
</Button>
|
||||
<div class="h-4 w-px bg-gray-300 dark:bg-gray-700"></div>
|
||||
<Button
|
||||
v-if="selectedFile"
|
||||
:disabled="parseLoading"
|
||||
size="small"
|
||||
type="link"
|
||||
@click="runParse"
|
||||
>
|
||||
重新解析
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800"
|
||||
>
|
||||
<div
|
||||
v-if="loadingAll || parseLoading"
|
||||
class="absolute inset-0 z-50 flex items-center justify-center bg-white/70 backdrop-blur-[2px] dark:bg-gray-900/70"
|
||||
>
|
||||
<Spin size="large" tip="数据处理中..." />
|
||||
</div>
|
||||
<div class="custom-scrollbar relative w-full flex-1 overflow-y-auto p-4">
|
||||
<PriceCompareCardGrid
|
||||
v-model:filter-tab="filterTab"
|
||||
:rows="compareRows"
|
||||
:unmatched-rows="unmatchedRows"
|
||||
:show-unit="false"
|
||||
:show-market-price="false"
|
||||
:show-row-save="false"
|
||||
:show-price-locked-tab="true"
|
||||
price-label="零售价"
|
||||
@cell-change="syncRowFromGrid"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<template v-if="parsedReady" #prepend-footer>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag
|
||||
:color="changedCount > 0 ? 'warning' : 'default'"
|
||||
class="!rounded-md shadow-sm"
|
||||
>
|
||||
待保存修改 {{ changedCount }}
|
||||
</Tag>
|
||||
<Tag
|
||||
:color="newCount > 0 ? 'processing' : 'default'"
|
||||
class="!rounded-md shadow-sm"
|
||||
>
|
||||
待新增数据 {{ newCount }}
|
||||
</Tag>
|
||||
<span v-if="parseHint" class="ml-2 text-xs text-gray-400">
|
||||
{{ parseHint }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ModalComp>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(107, 114, 128, 0.8);
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.batch-price-upload {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
.batch-price-upload :deep(.ant-upload-wrapper),
|
||||
.batch-price-upload :deep(.ant-upload-list),
|
||||
.batch-price-upload :deep(.ant-upload-drag) {
|
||||
width: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
.batch-price-upload--hero :deep(.ant-upload-drag) {
|
||||
background: rgba(249, 250, 251, 0.5);
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.batch-price-upload--hero :deep(.ant-upload-drag:hover) {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(239, 246, 255, 0.5);
|
||||
}
|
||||
.batch-price-upload--mini :deep(.ant-upload-drag) {
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.batch-price-upload--mini :deep(.ant-upload-drag:hover) {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(239, 246, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
/**
|
||||
* 门店仓库药品编辑弹窗
|
||||
* 绑定配送仓不可改价;有底价/最高价时提示改价区间
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { Alert, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
@@ -13,10 +16,7 @@ import {
|
||||
updateWarehouseDrugManagementStore,
|
||||
} from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
import {getDrugUseList} from "#/views/doctor/doctor-reception/api";
|
||||
|
||||
|
||||
const userStore = useUserStore();
|
||||
import { getDrugUseList } from '#/views/doctor/doctor-reception/api';
|
||||
|
||||
const drugTime = ref([]);
|
||||
const drugType = ref([]);
|
||||
@@ -52,10 +52,13 @@ getDrugUseList().then((res) => {
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
/** 是否绑定配送仓(不可改价) */
|
||||
const priceLocked = ref(false);
|
||||
/** 改价区间展示文案 */
|
||||
const priceRangeTip = ref('');
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
@@ -63,6 +66,10 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
if (priceLocked.value) {
|
||||
message.warning('该商品不可改价');
|
||||
return;
|
||||
}
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (e.valid) {
|
||||
const values = await formApi.getValues();
|
||||
@@ -86,7 +93,6 @@ const [Modal, modalApi] = useVbenModal({
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (isOpen) {
|
||||
|
||||
formApi.updateSchema([
|
||||
{
|
||||
componentProps: {
|
||||
@@ -115,19 +121,61 @@ const [Modal, modalApi] = useVbenModal({
|
||||
]);
|
||||
const { values, update } = modalApi.getData<Record<string, any>>();
|
||||
if (values) {
|
||||
console.log(values, 'sssssssssssss')
|
||||
isUpdate.value = update;
|
||||
formApi.setValues(values);
|
||||
// 从列表行带入配送仓标记与价格区间(drug 嵌套字段展平到表单)
|
||||
const hasDelivery =
|
||||
Number(values.has_delivery_warehouse) === 1 ||
|
||||
Number(values.drug?.has_delivery_warehouse) === 1;
|
||||
priceLocked.value = hasDelivery;
|
||||
const min = Number(
|
||||
values.min_adjust_price ?? values.drug?.min_adjust_price ?? 0,
|
||||
);
|
||||
const max = Number(
|
||||
values.max_adjust_price ?? values.drug?.max_adjust_price ?? 0,
|
||||
);
|
||||
if (min > 0 || max > 0) {
|
||||
const minTxt = min > 0 ? `¥${min.toFixed(2)}` : '不限';
|
||||
const maxTxt = max > 0 ? `¥${max.toFixed(2)}` : '不限';
|
||||
priceRangeTip.value = `改价区间:${minTxt} ~ ${maxTxt}`;
|
||||
} else {
|
||||
priceRangeTip.value = '';
|
||||
}
|
||||
formApi.setValues({
|
||||
...values,
|
||||
has_delivery_warehouse: hasDelivery ? 1 : 0,
|
||||
min_adjust_price: min || null,
|
||||
max_adjust_price: max || null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
priceLocked.value = false;
|
||||
priceRangeTip.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const modalTitle = computed(() =>
|
||||
isUpdate.value === true ? '编辑仓库药品' : '新增仓库药品',
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}仓库药品`"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Modal :title="modalTitle" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
|
||||
<Alert
|
||||
v-if="priceLocked"
|
||||
type="warning"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
message="该商品不可改价"
|
||||
description="已绑定配送仓库,商家侧不允许修改售价。"
|
||||
/>
|
||||
<Alert
|
||||
v-else-if="priceRangeTip"
|
||||
type="info"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
:message="priceRangeTip"
|
||||
description="售价必须落在平台设置的底价~最高价区间内。"
|
||||
/>
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -65,6 +65,40 @@ export const modalFormProps: VbenFormProps = {
|
||||
formItemClass: 'col-span-12',
|
||||
label: '价格',
|
||||
rules: 'required',
|
||||
// 绑定配送仓时禁用改价;区间提示由弹窗文案补充
|
||||
dependencies: {
|
||||
disabled(values: any) {
|
||||
return Number(values?.has_delivery_warehouse) === 1;
|
||||
},
|
||||
triggerFields: ['has_delivery_warehouse'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'has_delivery_warehouse',
|
||||
label: '配送仓',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['has_delivery_warehouse'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'min_adjust_price',
|
||||
label: '底价',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['min_adjust_price'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'max_adjust_price',
|
||||
label: '最高价',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['max_adjust_price'],
|
||||
},
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
|
||||
@@ -25,8 +25,8 @@ export function createGridOptions(productType: number): VxeGridProps<RowType> {
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'drug.drug_name', align: 'left', title: '药品名称' },
|
||||
{ field: 'drug.pinyin_simple', title: '拼音' },
|
||||
{ field: 'drug.drug_name', align: 'left', title: '药品名称', minWidth: 160, slots: { default: 'drug_name' } },
|
||||
{ field: 'drug.pinyin_simple', title: '拼音', minWidth: 120, slots: { default: 'drug_pinyin' } },
|
||||
{
|
||||
field: 'image',
|
||||
align: 'left',
|
||||
@@ -37,6 +37,12 @@ export function createGridOptions(productType: number): VxeGridProps<RowType> {
|
||||
{ field: 'buy_price', title: '供货价' },
|
||||
{ field: 'drug.drug_store_drug.price', title: '建议售价' },
|
||||
{ field: 'price', title: '售价' },
|
||||
{
|
||||
field: 'has_delivery_warehouse',
|
||||
title: '改价',
|
||||
width: 100,
|
||||
slots: { default: 'price_lock' },
|
||||
},
|
||||
{ field: 'status', title: '状态', slots: { default: 'status' } },
|
||||
{
|
||||
type: 'html',
|
||||
|
||||
@@ -12,20 +12,25 @@ import { TableAction } from '#/components/table-action';
|
||||
import { downloadByData } from '#/util/tool';
|
||||
|
||||
import { useWarehouseDrugTypeRoute } from '../shared/useWarehouseDrugTypeRoute';
|
||||
import WarehouseDrugNameCell from '../shared/components/WarehouseDrugNameCell.vue';
|
||||
import {
|
||||
checkSubscribeApi,
|
||||
deleteWarehouseDrugManagementStore,
|
||||
exportWarehouseDrugManagementStoreApi,
|
||||
getStoreExportDataApi,
|
||||
subscribeApi,
|
||||
syncDrugApi,
|
||||
syncDrugPriceApi,
|
||||
updateWarehouseDrugManagementShopUpdateStatusApi,
|
||||
updateWarehouseDrugManagementStoreStatusApi,
|
||||
} from './api';
|
||||
import ExcelUpload from './components/ExcelUpload.vue';
|
||||
import StorePriceBatchModal from './components/StorePriceBatchModal.vue';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { createGridOptions } from './config/table';
|
||||
import {
|
||||
exportStoreListExcel,
|
||||
type StoreExportRow,
|
||||
} from './utils/exportStorePriceExcel';
|
||||
|
||||
const { productType, typeLabel } = useWarehouseDrugTypeRoute(
|
||||
'/warehouse-drug-management-store/type/1',
|
||||
@@ -58,13 +63,17 @@ const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModalDemo,
|
||||
});
|
||||
|
||||
const [ExcelUploadModal, ExcelUploadModalApi] = useVbenModal({
|
||||
connectedComponent: ExcelUpload,
|
||||
const [StorePriceBatchModalComp, StorePriceBatchModalApi] = useVbenModal({
|
||||
connectedComponent: StorePriceBatchModal,
|
||||
});
|
||||
|
||||
const showModal = (data = {}, isUpdate = false) => {
|
||||
const showModal = (data: any = {}, isUpdate = false) => {
|
||||
// 绑定配送仓:直接提示不可改价,不打开编辑
|
||||
if (isUpdate && Number(data?.has_delivery_warehouse) === 1) {
|
||||
message.warning('该商品不可改价');
|
||||
return;
|
||||
}
|
||||
formModalApi.setData({
|
||||
// 表单值
|
||||
values: data,
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
@@ -84,24 +93,35 @@ const deleteApi = (row: any) => {
|
||||
gridApi.query();
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 导出
|
||||
*/
|
||||
const passApplication = () => {
|
||||
exportWarehouseDrugManagementStoreApi({ type: productType.value }).then((res) => {
|
||||
downloadByData(res.data, '萧康云医-仓库商品导出.xlsx');
|
||||
message.success('导出成功!');
|
||||
|
||||
/** 打开门店批量改价预览(ExcelJS 解析后确认) */
|
||||
const openStorePriceBatchModal = () => {
|
||||
StorePriceBatchModalApi.setData({
|
||||
gridApi,
|
||||
productType: productType.value,
|
||||
});
|
||||
StorePriceBatchModalApi.open();
|
||||
};
|
||||
|
||||
const openExcelUploadModal = (type = 1) => {
|
||||
ExcelUploadModalApi.setData({
|
||||
gridApi,
|
||||
type,
|
||||
uploadType: productType.value,
|
||||
passApplication,
|
||||
});
|
||||
ExcelUploadModalApi.open();
|
||||
/** 门店导出:export-data + ExcelJS */
|
||||
const exportStoreDrugs = async () => {
|
||||
try {
|
||||
const res = await getStoreExportDataApi({ type: productType.value });
|
||||
const rows = (res?.rows ?? []) as StoreExportRow[];
|
||||
if (rows.length === 0) {
|
||||
message.warning('暂无药品数据');
|
||||
return;
|
||||
}
|
||||
const buffer = await exportStoreListExcel(rows, '门店仓库');
|
||||
downloadByData(
|
||||
buffer,
|
||||
'萧康云医-门店仓库商品导出.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
message.success(`导出成功(${res?.total ?? rows.length} 条)`);
|
||||
} catch {
|
||||
message.error('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const syncDrugs = () => {
|
||||
@@ -153,17 +173,23 @@ checkSubscribe();
|
||||
|
||||
<template>
|
||||
<Page auto-content-height :title="typeLabel || '门店仓库管理'">
|
||||
<ExcelUploadModal />
|
||||
<StorePriceBatchModalComp />
|
||||
<FormModal />
|
||||
<Grid v-if="productType">
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '批量修改价格',
|
||||
label: '批量改价预览',
|
||||
type: 'primary',
|
||||
icon: 'ix:export-check',
|
||||
onClick: openExcelUploadModal.bind(null, 2),
|
||||
icon: 'mdi:file-compare',
|
||||
onClick: openStorePriceBatchModal,
|
||||
},
|
||||
{
|
||||
label: '导出',
|
||||
type: 'primary',
|
||||
icon: 'lucide:download',
|
||||
onClick: exportStoreDrugs,
|
||||
},
|
||||
{
|
||||
label: '更新总仓库商品(只更新未同步的商品)',
|
||||
@@ -209,6 +235,20 @@ checkSubscribe();
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<!-- 药名/拼音:搜索命中别名时展示匹配别名(对齐开方) -->
|
||||
<template #drug_name="{ row }">
|
||||
<WarehouseDrugNameCell :row="row" />
|
||||
</template>
|
||||
<template #drug_pinyin="{ row }">
|
||||
<span>
|
||||
{{
|
||||
row.matched_alias_pinyin ||
|
||||
row.drug?.matched_alias_pinyin ||
|
||||
row.drug?.pinyin_simple ||
|
||||
'-'
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<Image
|
||||
:src="
|
||||
@@ -237,6 +277,12 @@ checkSubscribe();
|
||||
@click="updateStatus(row.id)"
|
||||
/>
|
||||
</template>
|
||||
<template #price_lock="{ row }">
|
||||
<Tag v-if="Number(row.has_delivery_warehouse) === 1" color="orange">
|
||||
不可改价
|
||||
</Tag>
|
||||
<span v-else class="text-slate-400">可改价</span>
|
||||
</template>
|
||||
<template #is_shop="{ row }">
|
||||
<Switch
|
||||
:checked="row.is_shop"
|
||||
@@ -251,31 +297,15 @@ checkSubscribe();
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
// {
|
||||
// label: '上架/下架(仓库)',
|
||||
// type: 'link',
|
||||
// icon: 'uil:edit',
|
||||
// size: 'small',
|
||||
// // auth: ['western-medicine', 'sys:role:detail'],
|
||||
// onClick: updateStatus.bind(null, row.id),
|
||||
// },
|
||||
// {
|
||||
// label: '上架/下架(商城)',
|
||||
// type: 'link',
|
||||
// icon: 'uil:edit',
|
||||
// size: 'small',
|
||||
// // auth: ['western-medicine', 'sys:role:detail'],
|
||||
// onClick: updateWarehouseDrugManagementShopUpdateStatus.bind(
|
||||
// null,
|
||||
// row.id,
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
label: '编辑',
|
||||
label:
|
||||
Number(row.has_delivery_warehouse) === 1
|
||||
? '不可改价'
|
||||
: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
// auth: ['western-medicine', 'sys:role:detail'],
|
||||
disabled: Number(row.has_delivery_warehouse) === 1,
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
]"
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 门店仓库 ExcelJS 导出(数据来自 export-data)
|
||||
* 价格/单位强制文本写出,避免 IEEE 浮点回读假变更
|
||||
*/
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
const THIN_BORDER: Partial<ExcelJS.Borders> = {
|
||||
top: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
left: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
bottom: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
right: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
};
|
||||
|
||||
export type StoreExportRow = {
|
||||
drug_id?: number | string;
|
||||
drug_number?: string;
|
||||
drug_name?: string;
|
||||
pinyin_simple?: string;
|
||||
unit_name?: string;
|
||||
type_text?: string;
|
||||
status_text?: string;
|
||||
buy_price?: string | number;
|
||||
price?: string | number;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
const LIST_HEADERS = [
|
||||
'药品ID',
|
||||
'ErpCode',
|
||||
'药名',
|
||||
'拼音首拼',
|
||||
'单位',
|
||||
'类型',
|
||||
'状态',
|
||||
'供货价',
|
||||
'当前零售价',
|
||||
'创建时间',
|
||||
] as const;
|
||||
|
||||
const PRICE_HEADERS = [
|
||||
'药品ID',
|
||||
'ErpCode',
|
||||
'药名',
|
||||
'拼音首拼',
|
||||
'单位',
|
||||
'当前零售价',
|
||||
] as const;
|
||||
|
||||
function priceToCellText(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function forceTextCell(cell: ExcelJS.Cell, text: string) {
|
||||
cell.value = text;
|
||||
cell.numFmt = '@';
|
||||
cell.border = THIN_BORDER;
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
}
|
||||
|
||||
function styleHeader(row: ExcelJS.Row) {
|
||||
row.height = 28;
|
||||
row.eachCell((cell) => {
|
||||
cell.font = { bold: true };
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFFFFF00' },
|
||||
};
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
cell.border = THIN_BORDER;
|
||||
});
|
||||
}
|
||||
|
||||
function styleDataRowFill(row: ExcelJS.Row, index: number) {
|
||||
if (index % 2 !== 1) {
|
||||
return;
|
||||
}
|
||||
row.eachCell((cell) => {
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFF2F2F2' },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 门店列表导出 */
|
||||
export async function exportStoreListExcel(
|
||||
rows: StoreExportRow[],
|
||||
sheetName = '门店仓库',
|
||||
): Promise<ArrayBuffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet(sheetName);
|
||||
sheet.columns = [
|
||||
{ width: 10 },
|
||||
{ width: 14 },
|
||||
{ width: 18 },
|
||||
{ width: 12 },
|
||||
{ width: 10 },
|
||||
{ width: 12 },
|
||||
{ width: 10 },
|
||||
{ width: 12 },
|
||||
{ width: 14 },
|
||||
{ width: 18 },
|
||||
];
|
||||
styleHeader(sheet.addRow([...LIST_HEADERS]));
|
||||
rows.forEach((item, index) => {
|
||||
const row = sheet.addRow([]);
|
||||
const values = [
|
||||
String(item.drug_id ?? ''),
|
||||
String(item.drug_number ?? ''),
|
||||
String(item.drug_name ?? ''),
|
||||
String(item.pinyin_simple ?? ''),
|
||||
String(item.unit_name ?? ''),
|
||||
String(item.type_text ?? ''),
|
||||
String(item.status_text ?? ''),
|
||||
priceToCellText(item.buy_price),
|
||||
priceToCellText(item.price),
|
||||
String(item.created_at ?? ''),
|
||||
];
|
||||
values.forEach((text, i) => forceTextCell(row.getCell(i + 1), text));
|
||||
styleDataRowFill(row, index);
|
||||
});
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
/** 门店改价模板(预览弹窗) */
|
||||
export async function exportStorePriceExcel(
|
||||
items: Array<{
|
||||
drug_id?: number | string;
|
||||
drug_number?: string;
|
||||
drug_name?: string;
|
||||
pinyin_simple?: string;
|
||||
unit_name?: string;
|
||||
price?: string | number;
|
||||
}>,
|
||||
sheetName = '门店改价',
|
||||
): Promise<ArrayBuffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet(sheetName);
|
||||
sheet.columns = [
|
||||
{ width: 10 },
|
||||
{ width: 14 },
|
||||
{ width: 18 },
|
||||
{ width: 12 },
|
||||
{ width: 10 },
|
||||
{ width: 14 },
|
||||
];
|
||||
styleHeader(sheet.addRow([...PRICE_HEADERS]));
|
||||
items.forEach((item, index) => {
|
||||
const row = sheet.addRow([]);
|
||||
const values = [
|
||||
String(item.drug_id ?? ''),
|
||||
String(item.drug_number ?? ''),
|
||||
String(item.drug_name ?? ''),
|
||||
String(item.pinyin_simple ?? ''),
|
||||
String(item.unit_name ?? ''),
|
||||
priceToCellText(item.price),
|
||||
];
|
||||
values.forEach((text, i) => forceTextCell(row.getCell(i + 1), text));
|
||||
styleDataRowFill(row, index);
|
||||
});
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return buffer as ArrayBuffer;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 门店批量改价 Excel 解析(ExcelJS)
|
||||
* 只认药品ID + 当前零售价;若含单位列则标记 hasUnitColumn,由上层丢弃并提示
|
||||
* 价格优先读单元格原始字符串,避免 IEEE 浮点
|
||||
*/
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
import { roundPrice4 } from '../../admin/utils/priceCompare';
|
||||
|
||||
const MAX_ROWS = 5000;
|
||||
|
||||
export type ParsedStorePriceRow = {
|
||||
drug_id: number;
|
||||
drug_name: string;
|
||||
drug_number: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
export type ParseStorePriceResult = {
|
||||
items: ParsedStorePriceRow[];
|
||||
invalidCount: number;
|
||||
rowCount: number;
|
||||
/** Excel 是否包含单位列(门店导入须忽略) */
|
||||
hasUnitColumn: boolean;
|
||||
};
|
||||
|
||||
function normalizeCell(value: ExcelJS.CellValue): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'object' && value && 'text' in value) {
|
||||
return String((value as { text?: string }).text ?? '').trim();
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? String(value) : String(value).trim();
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string): string {
|
||||
return value.replace(/\s+/g, '').replace(/\u3000/g, '');
|
||||
}
|
||||
|
||||
function cellRawString(cell: ExcelJS.Cell): string {
|
||||
const v = cell.value;
|
||||
if (v === null || v === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
return v.trim().replace(/,/g, '').replace(/¥/g, '');
|
||||
}
|
||||
if (typeof v === 'number') {
|
||||
return String(v);
|
||||
}
|
||||
if (typeof v === 'object') {
|
||||
if ('result' in v && v.result !== null && v.result !== undefined) {
|
||||
return String((v as { result: unknown }).result).trim();
|
||||
}
|
||||
if ('text' in v && (v as { text?: string }).text != null) {
|
||||
return String((v as { text?: string }).text).trim();
|
||||
}
|
||||
if ('richText' in v && Array.isArray((v as { richText: { text: string }[] }).richText)) {
|
||||
return (v as { richText: { text: string }[] }).richText
|
||||
.map((p) => p.text)
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
return String(cell.text ?? '').trim().replace(/,/g, '');
|
||||
}
|
||||
|
||||
const DRUG_ID_ALIASES = ['药品ID', '药品Id', 'drug_id', 'DrugId'];
|
||||
const NUMBER_ALIASES = ['ErpCode', '商品编码', '药品编码', 'drug_number'];
|
||||
const NAME_ALIASES = ['药名', '通用名', '药品名称', 'drug_name'];
|
||||
const PRICE_ALIASES = ['当前零售价', '零售价', '建议售价', '每克零售价', 'price'];
|
||||
const UNIT_ALIASES = ['单位', '计量单位', 'unit', 'unit_name'];
|
||||
|
||||
function buildHeaderIndexMap(headerRow: ExcelJS.Row): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
headerRow.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
||||
const key = normalizeHeader(normalizeCell(cell.value));
|
||||
if (key) {
|
||||
map[key] = colNumber;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function pickColumn(
|
||||
headerMap: Record<string, number>,
|
||||
aliases: string[],
|
||||
): number | undefined {
|
||||
for (const alias of aliases) {
|
||||
const col = headerMap[normalizeHeader(alias)];
|
||||
if (col) {
|
||||
return col;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getCellNumber(row: ExcelJS.Row, col?: number): number {
|
||||
if (!col) {
|
||||
return 0;
|
||||
}
|
||||
const raw = cellRawString(row.getCell(col));
|
||||
if (raw === '' || !Number.isFinite(Number(raw))) {
|
||||
return 0;
|
||||
}
|
||||
return roundPrice4(raw);
|
||||
}
|
||||
|
||||
function getCellText(row: ExcelJS.Row, col?: number): string {
|
||||
if (!col) {
|
||||
return '';
|
||||
}
|
||||
const cell = row.getCell(col);
|
||||
const fromValue = normalizeCell(cell.value);
|
||||
if (fromValue) {
|
||||
return fromValue;
|
||||
}
|
||||
return String(cell.text ?? '').trim();
|
||||
}
|
||||
|
||||
function rowIsEmpty(row: ExcelJS.Row): boolean {
|
||||
let hasValue = false;
|
||||
row.eachCell({ includeEmpty: false }, (cell) => {
|
||||
if (normalizeCell(cell.value) !== '') {
|
||||
hasValue = true;
|
||||
}
|
||||
});
|
||||
return !hasValue;
|
||||
}
|
||||
|
||||
export async function parseStorePriceExcelBuffer(
|
||||
buffer: ArrayBuffer,
|
||||
): Promise<ParseStorePriceResult> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
|
||||
const sheet = workbook.worksheets[0];
|
||||
if (!sheet) {
|
||||
return { items: [], invalidCount: 0, rowCount: 0, hasUnitColumn: false };
|
||||
}
|
||||
|
||||
const headerRow = sheet.getRow(1);
|
||||
const headerMap = buildHeaderIndexMap(headerRow);
|
||||
|
||||
const drugIdCol = pickColumn(headerMap, DRUG_ID_ALIASES);
|
||||
const numberCol = pickColumn(headerMap, NUMBER_ALIASES);
|
||||
const nameCol = pickColumn(headerMap, NAME_ALIASES);
|
||||
const priceCol = pickColumn(headerMap, PRICE_ALIASES);
|
||||
const unitCol = pickColumn(headerMap, UNIT_ALIASES);
|
||||
const hasUnitColumn = !!unitCol;
|
||||
|
||||
const items: ParsedStorePriceRow[] = [];
|
||||
let invalidCount = 0;
|
||||
const rowCount = Math.min(sheet.rowCount, MAX_ROWS + 1);
|
||||
|
||||
for (let rowIndex = 2; rowIndex <= rowCount; rowIndex++) {
|
||||
const row = sheet.getRow(rowIndex);
|
||||
if (rowIsEmpty(row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const drugIdRaw = getCellText(row, drugIdCol);
|
||||
const drugId = Number.parseInt(drugIdRaw, 10) || 0;
|
||||
const drugNumber = getCellText(row, numberCol);
|
||||
const drugName = getCellText(row, nameCol);
|
||||
const price = getCellNumber(row, priceCol);
|
||||
|
||||
if (drugId <= 0 && !drugName && !drugNumber && price === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (drugId <= 0) {
|
||||
invalidCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
drug_id: drugId,
|
||||
drug_name: drugName,
|
||||
drug_number: drugNumber,
|
||||
price,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
invalidCount,
|
||||
rowCount: Math.max(0, rowCount - 1),
|
||||
hasUnitColumn,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 醒目提示条:病历/处方生成记录与模态框顶部展示
|
||||
* 样式仅用主题变量,禁止 :global(.dark) 以免污染全局配色
|
||||
*/
|
||||
defineProps<{
|
||||
/** 完整提示文案(可含换行) */
|
||||
text?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="text" class="ai-disclaimer" role="note">
|
||||
<div class="ai-disclaimer__title">重要提示</div>
|
||||
<div class="ai-disclaimer__body">{{ text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-disclaimer {
|
||||
border: 1px solid hsl(var(--warning) / 0.45);
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--warning) / 0.12);
|
||||
padding: 10px 12px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.ai-disclaimer__title {
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--warning));
|
||||
}
|
||||
.ai-disclaimer__body {
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -119,6 +119,8 @@ export async function getPrescriptionTypeOptionsApi(params?: {
|
||||
icon?: string;
|
||||
icon_text?: string;
|
||||
}>;
|
||||
allowed?: boolean;
|
||||
message?: string;
|
||||
}>(`${prefix}prescription-type-options`, {
|
||||
params,
|
||||
});
|
||||
@@ -142,6 +144,31 @@ export async function withdrawPrescriptionApi(data: {
|
||||
return requestClient.post<any>(`${prefix}withdraw-prescription`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本次挂号处方列表(分页,支持 keyword 按订单号/订单ID筛选)
|
||||
*/
|
||||
export async function getRegisterPrescriptionListApi(params: {
|
||||
register_id: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}register-prescription-list`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 患者历史处方(排除当前挂号),支持 keyword 按订单号/订单ID筛选
|
||||
*/
|
||||
export async function getPatientPrescriptionHistoryApi(params: {
|
||||
patient_id: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
exclude_register_id?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}patient-prescription-history`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 接诊
|
||||
*/
|
||||
@@ -401,3 +428,101 @@ export async function saveGranularCommonPrescriptionApi(data: {
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** AI 给处方 */
|
||||
export async function aiGeneratePrescriptionApi(data: {
|
||||
register_id: number;
|
||||
store_id?: number;
|
||||
prescription_type: number;
|
||||
/** 前端确认过的主诉(可编辑) */
|
||||
chief_complaint?: string;
|
||||
/** 当前病历表单快照(可未保存),供 AI 综合参考 */
|
||||
medical_record?: Record<string, any>;
|
||||
/** 中药:是否委托调剂 */
|
||||
use_entrusted_process?: 0 | 1 | boolean;
|
||||
/** 中药委托调剂:制剂要求 id(yii_process_rule pid=0) */
|
||||
process_rule_id?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-generate-prescription`, data);
|
||||
}
|
||||
|
||||
/** AI 功能门闸(醒目提示 + 是否已签署知情同意) */
|
||||
export async function aiFeatureGateApi(data: { feature_code: string }) {
|
||||
return requestClient.get<any>(`${prefix}ai-feature-gate`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 知情同意签署(按 VIP 功能码,签署一次即可) */
|
||||
export async function aiAgreeConsentApi(data: {
|
||||
feature_code: string;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-agree-consent`, data);
|
||||
}
|
||||
|
||||
/** 本挂号 AI 生成历史(轻量列表) */
|
||||
export async function aiListGenerationsApi(data: {
|
||||
register_id: number;
|
||||
scene?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}ai-list-generations`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 生成详情(点选历史) */
|
||||
export async function aiGenerationDetailApi(data: { id: number }) {
|
||||
return requestClient.get<any>(`${prefix}ai-generation-detail`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 处方药名模糊对照 */
|
||||
export async function aiMatchPrescriptionDrugsApi(data: {
|
||||
generation_id?: number;
|
||||
register_id?: number;
|
||||
store_id?: number;
|
||||
prescription_type?: number;
|
||||
items?: Array<Record<string, any>>;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-match-prescription-drugs`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 出方:改选对照药品并写回 result_json(标记 is_modified)
|
||||
*/
|
||||
export async function aiSavePrescriptionDrugSelectionApi(data: {
|
||||
generation_id: number;
|
||||
item_index: number;
|
||||
selected_drug_id: number;
|
||||
selected_drug_name?: string;
|
||||
}) {
|
||||
return requestClient.post<any>(
|
||||
`${prefix}ai-save-prescription-drug-selection`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 金方列表(VIP:golden_formula) */
|
||||
export async function goldenFormulaListApi(data: {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}golden-formula-list`, { params: data });
|
||||
}
|
||||
|
||||
/** 金方详情 */
|
||||
export async function goldenFormulaDetailApi(data: {
|
||||
id: number;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}golden-formula-detail`, { params: data });
|
||||
}
|
||||
|
||||
/** 金方导入药名对照 */
|
||||
export async function goldenFormulaApplyApi(data: {
|
||||
id: number;
|
||||
store_id?: number;
|
||||
register_id?: number;
|
||||
prescription_type?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}golden-formula-apply`, data);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,10 @@ import {
|
||||
addDoctorMyDiseaseApi,
|
||||
deleteDoctorMyDiseaseApi,
|
||||
} from "#/views/doctor/settings/api";
|
||||
import {
|
||||
applyDictRankIfNeeded,
|
||||
splitHighlight,
|
||||
} from '#/utils/dictSearchRank';
|
||||
|
||||
const searchKey = ref('');
|
||||
// 存储接口返回的所有诊断数据
|
||||
@@ -90,32 +94,37 @@ const [Modal, modalApi] = useVbenModal({
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取诊断列表
|
||||
* 获取诊断列表(按配置匹配度排序 + 关键字高亮片段)
|
||||
* @param searchKey
|
||||
*/
|
||||
function getDiagnosisList(searchKey = '') {
|
||||
getDiseaseList(searchKey).then((res) => {
|
||||
// 处理所有数据
|
||||
const processedList = res.map((item) => {
|
||||
const kw = String(searchKey || '').trim();
|
||||
getDiseaseList(kw).then((res) => {
|
||||
const rows = Array.isArray(res) ? res : [];
|
||||
const modeFromApi = rows[0]?.rank_mode;
|
||||
const ranked = applyDictRankIfNeeded(
|
||||
rows,
|
||||
kw,
|
||||
(item: any) => String(item?.name || ''),
|
||||
(item: any) => String(item?.pinyin || ''),
|
||||
modeFromApi,
|
||||
);
|
||||
const processedList = ranked.map((item: any) => {
|
||||
item.isSelect = 0;
|
||||
// 检查是否已在常用诊断中
|
||||
item.isInMyList = doctorMyDiseaseList.value.some(
|
||||
(myItem) => myItem.disease.id === item.id
|
||||
(myItem: any) => myItem.disease.id === item.id,
|
||||
);
|
||||
if (selectDiagnosisList.value) {
|
||||
// 把values的字符串转为数组根据,分解
|
||||
const valuesArr = selectDiagnosisList.value.split(',');
|
||||
// 判断valuesArr数组中是否有item.name
|
||||
valuesArr.forEach((value) => {
|
||||
if (value === item.name) {
|
||||
item.isSelect = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
item._hlParts = splitHighlight(item.name || '', kw);
|
||||
return item;
|
||||
});
|
||||
|
||||
// 更新总数据源并重置页码
|
||||
allDiagnosisList.value = processedList;
|
||||
currentPage.value = 1;
|
||||
});
|
||||
@@ -123,7 +132,7 @@ function getDiagnosisList(searchKey = '') {
|
||||
|
||||
const getDiagnosisListChage = debounce(async (searchText = '') => {
|
||||
getDiagnosisList(searchText);
|
||||
}, 300)
|
||||
}, 500)
|
||||
|
||||
/**
|
||||
* 选择诊断
|
||||
@@ -255,7 +264,12 @@ function handlePageChange(page: number, size: number) {
|
||||
:class="{ 'is-selected': item.isSelect === 1 }"
|
||||
@click="selectDiagnosis(item)"
|
||||
>
|
||||
<span class="tag-text">{{ item.name }}</span>
|
||||
<span class="tag-text">
|
||||
<template v-for="(p, pi) in (item._hlParts || [{ text: item.name, hit: false }])" :key="pi">
|
||||
<em v-if="p.hit" class="hit">{{ p.text }}</em>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<div class="tag-action-wrapper" @click.stop>
|
||||
<Tooltip v-if="!item.isInMyList" title="加入常用">
|
||||
@@ -500,4 +514,12 @@ function handlePageChange(page: number, size: number) {
|
||||
.dark .pagination-wrapper {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.hit {
|
||||
font-style: normal;
|
||||
color: #cf1322;
|
||||
background: rgba(255, 214, 102, 0.55);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 金方导入弹窗(PC 接诊 / 在线复诊共用)
|
||||
* - VIP:golden_formula
|
||||
* - 选方后调用药名对照,确认导入时抛出与 AI 出方相同结构的 rows
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Input,
|
||||
Spin,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
goldenFormulaApplyApi,
|
||||
goldenFormulaListApi,
|
||||
} from '../api';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'import',
|
||||
payload: {
|
||||
rows: Array<Record<string, any>>;
|
||||
formulaName?: string;
|
||||
},
|
||||
): void;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const applying = ref(false);
|
||||
const keyword = ref('');
|
||||
const list = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const storeId = ref(0);
|
||||
const registerId = ref(0);
|
||||
/** 处方类型:1中药 2西药(与 ProductTypeEnum 对齐;接诊 activeCategory 1=中 2=西) */
|
||||
const prescriptionType = ref(1);
|
||||
const selectedId = ref(0);
|
||||
const matchResult = ref<any | null>(null);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
footer: false,
|
||||
class: 'w-[880px]',
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
keyword.value = '';
|
||||
list.value = [];
|
||||
matchResult.value = null;
|
||||
selectedId.value = 0;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{
|
||||
storeId?: number;
|
||||
registerId?: number;
|
||||
/** 开方页 activeCategory:1中药 2西药 → ProductType 同值 */
|
||||
category?: number;
|
||||
}>() || {};
|
||||
storeId.value = Number(data.storeId || 0);
|
||||
registerId.value = Number(data.registerId || 0);
|
||||
// activeCategory 1=中药 2=西药,与 ProductTypeEnum 一致
|
||||
prescriptionType.value = Number(data.category || 1) === 2 ? 2 : 1;
|
||||
page.value = 1;
|
||||
loadList();
|
||||
},
|
||||
});
|
||||
|
||||
/** 加载金方列表 */
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
matchResult.value = null;
|
||||
selectedId.value = 0;
|
||||
try {
|
||||
const res = await goldenFormulaListApi({
|
||||
keyword: keyword.value,
|
||||
page: page.value,
|
||||
pageSize,
|
||||
store_id: storeId.value || undefined,
|
||||
});
|
||||
list.value = res?.items || [];
|
||||
total.value = Number(res?.total || 0);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载金方失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
loadList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中金方并做药名对照
|
||||
*/
|
||||
async function handleSelect(row: any) {
|
||||
selectedId.value = Number(row.id);
|
||||
applying.value = true;
|
||||
matchResult.value = null;
|
||||
try {
|
||||
const res = await goldenFormulaApplyApi({
|
||||
id: selectedId.value,
|
||||
store_id: storeId.value || undefined,
|
||||
register_id: registerId.value || undefined,
|
||||
prescription_type: prescriptionType.value,
|
||||
});
|
||||
matchResult.value = res;
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '对照失败');
|
||||
} finally {
|
||||
applying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认导入:把 matched 转成 AI 导入同构 rows(候选默认取第一项)
|
||||
*/
|
||||
function handleConfirmImport() {
|
||||
const matched = matchResult.value?.matched || [];
|
||||
if (!matched.length) {
|
||||
message.warning('没有可导入的已匹配药品');
|
||||
return;
|
||||
}
|
||||
const rows = matched.map((m: any) => {
|
||||
const candidates = m.candidates || [];
|
||||
const selectedId = Number(m.selected_drug_id || 0);
|
||||
const candidate =
|
||||
candidates.find((c: any) => Number(c.drug_id) === selectedId) ||
|
||||
candidates[0] ||
|
||||
{};
|
||||
return {
|
||||
ai_name: m.ai_name,
|
||||
dose: m.dose,
|
||||
unit: m.unit,
|
||||
usage: m.usage,
|
||||
frequency: m.frequency,
|
||||
candidate,
|
||||
};
|
||||
});
|
||||
emit('import', {
|
||||
rows,
|
||||
formulaName: matchResult.value?.formula?.name || '',
|
||||
});
|
||||
const unmatched = matchResult.value?.unmatched || [];
|
||||
if (unmatched.length) {
|
||||
message.warning(
|
||||
`已导入 ${rows.length} 味;未匹配 ${unmatched.length} 味请手动补录`,
|
||||
);
|
||||
} else {
|
||||
message.success(`已导入金方:${matchResult.value?.formula?.name || ''}`);
|
||||
}
|
||||
modalApi.close();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: (data: Record<string, any>) => {
|
||||
modalApi.setData(data);
|
||||
modalApi.open();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal title="导入金方">
|
||||
<div class="flex gap-3" style="min-height: 420px">
|
||||
<div class="w-[340px] shrink-0 border-r pr-3">
|
||||
<div class="mb-2 flex gap-2">
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
placeholder="名称/首拼/来源/速览"
|
||||
@press-enter="onSearch"
|
||||
/>
|
||||
<Button type="primary" @click="onSearch">搜索</Button>
|
||||
</div>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="!list.length" class="py-8">
|
||||
<Empty description="暂无金方" />
|
||||
</div>
|
||||
<div v-else class="max-h-[360px] space-y-2 overflow-y-auto">
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="cursor-pointer rounded border p-2 transition hover:border-primary"
|
||||
:class="selectedId === item.id ? 'border-primary bg-primary/5' : ''"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<div class="font-medium">{{ item.name }}</div>
|
||||
<div class="mt-1 text-xs text-gray-500 line-clamp-2">
|
||||
{{ item.herb_overview || '—' }}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<Tag v-if="item.source" color="gold">{{ item.source }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="total > pageSize" class="mt-2 text-center text-xs text-gray-400">
|
||||
共 {{ total }} 条(当前第 {{ page }} 页)
|
||||
<Button
|
||||
v-if="page * pageSize < total"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="page += 1; loadList()"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<Spin :spinning="applying">
|
||||
<div v-if="!matchResult" class="py-16">
|
||||
<Empty description="请选择左侧金方进行药名对照" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="mb-2 text-base font-medium">
|
||||
{{ matchResult.formula?.name }}
|
||||
<Tag v-if="matchResult.formula?.source" class="ml-2" color="gold">
|
||||
{{ matchResult.formula.source }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-if="matchResult.formula?.indication_translation || matchResult.formula?.indication_original"
|
||||
class="mb-2 text-xs text-gray-500"
|
||||
>
|
||||
主治:
|
||||
{{
|
||||
matchResult.formula.indication_translation ||
|
||||
matchResult.formula.indication_original
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="matchResult.formula?.original_formula"
|
||||
class="mb-2 max-h-24 overflow-y-auto rounded bg-amber-50 px-2 py-1 text-xs text-gray-700 whitespace-pre-wrap"
|
||||
>
|
||||
<span class="font-medium text-amber-700">原方:</span>
|
||||
{{ matchResult.formula.original_formula }}
|
||||
</div>
|
||||
<div class="mb-2 text-sm">
|
||||
已匹配
|
||||
<Tag color="success">{{ (matchResult.matched || []).length }}</Tag>
|
||||
未匹配
|
||||
<Tag color="warning">{{ (matchResult.unmatched || []).length }}</Tag>
|
||||
</div>
|
||||
<div class="max-h-[280px] space-y-1 overflow-y-auto text-sm">
|
||||
<div
|
||||
v-for="(m, idx) in matchResult.matched || []"
|
||||
:key="'m' + idx"
|
||||
class="rounded bg-green-50 px-2 py-1"
|
||||
>
|
||||
{{ m.show_dose || m.display_text || `${m.ai_name} ${m.dose || ''}${m.unit || ''}${m.usage ? `(${m.usage})` : ''}` }}
|
||||
→
|
||||
{{ (m.candidates && m.candidates[0] && m.candidates[0].drug_name) || '—' }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(u, idx) in matchResult.unmatched || []"
|
||||
:key="'u' + idx"
|
||||
class="rounded bg-orange-50 px-2 py-1"
|
||||
>
|
||||
{{ u.show_dose || u.display_text || `${u.ai_name} ${u.dose || ''}${u.unit || ''}` }}
|
||||
(未匹配)
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 text-right">
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="!(matchResult.matched || []).length"
|
||||
@click="handleConfirmImport"
|
||||
>
|
||||
确认导入到处方
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 接诊台顶部患者简要信息条
|
||||
* 上方居中展示诊所名 + 处方类型;下方展示门诊号/日期/费别/姓名等
|
||||
* 诊断只在处方/病历编辑区一处维护
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import { formatStoreNameWithHu } from '#/utils/formatStoreNameWithHu';
|
||||
|
||||
defineOptions({ name: 'PatientBriefBar' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 挂号订单(含 order_no、created_at) */
|
||||
register?: Record<string, any> | null;
|
||||
/** 就诊人 */
|
||||
patient?: Record<string, any> | null;
|
||||
/** 当前费别:1 自费 2 医保 */
|
||||
category?: number;
|
||||
/** 诊所名称(优先;也可从 register.store.name 兜底) */
|
||||
storeName?: string;
|
||||
/**
|
||||
* 是否在线复诊开方
|
||||
* false/0:门诊普通处方笺;true/1/2/3:在线处方
|
||||
*/
|
||||
isOnline?: boolean | number;
|
||||
}>(),
|
||||
{
|
||||
register: null,
|
||||
patient: null,
|
||||
category: 1,
|
||||
storeName: '',
|
||||
isOnline: false,
|
||||
},
|
||||
);
|
||||
|
||||
const onlineFlag = computed(() => {
|
||||
if (props.isOnline === true) return 1;
|
||||
if (props.isOnline === false) return 0;
|
||||
const fromProp = Number(props.isOnline);
|
||||
if (fromProp > 0) return fromProp;
|
||||
// 挂号单上可能带 is_online
|
||||
return Number(props.register?.is_online || 0);
|
||||
});
|
||||
|
||||
/** 居中诊所名:在线渠道按既有规则追加(互) */
|
||||
const displayStoreName = computed(() => {
|
||||
const raw =
|
||||
String(props.storeName || '').trim() ||
|
||||
String(props.register?.store?.name || props.register?.store_name || '').trim();
|
||||
return formatStoreNameWithHu(raw, onlineFlag.value) || '诊所';
|
||||
});
|
||||
|
||||
/** 处方类型标题:线下普通 / 线上复诊 */
|
||||
const prescriptionTypeTitle = computed(() => {
|
||||
const online = onlineFlag.value;
|
||||
if (online === 1 || online === 2 || online === 3) {
|
||||
return '在线处方';
|
||||
}
|
||||
return '门诊普通处方笺';
|
||||
});
|
||||
|
||||
const sexText = computed(() => {
|
||||
const sex = Number(props.patient?.sex || 0);
|
||||
if (sex === 1) return '男';
|
||||
if (sex === 2) return '女';
|
||||
return '未填';
|
||||
});
|
||||
|
||||
const categoryText = computed(() => {
|
||||
return Number(props.category) === 2 ? '医保' : '自费';
|
||||
});
|
||||
|
||||
const visitDate = computed(() => {
|
||||
const raw = props.register?.created_at;
|
||||
if (!raw) return '—';
|
||||
// 后端查询器已格式化为字符串时直接展示
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' && raw > 0) {
|
||||
const d = new Date(raw * 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())}`;
|
||||
}
|
||||
return '—';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="patient-brief-bar">
|
||||
<!-- 居中抬头:诊所名 + 处方类型 -->
|
||||
<div class="brief-header">
|
||||
<div class="brief-clinic">{{ displayStoreName }}</div>
|
||||
<div class="brief-rx-type">{{ prescriptionTypeTitle }}</div>
|
||||
</div>
|
||||
<div class="brief-meta">
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">门诊号</span>
|
||||
<span class="brief-value">{{ register?.order_no || '—' }}</span>
|
||||
</div>
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">日期</span>
|
||||
<span class="brief-value">{{ visitDate }}</span>
|
||||
</div>
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">费别</span>
|
||||
<Tag :color="Number(category) === 2 ? 'blue' : 'default'" class="m-0">
|
||||
{{ categoryText }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">姓名</span>
|
||||
<span class="brief-value brief-name">{{ patient?.name || '—' }}</span>
|
||||
</div>
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">性别</span>
|
||||
<span class="brief-value">{{ sexText }}</span>
|
||||
</div>
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">年龄</span>
|
||||
<span class="brief-value">{{ patient?.age ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">手机</span>
|
||||
<span class="brief-value">
|
||||
<SensitiveText
|
||||
:record="patient"
|
||||
field="mobile"
|
||||
:show-eye="false"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.patient-brief-bar {
|
||||
padding: 10px 14px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
}
|
||||
.brief-header {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid hsl(var(--border) / 0.7);
|
||||
}
|
||||
.brief-clinic {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: hsl(var(--foreground));
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.brief-rx-type {
|
||||
margin-top: 2px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: hsl(var(--foreground));
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.brief-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.brief-item {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.brief-label {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.brief-value {
|
||||
color: hsl(var(--foreground));
|
||||
word-break: break-all;
|
||||
}
|
||||
.brief-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 查看患者信息抽屉:完整患者字段 + 健康问诊信息(不进顶栏)
|
||||
* 特色方/转诊等业务入口由父级通过 slot 注入,避免抽屉耦合开方逻辑
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { Descriptions, Empty, Tag } from 'ant-design-vue';
|
||||
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
|
||||
defineOptions({ name: 'PatientInfoDrawer' });
|
||||
|
||||
type DrawerData = {
|
||||
patient?: Record<string, any> | null;
|
||||
register?: Record<string, any> | null;
|
||||
healthInquiry?: Record<string, any> | null;
|
||||
patientName?: string;
|
||||
};
|
||||
|
||||
const meta = ref<DrawerData>({});
|
||||
|
||||
const sexText = computed(() => {
|
||||
const sex = Number(meta.value.patient?.sex || 0);
|
||||
if (sex === 1) return '男';
|
||||
if (sex === 2) return '女';
|
||||
return '未填写';
|
||||
});
|
||||
|
||||
/** 逗号分隔标签拆分(与接诊页 splitString 一致) */
|
||||
function splitString(str: unknown): string[] {
|
||||
if (!str || typeof str !== 'string') return [];
|
||||
return str.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
class: 'w-[720px]',
|
||||
title: '患者信息',
|
||||
cancelText: '关闭',
|
||||
showConfirmButton: false,
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
meta.value = {};
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<DrawerData>() || {};
|
||||
meta.value = data;
|
||||
const name = data.patientName || data.patient?.name || '';
|
||||
drawerApi.setState({
|
||||
title: name ? `患者信息 - ${name}` : '患者信息',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
defineExpose({ drawerApi });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<Descriptions
|
||||
v-if="meta.patient"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
title="基本信息"
|
||||
class="mb-4"
|
||||
>
|
||||
<Descriptions.Item label="姓名">
|
||||
{{ meta.patient.name || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="性别">
|
||||
{{ sexText }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="年龄">
|
||||
{{ meta.patient.age ?? '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">
|
||||
<SensitiveText
|
||||
:record="meta.patient"
|
||||
field="mobile"
|
||||
:show-eye="false"
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门诊号">
|
||||
{{ meta.register?.order_no || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="就诊时间">
|
||||
{{ meta.register?.created_at || '—' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Empty v-else description="暂无患者信息" class="py-6" />
|
||||
|
||||
<Descriptions
|
||||
v-if="meta.healthInquiry"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
title="健康信息"
|
||||
class="mb-4"
|
||||
>
|
||||
<Descriptions.Item label="肝功能">
|
||||
<span>{{
|
||||
meta.healthInquiry.liver_function === 0 ? '正常' : '异常'
|
||||
}}</span>
|
||||
<div v-if="meta.healthInquiry.liver_function === 1" class="mt-2">
|
||||
<Tag
|
||||
v-for="(item, idx) in splitString(meta.healthInquiry.liver_index)"
|
||||
:key="`liver-${idx}`"
|
||||
color="#455cda"
|
||||
>
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="肾功能">
|
||||
<span>{{
|
||||
meta.healthInquiry.renal_function === 0 ? '正常' : '异常'
|
||||
}}</span>
|
||||
<div v-if="meta.healthInquiry.renal_function === 1" class="mt-2">
|
||||
<Tag
|
||||
v-for="(item, idx) in splitString(meta.healthInquiry.renal_index)"
|
||||
:key="`renal-${idx}`"
|
||||
color="#455cda"
|
||||
>
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="既往史" :span="2">
|
||||
<span>{{
|
||||
meta.healthInquiry.person_status === 0 ? '无' : '有'
|
||||
}}</span>
|
||||
<p v-if="meta.healthInquiry.person_status === 1" class="mt-2 mb-0">
|
||||
{{ meta.healthInquiry.person_history || '—' }}
|
||||
</p>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="过敏史" :span="2">
|
||||
<span>{{
|
||||
meta.healthInquiry.allergic_status === 0 ? '无' : '有'
|
||||
}}</span>
|
||||
<div v-if="meta.healthInquiry.allergic_status === 1" class="mt-2">
|
||||
<Tag
|
||||
v-for="(item, idx) in splitString(
|
||||
meta.healthInquiry.allergic_history,
|
||||
)"
|
||||
:key="`allergy-${idx}`"
|
||||
color="orange"
|
||||
>
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="家庭遗传史" :span="2">
|
||||
<span>{{
|
||||
meta.healthInquiry.family_status === 0 ? '无' : '有'
|
||||
}}</span>
|
||||
<p v-if="meta.healthInquiry.family_status === 1" class="mt-2 mb-0">
|
||||
{{ meta.healthInquiry.family_history || '—' }}
|
||||
</p>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Empty
|
||||
v-else
|
||||
description="暂无健康问诊信息"
|
||||
class="py-4"
|
||||
/>
|
||||
|
||||
<slot name="extra" />
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,326 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 患者处方记录抽屉:本次 | 历史,支持按订单号/订单ID搜索、展示支付态、复用/撤回
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Input,
|
||||
Spin,
|
||||
Tabs,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
getPatientPrescriptionHistoryApi,
|
||||
getRegisterPrescriptionListApi,
|
||||
withdrawPrescriptionApi,
|
||||
} from '../api';
|
||||
|
||||
defineOptions({ name: 'PatientPrescriptionHistoryDrawer' });
|
||||
|
||||
const TabPane = Tabs.TabPane;
|
||||
|
||||
type DrawerData = {
|
||||
registerId?: number;
|
||||
userPatientId?: number;
|
||||
patientName?: string;
|
||||
/** 复用回调:传入处方 id */
|
||||
onReuse?: (prescriptionId: number) => void | Promise<void>;
|
||||
/** 点击处方号打开详情 */
|
||||
onDetail?: (prescriptionId: number) => void;
|
||||
/** 是否允许撤回(线下接诊) */
|
||||
allowWithdraw?: boolean;
|
||||
/** 撤回成功后刷新 */
|
||||
onWithdrawn?: () => void;
|
||||
};
|
||||
|
||||
const meta = ref<DrawerData>({});
|
||||
const loading = ref(false);
|
||||
const withdrawingId = ref(0);
|
||||
const listTab = ref<'current' | 'history'>('current');
|
||||
const keyword = ref('');
|
||||
const list = ref<Record<string, any>[]>([]);
|
||||
const page = ref(1);
|
||||
const hasMore = ref(false);
|
||||
const pageSize = 20;
|
||||
|
||||
const categoryLabel = (c: number) => (Number(c) === 2 ? '医保' : '自费');
|
||||
|
||||
const typeLabel = (t: number) => {
|
||||
const map: Record<number, string> = {
|
||||
1: '中药',
|
||||
2: '西药',
|
||||
3: '简单产品',
|
||||
5: '简单产品',
|
||||
6: '简单产品',
|
||||
7: '简单产品',
|
||||
};
|
||||
return map[Number(t)] || `类型${t}`;
|
||||
};
|
||||
|
||||
const payColor = (row: Record<string, any>) => {
|
||||
const text = String(row.order_pay_text || '');
|
||||
if (text.includes('待支付')) return 'orange';
|
||||
if (text.includes('已支付')) return 'green';
|
||||
if (text.includes('取消')) return 'default';
|
||||
return 'blue';
|
||||
};
|
||||
|
||||
async function loadList(reset = true) {
|
||||
if (reset) {
|
||||
page.value = 1;
|
||||
list.value = [];
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const kw = keyword.value.trim();
|
||||
let res: any;
|
||||
if (listTab.value === 'current') {
|
||||
const rid = Number(meta.value.registerId || 0);
|
||||
if (!rid) {
|
||||
list.value = [];
|
||||
return;
|
||||
}
|
||||
res = await getRegisterPrescriptionListApi({
|
||||
register_id: rid,
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
keyword: kw || undefined,
|
||||
});
|
||||
} else {
|
||||
const pid = Number(meta.value.userPatientId || 0);
|
||||
if (!pid) {
|
||||
list.value = [];
|
||||
return;
|
||||
}
|
||||
res = await getPatientPrescriptionHistoryApi({
|
||||
patient_id: pid,
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
exclude_register_id: Number(meta.value.registerId || 0) || undefined,
|
||||
keyword: kw || undefined,
|
||||
});
|
||||
}
|
||||
const rows = Array.isArray(res?.list) ? res.list : [];
|
||||
list.value = reset ? rows : [...list.value, ...rows];
|
||||
hasMore.value = !!res?.has_more;
|
||||
} catch (e: any) {
|
||||
if (reset) list.value = [];
|
||||
message.error(e?.message || '加载处方失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
loadList(true);
|
||||
}
|
||||
|
||||
function onTabChange(key: string | number) {
|
||||
listTab.value = key === 'history' ? 'history' : 'current';
|
||||
loadList(true);
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore.value || loading.value) return;
|
||||
page.value += 1;
|
||||
loadList(false);
|
||||
}
|
||||
|
||||
async function handleReuse(row: Record<string, any>) {
|
||||
const id = Number(row.id || 0);
|
||||
if (!id) return;
|
||||
try {
|
||||
await meta.value.onReuse?.(id);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '复用失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击处方号打开处方详情弹窗 */
|
||||
function handleOpenDetail(row: Record<string, any>) {
|
||||
const id = Number(row.id || 0);
|
||||
if (!id) return;
|
||||
if (typeof meta.value.onDetail === 'function') {
|
||||
meta.value.onDetail(id);
|
||||
return;
|
||||
}
|
||||
message.warning('暂无法打开处方详情');
|
||||
}
|
||||
|
||||
async function handleWithdraw(row: Record<string, any>) {
|
||||
const prescriptionId = Number(row.id || 0);
|
||||
const registerId = Number(row.register_id || meta.value.registerId || 0);
|
||||
if (!prescriptionId || !registerId) return;
|
||||
withdrawingId.value = prescriptionId;
|
||||
try {
|
||||
await withdrawPrescriptionApi({
|
||||
prescription_id: prescriptionId,
|
||||
register_id: registerId,
|
||||
});
|
||||
message.success('撤回成功');
|
||||
meta.value.onWithdrawn?.();
|
||||
await loadList(true);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '撤回失败');
|
||||
} finally {
|
||||
withdrawingId.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const titleText = computed(() => {
|
||||
const name = meta.value.patientName ? ` - ${meta.value.patientName}` : '';
|
||||
return `患者处方记录${name}`;
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
class: 'w-[820px]',
|
||||
title: '患者处方记录',
|
||||
cancelText: '关闭',
|
||||
showConfirmButton: false,
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
meta.value = {};
|
||||
list.value = [];
|
||||
keyword.value = '';
|
||||
listTab.value = 'current';
|
||||
page.value = 1;
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<DrawerData>() || {};
|
||||
meta.value = data;
|
||||
drawerApi.setState({ title: titleText.value });
|
||||
loadList(true);
|
||||
},
|
||||
});
|
||||
|
||||
defineExpose({ drawerApi });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Input.Search
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
placeholder="按订单号或订单ID搜索"
|
||||
style="max-width: 280px"
|
||||
@search="onSearch"
|
||||
/>
|
||||
<Button @click="onSearch">查询</Button>
|
||||
</div>
|
||||
<Tabs :active-key="listTab" @change="onTabChange">
|
||||
<TabPane key="current" tab="本次" />
|
||||
<TabPane key="history" tab="历史" />
|
||||
</Tabs>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="list.length === 0 && !loading" class="py-10">
|
||||
<Empty description="暂无处方记录" />
|
||||
</div>
|
||||
<div v-else class="rx-history-list">
|
||||
<div
|
||||
v-for="row in list"
|
||||
:key="row.id"
|
||||
class="rx-history-item"
|
||||
>
|
||||
<div class="rx-history-main">
|
||||
<div class="rx-history-title">
|
||||
<a
|
||||
class="cursor-pointer font-medium text-primary hover:underline"
|
||||
title="查看处方详情"
|
||||
@click.prevent="handleOpenDetail(row)"
|
||||
>
|
||||
{{ row.prescription_no || `#${row.id}` }}
|
||||
</a>
|
||||
<Tag class="ml-2">{{ typeLabel(row.prescription_type) }}</Tag>
|
||||
<Tag>{{ categoryLabel(row.category) }}</Tag>
|
||||
<Tag :color="payColor(row)">{{ row.order_pay_text || '—' }}</Tag>
|
||||
</div>
|
||||
<div class="rx-history-meta text-muted-foreground">
|
||||
<span>开方时间:{{ row.created_at || '—' }}</span>
|
||||
<span class="ml-3">金额:¥{{ row.total_pay_price ?? '—' }}</span>
|
||||
<span v-if="row.order_no" class="ml-3">订单号:{{ row.order_no }}</span>
|
||||
<span v-if="row.order_id" class="ml-3">订单ID:{{ row.order_id }}</span>
|
||||
<span
|
||||
v-if="listTab === 'history' && row.register?.order_no"
|
||||
class="ml-3"
|
||||
>
|
||||
挂号:{{ row.register.order_no }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-history-actions">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleReuse(row)"
|
||||
>
|
||||
复用
|
||||
</Button>
|
||||
<Button
|
||||
v-if="meta.allowWithdraw && row.can_withdraw && listTab === 'current'"
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
:loading="withdrawingId === row.id"
|
||||
@click="handleWithdraw(row)"
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasMore" class="mt-3 text-center">
|
||||
<Button type="link" :loading="loading" @click="loadMore">加载更多</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rx-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: calc(100vh - 260px);
|
||||
overflow: auto;
|
||||
}
|
||||
.rx-history-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
}
|
||||
.rx-history-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.rx-history-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.rx-history-meta {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.rx-history-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.text-muted-foreground {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -322,7 +322,21 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor?.name }}</span>
|
||||
<!-- 二次签名:相冲继续开方时展示第二张医生签名 -->
|
||||
<img
|
||||
v-if="
|
||||
item.doctor_second_sign == 1 &&
|
||||
item.doctor_info?.identity_info?.sign_image
|
||||
"
|
||||
:src="
|
||||
getImageSource(item.doctor_info.identity_info.sign_image)
|
||||
"
|
||||
alt="二次签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else-if="!item.doctor_info?.identity_info?.sign_image">{{
|
||||
item.content?.doctor?.name
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>审核药师:</label>
|
||||
|
||||
@@ -141,8 +141,10 @@ const getDrugListByWesternModal = debounce(async (searchText = '') => {
|
||||
select_number: matched?.select_number || 0,
|
||||
drug: {
|
||||
...item.drug,
|
||||
// 如果已选,使用已选的数量,否则默认为1
|
||||
number: matched?.number || 1,
|
||||
// 中药克数不默认 1,需医生手动填写;西药仍默认 1
|
||||
number:
|
||||
matched?.number ??
|
||||
(Number(type.value) === 1 ? undefined : 1),
|
||||
// 如果已选,同步已选的用法
|
||||
frequency_id: matched?.frequency_id || item.drug.frequency_id,
|
||||
type_id: matched?.type_id || item.drug.type_id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1433
apps/web-antd/src/views/doctor/medical-record/MedicalRecordPanel.vue
Normal file
1433
apps/web-antd/src/views/doctor/medical-record/MedicalRecordPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user