Compare commits
28 Commits
dev/distri
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a504433da | ||
|
|
33dcd3f5d8 | ||
|
|
efd7e8ffc8 | ||
|
|
5b9f3ec3ed | ||
|
|
e434aa9534 | ||
|
|
32eb96b4ad | ||
|
|
9f37802ba7 | ||
|
|
cfc8390573 | ||
|
|
174693f6db | ||
|
|
c1bc234509 | ||
|
|
dc9f9c5c13 | ||
|
|
0331325929 | ||
|
|
9c629ff8ee | ||
|
|
bd5b0b2de1 | ||
|
|
029b45d0bb | ||
|
|
212b16a8ec | ||
|
|
feebf4b14e | ||
|
|
3bc22d02e6 | ||
|
|
371594bfda | ||
|
|
52a1cb1ce5 | ||
|
|
5506c8917f | ||
|
|
05885ed405 | ||
|
|
c8045e1da8 | ||
|
|
14414bf9b0 | ||
| 777737d063 | |||
| 2b13a19731 | |||
| 4224130ad4 | |||
| 2e7bc80a11 |
@@ -1,10 +0,0 @@
|
||||
---
|
||||
description:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
|
||||
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于2行
|
||||
3. 有封装好的方法、组件需要复用,不要重复造轮子
|
||||
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面(记得使用v-if而不是v-show)
|
||||
5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化
|
||||
@@ -1,10 +1,155 @@
|
||||
---
|
||||
description:
|
||||
description: 萧康云医管理后台(Vben Admin v5 + Vue 3 + TS + Ant Design Vue)代码规范
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 基础规范(所有项目通用)
|
||||
|
||||
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
|
||||
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于2行
|
||||
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于 2 行
|
||||
3. 有封装好的方法、组件需要复用,不要重复造轮子
|
||||
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面(记得使用v-if而不是v-show)
|
||||
4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(记得使用 v-if 而不是 v-show)
|
||||
5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化
|
||||
|
||||
# 全局架构规范
|
||||
|
||||
## 目录结构规范
|
||||
|
||||
- 业务页面统一放在 `apps/web-antd/src/views/<端>/<模块>/`(端:system / business / doctor 等)
|
||||
- 标准 CRUD 模块目录结构:
|
||||
|
||||
```
|
||||
views/<端>/<模块>/
|
||||
├── index.vue # 列表页(Page + Grid + Modal)
|
||||
├── api/index.ts # 本模块 API(CRUD)
|
||||
├── config/
|
||||
│ ├── table.ts # vxe-grid 列定义 + proxyConfig
|
||||
│ ├── search.ts # 顶部搜索表单 schema
|
||||
│ └── form.ts # 新增/编辑弹窗 form schema
|
||||
├── components/
|
||||
│ └── modal.vue # 新增/编辑弹窗
|
||||
└── utils/ # 模块工具(可选)
|
||||
```
|
||||
|
||||
- 全局复用组件放 `apps/web-antd/src/components/`
|
||||
- 表单内可用组件放 `apps/web-antd/src/components/form/components/`,文件名 kebab-case,自动注册为 schema 的 `component` 名(PascalCase 引用)
|
||||
|
||||
## 强制复用封装(禁止重复造轮子)
|
||||
|
||||
- 表格统一用 `useVbenVxeGrid`(来自 `#/adapter/vxe-table`),**不要直接 new VxeGrid**
|
||||
- 表单统一用 `useVbenForm`(来自 `#/adapter/form`),**不要直接写 `<Form>` + 手动校验**
|
||||
- 弹窗统一用 `useVbenModal`(来自 `@vben/common-ui`),**不要用原生 `<Modal>`**
|
||||
- 页面外壳统一用 `<Page>` 组件(来自 `@vben/common-ui`)
|
||||
- HTTP 请求统一用 `requestClient`(来自 `#/api/request`),**禁止直接 axios / fetch**
|
||||
- 行操作和工具栏按钮统一用 `<TableAction>` 组件(来自 `#/components/table-action`)
|
||||
- 业务选择器(员工、医生、门店、推广员、地区等)必须复用 `#/components/form/components/*-picker.vue`,不要重写
|
||||
|
||||
## API 层规范
|
||||
|
||||
- API 文件统一放在 `<模块>/api/index.ts`
|
||||
- 函数命名约定:`get{Entity}List` / `get{Entity}Option` / `get{Entity}Info` / `create{Entity}` / `update{Entity}` / `delete{Entity}`
|
||||
- URL 使用 kebab-case,统一前缀变量 `const prefix = 'xxx/'`,**URL 末尾不补 `/`**
|
||||
- GET 用 `params`,POST 用 `data`,例如:
|
||||
|
||||
```ts
|
||||
import { requestClient } from '#/api/request';
|
||||
const prefix = 'oa-robot/';
|
||||
export async function getOaRobotList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
```
|
||||
|
||||
## 字段命名对齐后端
|
||||
|
||||
- 数据库字段在前端 schema 的 `field` / `fieldName` 中保持 snake_case(如 `created_at`、`store_id`、`platform_code`),**不要转换为 camelCase**
|
||||
- 时间戳字段(`created_at` 等)后端查询器已格式化为字符串,前端**无需再次格式化**
|
||||
- TS 变量用 camelCase,常量用 UPPER_SNAKE_CASE,类型用 PascalCase
|
||||
|
||||
## 表单校验
|
||||
|
||||
- 必填用字符串规则 `rules: 'required'`(选择项用 `'selectRequired'`),规则定义在 `#/adapter/form.ts` 的 `defineRules`
|
||||
- 校验调用统一用 `formApi.validate().then(e => { if (e.valid) {...} })`
|
||||
- 必填字段在 schema 中加 `rules: 'required'`,禁止散落 `validator` 写法
|
||||
|
||||
## 字典/枚举管理
|
||||
|
||||
- 模块内建 `config/constants.ts`,导出 `{ label, value }[]` 数组,禁止在 schema 中散落字面量
|
||||
- 例如:
|
||||
|
||||
```ts
|
||||
export const OA_MESSAGE_TYPE_OPTIONS = [
|
||||
{ label: '文本', value: 'text' },
|
||||
{ label: 'Markdown', value: 'markdown' },
|
||||
];
|
||||
```
|
||||
|
||||
## 路由规范
|
||||
|
||||
- 业务路由通过后端动态菜单驱动(`xk_menu` 表),**不要在前端 `router/routes/modules/` 静态注册业务路由**
|
||||
- `defineOptions({ name: 'PascalCaseName' })` 必须与 `xk_menu.name` 字段一致
|
||||
|
||||
## Tab 用法
|
||||
|
||||
- 统一用 antd 原生 `Tabs` + `Tabs.TabPane`,**不要找 Vben 自封装的 Tabs**
|
||||
- Tab 选中状态持久化走 `localStorage`(参考 `system-config/index.vue` 的 `TAB_STORAGE_KEY` 模式)
|
||||
|
||||
## 抽屉用法(小程序端,admin 不涉及)
|
||||
|
||||
- 小程序端的抽屉用 `page-container` + `v-if`(不是 `v-show`)防止用户意外退出页面
|
||||
|
||||
## 中文注释
|
||||
|
||||
- 每个 `<script setup>` 顶部说明模块用途
|
||||
- 每个主要函数(特别是有业务逻辑的)必须有中文注释
|
||||
- 复杂的 schema 字段配置要注释意图
|
||||
|
||||
## 空行控制
|
||||
|
||||
- 上一部分代码和下一部分代码中间空行不超过 2 行
|
||||
|
||||
## Vben 组件使用规范
|
||||
|
||||
### VbenModal(来自 @vben/common-ui)
|
||||
|
||||
- 创建弹窗必须用 `useVbenModal`,禁止直接用 antd `Modal` 或原生 `<dialog>`
|
||||
- 抽离弹窗内容到子组件时,外层用 `connectedComponent` 参数连接,内外组件共享 `modalApi`
|
||||
- 底部按钮扩展 slot 优先级:`prepend-footer`(取消按钮左侧)> `center-footer`(取消/确定之间)> `append-footer`(确定右侧)
|
||||
- **严禁覆盖 `#footer` slot**,会丢失默认的取消/确定按钮和 loading 行为
|
||||
- 表单提交 loading 走 `modalApi.setState({ confirmLoading: true })`,禁止自己写 button loading
|
||||
- 提交防抖/防重复用 `modalApi.lock()` / `unlock()`(>5.5.3),禁止手动 disabled
|
||||
- 拖拽开 `draggable: true`,需要拖出可视区时同时开 `overflow: true`
|
||||
- 数据回填走 `modalApi.setData()` + `onOpenChange(isOpen) { const data = modalApi.getData() }`
|
||||
|
||||
### VbenDrawer(来自 @vben/common-ui)
|
||||
|
||||
- 创建抽屉必须用 `useVbenDrawer`,禁止直接用 antd `Drawer`
|
||||
- 底部按钮扩展 slot 与 Modal 一致:`prepend-footer` / `center-footer` / `append-footer`
|
||||
- **同样严禁覆盖 `#footer` slot**
|
||||
- 关闭前校验用 `onBeforeClose`(>5.5.2 支持 Promise),禁止自己拦截
|
||||
- 提交防抖/防重复用 `drawerApi.lock()` / `unlock()`(>5.5.3)
|
||||
- 小程序端的「抽屉」概念与此无关,小程序端按现有规则用 `page-container`
|
||||
|
||||
### VbenVxeTable(来自 #/adapter/vxe-table)
|
||||
|
||||
- 表格必须用 `useVbenVxeGrid`,禁止直接 `new VxeGrid` 或 antd `Table`
|
||||
- 远程数据加载走 `gridOptions.proxyConfig.ajax.query`,**禁止自己 fetch 后 `data = []` 赋值**
|
||||
- query 接口返回结构必须匹配适配器配置:`{ items: [], total: number }`(在 `apps/web-antd/src/adapter/vxe-table.ts` 的 `response` 字段统一配置)
|
||||
- 分页参数从 `{ page }` 解构取:`page.currentPage` / `page.pageSize`,禁止自己读 URL
|
||||
- 刷新表格用 `gridApi.query()`(保留当前页)或 `gridApi.reload()`(回到第一页),禁止整页 `window.location.reload()`
|
||||
- 工具栏按钮插 slot:`toolbar-actions`(标题左侧)、`toolbar-tools`(工具按钮左侧),禁止改 vxe-grid 内部模板
|
||||
- 表格 loading 用 `gridApi.setLoading(bool)`,禁止自己遮罩
|
||||
- 搜索表单由 `formOptions` 配置(底层是 Vben Form),开关走 `gridOptions.toolbarConfig.search = true`
|
||||
- 自定义列渲染走 `slots: { default: 'action' }` + Grid 子组件内 `<template #action="{ row }">`,禁止 column 渲染函数里写 JSX
|
||||
|
||||
### VbenForm(来自 #/adapter/form)
|
||||
|
||||
- 表单必须用 `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` 注册
|
||||
- schema 中字段名(`fieldName`)必须与后端字段 snake_case 对齐,禁止前端 camelCase
|
||||
- 密码/敏感字段用 `VbenInputPassword` 组件,禁止用 `VbenInput` 配 `type="password"`
|
||||
- 单选/多选选项来自接口时,必须在弹窗/页面 `onMounted` 或 `onOpenChange` 里拉取后通过 `updateSchema` 注入,禁止写死常量
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export type ComponentType =
|
||||
| 'DatePicker'
|
||||
| 'DefaultButton'
|
||||
| 'Divider'
|
||||
| 'GalleryPickLink' // OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型)
|
||||
| 'IconPicker'
|
||||
| 'Input'
|
||||
| 'InputNumber'
|
||||
@@ -81,6 +82,7 @@ export type ComponentType =
|
||||
| 'UploadImage'
|
||||
| 'UploadImageSortable'
|
||||
| 'UploadOssFile'
|
||||
| 'UploadDraggerPaste'
|
||||
| BaseFormComponentType;
|
||||
|
||||
async function initComponentAdapter() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,11 +8,17 @@ const props = withDefaults(
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 限制可选择的文件类型(透传给 image-gallery-picker,按 xk_file.type / xk_file_type.value 过滤)
|
||||
* 例如图片:[0];视频:[1];文件:[6];语音:[3]
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}>(),
|
||||
{
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
disabled: false,
|
||||
acceptTypes: () => [] as number[],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -41,6 +47,7 @@ function onSelect(urls: string[]) {
|
||||
v-model:open="galleryOpen"
|
||||
:multiple="multiple"
|
||||
:max-count="maxCount"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue';
|
||||
|
||||
@@ -14,10 +14,18 @@ const props = withDefaults(
|
||||
open: boolean;
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
|
||||
* - 不传:显示全部类型(默认行为,保持向后兼容)
|
||||
* - 传数组:只展示指定类型 tab,且自动锁定到第一个匹配的类型
|
||||
* 例如图片:[0];图片+视频:[0, 1];文件:[6]
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}>(),
|
||||
{
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
acceptTypes: () => [] as number[],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -34,6 +42,14 @@ const pageSize = ref(24);
|
||||
const pageSizeOptions = ['12', '24', '48'];
|
||||
const selected = ref<string[]>([]);
|
||||
|
||||
/** 按 acceptTypes 隔离 localStorage,避免图片/视频选择器互相污染 */
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`file-picker-type-${
|
||||
props.acceptTypes?.length ? props.acceptTypes.join('-') : 'all'
|
||||
}`,
|
||||
);
|
||||
|
||||
const {
|
||||
activeType,
|
||||
keyword,
|
||||
@@ -42,14 +58,55 @@ const {
|
||||
buildListParams,
|
||||
fileTypes,
|
||||
typesLoading,
|
||||
} = useFileGalleryFilter('file-picker-active-type');
|
||||
} = useFileGalleryFilter(() => storageKey.value);
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/**
|
||||
* 实际渲染的 tabs(按 acceptTypes 过滤)
|
||||
*/
|
||||
const visibleTabs = computed(() => {
|
||||
if (!props.acceptTypes || props.acceptTypes.length === 0) {
|
||||
return tabs.value;
|
||||
}
|
||||
return tabs.value.filter(
|
||||
(tab) => tab.value !== -1 && props.acceptTypes.includes(Number(tab.value)),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 强制锁定到 acceptTypes 首项(打开弹窗 / 类型字典就绪时调用)
|
||||
*/
|
||||
function lockAcceptType() {
|
||||
const arr = props.acceptTypes;
|
||||
if (!arr || arr.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (activeType.value !== arr[0]) {
|
||||
setActiveType(arr[0]!);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否允许拉列表:有 acceptTypes 时需等类型字典加载完且 visibleTabs 非空
|
||||
*/
|
||||
function canLoadList() {
|
||||
if (typesLoading.value) {
|
||||
return false;
|
||||
}
|
||||
if (props.acceptTypes && props.acceptTypes.length > 0) {
|
||||
return visibleTabs.value.length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!canLoadList()) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getFileGalleryList(buildListParams(page.value, pageSize.value));
|
||||
const res = await getFileGalleryList(
|
||||
buildListParams(page.value, pageSize.value),
|
||||
);
|
||||
const data = (res as any)?.data ?? res;
|
||||
items.value = data?.items ?? [];
|
||||
total.value = data?.total ?? 0;
|
||||
@@ -58,21 +115,37 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.acceptTypes,
|
||||
(arr) => {
|
||||
if (!arr || arr.length === 0) {
|
||||
return;
|
||||
}
|
||||
lockAcceptType();
|
||||
page.value = 1;
|
||||
void load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
selected.value = [];
|
||||
page.value = 1;
|
||||
if (!typesLoading.value) {
|
||||
void load();
|
||||
}
|
||||
// 打开时再次按 acceptTypes 重锁,避免沿用其它选择器的视频 tab
|
||||
lockAcceptType();
|
||||
void load();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(typesLoading, (val) => {
|
||||
if (!val && props.open) {
|
||||
lockAcceptType();
|
||||
void load();
|
||||
}
|
||||
});
|
||||
@@ -143,6 +216,11 @@ function onSearch(value: string) {
|
||||
function itemIcon(item: FileGalleryItem) {
|
||||
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
|
||||
}
|
||||
|
||||
/** 图片类型展示缩略图(xk_file.type:0=图片;兼容历史错误写入的 1) */
|
||||
function isImageItem(item: FileGalleryItem) {
|
||||
return Number(item.type) === 0 || Number(item.type) === 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -155,7 +233,7 @@ function itemIcon(item: FileGalleryItem) {
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<Tabs :active-key="String(activeType)" size="small" @change="onTabChange">
|
||||
<Tabs.TabPane v-for="tab in tabs" :key="String(tab.value)">
|
||||
<Tabs.TabPane v-for="tab in visibleTabs" :key="String(tab.value)">
|
||||
<template #tab>
|
||||
<span class="tab-label">
|
||||
<MIcon v-if="tab.icon" :icon="tab.icon" size="12" />
|
||||
@@ -176,7 +254,7 @@ function itemIcon(item: FileGalleryItem) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spin :spinning="loading">
|
||||
<Spin :spinning="loading || typesLoading">
|
||||
<div v-if="items.length" class="gallery-grid">
|
||||
<div
|
||||
v-for="item in items"
|
||||
@@ -185,10 +263,17 @@ function itemIcon(item: FileGalleryItem) {
|
||||
:class="{ active: isSelected(item.url) }"
|
||||
@click="toggleSelect(item.url)"
|
||||
>
|
||||
<img v-if="item.type === 0" :src="item.url" alt="" class="gallery-thumb" />
|
||||
<img
|
||||
v-if="isImageItem(item)"
|
||||
:src="item.url"
|
||||
alt=""
|
||||
class="gallery-thumb"
|
||||
/>
|
||||
<div v-else class="gallery-file-icon">
|
||||
<MIcon :icon="itemIcon(item)" size="28" />
|
||||
<span class="file-name">{{ item.file_name || item.original_name || '未命名' }}</span>
|
||||
<span class="file-name">{{
|
||||
item.file_name || item.original_name || '未命名'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="gallery-meta">
|
||||
<div class="file-title">
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用拖拽 + 粘贴上传:Dragger 点击/拖拽,下方外侧「开始监听粘贴」
|
||||
* v-model 为已上传 URL 字符串数组;列表支持图片/PDF 预览(blob,避免直接下载)
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { Button, Image, Modal, Upload, message } from 'ant-design-vue';
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { uploadToOss } from '#/utils/oss-upload';
|
||||
import {
|
||||
matchAccept,
|
||||
usePasteUploadListen,
|
||||
} from '#/utils/use-paste-upload-listen';
|
||||
|
||||
defineOptions({ name: 'UploadDraggerPaste' });
|
||||
|
||||
interface FileItem {
|
||||
uid: string;
|
||||
name: string;
|
||||
status: 'uploading' | 'done' | 'error';
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue?: string[];
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/** 单文件最大 MB */
|
||||
maxSizeMb?: number;
|
||||
tip?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
accept: '.jpg,.jpeg,.png,.gif,.webp,.pdf,image/*,application/pdf',
|
||||
multiple: true,
|
||||
maxCount: 9,
|
||||
maxSizeMb: 20,
|
||||
tip: '点击或拖拽文件到此处上传',
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]];
|
||||
}>();
|
||||
|
||||
const UploadDragger = Upload.Dragger;
|
||||
const fileList = ref<FileItem[]>([]);
|
||||
const uploading = ref(false);
|
||||
|
||||
/** 文件预览弹窗状态 */
|
||||
const previewVisible = ref(false);
|
||||
const previewTitle = ref('');
|
||||
const previewIsImage = ref(false);
|
||||
const previewImageUrl = ref('');
|
||||
const previewPdfUrl = ref('');
|
||||
const previewLoading = ref(false);
|
||||
|
||||
const maxBytes = computed(() => props.maxSizeMb * 1024 * 1024);
|
||||
|
||||
function syncFromValue(urls: string[]) {
|
||||
fileList.value = (urls || []).map((url) => ({
|
||||
uid: url,
|
||||
name: url.split('/').pop() || 'file',
|
||||
status: 'done' as const,
|
||||
url,
|
||||
}));
|
||||
}
|
||||
|
||||
syncFromValue(props.modelValue);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
const current = fileList.value
|
||||
.filter((f) => f.status === 'done')
|
||||
.map((f) => f.url);
|
||||
if (JSON.stringify(val || []) !== JSON.stringify(current)) {
|
||||
syncFromValue(val || []);
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function emitUrls() {
|
||||
const urls = fileList.value
|
||||
.filter((f) => f.status === 'done' && f.url)
|
||||
.map((f) => f.url);
|
||||
emit('update:modelValue', urls);
|
||||
}
|
||||
|
||||
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 = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览已上传文件:图片用 Image;PDF fetch 成 blob 后 iframe,避免 OSS 直接下载
|
||||
*/
|
||||
async function previewItem(item: FileItem) {
|
||||
if (!item.url) return;
|
||||
revokePdfBlob();
|
||||
previewTitle.value = item.name || '预览';
|
||||
if (isImageUrl(item.url)) {
|
||||
previewIsImage.value = true;
|
||||
previewImageUrl.value = item.url;
|
||||
previewVisible.value = true;
|
||||
return;
|
||||
}
|
||||
previewIsImage.value = false;
|
||||
previewLoading.value = true;
|
||||
previewVisible.value = true;
|
||||
try {
|
||||
const resp = await fetch(item.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('预览失败');
|
||||
previewVisible.value = false;
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPreviewClose() {
|
||||
previewVisible.value = false;
|
||||
revokePdfBlob();
|
||||
previewImageUrl.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单个文件并写入列表
|
||||
*/
|
||||
async function uploadOneFile(file: File) {
|
||||
if (props.disabled) return;
|
||||
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||||
if (doneCount >= props.maxCount) {
|
||||
message.warning(`最多上传 ${props.maxCount} 个文件`);
|
||||
return;
|
||||
}
|
||||
if (!matchAccept(file, props.accept)) {
|
||||
message.warning('文件类型不符合要求');
|
||||
return;
|
||||
}
|
||||
if (file.size > maxBytes.value) {
|
||||
message.warning(`文件不能超过 ${props.maxSizeMb}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
fileList.value = [
|
||||
...fileList.value,
|
||||
{ uid, name: file.name, status: 'uploading', url: '' },
|
||||
];
|
||||
uploading.value = true;
|
||||
try {
|
||||
const uploadMethod = preferences.app.uploadMethod || 'direct';
|
||||
const res =
|
||||
uploadMethod === 'direct'
|
||||
? await uploadToOss({ file })
|
||||
: await uploadFile({ file });
|
||||
const url = res?.url || '';
|
||||
if (!url) throw new Error('上传失败');
|
||||
fileList.value = fileList.value.map((item) =>
|
||||
item.uid === uid ? { ...item, status: 'done' as const, url } : item,
|
||||
);
|
||||
emitUrls();
|
||||
} catch (e: any) {
|
||||
fileList.value = fileList.value.filter((item) => item.uid !== uid);
|
||||
message.error(e?.message || '上传失败');
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ant Design Upload customRequest */
|
||||
const customRequest = async (options: any) => {
|
||||
const { file, onSuccess, onError } = options;
|
||||
try {
|
||||
await uploadOneFile(file as File);
|
||||
onSuccess?.({});
|
||||
} catch (e) {
|
||||
onError?.(e);
|
||||
}
|
||||
};
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
if (props.disabled) return Upload.LIST_IGNORE;
|
||||
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||||
if (doneCount >= props.maxCount) {
|
||||
message.warning(`最多上传 ${props.maxCount} 个文件`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (!matchAccept(file, props.accept)) {
|
||||
message.warning('文件类型不符合要求');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (file.size > maxBytes.value) {
|
||||
message.warning(`文件不能超过 ${props.maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleRemove(file: UploadFile) {
|
||||
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
|
||||
emitUrls();
|
||||
return true;
|
||||
}
|
||||
|
||||
const {
|
||||
listening: pasteListening,
|
||||
toggle: togglePaste,
|
||||
} = usePasteUploadListen({
|
||||
get accept() {
|
||||
return props.accept;
|
||||
},
|
||||
get maxSize() {
|
||||
return maxBytes.value;
|
||||
},
|
||||
onFiles: async (files) => {
|
||||
for (const file of files) {
|
||||
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||||
if (doneCount >= props.maxCount) {
|
||||
message.warning(`最多上传 ${props.maxCount} 个文件`);
|
||||
break;
|
||||
}
|
||||
await uploadOneFile(file);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function onTogglePaste() {
|
||||
if (props.disabled) return;
|
||||
togglePaste();
|
||||
if (pasteListening.value) {
|
||||
message.success('已开启粘贴监听,可 Ctrl+V 粘贴文件/截图');
|
||||
} else {
|
||||
message.info('已停止粘贴监听');
|
||||
}
|
||||
}
|
||||
|
||||
const showDragger = computed(() => {
|
||||
if (!props.multiple) {
|
||||
return fileList.value.filter((f) => f.status === 'done').length === 0;
|
||||
}
|
||||
return fileList.value.filter((f) => f.status === 'done').length < props.maxCount;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokePdfBlob();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-dragger-paste">
|
||||
<UploadDragger
|
||||
v-if="showDragger"
|
||||
:file-list="[]"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
:disabled="disabled || uploading"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:show-upload-list="false"
|
||||
:max-count="maxCount"
|
||||
>
|
||||
<p class="ant-upload-drag-icon">
|
||||
<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-muted-foreground">
|
||||
支持拖拽或点击选择,单文件不超过 {{ maxSizeMb }}MB
|
||||
</p>
|
||||
</UploadDragger>
|
||||
|
||||
<div v-if="fileList.length" class="mt-3 space-y-2">
|
||||
<div
|
||||
v-for="item in fileList"
|
||||
:key="item.uid"
|
||||
class="flex items-center gap-2 rounded border border-border bg-muted/20 px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<Icon
|
||||
:icon="
|
||||
item.status === 'uploading'
|
||||
? 'ant-design:loading-outlined'
|
||||
: item.status === 'error'
|
||||
? 'ant-design:close-circle-outlined'
|
||||
: 'ant-design:paper-clip-outlined'
|
||||
"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<button
|
||||
v-if="item.url"
|
||||
type="button"
|
||||
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-muted-foreground">{{ item.name }}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
:disabled="disabled"
|
||||
@click="handleRemove(item as any)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 粘贴监听按钮放在拖拽区域下方外侧 -->
|
||||
<div class="mt-3">
|
||||
<Button
|
||||
:type="pasteListening ? 'primary' : 'default'"
|
||||
:danger="pasteListening"
|
||||
:disabled="disabled"
|
||||
@click="onTogglePaste"
|
||||
>
|
||||
<Icon
|
||||
:icon="pasteListening ? 'ant-design:stop-outlined' : 'ant-design:snippets-outlined'"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
|
||||
</Button>
|
||||
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500 dark:text-orange-400">
|
||||
监听中:请将文件或截图 Ctrl+V 粘贴
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
:open="previewVisible"
|
||||
:title="previewTitle"
|
||||
:footer="null"
|
||||
width="80%"
|
||||
destroy-on-close
|
||||
@cancel="onPreviewClose"
|
||||
>
|
||||
<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 rounded border border-border bg-background"
|
||||
title="文件预览"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 多图上传(picture-card)+ 下方「开始监听粘贴」支持截图/图片粘贴
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Modal, Upload } from 'ant-design-vue';
|
||||
import { Button, Modal, Upload, message } from 'ant-design-vue';
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
|
||||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
||||
@@ -10,9 +13,12 @@ import {
|
||||
beforeImageUpload,
|
||||
resolveUploadFile,
|
||||
} from '#/utils/use-image-upload-pending';
|
||||
import { usePasteUploadListen } from '#/utils/use-paste-upload-listen';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
defineOptions({ name: 'UploadImage' });
|
||||
|
||||
interface FileItem {
|
||||
uid: string;
|
||||
name: string;
|
||||
@@ -24,18 +30,26 @@ 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<{
|
||||
'update:modelValue': [value: string[]];
|
||||
}>();
|
||||
|
||||
const IMAGE_ACCEPT = 'image/*,.jpg,.jpeg,.png,.gif,.webp,.bmp';
|
||||
|
||||
const fileList = ref<FileItem[]>([]);
|
||||
|
||||
const initFileList = () => {
|
||||
@@ -74,6 +88,41 @@ const updateModelValue = () => {
|
||||
emit('update:modelValue', urls);
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传单个图片文件(点击上传与粘贴共用)
|
||||
*/
|
||||
async function uploadOneImage(file: File) {
|
||||
const remain = props.maxCount - fileList.value.filter((f) => f.status === 'done').length;
|
||||
if (remain <= 0) {
|
||||
message.warning(`最多上传 ${props.maxCount} 张图片`);
|
||||
return;
|
||||
}
|
||||
const actualFile = resolveUploadFile(file);
|
||||
const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const uploadingFile: FileItem = {
|
||||
uid,
|
||||
name: actualFile.name || file.name || 'paste.png',
|
||||
status: 'uploading',
|
||||
url: '',
|
||||
};
|
||||
fileList.value = [...fileList.value, uploadingFile];
|
||||
try {
|
||||
const uploadMethod = preferences.app.uploadMethod || 'direct';
|
||||
const res =
|
||||
uploadMethod === 'direct'
|
||||
? await uploadToOss({ file: actualFile })
|
||||
: await uploadFile({ file: actualFile });
|
||||
fileList.value = fileList.value.map((item) =>
|
||||
item.uid === uid ? { ...item, status: 'done' as const, url: res.url } : item,
|
||||
);
|
||||
updateModelValue();
|
||||
} catch (error) {
|
||||
console.error('上传失败', error);
|
||||
fileList.value = fileList.value.filter((item) => item.uid !== uid);
|
||||
message.error('上传失败');
|
||||
}
|
||||
}
|
||||
|
||||
const customRequest = async (options: any) => {
|
||||
const { file, onProgress, onSuccess, onError } = options;
|
||||
const actualFile = resolveUploadFile(file as File);
|
||||
@@ -155,6 +204,30 @@ const showUploadButton = computed(() =>
|
||||
);
|
||||
|
||||
const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0));
|
||||
|
||||
const { listening: pasteListening, toggle: togglePaste } = usePasteUploadListen({
|
||||
accept: IMAGE_ACCEPT,
|
||||
maxSize: 20 * 1024 * 1024,
|
||||
onFiles: async (files) => {
|
||||
for (const file of files) {
|
||||
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||||
if (doneCount >= props.maxCount) {
|
||||
message.warning(`最多上传 ${props.maxCount} 张图片`);
|
||||
break;
|
||||
}
|
||||
await uploadOneImage(file);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function onTogglePaste() {
|
||||
togglePaste();
|
||||
if (pasteListening.value) {
|
||||
message.success('已开启粘贴监听,可 Ctrl+V 粘贴图片/截图');
|
||||
} else {
|
||||
message.info('已停止粘贴监听');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -184,9 +257,29 @@ const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.val
|
||||
v-if="showUploadButton"
|
||||
:multiple="multiple"
|
||||
:max-count="galleryRemainCount"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onGallerySelect"
|
||||
/>
|
||||
|
||||
<!-- 粘贴监听放在上传区下方外侧 -->
|
||||
<div class="mt-2">
|
||||
<Button
|
||||
size="small"
|
||||
:type="pasteListening ? 'primary' : 'default'"
|
||||
:danger="pasteListening"
|
||||
@click="onTogglePaste"
|
||||
>
|
||||
<Icon
|
||||
:icon="pasteListening ? 'ant-design:stop-outlined' : 'ant-design:snippets-outlined'"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
|
||||
</Button>
|
||||
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500">
|
||||
监听中:Ctrl+V 粘贴图片
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Modal v-model:visible="previewVisible" :title="previewTitle" footer="" width="60%">
|
||||
<img alt="预览图片" style="width: 100%" :src="previewImage" />
|
||||
</Modal>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OSS 文件上传(单文件 URL 字符串)
|
||||
* 可选:从素材库选择(acceptTypes 过滤,如文件=6、语音=3)
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { Button, Upload } from 'ant-design-vue';
|
||||
|
||||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
||||
import { Icon } from '#/components/icon';
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { uploadToOss } from '#/utils/oss-upload';
|
||||
@@ -17,11 +23,17 @@ interface FileItem {
|
||||
interface Props {
|
||||
modelValue?: string;
|
||||
maxCount?: number;
|
||||
/** 素材库类型过滤,如 [6] 文件、[3] 语音 */
|
||||
acceptTypes?: number[];
|
||||
/** input accept,如 .pdf,.doc 或 audio/* */
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
maxCount: 1,
|
||||
acceptTypes: () => [6],
|
||||
accept: '.pdf,.doc,.docx,.zip,.rar,.txt,application/pdf,audio/*,*/*',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -131,26 +143,50 @@ function handleRemove() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function onGallerySelect(urls: string[]) {
|
||||
const url = urls[0] || '';
|
||||
if (!url) return;
|
||||
syncFileListFromValue(url);
|
||||
updateModelValue();
|
||||
}
|
||||
|
||||
const showUploadButton = computed(
|
||||
() => fileList.value.filter((item) => item.status !== 'error').length === 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Upload
|
||||
:file-list="fileList"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:max-count="maxCount"
|
||||
list-type="text"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,image/*,application/pdf"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<Button v-if="showUploadButton">
|
||||
上传文件
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:cloud-upload-outlined" />
|
||||
</template>
|
||||
</Button>
|
||||
</Upload>
|
||||
<div class="upload-oss-file-wrap">
|
||||
<Upload
|
||||
:file-list="fileList"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:max-count="maxCount"
|
||||
list-type="text"
|
||||
:accept="accept"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<Button v-if="showUploadButton">
|
||||
上传文件
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:cloud-upload-outlined" />
|
||||
</template>
|
||||
</Button>
|
||||
</Upload>
|
||||
<GalleryPickLink
|
||||
v-if="showUploadButton"
|
||||
:max-count="1"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onGallerySelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-oss-file-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
170
apps/web-antd/src/components/form/components/user-tag-select.vue
Normal file
170
apps/web-antd/src/components/form/components/user-tag-select.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用 Tag 选择器(带头像)
|
||||
*
|
||||
* - 基于 antd Select,multiple 模式默认开启,单选传 multiple=false
|
||||
* - 已选项以 Tag 形式展示头像+名字:
|
||||
* - 有 avatar 用图片头像
|
||||
* - 无 avatar 用文本头像(取 label 首字符)
|
||||
* - 下拉选项同样展示头像+名字
|
||||
* - 选项数据:options: { value, label, avatar? }[]
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Avatar, Select, Tag } from 'ant-design-vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'UserTagSelect',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
/** 单个选项结构 */
|
||||
export interface UserTagOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 已选值数组(多选)或单值(单选时仍用数组包装) */
|
||||
modelValue?: (string | number)[];
|
||||
/** 选项列表 */
|
||||
options: UserTagOption[];
|
||||
/** 是否多选,默认 true */
|
||||
multiple?: boolean;
|
||||
/** 最大选择数,超出截断 */
|
||||
maxCount?: number;
|
||||
placeholder?: string;
|
||||
allowClear?: boolean;
|
||||
showSearch?: boolean;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
maxCount: 99,
|
||||
placeholder: '请选择',
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [v: (string | number)[]];
|
||||
}>();
|
||||
|
||||
// 双向绑定:useVModel 自动处理 modelValue 的读写
|
||||
const innerValue = useVModel(props, 'modelValue', emit, {
|
||||
defaultValue: [] as (string | number)[],
|
||||
passive: true,
|
||||
});
|
||||
|
||||
/** 用于 Select 的 mode,单选时不传 mode */
|
||||
const selectMode = computed(() => (props.multiple ? 'multiple' : undefined));
|
||||
|
||||
/** 按 value 索引选项,方便 tagRender/option 插槽快速查头像与名字 */
|
||||
const optionMap = computed(() => {
|
||||
const map = new Map<string | number, UserTagOption>();
|
||||
for (const opt of props.options) {
|
||||
map.set(opt.value, opt);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** 取某 value 对应的展示名 */
|
||||
function labelOf(value: string | number) {
|
||||
return optionMap.value.get(value)?.label || String(value);
|
||||
}
|
||||
|
||||
/** 取某 value 对应的头像 */
|
||||
function avatarOf(value: string | number) {
|
||||
return optionMap.value.get(value)?.avatar;
|
||||
}
|
||||
|
||||
/** 取首字符(文本头像) */
|
||||
function firstChar(label?: string) {
|
||||
return (label || '?').trim().charAt(0) || '?';
|
||||
}
|
||||
|
||||
/** 选项过滤:按 label 包含输入关键字 */
|
||||
function filterOption(input: string, option: any) {
|
||||
const v = String(option?.label || '').toLowerCase();
|
||||
return v.includes((input || '').toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* change 处理:
|
||||
* - 单选:把单值包成数组
|
||||
* - 多选:超 maxCount 截断
|
||||
*/
|
||||
function onChange(v: any) {
|
||||
if (!props.multiple) {
|
||||
innerValue.value = v == null ? [] : [v];
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(v) && v.length > props.maxCount) {
|
||||
v = v.slice(0, props.maxCount);
|
||||
}
|
||||
innerValue.value = v;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select
|
||||
:value="innerValue as any"
|
||||
:mode="selectMode"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
:allow-clear="allowClear"
|
||||
:show-search="showSearch"
|
||||
:filter-option="filterOption"
|
||||
:disabled="disabled"
|
||||
:max-tag-count="10"
|
||||
option-filter-prop="label"
|
||||
style="width: 100%"
|
||||
@change="onChange"
|
||||
>
|
||||
<!-- 多选已选项展示头像+名字 -->
|
||||
<template v-if="multiple" #tagRender="{ value, closable, onClose }">
|
||||
<Tag :closable="closable" @close="onClose" class="user-tag-chip">
|
||||
<Avatar :size="16" :src="avatarOf(value)">
|
||||
{{ firstChar(labelOf(value)) }}
|
||||
</Avatar>
|
||||
<span class="user-tag-name">{{ labelOf(value) }}</span>
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 下拉选项展示头像+名字 -->
|
||||
<template #option="{ label, avatar }">
|
||||
<div class="user-tag-option">
|
||||
<Avatar :size="20" :src="avatar">{{ firstChar(label) }}</Avatar>
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.user-tag-name {
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-tag-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import type { BlockSchema } from './types';
|
||||
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
|
||||
/**
|
||||
* 左侧组件库:渲染所有可拖拽块类型
|
||||
* 用户从这里拖动到中间画布(SortableJS 用 pull:clone 模式,左侧库不会减少)
|
||||
*/
|
||||
defineProps<{
|
||||
/** 所有可拖拽块定义 */
|
||||
blocks: BlockSchema[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="block-library">
|
||||
<div class="panel-header">
|
||||
<span class="title">组件库</span>
|
||||
<span class="hint">拖入画布</span>
|
||||
</div>
|
||||
<div class="block-list">
|
||||
<div
|
||||
v-for="block in blocks"
|
||||
:key="block.type"
|
||||
class="block-item"
|
||||
:data-block-type="block.type"
|
||||
>
|
||||
<MIcon v-if="block.icon" :icon="block.icon" size="16" />
|
||||
<span class="block-label">{{ block.label }}</span>
|
||||
</div>
|
||||
<div v-if="blocks.length === 0" class="empty-hint">暂无可拖入组件</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.block-library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
background: var(--ant-color-fill-quaternary, #fafbfc);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.block-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.block-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px dashed var(--ant-color-border, #c9cdd4);
|
||||
border-radius: 4px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
font-size: 13px;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ant-color-primary, #165dff);
|
||||
color: var(--ant-color-primary, #165dff);
|
||||
background: var(--ant-color-primary-bg, #f2f7ff);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
color: var(--ant-color-text-quaternary, #c9cdd4);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
300
apps/web-antd/src/components/oa-template-card-editor/Canvas.vue
Normal file
300
apps/web-antd/src/components/oa-template-card-editor/Canvas.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import type { BlockInstance, BlockSchema } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
|
||||
/**
|
||||
* 中间画布:按 targetArrayPath 分组渲染块实例
|
||||
* 每个分组是一个 SortableJS 接收区,可拖入、排序、删除
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 所有块定义(按 type 查找对应 schema) */
|
||||
blockSchemas: BlockSchema[];
|
||||
/** 所有块实例(已拖入画布的) */
|
||||
instances: BlockInstance[];
|
||||
/** 当前选中的块实例 ID(高亮显示) */
|
||||
selectedInstanceId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 选中块实例(点击时触发) */
|
||||
select: [instanceId: string];
|
||||
/** 删除块实例 */
|
||||
remove: [instanceId: string];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 按 targetArrayPath 分组的渲染结构
|
||||
* 每个分组对应一个 SortableJS 接收区
|
||||
*/
|
||||
const groupedRender = computed(() => {
|
||||
// 收集所有出现的 targetArrayPath,按 blockSchemas 顺序排序
|
||||
const orderedPaths: string[] = [];
|
||||
const labelMap: Record<string, { label: string; maxCount?: number }> = {};
|
||||
for (const schema of props.blockSchemas) {
|
||||
if (!orderedPaths.includes(schema.targetArrayPath)) {
|
||||
orderedPaths.push(schema.targetArrayPath);
|
||||
labelMap[schema.targetArrayPath] = {
|
||||
label: schema.targetArrayPath,
|
||||
maxCount: schema.maxCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 把 instances 按目标路径分组
|
||||
const grouped: Record<string, BlockInstance[]> = {};
|
||||
for (const inst of props.instances) {
|
||||
const schema = props.blockSchemas.find((s) => s.type === inst.type);
|
||||
if (!schema) continue;
|
||||
const path = schema.targetArrayPath;
|
||||
if (!grouped[path]) grouped[path] = [];
|
||||
grouped[path].push(inst);
|
||||
}
|
||||
|
||||
return orderedPaths.map((path) => ({
|
||||
path,
|
||||
label: labelMap[path]?.label ?? path,
|
||||
maxCount: labelMap[path]?.maxCount,
|
||||
items: grouped[path] ?? [],
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取块实例的 schema 定义
|
||||
*/
|
||||
function getSchema(type: string): BlockSchema | undefined {
|
||||
return props.blockSchemas.find((s) => s.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取块实例的简要描述(属性面板编辑的字段值预览)
|
||||
*/
|
||||
function getInstanceSummary(inst: BlockInstance): string {
|
||||
const schema = getSchema(inst.type);
|
||||
if (!schema) return '';
|
||||
const firstField = schema.fields[0];
|
||||
if (!firstField) return '';
|
||||
// 嵌套路径取最末级
|
||||
const path = firstField.name.split('.');
|
||||
let value: any = inst.data;
|
||||
for (const key of path) {
|
||||
value = value?.[key];
|
||||
}
|
||||
return value ? String(value) : '(未填写)';
|
||||
}
|
||||
|
||||
function handleClick(instanceId: string) {
|
||||
emit('select', instanceId);
|
||||
}
|
||||
|
||||
function handleRemove(instanceId: string) {
|
||||
emit('remove', instanceId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas">
|
||||
<div class="panel-header">
|
||||
<span class="title">画布</span>
|
||||
<span class="hint">拖入 / 排序 / 点击编辑属性</span>
|
||||
</div>
|
||||
<div class="canvas-body">
|
||||
<div
|
||||
v-for="group in groupedRender"
|
||||
:key="group.path"
|
||||
class="canvas-group"
|
||||
>
|
||||
<div class="group-header">
|
||||
<span class="group-label">{{ group.label }}</span>
|
||||
<span class="group-count">
|
||||
{{ group.items.length }}{{ group.maxCount ? `/${group.maxCount}` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="group-dropzone"
|
||||
:data-target-array-path="group.path"
|
||||
>
|
||||
<div
|
||||
v-for="inst in group.items"
|
||||
:key="inst.id"
|
||||
class="instance-item"
|
||||
:class="{ selected: inst.id === selectedInstanceId }"
|
||||
:data-instance-id="inst.id"
|
||||
@click.stop="handleClick(inst.id)"
|
||||
>
|
||||
<div class="instance-info">
|
||||
<MIcon
|
||||
v-if="getSchema(inst.type)?.icon"
|
||||
:icon="getSchema(inst.type)!.icon"
|
||||
size="14"
|
||||
/>
|
||||
<div class="instance-text">
|
||||
<div class="instance-label">{{ getSchema(inst.type)?.label }}</div>
|
||||
<div class="instance-summary">{{ getInstanceSummary(inst) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="instance-remove"
|
||||
title="删除"
|
||||
@click.stop="handleRemove(inst.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="group.items.length === 0" class="empty-dropzone">
|
||||
拖入组件
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.canvas-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
.group-label {
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
font-weight: 500;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
}
|
||||
}
|
||||
|
||||
.group-dropzone {
|
||||
min-height: 60px;
|
||||
padding: 8px;
|
||||
border: 2px dashed var(--ant-color-border, #d9d9d9);
|
||||
border-radius: 6px;
|
||||
background: var(--ant-color-fill-quaternary, #fafbfc);
|
||||
}
|
||||
|
||||
.empty-dropzone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
color: var(--ant-color-text-quaternary, #c9cdd4);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.instance-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
border-radius: 4px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ant-color-primary, #165dff);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--ant-color-primary, #165dff);
|
||||
background: var(--ant-color-primary-bg, #f2f7ff);
|
||||
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.instance-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.instance-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.instance-label {
|
||||
font-size: 13px;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.instance-summary {
|
||||
font-size: 11px;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.instance-remove {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--ant-color-error, #f53f3f);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
/**
|
||||
* JSON 实时预览面板
|
||||
* - 显示最终生成的 JSON 字符串(语法高亮,简单的 key/string 高亮)
|
||||
* - 提供「复制 JSON」按钮(一键拷贝到剪贴板)
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 最终生成的 JSON 数据 */
|
||||
value: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const formattedJson = computed(() => {
|
||||
try {
|
||||
return JSON.stringify(props.value, null, 2);
|
||||
} catch {
|
||||
return '// JSON 序列化失败';
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 简单的 JSON 语法高亮
|
||||
* 匹配 key / string / number / boolean 并套用不同颜色 span
|
||||
*/
|
||||
const highlightedHtml = computed(() => {
|
||||
const escape = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const json = escape(formattedJson.value);
|
||||
return json
|
||||
.replace(
|
||||
/("(?:\\.|[^"\\])*")(\s*:)/g,
|
||||
'<span class="json-key">$1</span>$2',
|
||||
)
|
||||
.replace(
|
||||
/:\s*("(?:\\.|[^"\\])*")/g,
|
||||
': <span class="json-string">$1</span>',
|
||||
)
|
||||
.replace(/:\s*(-?\d+(?:\.\d+)?)/g, ': <span class="json-number">$1</span>')
|
||||
.replace(/:\s*(true|false|null)/g, ': <span class="json-bool">$1</span>');
|
||||
});
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(formattedJson.value);
|
||||
message.success('JSON 已复制');
|
||||
} catch {
|
||||
message.error('复制失败,请手动选择');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="json-preview">
|
||||
<div class="panel-header">
|
||||
<span class="title">JSON 预览</span>
|
||||
<Button size="small" type="link" @click="handleCopy">复制 JSON</Button>
|
||||
</div>
|
||||
<pre class="json-body" v-html="highlightedHtml" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.json-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
/* JSON 代码面板保留 VS Code Dark+ 风格暗底(无论浅色/暗色主题) */
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #374151;
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.json-body {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #d4d4d4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* JSON 语法高亮颜色 */
|
||||
:deep(.json-key) {
|
||||
color: #9cdcfe;
|
||||
}
|
||||
|
||||
:deep(.json-string) {
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
:deep(.json-number) {
|
||||
color: #b5cea8;
|
||||
}
|
||||
|
||||
:deep(.json-bool) {
|
||||
color: #569cd6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,348 @@
|
||||
<script setup lang="ts">
|
||||
import type { BlockInstance, BlockSchema, FixedField, FormFieldSchema } from './types';
|
||||
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
import { Empty, Input, InputNumber, Radio, RadioGroup, Select, Textarea } from 'ant-design-vue';
|
||||
|
||||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
||||
|
||||
/**
|
||||
* 右侧属性面板:根据选中块的字段 schema 渲染表单
|
||||
* - 顶层固定字段(fixedFields):渲染在顶部,不可删除
|
||||
* - 选中块的 fields:渲染在下方
|
||||
* - 图片类型字段用 GalleryPickLink(透传 acceptTypes)
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 顶层固定字段定义 */
|
||||
fixedFields?: FixedField[];
|
||||
/** 顶层固定字段当前值 */
|
||||
fixedValues?: Record<string, any>;
|
||||
/** 当前选中的块定义(可能为空) */
|
||||
selectedBlockSchema?: BlockSchema;
|
||||
/** 当前选中的块实例(可能为空) */
|
||||
selectedInstance?: BlockInstance;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 顶层固定字段值变化 */
|
||||
'update:fixedValues': [value: Record<string, any>];
|
||||
/** 选中块实例数据变化 */
|
||||
'update:instance': [instance: BlockInstance];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 设置顶层固定字段的值
|
||||
* 支持 a.b.c 嵌套路径
|
||||
*/
|
||||
function setFixedValue(path: string, value: any) {
|
||||
const next = { ...(props.fixedValues || {}) };
|
||||
setByPath(next, path, value);
|
||||
emit('update:fixedValues', next);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置选中块实例的字段值
|
||||
*/
|
||||
function setInstanceValue(path: string, value: any) {
|
||||
if (!props.selectedInstance) return;
|
||||
const nextData = { ...props.selectedInstance.data };
|
||||
setByPath(nextData, path, value);
|
||||
emit('update:instance', {
|
||||
...props.selectedInstance,
|
||||
data: nextData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 a.b.c 路径设置对象嵌套属性(不可变更新)
|
||||
*/
|
||||
function setByPath(obj: Record<string, any>, path: string, value: any) {
|
||||
const keys = path.split('.');
|
||||
let cursor: any = obj;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const k = keys[i]!;
|
||||
if (!cursor[k] || typeof cursor[k] !== 'object') {
|
||||
cursor[k] = {};
|
||||
} else {
|
||||
cursor[k] = { ...cursor[k] };
|
||||
}
|
||||
cursor = cursor[k];
|
||||
}
|
||||
cursor[keys[keys.length - 1]!] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 a.b.c 路径读取对象嵌套属性
|
||||
*/
|
||||
function getByPath(obj: Record<string, any> | undefined, path: string): any {
|
||||
if (!obj) return undefined;
|
||||
const keys = path.split('.');
|
||||
let cursor: any = obj;
|
||||
for (const k of keys) {
|
||||
if (cursor == null) return undefined;
|
||||
cursor = cursor[k];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
const hasAnyField = computed(() => {
|
||||
return (
|
||||
(props.fixedFields && props.fixedFields.length > 0) ||
|
||||
(props.selectedBlockSchema && props.selectedBlockSchema.fields.length > 0)
|
||||
);
|
||||
});
|
||||
|
||||
// 当选中的块变化时,触发 watch 让父级感知(用于属性面板切换的动画/数据初始化)
|
||||
watch(
|
||||
() => props.selectedInstance?.id,
|
||||
() => {
|
||||
// 仅做依赖追踪,实际更新在用户交互时通过 emit 触发
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="property-panel">
|
||||
<div class="panel-header">
|
||||
<span class="title">属性</span>
|
||||
<span v-if="selectedBlockSchema" class="hint">{{ selectedBlockSchema.label }}</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<!-- 顶层固定字段 -->
|
||||
<div v-if="fixedFields && fixedFields.length > 0" class="field-section">
|
||||
<div class="section-title">基础信息</div>
|
||||
<div
|
||||
v-for="field in fixedFields"
|
||||
:key="field.name"
|
||||
class="form-row"
|
||||
>
|
||||
<label class="form-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<!-- 文本 -->
|
||||
<Input
|
||||
v-if="!field.type || field.type === 'string' || field.type === 'url'"
|
||||
:value="getByPath(fixedValues, field.name)"
|
||||
:placeholder="field.placeholder"
|
||||
allow-clear
|
||||
size="small"
|
||||
@update:value="setFixedValue(field.name, $event)"
|
||||
/>
|
||||
<!-- 数字 -->
|
||||
<InputNumber
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="getByPath(fixedValues, field.name)"
|
||||
:placeholder="field.placeholder"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
@update:value="setFixedValue(field.name, $event)"
|
||||
/>
|
||||
<!-- 多行文本 -->
|
||||
<Textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:value="getByPath(fixedValues, field.name)"
|
||||
:placeholder="field.placeholder"
|
||||
:rows="3"
|
||||
size="small"
|
||||
@update:value="setFixedValue(field.name, $event)"
|
||||
/>
|
||||
<!-- 下拉选择 -->
|
||||
<Select
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="getByPath(fixedValues, field.name)"
|
||||
:options="field.options"
|
||||
:placeholder="field.placeholder"
|
||||
size="small"
|
||||
@update:value="setFixedValue(field.name, $event)"
|
||||
/>
|
||||
<!-- 单选 -->
|
||||
<RadioGroup
|
||||
v-else-if="field.type === 'radio'"
|
||||
:value="getByPath(fixedValues, field.name)"
|
||||
size="small"
|
||||
@update:value="setFixedValue(field.name, $event)"
|
||||
>
|
||||
<Radio
|
||||
v-for="opt in field.options"
|
||||
:key="String(opt.value)"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
<!-- 图片素材 -->
|
||||
<div v-else-if="field.type === 'image'" class="image-picker-row">
|
||||
<Input
|
||||
:value="getByPath(fixedValues, field.name)"
|
||||
:placeholder="field.placeholder || '从素材库选择图片'"
|
||||
size="small"
|
||||
read-only
|
||||
/>
|
||||
<GalleryPickLink
|
||||
:accept-types="field.acceptTypes || [0]"
|
||||
@select="(urls) => setFixedValue(field.name, urls[0] || '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选中块的属性字段 -->
|
||||
<div v-if="selectedBlockSchema && selectedInstance" class="field-section">
|
||||
<div class="section-title">{{ selectedBlockSchema.label }}属性</div>
|
||||
<div
|
||||
v-for="field in selectedBlockSchema.fields"
|
||||
:key="field.name"
|
||||
class="form-row"
|
||||
>
|
||||
<label class="form-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<Input
|
||||
v-if="!field.type || field.type === 'string' || field.type === 'url'"
|
||||
:value="getByPath(selectedInstance.data, field.name)"
|
||||
:placeholder="field.placeholder"
|
||||
allow-clear
|
||||
size="small"
|
||||
@update:value="setInstanceValue(field.name, $event)"
|
||||
/>
|
||||
<InputNumber
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="getByPath(selectedInstance.data, field.name)"
|
||||
:placeholder="field.placeholder"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
@update:value="setInstanceValue(field.name, $event)"
|
||||
/>
|
||||
<Textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:value="getByPath(selectedInstance.data, field.name)"
|
||||
:placeholder="field.placeholder"
|
||||
:rows="3"
|
||||
size="small"
|
||||
@update:value="setInstanceValue(field.name, $event)"
|
||||
/>
|
||||
<Select
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="getByPath(selectedInstance.data, field.name)"
|
||||
:options="field.options"
|
||||
:placeholder="field.placeholder"
|
||||
size="small"
|
||||
@update:value="setInstanceValue(field.name, $event)"
|
||||
/>
|
||||
<RadioGroup
|
||||
v-else-if="field.type === 'radio'"
|
||||
:value="getByPath(selectedInstance.data, field.name)"
|
||||
size="small"
|
||||
@update:value="setInstanceValue(field.name, $event)"
|
||||
>
|
||||
<Radio
|
||||
v-for="opt in field.options"
|
||||
:key="String(opt.value)"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
<div v-else-if="field.type === 'image'" class="image-picker-row">
|
||||
<Input
|
||||
:value="getByPath(selectedInstance.data, field.name)"
|
||||
:placeholder="field.placeholder || '从素材库选择图片'"
|
||||
size="small"
|
||||
read-only
|
||||
/>
|
||||
<GalleryPickLink
|
||||
:accept-types="field.acceptTypes || [0]"
|
||||
@select="(urls) => setInstanceValue(field.name, urls[0] || '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未选中任何块时的提示 -->
|
||||
<Empty
|
||||
v-if="!hasAnyField"
|
||||
description="点击画布中的块以编辑属性"
|
||||
:image-style="{ height: '60px' }"
|
||||
style="margin-top: 60px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.property-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 11px;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.field-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--ant-color-split, #f2f3f5);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
|
||||
.required {
|
||||
margin-left: 2px;
|
||||
color: var(--ant-color-error, #f53f3f);
|
||||
}
|
||||
}
|
||||
|
||||
.image-picker-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
522
apps/web-antd/src/components/oa-template-card-editor/index.vue
Normal file
522
apps/web-antd/src/components/oa-template-card-editor/index.vue
Normal file
@@ -0,0 +1,522 @@
|
||||
<script setup lang="ts">
|
||||
import type { BlockInstance, BlockSchema, FixedField } from './types';
|
||||
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import type Sortable from 'sortablejs';
|
||||
import type { SortableOptions } from 'sortablejs';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import BlockLibrary from './BlockLibrary.vue';
|
||||
import Canvas from './Canvas.vue';
|
||||
import JsonPreview from './JsonPreview.vue';
|
||||
import PropertyPanel from './PropertyPanel.vue';
|
||||
|
||||
/**
|
||||
* 通用拖拽式可视化编辑器
|
||||
*
|
||||
* 用途:把「左侧组件库 → 中间画布拖拽组装 → 右侧属性面板 → 自动生成 JSON」
|
||||
* 做成 schema 驱动的通用组件,未来 OA / 工单 / 通知等任何场景需要可视化拼装 JSON 都能复用。
|
||||
*
|
||||
* 核心机制:
|
||||
* - 左侧库 + 画布各分组共用同一个 SortableJS group(pull:clone 模式:左侧库克隆到画布)
|
||||
* - 拖入 / 排序 / 删除时同步内部 instances 状态
|
||||
* - 通过 computed 把「fixedFields 值 + 各 targetArrayPath 数组」合并成最终 JSON,emit 给父级
|
||||
*
|
||||
* SortableJS 用法参照项目内 views/system/role/components/quick-nav-transfer.vue
|
||||
*/
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 双向绑定的 JSON 数据 */
|
||||
modelValue: Record<string, any>;
|
||||
/** 块定义 schema(决定左侧能拖入哪些块、各自有哪些字段) */
|
||||
schema: BlockSchema[];
|
||||
/** 是否显示 JSON 预览面板 */
|
||||
showJsonPreview?: boolean;
|
||||
/** 顶层固定字段(不可拖拽,固定显示在属性面板顶部) */
|
||||
fixedFields?: FixedField[];
|
||||
}>(),
|
||||
{
|
||||
showJsonPreview: true,
|
||||
fixedFields: () => [] as FixedField[],
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Record<string, any>];
|
||||
}>();
|
||||
|
||||
// ====== 内部状态 ======
|
||||
|
||||
/** 所有块实例(按拖入顺序) */
|
||||
const instances = ref<BlockInstance[]>([]);
|
||||
/** 当前选中的块实例 ID */
|
||||
const selectedInstanceId = ref<string>('');
|
||||
/** 顶层固定字段值 */
|
||||
const fixedValues = ref<Record<string, any>>({});
|
||||
/** 顶层固定字段 + 块数组组装后的最终 JSON(双向同步给父级) */
|
||||
const assembledJson = computed<Record<string, any>>(() => {
|
||||
const result: Record<string, any> = { ...fixedValues.value };
|
||||
// 按 targetArrayPath 收集块实例数据
|
||||
for (const schema of props.schema) {
|
||||
const path = schema.targetArrayPath;
|
||||
const items = instances.value
|
||||
.filter((inst) => inst.type === schema.type)
|
||||
.map((inst) => {
|
||||
// 深拷贝避免引用问题
|
||||
const data = JSON.parse(JSON.stringify(inst.data));
|
||||
// 移除内部使用的 id 字段(如果有)
|
||||
delete data._blockType;
|
||||
return data;
|
||||
});
|
||||
if (items.length > 0) {
|
||||
// 设置嵌套路径
|
||||
setByPath(result, path, items);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
/** 当前选中的块实例对象 */
|
||||
const selectedInstance = computed<BlockInstance | undefined>(() => {
|
||||
return instances.value.find((i) => i.id === selectedInstanceId.value);
|
||||
});
|
||||
|
||||
/** 当前选中的块 schema */
|
||||
const selectedBlockSchema = computed<BlockSchema | undefined>(() => {
|
||||
if (!selectedInstance.value) return undefined;
|
||||
return props.schema.find((s) => s.type === selectedInstance.value!.type);
|
||||
});
|
||||
|
||||
// ====== SortableJS 实例管理 ======
|
||||
|
||||
const editorRootRef = ref<HTMLElement>();
|
||||
const libraryRef = ref<HTMLElement>();
|
||||
/** 画布中各分组的 dropzone DOM 引用(key = targetArrayPath) */
|
||||
const dropzoneRefs = ref<Record<string, HTMLElement>>({});
|
||||
const librarySortable = ref<Sortable | null>(null);
|
||||
const dropzoneSortables = ref<Record<string, Sortable>>({});
|
||||
|
||||
/**
|
||||
* 动态导入 SortableJS(参照 quick-nav-transfer.vue 模式)
|
||||
*/
|
||||
async function createSortable(
|
||||
el: HTMLElement,
|
||||
options: SortableOptions = {},
|
||||
): Promise<Sortable> {
|
||||
const mod = await import(
|
||||
// @ts-expect-error sortablejs modular esm path
|
||||
'sortablejs/modular/sortable.complete.esm.js'
|
||||
);
|
||||
return mod.default.create(el, {
|
||||
animation: 200,
|
||||
...options,
|
||||
}) as Sortable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 SortableJS 实例
|
||||
* 1. 左侧组件库:pull:clone 模式(拖出后元素保留)
|
||||
* 2. 画布各分组:pull:clone/put,跨组可移动
|
||||
*/
|
||||
async function initSortables() {
|
||||
destroySortables();
|
||||
await nextTick();
|
||||
|
||||
if (libraryRef.value) {
|
||||
librarySortable.value = await createSortable(libraryRef.value, {
|
||||
group: {
|
||||
name: 'template-editor',
|
||||
pull: 'clone',
|
||||
put: false, // 左侧库不接受拖入
|
||||
},
|
||||
sort: false, // 左侧库不排序
|
||||
onEnd: handleLibraryDragEnd,
|
||||
});
|
||||
}
|
||||
|
||||
// 通过 querySelector 直接查询画布中的所有 dropzone DOM(避免父子组件 DOM 引用传递)
|
||||
const editorRoot = editorRootRef.value;
|
||||
const dropzoneEls = editorRoot?.querySelectorAll('.group-dropzone[data-target-array-path]') ?? [];
|
||||
dropzoneEls.forEach((el) => {
|
||||
const path = el.getAttribute('data-target-array-path');
|
||||
if (!path) return;
|
||||
dropzoneRefs.value[path] = el as HTMLElement;
|
||||
void createSortable(el as HTMLElement, {
|
||||
group: {
|
||||
name: 'template-editor',
|
||||
pull: true,
|
||||
put: true,
|
||||
},
|
||||
onAdd: (evt) => handleDropzoneAdd(evt, path),
|
||||
onRemove: (evt) => handleDropzoneRemove(evt, path),
|
||||
onUpdate: (evt) => handleDropzoneUpdate(evt, path),
|
||||
}).then((s) => {
|
||||
dropzoneSortables.value[path] = s;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function destroySortables() {
|
||||
librarySortable.value?.destroy();
|
||||
librarySortable.value = null;
|
||||
for (const sortable of Object.values(dropzoneSortables.value)) {
|
||||
sortable.destroy();
|
||||
}
|
||||
dropzoneRefs.value = {};
|
||||
dropzoneSortables.value = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 dropzone DOM 引用(Canvas 子组件通过回调注入)
|
||||
*/
|
||||
function setDropzoneRef(path: string, el: HTMLElement | null) {
|
||||
if (el) {
|
||||
dropzoneRefs.value[path] = el;
|
||||
} else {
|
||||
delete dropzoneRefs.value[path];
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 拖拽事件处理(在 SortableJS 修改 DOM 后,同步到 Vue 状态) ======
|
||||
|
||||
/**
|
||||
* 左侧库 → 画布的拖拽结束回调
|
||||
* SortableJS 会把左侧库的元素 clone 一份移到画布 DOM,我们要:
|
||||
* 1. 移除 DOM 中被克隆过去的元素(由 Vue 重新渲染)
|
||||
* 2. 在 instances 中追加新块
|
||||
*/
|
||||
function handleLibraryDragEnd(evt: any) {
|
||||
// 如果只是库内拖动(没拖到画布),不做处理
|
||||
if (!evt.to || !evt.to.classList.contains('group-dropzone')) {
|
||||
return;
|
||||
}
|
||||
const blockType = evt.item?.dataset?.blockType;
|
||||
if (!blockType) return;
|
||||
|
||||
// 检查 maxCount 限制
|
||||
const schema = props.schema.find((s) => s.type === blockType);
|
||||
if (!schema) return;
|
||||
|
||||
const targetPath = schema.targetArrayPath;
|
||||
const currentCount = instances.value.filter(
|
||||
(i) => props.schema.find((s) => s.type === i.type)?.targetArrayPath === targetPath,
|
||||
).length;
|
||||
if (schema.maxCount && currentCount >= schema.maxCount) {
|
||||
// 移除 SortableJS 已经克隆到画布 DOM 的元素
|
||||
evt.item?.remove();
|
||||
message.warning(`「${schema.label}」最多 ${schema.maxCount} 项`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 移除 SortableJS 已经克隆到画布 DOM 的元素(Vue 会重新渲染)
|
||||
evt.item?.remove();
|
||||
|
||||
// 根据 SortableJS 在画布 DOM 中的位置,计算插入位置
|
||||
// 简化处理:直接追加到末尾(实现严格的位置同步太复杂,先保留追加行为)
|
||||
const newInstance: BlockInstance = {
|
||||
id: generateId(),
|
||||
type: blockType,
|
||||
data: schema.defaultValue ? JSON.parse(JSON.stringify(schema.defaultValue)) : {},
|
||||
};
|
||||
instances.value = [...instances.value, newInstance];
|
||||
selectedInstanceId.value = newInstance.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布分组间或分组内的添加事件(块从其他分组拖入)
|
||||
*/
|
||||
function handleDropzoneAdd(_evt: any, _path: string) {
|
||||
// SortableJS 自动处理 DOM 顺序,但因为 Vue 控制渲染,需要同步状态
|
||||
syncInstancesFromDom();
|
||||
}
|
||||
|
||||
function handleDropzoneRemove(_evt: any, _path: string) {
|
||||
syncInstancesFromDom();
|
||||
}
|
||||
|
||||
function handleDropzoneUpdate(_evt: any, _path: string) {
|
||||
syncInstancesFromDom();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从画布 DOM 同步块实例状态
|
||||
* SortableJS 修改了 DOM 后,从 DOM 重新读取 instanceId 顺序
|
||||
*/
|
||||
function syncInstancesFromDom() {
|
||||
const newInstances: BlockInstance[] = [];
|
||||
for (const path of Object.keys(dropzoneRefs.value)) {
|
||||
const el = dropzoneRefs.value[path];
|
||||
if (!el) continue;
|
||||
const items = el.querySelectorAll('[data-instance-id]');
|
||||
items.forEach((item) => {
|
||||
const id = item.getAttribute('data-instance-id');
|
||||
if (!id) return;
|
||||
const existing = instances.value.find((i) => i.id === id);
|
||||
if (existing) {
|
||||
newInstances.push(existing);
|
||||
}
|
||||
});
|
||||
}
|
||||
instances.value = newInstances;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除块实例
|
||||
*/
|
||||
function removeInstance(id: string) {
|
||||
instances.value = instances.value.filter((i) => i.id !== id);
|
||||
if (selectedInstanceId.value === id) {
|
||||
selectedInstanceId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中块实例
|
||||
*/
|
||||
function selectInstance(id: string) {
|
||||
selectedInstanceId.value = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新顶层固定字段值(PropertyPanel 触发)
|
||||
*/
|
||||
function updateFixedValues(values: Record<string, any>) {
|
||||
fixedValues.value = values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新块实例数据(PropertyPanel 触发)
|
||||
*/
|
||||
function updateInstance(updated: BlockInstance) {
|
||||
instances.value = instances.value.map((i) =>
|
||||
i.id === updated.id ? updated : i,
|
||||
);
|
||||
}
|
||||
|
||||
// ====== 工具函数 ======
|
||||
|
||||
function generateId(): string {
|
||||
return `inst_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 a.b.c 路径设置对象嵌套属性(不可变更新)
|
||||
*/
|
||||
function setByPath(obj: Record<string, any>, path: string, value: any) {
|
||||
const keys = path.split('.');
|
||||
let cursor: any = obj;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const k = keys[i]!;
|
||||
if (!cursor[k]) cursor[k] = {};
|
||||
cursor = cursor[k];
|
||||
}
|
||||
cursor[keys[keys.length - 1]!] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 a.b.c 路径读取对象嵌套属性
|
||||
*/
|
||||
function getByPath(obj: Record<string, any> | undefined, path: string): any {
|
||||
if (!obj) return undefined;
|
||||
const keys = path.split('.');
|
||||
let cursor: any = obj;
|
||||
for (const k of keys) {
|
||||
if (cursor == null) return undefined;
|
||||
cursor = cursor[k];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
// ====== modelValue 双向同步 ======
|
||||
|
||||
/**
|
||||
* 监听 modelValue 变化,反向初始化内部状态(用于编辑场景回显)
|
||||
*/
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (!val || Object.keys(val).length === 0) {
|
||||
// 重置
|
||||
instances.value = [];
|
||||
fixedValues.value = {};
|
||||
selectedInstanceId.value = '';
|
||||
return;
|
||||
}
|
||||
// 简化处理:只在 modelValue 引用变化时才反解析(避免无限循环)
|
||||
// 从 modelValue 还原 fixedValues 和 instances
|
||||
const newFixed: Record<string, any> = {};
|
||||
for (const field of props.fixedFields || []) {
|
||||
const v = getByPath(val, field.name);
|
||||
if (v !== undefined) {
|
||||
setByPath(newFixed, field.name, v);
|
||||
}
|
||||
}
|
||||
fixedValues.value = newFixed;
|
||||
|
||||
// 从各 targetArrayPath 还原 instances
|
||||
const newInstances: BlockInstance[] = [];
|
||||
for (const schema of props.schema) {
|
||||
const arr = getByPath(val, schema.targetArrayPath);
|
||||
if (Array.isArray(arr)) {
|
||||
for (const item of arr) {
|
||||
newInstances.push({
|
||||
id: generateId(),
|
||||
type: schema.type,
|
||||
data: JSON.parse(JSON.stringify(item)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
instances.value = newInstances;
|
||||
},
|
||||
{ immediate: false, deep: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听 assembledJson 变化,emit 给父级
|
||||
*/
|
||||
watch(
|
||||
assembledJson,
|
||||
(val) => {
|
||||
// 浅比较避免不必要的 emit
|
||||
const current = JSON.stringify(props.modelValue || {});
|
||||
const next = JSON.stringify(val);
|
||||
if (current !== next) {
|
||||
emit('update:modelValue', val);
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// 监听 schema 变化时重置 SortableJS(画布 DOM 可能重建)
|
||||
watch(
|
||||
() => props.schema,
|
||||
() => {
|
||||
void nextTick(() => initSortables());
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
// 初始化 fixedValues(从 props.fixedFields 的 defaultValue)
|
||||
for (const field of props.fixedFields || []) {
|
||||
if (field.defaultValue !== undefined) {
|
||||
setByPath(fixedValues.value, field.name, field.defaultValue);
|
||||
}
|
||||
}
|
||||
// 从 modelValue 回显数据
|
||||
if (props.modelValue && Object.keys(props.modelValue).length > 0) {
|
||||
for (const field of props.fixedFields || []) {
|
||||
const v = getByPath(props.modelValue, field.name);
|
||||
if (v !== undefined) {
|
||||
setByPath(fixedValues.value, field.name, v);
|
||||
}
|
||||
}
|
||||
for (const schema of props.schema) {
|
||||
const arr = getByPath(props.modelValue, schema.targetArrayPath);
|
||||
if (Array.isArray(arr)) {
|
||||
for (const item of arr) {
|
||||
instances.value.push({
|
||||
id: generateId(),
|
||||
type: schema.type,
|
||||
data: JSON.parse(JSON.stringify(item)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await nextTick();
|
||||
await initSortables();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
destroySortables();
|
||||
});
|
||||
|
||||
// 暴露给父组件
|
||||
defineExpose({
|
||||
initSortables,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="editorRootRef" class="template-editor">
|
||||
<div class="editor-toolbar">
|
||||
<span class="title">模板编辑器</span>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
@click="initSortables"
|
||||
>
|
||||
重置拖拽
|
||||
</Button>
|
||||
</div>
|
||||
<div class="editor-main">
|
||||
<!-- 左侧:组件库(用 ref 暴露给 SortableJS 初始化) -->
|
||||
<div ref="libraryRef">
|
||||
<BlockLibrary :blocks="schema" />
|
||||
</div>
|
||||
|
||||
<!-- 中间:画布 -->
|
||||
<Canvas
|
||||
:block-schemas="schema"
|
||||
:instances="instances"
|
||||
:selected-instance-id="selectedInstanceId"
|
||||
@select="selectInstance"
|
||||
@remove="removeInstance"
|
||||
/>
|
||||
|
||||
<!-- 右侧:属性面板 -->
|
||||
<PropertyPanel
|
||||
:fixed-fields="fixedFields"
|
||||
:fixed-values="fixedValues"
|
||||
:selected-block-schema="selectedBlockSchema"
|
||||
:selected-instance="selectedInstance"
|
||||
@update:fixed-values="updateFixedValues"
|
||||
@update:instance="updateInstance"
|
||||
/>
|
||||
|
||||
<!-- 最右侧:JSON 预览(可选) -->
|
||||
<JsonPreview
|
||||
v-if="showJsonPreview"
|
||||
:value="assembledJson"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.template-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
background: var(--ant-color-fill-quaternary, #fafbfc);
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 通用拖拽式可视化编辑器类型定义
|
||||
*
|
||||
* 设计目标:把「左侧组件库 → 中间画布拖拽 → 右侧属性面板 → 自动生成 JSON」
|
||||
* 做成 schema 驱动的通用编辑器,未来任何需要拼装 JSON 结构的场景都能复用。
|
||||
*
|
||||
* 核心概念:
|
||||
* - BlockSchema:可拖拽块定义(左侧组件库中展示,决定属性面板渲染什么字段)
|
||||
* - FormFieldSchema:单个字段定义(驱动右侧属性面板表单渲染)
|
||||
* - 块的 instances 按 targetArrayPath 分组存到画布,每组数量不超过 maxCount
|
||||
*/
|
||||
|
||||
/**
|
||||
* 字段类型枚举
|
||||
* - string:单行文本
|
||||
* - number:数字
|
||||
* - textarea:多行文本
|
||||
* - select:下拉选择
|
||||
* - radio:单选按钮组
|
||||
* - image:图片素材选择(用 GalleryPickLink,acceptTypes 限定图片)
|
||||
* - url:URL 链接
|
||||
*/
|
||||
export type FormFieldType =
|
||||
| 'image'
|
||||
| 'number'
|
||||
| 'radio'
|
||||
| 'select'
|
||||
| 'string'
|
||||
| 'textarea'
|
||||
| 'url';
|
||||
|
||||
/**
|
||||
* 单个字段定义(驱动属性面板表单渲染)
|
||||
*/
|
||||
export interface FormFieldSchema {
|
||||
/** 字段名(在 payload 对象中的 key,支持 a.b.c 嵌套路径) */
|
||||
name: string;
|
||||
/** 中文标签 */
|
||||
label: string;
|
||||
/** 字段类型 */
|
||||
type: FormFieldType;
|
||||
/** 占位提示 */
|
||||
placeholder?: string;
|
||||
/** 是否必填 */
|
||||
required?: boolean;
|
||||
/** select / radio 类型的选项列表 */
|
||||
options?: { label: string; value: any }[];
|
||||
/** 图片字段:限定素材库文件类型(如 [0] 只显示图片) */
|
||||
acceptTypes?: number[];
|
||||
/** 最大长度 */
|
||||
maxLength?: number;
|
||||
/** 默认值 */
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可拖拽块定义(左侧组件库中展示,决定画布上能放什么块、各块有哪些字段)
|
||||
*/
|
||||
export interface BlockSchema {
|
||||
/** 块类型标识(唯一) */
|
||||
type: string;
|
||||
/** 块显示名(如「二级标题+文本项」「跳转链接项」) */
|
||||
label: string;
|
||||
/** 块图标(Iconify) */
|
||||
icon: string;
|
||||
/** 该块的字段定义(驱动右侧属性面板表单渲染) */
|
||||
fields: FormFieldSchema[];
|
||||
/**
|
||||
* 该块在最终 JSON 中放到的数组路径
|
||||
* 例如 'horizontal_content_list' 表示拖入的块实例会被收集到 result.horizontal_content_list 数组
|
||||
* 多个块共用同一个 targetArrayPath 即被收集到同一数组
|
||||
*/
|
||||
targetArrayPath: string;
|
||||
/** 该数组路径下最多允许的块数量(如企微 jump_list 最多 3 项) */
|
||||
maxCount?: number;
|
||||
/** 块实例的默认值(拖入时初始化) */
|
||||
defaultValue?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布中的块实例(拖入后生成的实际数据)
|
||||
*/
|
||||
export interface BlockInstance {
|
||||
/** 块实例唯一 ID(用于 SortableJS 拖拽排序追踪) */
|
||||
id: string;
|
||||
/** 关联的块类型(对应 BlockSchema.type) */
|
||||
type: string;
|
||||
/** 块实例的当前数据(属性面板编辑后会更新这里) */
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层固定字段(不可拖拽删除,固定显示在属性面板顶部)
|
||||
* 用于 card_type / main_title / source 等必填顶层字段
|
||||
*/
|
||||
export interface FixedField extends FormFieldSchema {
|
||||
/** 字段在最终 JSON 中的路径(支持 a.b.c 嵌套,如 'main_title.title') */
|
||||
name: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, toValue, type MaybeRefOrGetter } from 'vue';
|
||||
|
||||
import type { FileTypeItem } from '#/api/core/file-gallery';
|
||||
import { getFileTypes } from '#/api/core/file-gallery';
|
||||
import type { FileGroupOptionItem } from '#/api/core/file-group';
|
||||
import { useFileGroupCache } from '#/composables/use-file-group-cache';
|
||||
import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon';
|
||||
|
||||
export const ALL_TYPE_VALUE = -1;
|
||||
|
||||
export const ALL_GROUP_VALUE = 0;
|
||||
|
||||
import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon';
|
||||
|
||||
export interface FileGalleryTab {
|
||||
key: string;
|
||||
value: number;
|
||||
@@ -18,7 +17,13 @@ export interface FileGalleryTab {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export function useFileGalleryFilter(storageKey: string) {
|
||||
/**
|
||||
* 文件库筛选(类型 tab + 分组 + 关键字)
|
||||
* @param storageKeyInput localStorage 键;支持动态 getter,便于按 acceptTypes 隔离记忆
|
||||
*/
|
||||
export function useFileGalleryFilter(
|
||||
storageKeyInput: MaybeRefOrGetter<string> = 'file-picker-active-type',
|
||||
) {
|
||||
const fileTypes = ref<FileTypeItem[]>([]);
|
||||
const activeType = ref<number>(ALL_TYPE_VALUE);
|
||||
const activeGroupId = ref<number>(ALL_GROUP_VALUE);
|
||||
@@ -27,6 +32,10 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
|
||||
const { groupOptions, loadGroupOptions } = useFileGroupCache();
|
||||
|
||||
function resolveStorageKey() {
|
||||
return toValue(storageKeyInput);
|
||||
}
|
||||
|
||||
const tabs = computed<FileGalleryTab[]>(() => [
|
||||
{ key: 'all', value: ALL_TYPE_VALUE, label: '全部', icon: ALL_TYPE_ICON },
|
||||
...fileTypes.value.map((item) => ({
|
||||
@@ -48,7 +57,7 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
]);
|
||||
|
||||
function restoreActiveType() {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
const saved = localStorage.getItem(resolveStorageKey());
|
||||
if (saved === null) {
|
||||
activeType.value = ALL_TYPE_VALUE;
|
||||
return;
|
||||
@@ -64,7 +73,7 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
|
||||
function setActiveType(value: number) {
|
||||
activeType.value = value;
|
||||
localStorage.setItem(storageKey, String(value));
|
||||
localStorage.setItem(resolveStorageKey(), String(value));
|
||||
}
|
||||
|
||||
function setActiveGroupId(value: number) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -582,8 +605,25 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// 药品操作
|
||||
/**
|
||||
* 从商品/药品数据解析处方分类 tab(与 activeCategory 对齐)
|
||||
* 1 中药 / 2 西药 / 3 保健食品 / 5 产品服务包 / 6 非药品 / 7 医疗器械
|
||||
* 缺省按西药 2,避免挂号选药误入中药开方
|
||||
*/
|
||||
const resolveDrugCategory = (data: any): number => {
|
||||
const raw = data?.drug?.type ?? data?.type;
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
return 2;
|
||||
};
|
||||
|
||||
// 药品操作:写入前按药型自动切到对应 tab,避免西药落入中药开方组件
|
||||
const addProducts = (data: any) => {
|
||||
const targetCategory = resolveDrugCategory(data);
|
||||
if (Number(activeCategory.value) !== targetCategory) {
|
||||
changeCategory(targetCategory);
|
||||
}
|
||||
|
||||
const existItem = currentDrugs.value.find(
|
||||
(item) => item.index_id === data.id,
|
||||
);
|
||||
@@ -688,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) {
|
||||
@@ -699,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;
|
||||
// 选药后立刻带上真实单位,供新行数量后缀展示
|
||||
@@ -784,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;
|
||||
@@ -795,6 +922,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
const success = addProducts(data);
|
||||
if (success) {
|
||||
newDrugInfo.value = {};
|
||||
pendingNewChineseProduct.value = null;
|
||||
}
|
||||
return success;
|
||||
};
|
||||
@@ -1077,6 +1205,38 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
},
|
||||
userStore.currentUser.doctor_id,
|
||||
);
|
||||
// 在线复诊开方后自动连发购药指引与用药温馨提示(小助手发送)
|
||||
const tipTexts = [
|
||||
'根据您的病情描述,已为您开具电子处方,请点击上方的‘立即支付’,进行购药;如有商品价格和订单等问题请咨询平台客服。',
|
||||
'温馨提示:用药前请详细阅读药品说明书,并严格按照线下医嘱用药。如用药过程中出现病情变化或其它不适症状,请立即停药并及时就医。(请您注意,如果您尚未在医院就诊或未曾使用过本次申请的药品,请暂时不要支付订单。我们建议您在医生的指导下使用药品,以确保用药安全。)',
|
||||
];
|
||||
tipTexts.forEach((tip, tipIdx) => {
|
||||
sendMessage({
|
||||
roomId: chatStore.currentFriend.room_id,
|
||||
senderId: 'doctor_assistant',
|
||||
receiverId: chatStore.currentFriend.id,
|
||||
type: 'text',
|
||||
content: tip,
|
||||
});
|
||||
chatStore.addMessage(
|
||||
{
|
||||
id: `${Date.now()}_tip_${tipIdx}`,
|
||||
room_id: chatStore.currentFriend.room_id,
|
||||
sender_user_id: 'doctor_assistant',
|
||||
receiver_user_id: chatStore.currentFriend.id,
|
||||
message_type: 0,
|
||||
message_content: tip,
|
||||
messageTypeName: 'text',
|
||||
created_at: Date.now() + tipIdx + 1,
|
||||
created_at_text: new Date().toLocaleTimeString().slice(0, 5),
|
||||
timestamp: Date.now() + tipIdx + 1,
|
||||
isSent: true,
|
||||
read: true,
|
||||
duration: 0,
|
||||
},
|
||||
userStore.currentUser.doctor_id,
|
||||
);
|
||||
});
|
||||
resetForm();
|
||||
// 发送成功后清空本挂号下全部分类草稿(含另一分类与 activeCategory)
|
||||
clearLocalPrescriptionCache();
|
||||
@@ -1200,6 +1360,9 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
activeCategory,
|
||||
categories,
|
||||
prescriptionTypeDefault,
|
||||
prescriptionTypeLoading,
|
||||
prescriptionTypeAllowed,
|
||||
prescriptionTypeDenyMessage,
|
||||
diagnosis,
|
||||
medicalAdvice,
|
||||
treatmentPrice,
|
||||
@@ -1240,6 +1403,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
resetInitializationState,
|
||||
getDrugList,
|
||||
addProducts,
|
||||
resolveDrugCategory,
|
||||
removeDrug,
|
||||
updateDrugQuantity,
|
||||
increment,
|
||||
@@ -1249,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;
|
||||
});
|
||||
}
|
||||
@@ -115,6 +115,23 @@ export function generateObjectName(file: File): string {
|
||||
return objectName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 File 推断上传用 Content-Type(浏览器可能给空 type)
|
||||
*/
|
||||
function resolveUploadContentType(file: File): string {
|
||||
if (file.type && file.type !== 'application/octet-stream') {
|
||||
return file.type;
|
||||
}
|
||||
const name = (file.name || '').toLowerCase();
|
||||
if (name.endsWith('.pdf')) return 'application/pdf';
|
||||
if (name.endsWith('.png')) return 'image/png';
|
||||
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (name.endsWith('.gif')) return 'image/gif';
|
||||
if (name.endsWith('.webp')) return 'image/webp';
|
||||
if (name.endsWith('.bmp')) return 'image/bmp';
|
||||
return file.type || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端获取OSS上传签名信息
|
||||
*
|
||||
@@ -252,6 +269,12 @@ export async function uploadToOss(options: UploadOptions): Promise<UploadResult>
|
||||
// 这与后端上传保持一致(后端上传后会调用putObjectAcl设置公共读)
|
||||
// 注意:此字段必须与后端Policy中的条件一致,否则会上传失败
|
||||
formData.append('x-oss-object-acl', 'public-read');
|
||||
|
||||
// Content-Type:按文件 MIME 写入,避免 PDF 被当成 octet-stream 导致浏览器直接下载
|
||||
const contentType = resolveUploadContentType(file);
|
||||
formData.append('Content-Type', contentType);
|
||||
// Content-Disposition: inline —— 浏览器倾向预览而非附件下载
|
||||
formData.append('Content-Disposition', 'inline');
|
||||
|
||||
// file: 要上传的文件
|
||||
// 必须是最后一个字段,OSS要求file字段在FormData的最后
|
||||
|
||||
160
apps/web-antd/src/utils/use-paste-upload-listen.ts
Normal file
160
apps/web-antd/src/utils/use-paste-upload-listen.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { onUnmounted, ref, type Ref } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
export interface UsePasteUploadListenOptions {
|
||||
/** accept 字符串,如 `.jpg,.png,image/*,application/pdf`;支持 getter */
|
||||
accept?: string | (() => string);
|
||||
/** 单文件最大体积(字节);支持 getter */
|
||||
maxSize?: number | (() => number);
|
||||
/** 过滤后的文件回调 */
|
||||
onFiles: (files: File[]) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 accept 规则解析为便于匹配的片段列表
|
||||
*/
|
||||
function parseAccept(accept: string): string[] {
|
||||
return accept
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否匹配 accept(扩展名 / mime / image/* 等)
|
||||
*/
|
||||
export function matchAccept(file: File, accept: string): boolean {
|
||||
const rules = parseAccept(accept);
|
||||
if (!rules.length) return true;
|
||||
const name = (file.name || '').toLowerCase();
|
||||
const type = (file.type || '').toLowerCase();
|
||||
const ext = name.includes('.') ? `.${name.split('.').pop()}` : '';
|
||||
return rules.some((rule) => {
|
||||
if (rule === '*/*') return true;
|
||||
if (rule.endsWith('/*')) {
|
||||
const prefix = rule.slice(0, -1); // image/
|
||||
return type.startsWith(prefix);
|
||||
}
|
||||
if (rule.startsWith('.')) {
|
||||
return ext === rule;
|
||||
}
|
||||
return type === rule;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从粘贴事件中收集 File(files + items 截图等)
|
||||
*/
|
||||
export function collectPasteFiles(event: ClipboardEvent): File[] {
|
||||
const result: File[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (file: File | null | undefined) => {
|
||||
if (!file) return;
|
||||
const key = `${file.name}|${file.size}|${file.type}|${file.lastModified}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
result.push(file);
|
||||
};
|
||||
|
||||
const files = event.clipboardData?.files;
|
||||
if (files?.length) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
push(files.item(i));
|
||||
}
|
||||
}
|
||||
|
||||
const items = event.clipboardData?.items;
|
||||
if (items?.length) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item?.kind === 'file') {
|
||||
push(item.getAsFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始/停止监听窗口粘贴,用于上传组件在拖拽区外主动开启粘贴上传
|
||||
*/
|
||||
export function usePasteUploadListen(options: UsePasteUploadListenOptions) {
|
||||
const listening: Ref<boolean> = ref(false);
|
||||
let handler: ((e: ClipboardEvent) => void) | null = null;
|
||||
|
||||
function stop() {
|
||||
if (handler) {
|
||||
window.removeEventListener('paste', handler);
|
||||
handler = null;
|
||||
}
|
||||
listening.value = false;
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop();
|
||||
handler = (event: ClipboardEvent) => {
|
||||
const acceptRaw = options.accept;
|
||||
const accept =
|
||||
typeof acceptRaw === 'function' ? acceptRaw() : acceptRaw || '';
|
||||
const maxSizeRaw = options.maxSize;
|
||||
const maxSize =
|
||||
typeof maxSizeRaw === 'function'
|
||||
? maxSizeRaw()
|
||||
: (maxSizeRaw ?? 20 * 1024 * 1024);
|
||||
const raw = collectPasteFiles(event);
|
||||
if (!raw.length) {
|
||||
message.warning('剪贴板中没有可上传的文件');
|
||||
return;
|
||||
}
|
||||
const filtered: File[] = [];
|
||||
for (const file of raw) {
|
||||
if (accept && !matchAccept(file, accept)) {
|
||||
continue;
|
||||
}
|
||||
if (file.size > maxSize) {
|
||||
message.warning(`${file.name || '文件'} 超过大小限制`);
|
||||
continue;
|
||||
}
|
||||
// 截图常无名为空,补一个默认名便于上传
|
||||
if (!file.name) {
|
||||
const ext =
|
||||
file.type === 'image/png'
|
||||
? 'png'
|
||||
: file.type === 'image/jpeg'
|
||||
? 'jpg'
|
||||
: file.type === 'application/pdf'
|
||||
? 'pdf'
|
||||
: 'bin';
|
||||
filtered.push(new File([file], `paste-${Date.now()}.${ext}`, { type: file.type }));
|
||||
} else {
|
||||
filtered.push(file);
|
||||
}
|
||||
}
|
||||
if (!filtered.length) {
|
||||
message.warning('剪贴板文件类型不符合要求');
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void options.onFiles(filtered);
|
||||
};
|
||||
window.addEventListener('paste', handler);
|
||||
listening.value = true;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (listening.value) stop();
|
||||
else start();
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stop();
|
||||
});
|
||||
|
||||
return {
|
||||
listening,
|
||||
start,
|
||||
stop,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,29 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 诊所复诊 type11 follow_up_drug 助理用药确认卡
|
||||
* 展示问题、药品、是否用过、适用症/补充;可一键导入处方单
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
/** 是否展示「添加到处方单」(接诊中且有药) */
|
||||
showAddToRx: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
addToRxLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add-to-rx']);
|
||||
|
||||
const data = computed(() =>
|
||||
typeof props.content === 'string'
|
||||
? (() => {
|
||||
@@ -27,9 +43,36 @@ const answeredLabel = computed(() => {
|
||||
return '';
|
||||
});
|
||||
|
||||
/** 是否就诊过:有明确 0/1 才展示 */
|
||||
const visitedLabel = computed(() => {
|
||||
const v = data.value.has_visited;
|
||||
if (v === 1 || v === '1') return '是';
|
||||
if (v === 0 || v === '0') return '否';
|
||||
return '';
|
||||
});
|
||||
|
||||
const drugs = computed(() =>
|
||||
Array.isArray(data.value.drugs) ? data.value.drugs : [],
|
||||
);
|
||||
|
||||
/** 适用症文案(有值才展示) */
|
||||
const illnessInfo = computed(() => {
|
||||
const v = data.value.illness_info;
|
||||
return v != null && String(v).trim() ? String(v).trim() : '';
|
||||
});
|
||||
|
||||
/**
|
||||
* 补充信息:优先独立字段;若 illness_info 已含「;补充:」且无独立字段则不重复展示
|
||||
*/
|
||||
const supplementText = computed(() => {
|
||||
const v = data.value.supplement;
|
||||
if (v != null && String(v).trim()) return String(v).trim();
|
||||
return '';
|
||||
});
|
||||
|
||||
const canShowAddBtn = computed(
|
||||
() => props.showAddToRx && drugs.value.length > 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -63,12 +106,41 @@ const drugs = computed(() =>
|
||||
<span class="value">{{ answeredLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="visitedLabel" class="answer-row">
|
||||
<span class="label">是否就诊过</span>
|
||||
<span class="value">{{ visitedLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="illnessInfo" class="info-block">
|
||||
<div class="info-block-label">适用症/症状</div>
|
||||
<div class="info-block-content">{{ illnessInfo }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="supplementText" class="info-block">
|
||||
<div class="info-block-label">补充信息</div>
|
||||
<div class="info-block-content">{{ supplementText }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="data.answer_status === 'pending'"
|
||||
v-else-if="!answeredLabel && data.answer_status === 'pending'"
|
||||
class="pending-tip"
|
||||
>
|
||||
待患者在小程序端填写用药信息
|
||||
</div>
|
||||
|
||||
<div v-if="canShowAddBtn" class="add-rx-wrap">
|
||||
<Button
|
||||
:loading="addToRxLoading"
|
||||
block
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
@click.stop="emit('add-to-rx')"
|
||||
>
|
||||
<i class="fas fa-plus-circle mr-1"></i>
|
||||
添加到处方单
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -122,7 +194,7 @@ const drugs = computed(() =>
|
||||
}
|
||||
|
||||
.answer-row {
|
||||
@apply flex items-center justify-between rounded-lg bg-emerald-50 px-3 py-2 text-sm dark:bg-emerald-900/30;
|
||||
@apply mb-3 flex items-center justify-between rounded-lg bg-emerald-50 px-3 py-2 text-sm dark:bg-emerald-900/30;
|
||||
}
|
||||
|
||||
.answer-row .label {
|
||||
@@ -133,7 +205,23 @@ const drugs = computed(() =>
|
||||
@apply font-medium text-emerald-800 dark:text-emerald-300;
|
||||
}
|
||||
|
||||
.info-block {
|
||||
@apply mb-3 rounded-lg bg-slate-50 px-3 py-2 dark:bg-slate-900/50;
|
||||
}
|
||||
|
||||
.info-block-label {
|
||||
@apply mb-1 text-xs text-slate-500 dark:text-slate-400;
|
||||
}
|
||||
|
||||
.info-block-content {
|
||||
@apply text-sm leading-relaxed text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
|
||||
.pending-tip {
|
||||
@apply rounded-lg border border-dashed border-amber-200 bg-amber-50 px-3 py-2 text-center text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200;
|
||||
}
|
||||
|
||||
.add-rx-wrap {
|
||||
@apply mt-3 border-t border-slate-100 pt-3 dark:border-slate-600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -251,10 +251,15 @@ const canAddRegisterDrugsToRx = computed(() => {
|
||||
const handleAddRegisterDrugsToPrescription = async () => {
|
||||
if (registerAddToRxLoading.value) return;
|
||||
const cardRegisterId = registerCardDisplay.value?.id;
|
||||
const followUpRegisterId =
|
||||
isFollowUpDrugAssistant.value && parsedContent.value?.register_id
|
||||
? parsedContent.value.register_id
|
||||
: '';
|
||||
const registerId =
|
||||
prescriptionStore.currentRegisterId ||
|
||||
chatStore.currentFriend?.register_id ||
|
||||
cardRegisterId;
|
||||
cardRegisterId ||
|
||||
followUpRegisterId;
|
||||
if (!registerId) {
|
||||
antdMessage.warning('请先接诊患者');
|
||||
return;
|
||||
@@ -308,6 +313,22 @@ const isFollowUpDrugAssistant = computed(() => {
|
||||
return !!(pc && typeof pc === 'object' && pc.flow === 'follow_up_drug');
|
||||
});
|
||||
|
||||
/**
|
||||
* 复诊用药卡「添加到处方单」:有药、非本人发送、当前会话有挂号
|
||||
* 与挂号卡加方共用 handleAddRegisterDrugsToPrescription
|
||||
*/
|
||||
const canAddFollowUpDrugsToRx = computed(() => {
|
||||
if (!isFollowUpDrugAssistant.value || props.isSent) return false;
|
||||
const pc = parsedContent.value;
|
||||
const drugs = pc && Array.isArray(pc.drugs) ? pc.drugs : [];
|
||||
if (!drugs.length) return false;
|
||||
return !!(
|
||||
prescriptionStore.currentRegisterId ||
|
||||
chatStore.currentFriend?.register_id ||
|
||||
pc.register_id
|
||||
);
|
||||
});
|
||||
|
||||
function fetchRegisterCard() {
|
||||
if (props.message.message_type !== 10) return;
|
||||
const content = getParsedContent(
|
||||
@@ -726,9 +747,13 @@ const getSexText = (sex) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主诉 / 所选药品(逐药:缩略图 + 规格 + 数量,对齐小程序) -->
|
||||
<!-- 主诉 / 适用症 / 所选药品(逐药:缩略图 + 规格 + 数量,对齐小程序) -->
|
||||
<div
|
||||
v-if="registerCardDisplay.chief_complaint || registerCardDrugRows.length"
|
||||
v-if="
|
||||
registerCardDisplay.chief_complaint ||
|
||||
registerCardDisplay.illnessInfo ||
|
||||
registerCardDrugRows.length
|
||||
"
|
||||
class="mt-3 space-y-2 rounded-lg border border-gray-100 p-3 text-sm dark:border-gray-600"
|
||||
>
|
||||
<div v-if="registerCardDisplay.chief_complaint">
|
||||
@@ -737,6 +762,33 @@ const getSexText = (sex) => {
|
||||
registerCardDisplay.chief_complaint
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="registerCardDisplay.illnessInfo">
|
||||
<span class="font-medium text-gray-600 dark:text-gray-300">适用症/症状:</span>
|
||||
<span class="text-gray-800 dark:text-gray-100">{{
|
||||
registerCardDisplay.illnessInfo
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="registerCardDisplay.illnessInfo"
|
||||
class="flex flex-wrap gap-x-4 gap-y-1 text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
<span>
|
||||
是否就诊过:{{
|
||||
registerCardDisplay.has_visited === 1 ||
|
||||
registerCardDisplay.has_visited === '1'
|
||||
? '是'
|
||||
: '否'
|
||||
}}
|
||||
</span>
|
||||
<span>
|
||||
是否使用过药品:{{
|
||||
registerCardDisplay.has_used_drug === 1 ||
|
||||
registerCardDisplay.has_used_drug === '1'
|
||||
? '是'
|
||||
: '否'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="registerCardDrugRows.length" class="space-y-2">
|
||||
<div class="font-medium text-gray-600 dark:text-gray-300">所选药品:</div>
|
||||
<div
|
||||
@@ -1044,6 +1096,9 @@ const getSexText = (sex) => {
|
||||
<FollowUpDrugAssistantCard
|
||||
v-else-if="message.message_type === 11 && isFollowUpDrugAssistant"
|
||||
:content="parsedContent"
|
||||
:show-add-to-rx="canAddFollowUpDrugsToRx"
|
||||
:add-to-rx-loading="registerAddToRxLoading"
|
||||
@add-to-rx="handleAddRegisterDrugsToPrescription"
|
||||
/>
|
||||
|
||||
<!-- 患者就诊经历卡片 (type=11) -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -134,9 +134,12 @@ export const sendMessage = (data) => {
|
||||
const receiverId = data.receiverId.toString();
|
||||
const normalizedReceiverId = receiverId.startsWith('user-') ? receiverId : `user-${receiverId}`;
|
||||
|
||||
// 确保 sender_user_id 有 doctor- 前缀(医生端)
|
||||
// 确保 sender_user_id:小助手保持 doctor_assistant;医生端补 doctor- 前缀
|
||||
const senderId = data.senderId.toString();
|
||||
const normalizedSenderId = senderId.startsWith('doctor-') ? senderId : `doctor-${senderId}`;
|
||||
const normalizedSenderId =
|
||||
senderId === 'doctor_assistant' || senderId.startsWith('doctor-')
|
||||
? senderId
|
||||
: `doctor-${senderId}`;
|
||||
|
||||
const requestData = {
|
||||
room_id: data.roomId,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -37,7 +37,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="处方溯源">
|
||||
<Modal class="w-[80%] h-[80%]" title="处方溯源">
|
||||
<PrescriptionSourceContent :data="data" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -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',
|
||||
@@ -47,9 +38,14 @@ export const formOptions: VbenFormProps = {
|
||||
{
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD', // 确保格式与你需要的一致
|
||||
format: 'YYYY-MM-DD',
|
||||
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
},
|
||||
defaultValue: [dayjs().startOf('month'), dayjs()],
|
||||
defaultValue: [
|
||||
dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||
dayjs().format('YYYY-MM-DD'),
|
||||
],
|
||||
fieldName: 'search_time',
|
||||
label: '时间范围',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
@@ -263,12 +309,20 @@ function prescriptionStatusColor() {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="订单详情">
|
||||
<Modal class="h-[80%] w-[80%]" title="订单详情">
|
||||
<div v-if="data" class="flex flex-col gap-4">
|
||||
<Space>
|
||||
<Button type="primary" size="small" @click="openOrderTrace">
|
||||
查看溯源
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canRetrySettle"
|
||||
size="small"
|
||||
:loading="retrySettling"
|
||||
@click="handleRetrySettle"
|
||||
>
|
||||
重新结算
|
||||
</Button>
|
||||
</Space>
|
||||
<h3 class="mt-4">订单信息</h3>
|
||||
<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',
|
||||
@@ -106,8 +99,13 @@ export const formOptions: VbenFormProps = {
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD',
|
||||
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
},
|
||||
defaultValue: [dayjs().startOf('month'), dayjs()],
|
||||
defaultValue: [
|
||||
dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||
dayjs().format('YYYY-MM-DD'),
|
||||
],
|
||||
fieldName: 'search_time',
|
||||
label: '时间范围',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -18,9 +18,14 @@ export const formOptions: VbenFormProps = {
|
||||
{
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD', // 确保格式与你需要的一致
|
||||
format: 'YYYY-MM-DD',
|
||||
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
},
|
||||
defaultValue: [dayjs().startOf('month'), dayjs()],
|
||||
defaultValue: [
|
||||
dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||
dayjs().format('YYYY-MM-DD'),
|
||||
],
|
||||
fieldName: 'search_time',
|
||||
label: '时间范围',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 切换药品单品包邮(非中药商品共用)
|
||||
* @param drugId 药品 ID
|
||||
*/
|
||||
export async function toggleDrugFreeShipping(drugId: number) {
|
||||
return requestClient.post<{ is_free_shipping: number }>(
|
||||
'drug-free-shipping/toggle',
|
||||
{ drug_id: drugId },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { toggleDrugFreeShipping } from './drug-free-shipping-api';
|
||||
|
||||
const props = defineProps<{
|
||||
/** 列表行:需含 id、is_free_shipping */
|
||||
row: {
|
||||
id: number | string;
|
||||
is_free_shipping?: number;
|
||||
};
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 切换成功后通知父级刷新列表 */
|
||||
(e: 'changed'): void;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
/**
|
||||
* 点击 Tag 切换单品包邮;成功后回写行内状态并通知父级
|
||||
*/
|
||||
const onToggle = async () => {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
const drugId = Number(props.row.id);
|
||||
if (!drugId) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await toggleDrugFreeShipping(drugId);
|
||||
const next = Number(res?.is_free_shipping ?? 0);
|
||||
props.row.is_free_shipping = next;
|
||||
message.success(next === 1 ? '已设为单品包邮' : '已取消单品包邮');
|
||||
emit('changed');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tag
|
||||
:color="row.is_free_shipping === 1 ? 'success' : 'default'"
|
||||
class="cursor-pointer"
|
||||
:class="{ 'opacity-60': loading }"
|
||||
@click="onToggle"
|
||||
>
|
||||
{{ row.is_free_shipping === 1 ? '包邮' : '不包邮' }}
|
||||
</Tag>
|
||||
</template>
|
||||
@@ -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`, {});
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ const handleChange = (info: { file: UploadFile }) => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
|
||||
<Modal class="w-[80%] h-[80%]" title="上传Excel">
|
||||
<!-- 新增:显示文件选择状态 -->
|
||||
<div v-if="isFileSelected" class="mb-4 p-3 bg-green-50 border border-green-200 rounded">
|
||||
<p class="text-green-700 text-sm">已选择文件:{{ selectedFile?.name }}</p>
|
||||
|
||||
@@ -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`, {});
|
||||
}
|
||||
@@ -102,7 +102,7 @@ const handleChange = (info: { file: UploadFile }) => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
|
||||
<Modal class="w-[80%] h-[80%]" title="上传Excel">
|
||||
<!-- 显示文件选择状态提示 -->
|
||||
<div
|
||||
v-if="isFileSelected"
|
||||
|
||||
@@ -57,6 +57,12 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
},
|
||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
|
||||
{ field: 'specification', title: '规格', width: 120 },
|
||||
{
|
||||
field: 'is_free_shipping',
|
||||
title: '单品包邮',
|
||||
width: 100,
|
||||
slots: { default: 'is_free_shipping' },
|
||||
},
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
|
||||
],
|
||||
|
||||
@@ -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,13 +20,19 @@ 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';
|
||||
|
||||
// 导入保健食品管理相关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';
|
||||
@@ -40,6 +46,7 @@ const PRODUCT_TYPE = 3;
|
||||
|
||||
// 控制顶部表格下拉操作按钮的显示状态
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
|
||||
// 定义表格事件监听器
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
@@ -93,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 时打开后立刻弹出新增绑定表单
|
||||
@@ -233,6 +274,7 @@ const openExcelUploadModal = () => {
|
||||
<Page auto-content-height title="保健食品管理">
|
||||
<ExcelUploadModal />
|
||||
<FormModal />
|
||||
<DrugDetailModalComp />
|
||||
<CategoryModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
@@ -280,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
|
||||
@@ -303,6 +348,9 @@ const openExcelUploadModal = () => {
|
||||
<template #supplier="{ row }">
|
||||
{{ row.supplier?.name || row.source }}
|
||||
</template>
|
||||
<template #is_free_shipping="{ row }">
|
||||
<DrugFreeShippingCell :row="row" />
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||
</template>
|
||||
|
||||
@@ -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`, {});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ const handleChange = (info: { file: UploadFile }) => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
|
||||
<Modal class="w-[80%] h-[80%]" title="上传Excel">
|
||||
<div
|
||||
v-if="isFileSelected"
|
||||
class="mb-4 rounded border border-green-200 bg-green-50 p-3"
|
||||
|
||||
@@ -50,6 +50,12 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
|
||||
{ field: 'function', title: '器械功能', width: 200 },
|
||||
{ field: 'specification', title: '规格型号', width: 120 },
|
||||
{
|
||||
field: 'is_free_shipping',
|
||||
title: '单品包邮',
|
||||
width: 100,
|
||||
slots: { default: 'is_free_shipping' },
|
||||
},
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
|
||||
],
|
||||
|
||||
@@ -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,12 +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';
|
||||
@@ -29,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() {
|
||||
@@ -71,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 时打开后立刻弹出新增绑定表单
|
||||
@@ -180,6 +221,7 @@ const openExcelUploadModal = () => {
|
||||
<Page auto-content-height title="医疗器械管理">
|
||||
<ExcelUploadModal />
|
||||
<FormModal />
|
||||
<DrugDetailModalComp />
|
||||
<CategoryModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
@@ -227,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
|
||||
@@ -250,6 +295,9 @@ const openExcelUploadModal = () => {
|
||||
<template #supplier="{ row }">
|
||||
{{ row.supplier?.name || row.source }}
|
||||
</template>
|
||||
<template #is_free_shipping="{ row }">
|
||||
<DrugFreeShippingCell :row="row" />
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||
</template>
|
||||
|
||||
@@ -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`, {});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ const handleChange = (info: { file: UploadFile }) => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
|
||||
<Modal class="w-[80%] h-[80%]" title="上传Excel">
|
||||
<div
|
||||
v-if="isFileSelected"
|
||||
class="mb-4 rounded border border-green-200 bg-green-50 p-3"
|
||||
|
||||
@@ -50,6 +50,12 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
|
||||
{ field: 'function', title: '产品功能', width: 200 },
|
||||
{ field: 'specification', title: '规格', width: 120 },
|
||||
{
|
||||
field: 'is_free_shipping',
|
||||
title: '单品包邮',
|
||||
width: 100,
|
||||
slots: { default: 'is_free_shipping' },
|
||||
},
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
|
||||
],
|
||||
|
||||
@@ -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,12 +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';
|
||||
@@ -29,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() {
|
||||
@@ -71,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 时打开后立刻弹出新增绑定表单
|
||||
@@ -180,6 +217,7 @@ const openExcelUploadModal = () => {
|
||||
<Page auto-content-height title="非药品管理">
|
||||
<ExcelUploadModal />
|
||||
<FormModal />
|
||||
<DrugDetailModalComp />
|
||||
<CategoryModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
@@ -227,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
|
||||
@@ -250,6 +291,9 @@ const openExcelUploadModal = () => {
|
||||
<template #supplier="{ row }">
|
||||
{{ row.supplier?.name || row.source }}
|
||||
</template>
|
||||
<template #is_free_shipping="{ row }">
|
||||
<DrugFreeShippingCell :row="row" />
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||
</template>
|
||||
|
||||
@@ -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`, {});
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
{ field: 'supplier.name', title: '供应商', width: 130 },
|
||||
{ field: 'function', title: '主要功能', width: 200 },
|
||||
{ field: 'specification', title: '规格', width: 120 },
|
||||
{
|
||||
field: 'is_free_shipping',
|
||||
title: '单品包邮',
|
||||
width: 100,
|
||||
slots: { default: 'is_free_shipping' },
|
||||
},
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
|
||||
],
|
||||
|
||||
@@ -5,19 +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';
|
||||
@@ -27,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() {
|
||||
@@ -67,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 时打开后立刻弹出新增绑定表单
|
||||
@@ -163,6 +200,7 @@ const deleteApi = (row: any) => {
|
||||
<template>
|
||||
<Page auto-content-height title="产品服务包管理">
|
||||
<FormModal />
|
||||
<DrugDetailModalComp />
|
||||
<ZoneCategoryModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
@@ -199,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
|
||||
@@ -219,6 +260,9 @@ const deleteApi = (row: any) => {
|
||||
@stock-in="openStockIn"
|
||||
/>
|
||||
</template>
|
||||
<template #is_free_shipping="{ row }">
|
||||
<DrugFreeShippingCell :row="row" />
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||
</template>
|
||||
|
||||
@@ -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`, {});
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ const handleChange = (info: { file: UploadFile }) => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
|
||||
<Modal class="w-[80%] h-[80%]" title="上传Excel">
|
||||
<!-- 新增:显示文件选择状态 -->
|
||||
<div
|
||||
v-if="isFileSelected"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -55,11 +55,17 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
width: 100,
|
||||
slots: { default: 'is_otc' },
|
||||
},
|
||||
{
|
||||
field: 'is_free_shipping',
|
||||
title: '单品包邮',
|
||||
width: 100,
|
||||
slots: { default: 'is_free_shipping' },
|
||||
},
|
||||
{ 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,12 +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';
|
||||
@@ -31,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() {
|
||||
@@ -83,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 时打开后立刻弹出新增绑定表单
|
||||
@@ -156,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();
|
||||
@@ -217,6 +261,7 @@ const openExcelUploadModal = () => {
|
||||
<Page auto-content-height title="西(中成)药管理">
|
||||
<ExcelUploadModal />
|
||||
<FormModal />
|
||||
<DrugDetailModalComp />
|
||||
<CategoryModalComp />
|
||||
<MinPriceModalComp />
|
||||
<ErpQtyFactorModalComp />
|
||||
@@ -270,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
|
||||
@@ -298,6 +346,9 @@ const openExcelUploadModal = () => {
|
||||
{{ row.is_otc === 0 ? '处方药' : '非处方药' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #is_free_shipping="{ row }">
|
||||
<DrugFreeShippingCell :row="row" />
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||
</template>
|
||||
@@ -307,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>
|
||||
@@ -340,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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user