Compare commits
11 Commits
dev/messag
...
dev/distri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1df6fb4e8 | ||
|
|
5fcb65aa0d | ||
|
|
bbb133476a | ||
|
|
700db76f16 | ||
|
|
ee4490a91c | ||
|
|
7656c3ac49 | ||
|
|
c1b71142df | ||
|
|
92149f43a6 | ||
|
|
47a829c1b1 | ||
|
|
5ff6f0d837 | ||
|
|
8eca0bd84d |
@@ -1,233 +1,10 @@
|
||||
---
|
||||
description: 萧康云医管理后台(Vben Admin v5 + Vue 3 + TS + Ant Design Vue)代码规范
|
||||
description:
|
||||
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.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` 注入,禁止写死常量
|
||||
|
||||
# 专项模块规范
|
||||
|
||||
## OA 通知模块前端规则
|
||||
|
||||
### 组件与封装复用
|
||||
|
||||
- 所有 OA 配置入口(系统配置 tab、CRUD 页面、员工 OA 账号弹窗)必须复用 `Tabs.TabPane`、`useVbenVxeGrid`、`useVbenModal`、`useVbenForm`,禁止用原生 antd 表格/表单
|
||||
|
||||
### 平台动态化(禁止写死)
|
||||
|
||||
- **平台列表必须从接口 `oa-platform/list` 动态拉取**,禁止在前端写死 `['work_wechat', 'dingtalk', 'feishu']` 常量数组
|
||||
- 系统配置 tab 中的平台开关行、机器人表单中的平台下拉、场景表单中的机器人分组,都要走接口动态渲染
|
||||
- 平台编码统一为字符串 snake_case(如 `work_wechat`、`dingtalk`、`feishu`),与后端 `xk_oa_platform.platform_code` 字段对齐
|
||||
|
||||
### 系统配置 tab 交互
|
||||
|
||||
- 系统配置 tab 中的「总开关关闭」必须禁用下方所有平台子开关(视觉上置灰),禁止总开关关闭时仍能切换子开关
|
||||
- 平台子开关切换必须立即调 `oa-platform/update-enabled` 接口,**禁止依赖保存按钮批量提交**(避免总开关与子开关状态不一致)
|
||||
- 系统配置页的总开关走现有 `getSystemConfigList` / `saveSystemConfig` 接口(config_key = `oa_notify_enabled`),**禁止新建专用接口**
|
||||
|
||||
### 表单字段约定
|
||||
|
||||
- @ 人配置必须按平台分组(用 Tabs 或 Collapse 分组),员工必须从 `xk_admin` 表选择(复用员工 picker 组件)
|
||||
- OA 机器人表单中的 webhook、secret 字段必须用密码型输入框(`InputPassword`),禁止明文展示
|
||||
- OA 场景表单中的机器人多选必须按平台分组展示(用 `Tabs` 或 `Collapse` 分组)
|
||||
|
||||
### 消息类型全量支持(数据库驱动 schema)
|
||||
|
||||
- 场景弹窗每个平台 Tab 内的消息类型选项**必须**通过 `GET /oa-scene/message-types` 接口动态拉取,
|
||||
按 `platform_code` 过滤当前平台支持的所有 `message_type`
|
||||
- 每个消息类型对应的 payload 表单**必须**按后端返回的 `payload_schema`(JSON 字符串)动态渲染,
|
||||
禁止前端写死 schema(数据库可启停类型,前端硬编码会不同步)
|
||||
- payload 字段渲染规则(与后端 `payload_schema` 约定):
|
||||
- `Textarea`:多行文本
|
||||
- `Input` / `InputNumber`:单行文本/数字
|
||||
- `RadioGroup` / `Select`:选项组
|
||||
- `GalleryPickLink`:素材库选择器,透传 `props.acceptTypes: number[]` 过滤文件类型
|
||||
- `TemplateCardEditor`:企微模板卡片专用编辑器(用 `views/system/oa-scene/components/template-card-form.vue`)
|
||||
- 提交结构:`message_config: { platform_code: { message_type, payload } }`
|
||||
|
||||
### image-gallery-picker 文件类型过滤
|
||||
|
||||
- `image-gallery-picker.vue` 和 `gallery-pick-link.vue` 都支持 `acceptTypes?: number[]` prop
|
||||
- 取值对应 `xk_file_type.value`:1=图片、2=视频、3=音频、6=文件 等
|
||||
- 不传 `acceptTypes` 时显示全部类型(默认行为,保持向后兼容)
|
||||
- 传数组时只展示匹配类型 tab,且自动锁定到第一个匹配类型
|
||||
- OA 场景中图片字段必须传 `[1]`、语音字段传 `[3]`、文件字段传 `[6]`,避免用户选错类型
|
||||
|
||||
### 通用拖拽可视化编辑器(`components/oa-template-card-editor/`)
|
||||
|
||||
- 任何「左侧组件库 → 右侧画布拖拽组装 → 自动生成 JSON」的需求,**必须复用此通用组件**,禁止重复造轮子
|
||||
- schema 驱动:新增块类型只需扩展 `BlockSchema[]` 配置,禁止改组件内部
|
||||
- SortableJS 用法参照 `views/system/role/components/quick-nav-transfer.vue`(动态 import `sortablejs/modular/sortable.complete.esm.js`)
|
||||
- 双向绑定走 `v-model`(`modelValue` + `update:modelValue`),禁止内部直接修改 props
|
||||
- OA 场景中的企微模板卡片必须用 `views/system/oa-scene/components/template-card-form.vue`(在通用编辑器之上做 OA 封装)
|
||||
|
||||
### 测试发送(全类型循环)
|
||||
|
||||
- OA 机器人 modal 的「测试发送」必须循环跑该平台支持的所有消息类型(单平台最多 8 种)
|
||||
- 后端返回 `results` 数组,前端必须遍历分类型展示成功/失败(不要只展示第一条)
|
||||
- 测试按钮 loading 用独立状态(不要复用 modal 的 `confirmLoading`)
|
||||
- 测试发送结果以列表形式展示在表单下方,每种类型显示状态/耗时/错误信息
|
||||
- 测试发送支持两套入口(共用 `testSendOaRobot` API):
|
||||
- **表单弹窗内测试**(modal.vue):用于「保存前验证密钥」,直接传 webhook_url/secret(不带 id)
|
||||
- **行级测试**(独立组件 `test-send-modal.vue`):用于列表中已有机器人,仅传 `id`,后端自动取已加密密钥
|
||||
- 行级测试弹窗入参:`test_content` + `test_at_all` 开关 + `test_mobiles` 手机号多选
|
||||
- 飞书 webhook 不支持手机号 @,前端必须在飞书平台下隐藏手机号字段并提示用户(仅 @all 有效)
|
||||
|
||||
### 群聊绑定(按平台过滤的 N:N 多选)
|
||||
|
||||
- 「OA群聊管理」是独立菜单(pid=232 OA 通知父级下,菜单 id=233,路由 `/system/oa-chat`)
|
||||
- 群聊管理页结构必须与 `oa-robot` 一致:`index.vue + api/index.ts + config/{table,search,form}.ts + components/modal.vue`
|
||||
- 机器人表单中的「绑定群聊」字段必须用 `Checkbox.Group`:
|
||||
- 群聊列表通过 `getOaChatListByPlatform()` 一次性拉取全量(按平台分组的对象)
|
||||
- 前端按当前 `platform_code` 过滤展示(平台切换时自动过滤掉不属于当前平台的勾选)
|
||||
- 平台切换通过 `componentProps.onChange` 监听(vben form 字段值变化无现成 watch,必须通过 schema 注入 onChange 回调)
|
||||
- 编辑场景下,机器人详情接口返回 `chat_ids: number[]`,前端 `selectedChatIds` 直接回显
|
||||
- 提交时把 `chat_ids` 数组合并到 payload(后端 `OaRobotController::notRequest` 已包含该字段)
|
||||
- 列表展示用后端拼接好的 `chat_names` 字符串(按「、」拼接),详情接口才返回 `chat_ids` 数组
|
||||
|
||||
@@ -21,3 +21,4 @@ VITE_ARCHIVER=true
|
||||
|
||||
# WebSocket 连接地址
|
||||
VITE_WS_URL=wss://api.ws.g.xiaokang88.com/ws
|
||||
# VITE_WS_URL=wss://xk.ws.nailaoyun.cn/ws
|
||||
|
||||
@@ -61,7 +61,6 @@ export type ComponentType =
|
||||
| 'DatePicker'
|
||||
| 'DefaultButton'
|
||||
| 'Divider'
|
||||
| 'GalleryPickLink' // OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型)
|
||||
| 'IconPicker'
|
||||
| 'Input'
|
||||
| 'InputNumber'
|
||||
|
||||
@@ -6,19 +6,20 @@
|
||||
* - 支持中药/西药类型切换
|
||||
* - 下拉选项展示:药品图片、名称、供应商、规格、价格
|
||||
* - 选择时返回完整药品数据(包含默认用法字段)
|
||||
* - isCard:单选确认场景(如仓绑药)选中后以内嵌卡片展示已选药品
|
||||
* @author 系统
|
||||
* @date 2024
|
||||
*/
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
import { SearchOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||
import { Input, Spin } from 'ant-design-vue';
|
||||
import { Button, Input, Spin } from 'ant-design-vue';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
|
||||
|
||||
/** 中药无图时与仓库列表一致的占位图 */
|
||||
const TCM_PLACEHOLDER_IMAGE =
|
||||
/** 无图时占位(中药/西药等统一兜底,避免仓绑药下拉只剩文字) */
|
||||
const DRUG_PLACEHOLDER_IMAGE =
|
||||
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
|
||||
|
||||
// ==================== Props 定义 ====================
|
||||
@@ -29,12 +30,17 @@ interface Props {
|
||||
* 1-中药,2-西药
|
||||
*/
|
||||
type?: number;
|
||||
/**
|
||||
* 多药品类型(优先于 type;有值时支持空关键词拉全量)
|
||||
* 例如 [2,4] 表示西药+中成药
|
||||
*/
|
||||
types?: number[];
|
||||
/**
|
||||
* 占位提示文本
|
||||
*/
|
||||
placeholder?: string;
|
||||
/**
|
||||
* 诊所ID
|
||||
* 诊所ID(接诊/开方必传;仓药绑定等平台场景可省略,默认 0 走主库 types 检索)
|
||||
*/
|
||||
storeId?: number;
|
||||
/**
|
||||
@@ -45,14 +51,21 @@ interface Props {
|
||||
* 是否禁用
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 是否以卡片展示已选药品(仓绑药等单选确认场景;开方默认 false)
|
||||
*/
|
||||
isCard?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 1,
|
||||
types: () => [],
|
||||
placeholder: '输入药品名称搜索',
|
||||
storeId: 2,
|
||||
// 默认 0:平台无门店;诊所场景由调用方传 :store-id
|
||||
storeId: 0,
|
||||
registerId: undefined,
|
||||
disabled: false,
|
||||
isCard: false,
|
||||
});
|
||||
|
||||
// ==================== Emits 定义 ====================
|
||||
@@ -97,10 +110,21 @@ const containerRef = ref<HTMLElement | null>(null);
|
||||
*/
|
||||
const highlightIndex = ref(-1);
|
||||
|
||||
/**
|
||||
* isCard 模式下已选药品(用于输入框下方卡片)
|
||||
*/
|
||||
const selectedDrug = ref<any | null>(null);
|
||||
|
||||
/**
|
||||
* 搜索框引用,便于「重新选择」时聚焦
|
||||
*/
|
||||
const inputRef = ref<{ focus?: () => void } | null>(null);
|
||||
|
||||
/**
|
||||
* 解析下拉展示图:优先真图,无图用统一占位
|
||||
*/
|
||||
function resolveDropdownImage(item: any): string {
|
||||
if (item._image) return item._image;
|
||||
if (props.type === 1) return TCM_PLACEHOLDER_IMAGE;
|
||||
return '';
|
||||
return item._image || DRUG_PLACEHOLDER_IMAGE;
|
||||
}
|
||||
|
||||
// ==================== 方法定义 ====================
|
||||
@@ -108,10 +132,12 @@ function resolveDropdownImage(item: any): string {
|
||||
/**
|
||||
* 搜索药品
|
||||
* @param keyword 搜索关键词
|
||||
* @description 调用后端API搜索药品,支持按名称和拼音搜索
|
||||
* @description 调用后端API搜索药品,支持按名称和拼音搜索;
|
||||
* 传入 types 时允许空关键词拉取该类型全量列表(用于下拉展开)
|
||||
*/
|
||||
const searchDrugs = debounce(async (keyword: string) => {
|
||||
if (!keyword || keyword.length < 1) {
|
||||
// 单类型模式:空关键词清空;多类型模式:空关键词仍请求后端拉全量
|
||||
if ((!keyword || keyword.length < 1) && props.types.length === 0) {
|
||||
searchResults.value = [];
|
||||
showDropdown.value = false;
|
||||
return;
|
||||
@@ -121,37 +147,43 @@ const searchDrugs = debounce(async (keyword: string) => {
|
||||
showDropdown.value = true;
|
||||
|
||||
try {
|
||||
// types 优先:有多类型时只传 types,不传 type,避免后端被单 type 覆盖
|
||||
const typeParams =
|
||||
props.types.length > 0
|
||||
? { types: props.types }
|
||||
: { type: props.type };
|
||||
const res = await getProductListDoctorReception({
|
||||
name: keyword,
|
||||
type: props.type,
|
||||
name: keyword || '',
|
||||
...typeParams,
|
||||
store_id: props.storeId,
|
||||
...(props.registerId ? { register_id: props.registerId } : {}),
|
||||
});
|
||||
|
||||
// 处理返回数据
|
||||
// 处理返回数据(兼容门店 relation 与平台主库打平结构)
|
||||
if (Array.isArray(res)) {
|
||||
searchResults.value = res.map((item: any) => ({
|
||||
// 保留原始数据
|
||||
...item,
|
||||
// 提取常用字段便于访问
|
||||
_id: item.id,
|
||||
_drugId: item.drug_id || item.drug?.id,
|
||||
_drugName: item.drug?.drug_name || item.drug_name || '',
|
||||
_image: item.drug?.image || '',
|
||||
_specification: item.drug?.specification || '',
|
||||
_supplier: item.drug?.supplier?.name || '',
|
||||
_price: item.price || 0,
|
||||
// 默认用法字段
|
||||
_timeId: item.drug?.time_id || 0,
|
||||
_typeId: item.drug?.type_id || 0,
|
||||
_frequencyId: item.drug?.frequency_id || 0,
|
||||
_unitId: item.drug?.unit_id || 0,
|
||||
_number: item.drug?.number || 1,
|
||||
// 用法名称
|
||||
_useNum: item.drug?.useNum,
|
||||
_useType: item.drug?.useType,
|
||||
_useFrequency: item.drug?.useFrequency,
|
||||
}));
|
||||
searchResults.value = res.map((item: any) => {
|
||||
const drug = item.drug || {};
|
||||
return {
|
||||
...item,
|
||||
_id: item.id,
|
||||
_drugId: item.drug_id || drug.id || item.id,
|
||||
_drugName: drug.drug_name || item.drug_name || '',
|
||||
_image: drug.image || item.image || '',
|
||||
_specification: drug.specification || item.specification || '',
|
||||
_supplier: drug.supplier?.name || item.supplier?.name || '',
|
||||
_price: Number(item.price ?? 0),
|
||||
// 已绑定配送仓:列表展示「多仓」标识
|
||||
_hasDeliveryWarehouse: Number(item.has_delivery_warehouse ?? 0) === 1,
|
||||
_timeId: drug.time_id || 0,
|
||||
_typeId: drug.type_id || 0,
|
||||
_frequencyId: drug.frequency_id || 0,
|
||||
_unitId: drug.unit_id || 0,
|
||||
_number: drug.number || 1,
|
||||
_useNum: drug.useNum,
|
||||
_useType: drug.useType,
|
||||
_useFrequency: drug.useFrequency,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
searchResults.value = [];
|
||||
}
|
||||
@@ -176,8 +208,14 @@ function handleInput(e: Event) {
|
||||
|
||||
/**
|
||||
* 处理输入框获得焦点
|
||||
* @description 多类型模式聚焦即拉取列表并展开;单类型模式仅在已有结果时展示下拉
|
||||
*/
|
||||
function handleFocus() {
|
||||
if (props.types.length > 0) {
|
||||
searchDrugs('');
|
||||
showDropdown.value = true;
|
||||
return;
|
||||
}
|
||||
if (searchResults.value.length > 0) {
|
||||
showDropdown.value = true;
|
||||
}
|
||||
@@ -186,15 +224,36 @@ function handleFocus() {
|
||||
/**
|
||||
* 处理选中药品
|
||||
* @param drug 选中的药品数据
|
||||
* @description isCard 时保留 selectedDrug 用于卡片展示;否则清空选中态
|
||||
*/
|
||||
function handleSelectDrug(drug: any) {
|
||||
emit('select', drug);
|
||||
|
||||
// 清空搜索状态
|
||||
searchKeyword.value = '';
|
||||
searchResults.value = [];
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
if (props.isCard) {
|
||||
selectedDrug.value = drug;
|
||||
} else {
|
||||
selectedDrug.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空已选卡片并重新搜索(仅 isCard)
|
||||
* 同步 emit null,便于父级清空 drug_id
|
||||
*/
|
||||
function handleReselect() {
|
||||
selectedDrug.value = null;
|
||||
searchKeyword.value = '';
|
||||
searchResults.value = [];
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
emit('select', null);
|
||||
// 下一帧聚焦搜索框,方便重新选药
|
||||
setTimeout(() => {
|
||||
inputRef.value?.focus?.();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,6 +320,7 @@ watch(
|
||||
searchResults.value = [];
|
||||
showDropdown.value = false;
|
||||
searchKeyword.value = '';
|
||||
selectedDrug.value = null;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@@ -269,6 +329,7 @@ watch(
|
||||
<div ref="containerRef" class="drug-search-select">
|
||||
<!-- 搜索输入框 -->
|
||||
<Input
|
||||
ref="inputRef"
|
||||
v-model:value="searchKeyword"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@@ -283,6 +344,49 @@ watch(
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<!-- isCard:已选药品卡片(与下拉项同结构,带边框区分) -->
|
||||
<div
|
||||
v-if="isCard && selectedDrug"
|
||||
class="drug-item drug-item--selected-card"
|
||||
>
|
||||
<div class="drug-item__image">
|
||||
<img
|
||||
:src="resolveDropdownImage(selectedDrug)"
|
||||
alt=""
|
||||
class="drug-item__img"
|
||||
@error="
|
||||
(e) => ((e.target as HTMLImageElement).src = DRUG_PLACEHOLDER_IMAGE)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="drug-item__info">
|
||||
<div class="drug-item__name">{{ selectedDrug._drugName }}</div>
|
||||
<div class="drug-item__meta">
|
||||
<span class="drug-item__id">ID:{{ selectedDrug._drugId }}</span>
|
||||
<span v-if="selectedDrug._specification" class="drug-item__spec">
|
||||
规格:{{ selectedDrug._specification }}
|
||||
</span>
|
||||
<span v-if="selectedDrug._supplier" class="drug-item__supplier">
|
||||
供应商:{{ selectedDrug._supplier }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drug-item__price">
|
||||
<template v-if="Number(selectedDrug._price) > 0">
|
||||
¥{{ Number(selectedDrug._price).toFixed(2) }}
|
||||
</template>
|
||||
<span v-else class="drug-item__price--empty">暂无</span>
|
||||
</div>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
class="drug-item__reselect"
|
||||
@click="handleReselect"
|
||||
>
|
||||
重新选择
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 下拉列表 -->
|
||||
<div v-if="showDropdown" class="drug-dropdown">
|
||||
<!-- 加载中 -->
|
||||
@@ -321,12 +425,14 @@ watch(
|
||||
<div v-else class="drug-item__img-placeholder">无图</div>
|
||||
</div>
|
||||
|
||||
<!-- 药品信息 -->
|
||||
<!-- 药品信息:药名 / 多仓 / ID / 规格 / 供应商 -->
|
||||
<div class="drug-item__info">
|
||||
<!-- 药品名称 -->
|
||||
<div class="drug-item__name">{{ item._drugName }}</div>
|
||||
<!-- 规格和供应商 -->
|
||||
<div class="drug-item__name-row">
|
||||
<div class="drug-item__name">{{ item._drugName }}</div>
|
||||
<span v-if="item._hasDeliveryWarehouse" class="drug-item__multi-wh">多仓</span>
|
||||
</div>
|
||||
<div class="drug-item__meta">
|
||||
<span class="drug-item__id">ID:{{ item._drugId }}</span>
|
||||
<span v-if="item._specification" class="drug-item__spec">
|
||||
规格:{{ item._specification }}
|
||||
</span>
|
||||
@@ -336,9 +442,12 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 价格 -->
|
||||
<!-- 价格:无价(平台主库常见)展示「暂无」 -->
|
||||
<div class="drug-item__price">
|
||||
¥{{ Number(item._price).toFixed(2) }}
|
||||
<template v-if="Number(item._price) > 0">
|
||||
¥{{ Number(item._price).toFixed(2) }}
|
||||
</template>
|
||||
<span v-else class="drug-item__price--empty">暂无</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -347,6 +456,7 @@ watch(
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 只用框架主题变量,随亮暗/主题色自动适配,不写 .dark 硬编码覆盖 */
|
||||
.drug-search-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -359,8 +469,8 @@ watch(
|
||||
right: 0;
|
||||
z-index: 1050;
|
||||
margin-top: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
max-height: 400px;
|
||||
@@ -373,7 +483,7 @@ watch(
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: #999;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -387,7 +497,7 @@ watch(
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d9d9d9;
|
||||
background: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -407,7 +517,25 @@ watch(
|
||||
|
||||
&:hover,
|
||||
&--active {
|
||||
background-color: #f5f7fa;
|
||||
background-color: hsl(var(--accent-hover));
|
||||
}
|
||||
|
||||
/* 已选确认卡片:固定展示,非下拉浮层 */
|
||||
&--selected-card {
|
||||
margin-top: 8px;
|
||||
cursor: default;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__reselect {
|
||||
flex-shrink: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
&__image {
|
||||
@@ -421,7 +549,7 @@ watch(
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
&__img-placeholder {
|
||||
@@ -430,10 +558,10 @@ watch(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
background: hsl(var(--accent));
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #bbb;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
&__info {
|
||||
@@ -442,14 +570,35 @@ watch(
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 配送仓绑定药品标识:小号胶囊,不抢主信息 */
|
||||
&__multi-wh {
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
line-height: 16px;
|
||||
font-size: 11px;
|
||||
color: #2b6de5;
|
||||
background: rgba(43, 109, 229, 0.08);
|
||||
border: 1px solid rgba(43, 109, 229, 0.45);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
@@ -457,57 +606,31 @@ watch(
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
&__id {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
&__spec {
|
||||
color: #666;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
&__supplier {
|
||||
color: #1890ff;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
&__price {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
color: hsl(var(--destructive));
|
||||
|
||||
/* 暗色模式适配 */
|
||||
.dark {
|
||||
.drug-dropdown {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.drug-item {
|
||||
&:hover,
|
||||
&--active {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
&__img-placeholder {
|
||||
background: #374151;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__name {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
&__spec {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
&__supplier {
|
||||
color: #60a5fa;
|
||||
&--empty {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Empty, Input, Popover, Spin } from 'ant-design-vue';
|
||||
|
||||
import { matchExpressByTrackingNo } from '#/utils/matchExpressByTrackingNo';
|
||||
import { getExpressCompaniesOption } from '#/views/business/express/express-company/api';
|
||||
|
||||
defineOptions({
|
||||
@@ -12,9 +13,11 @@ defineOptions({
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
/** 运单号:变化时按前缀自动匹配快递公司(用户手动改选后不再覆盖) */
|
||||
trackingNo?: string;
|
||||
value?: string;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
@@ -24,9 +27,9 @@ const emits = defineEmits<{
|
||||
const mValue = useVModel(props, 'value', emits, { passive: true });
|
||||
|
||||
type ExpressCompanyOption = {
|
||||
code: string;
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
const open = ref(false);
|
||||
@@ -34,8 +37,13 @@ const loading = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const options = ref<ExpressCompanyOption[]>([]);
|
||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
/** 用户是否已手动选择/清空;为 true 时不再按单号自动覆盖 */
|
||||
const userPicked = ref(false);
|
||||
|
||||
function filterExpressCompanyOptions(keyword: string, list: ExpressCompanyOption[]) {
|
||||
function filterExpressCompanyOptions(
|
||||
keyword: string,
|
||||
list: ExpressCompanyOption[],
|
||||
) {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter((item) => {
|
||||
@@ -56,20 +64,39 @@ const selectedOption = computed(() =>
|
||||
const displayText = computed(() => {
|
||||
const item = selectedOption.value;
|
||||
if (!item) return '';
|
||||
return item.code ? `${item.name || '-'}(${item.code})` : (item.name || '-');
|
||||
return item.code ? `${item.name || '-'}(${item.code})` : item.name || '-';
|
||||
});
|
||||
|
||||
/**
|
||||
* 根据 trackingNo 自动选中快递公司
|
||||
* 规则:尚未手动改选,或当前选中仍与规则结果一致时才覆盖
|
||||
*/
|
||||
function tryAutoMatchByTrackingNo() {
|
||||
if (options.value.length === 0) return;
|
||||
const matched = matchExpressByTrackingNo(props.trackingNo, options.value);
|
||||
if (!matched?.code) return;
|
||||
const matchedCode = matched.code;
|
||||
if (userPicked.value && mValue.value && mValue.value !== matchedCode) {
|
||||
// 用户已选其他公司,不覆盖
|
||||
return;
|
||||
}
|
||||
if (mValue.value === matchedCode) return;
|
||||
mValue.value = matchedCode;
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getExpressCompaniesOption({});
|
||||
options.value = Array.isArray(res) ? res : [];
|
||||
tryAutoMatchByTrackingNo();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(item: ExpressCompanyOption) {
|
||||
userPicked.value = true;
|
||||
mValue.value = item.code;
|
||||
open.value = false;
|
||||
searchKeyword.value = '';
|
||||
@@ -77,6 +104,7 @@ function selectOption(item: ExpressCompanyOption) {
|
||||
|
||||
function clearSelection(e: Event) {
|
||||
e.stopPropagation();
|
||||
userPicked.value = true;
|
||||
mValue.value = undefined;
|
||||
}
|
||||
|
||||
@@ -85,7 +113,7 @@ function onOpenChange(next: boolean) {
|
||||
open.value = next;
|
||||
if (next) {
|
||||
searchKeyword.value = '';
|
||||
if (!options.value.length) {
|
||||
if (options.value.length === 0) {
|
||||
loadOptions();
|
||||
}
|
||||
nextTick(() => searchInputRef.value?.focus?.());
|
||||
@@ -99,6 +127,14 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.trackingNo,
|
||||
() => {
|
||||
// 单号变化时允许再自动匹配(除非用户已改选为不一致公司)
|
||||
tryAutoMatchByTrackingNo();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadOptions();
|
||||
});
|
||||
@@ -107,9 +143,9 @@ onMounted(() => {
|
||||
<template>
|
||||
<Popover
|
||||
:open="open"
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
overlay-class-name="express-company-select-popover"
|
||||
placement="bottomLeft"
|
||||
trigger="click"
|
||||
@open-change="onOpenChange"
|
||||
>
|
||||
<template #content>
|
||||
@@ -118,17 +154,17 @@ onMounted(() => {
|
||||
ref="searchInputRef"
|
||||
v-model:value="searchKeyword"
|
||||
allow-clear
|
||||
placeholder="搜索公司名称或编码"
|
||||
class="express-search"
|
||||
placeholder="搜索公司名称或编码"
|
||||
/>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="filteredOptions.length" class="express-list">
|
||||
<div v-if="filteredOptions.length > 0" class="express-list">
|
||||
<button
|
||||
v-for="item in filteredOptions"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="express-item"
|
||||
:class="{ active: item.code === mValue }"
|
||||
class="express-item"
|
||||
type="button"
|
||||
@click="selectOption(item)"
|
||||
>
|
||||
<div class="express-meta">
|
||||
@@ -137,20 +173,20 @@ onMounted(() => {
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<Empty v-else description="无匹配快递公司" class="express-empty" />
|
||||
<Empty v-else class="express-empty" description="无匹配快递公司" />
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
:class="{ disabled, placeholder: !displayText }"
|
||||
class="express-trigger"
|
||||
:class="{ disabled: disabled, placeholder: !displayText }"
|
||||
>
|
||||
<Input
|
||||
:value="displayText"
|
||||
readonly
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder || '请选择快递公司'"
|
||||
:value="displayText"
|
||||
class="express-trigger-input"
|
||||
readonly
|
||||
>
|
||||
<template v-if="displayText && !disabled" #suffix>
|
||||
<span class="clear-btn" @click="clearSelection">×</span>
|
||||
@@ -163,6 +199,8 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.express-trigger {
|
||||
width: 100%;
|
||||
/* min-width: fit-content; */
|
||||
min-width: 250px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.express-trigger.disabled {
|
||||
@@ -177,7 +215,7 @@ onMounted(() => {
|
||||
.clear-btn {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
color: #86909c;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
@@ -203,10 +241,11 @@ onMounted(() => {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.express-item:hover,
|
||||
.express-item.active {
|
||||
background: #f2f3f5;
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
.express-meta {
|
||||
min-width: 0;
|
||||
@@ -214,11 +253,11 @@ onMounted(() => {
|
||||
}
|
||||
.express-name {
|
||||
font-size: 14px;
|
||||
color: #1d2129;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.express-sub {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.express-empty {
|
||||
margin: 12px 0;
|
||||
@@ -228,5 +267,8 @@ onMounted(() => {
|
||||
<style>
|
||||
.express-company-select-popover .ant-popover-inner {
|
||||
padding: 12px;
|
||||
background: hsl(var(--card));
|
||||
color: hsl(var(--foreground));
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,17 +8,11 @@ const props = withDefaults(
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 限制可选择的文件类型(透传给 image-gallery-picker,按 xk_file_type.value 过滤)
|
||||
* 例如图片:[1];文件:[6];语音:[3]
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}>(),
|
||||
{
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
disabled: false,
|
||||
acceptTypes: () => [] as number[],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -47,7 +41,6 @@ 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 { computed, ref, watch } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue';
|
||||
|
||||
@@ -14,18 +14,10 @@ const props = withDefaults(
|
||||
open: boolean;
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
|
||||
* - 不传:显示全部类型(默认行为,保持向后兼容)
|
||||
* - 传数组:只展示指定类型 tab,且自动锁定到第一个匹配的类型
|
||||
* 例如图片:[1];图片+视频:[1, 2];文件:[6]
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}>(),
|
||||
{
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
acceptTypes: () => [] as number[],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -42,14 +34,6 @@ 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,
|
||||
@@ -58,55 +42,14 @@ const {
|
||||
buildListParams,
|
||||
fileTypes,
|
||||
typesLoading,
|
||||
} = useFileGalleryFilter(() => storageKey.value);
|
||||
} = useFileGalleryFilter('file-picker-active-type');
|
||||
|
||||
/**
|
||||
* 实际渲染的 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;
|
||||
}
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
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;
|
||||
@@ -115,37 +58,21 @@ 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;
|
||||
// 打开时再次按 acceptTypes 重锁,避免沿用其它选择器的视频 tab
|
||||
lockAcceptType();
|
||||
void load();
|
||||
if (!typesLoading.value) {
|
||||
void load();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(typesLoading, (val) => {
|
||||
if (!val && props.open) {
|
||||
lockAcceptType();
|
||||
void load();
|
||||
}
|
||||
});
|
||||
@@ -216,11 +143,6 @@ function onSearch(value: string) {
|
||||
function itemIcon(item: FileGalleryItem) {
|
||||
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
|
||||
}
|
||||
|
||||
/** 图片类型展示缩略图(xk_file_type:1=图片) */
|
||||
function isImageItem(item: FileGalleryItem) {
|
||||
return Number(item.type) === 1 || Number(item.type) === 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -233,7 +155,7 @@ function isImageItem(item: FileGalleryItem) {
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<Tabs :active-key="String(activeType)" size="small" @change="onTabChange">
|
||||
<Tabs.TabPane v-for="tab in visibleTabs" :key="String(tab.value)">
|
||||
<Tabs.TabPane v-for="tab in tabs" :key="String(tab.value)">
|
||||
<template #tab>
|
||||
<span class="tab-label">
|
||||
<MIcon v-if="tab.icon" :icon="tab.icon" size="12" />
|
||||
@@ -254,7 +176,7 @@ function isImageItem(item: FileGalleryItem) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spin :spinning="loading || typesLoading">
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="items.length" class="gallery-grid">
|
||||
<div
|
||||
v-for="item in items"
|
||||
@@ -263,17 +185,10 @@ function isImageItem(item: FileGalleryItem) {
|
||||
:class="{ active: isSelected(item.url) }"
|
||||
@click="toggleSelect(item.url)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageItem(item)"
|
||||
:src="item.url"
|
||||
alt=""
|
||||
class="gallery-thumb"
|
||||
/>
|
||||
<img v-if="item.type === 0" :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,346 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 门店多选搜索组件
|
||||
* - 普通输入框输入,下方气泡卡片展示匹配结果(名称 / 拼音首拼)
|
||||
* - 选中后在输入框下方用可关闭 Tag 展示「名称【id】」
|
||||
* - 搜索结果默认高亮第一项,Enter 可直接选中
|
||||
* - 样式使用主题 CSS 变量,自动适配暗色
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||
import { useDebounceFn, useVModel } from '@vueuse/core';
|
||||
import { Empty, Input, Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { searchStoreOption } from '#/views/system/store/api';
|
||||
|
||||
defineOptions({
|
||||
name: 'StoreMultiSearch',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
export type StoreSearchItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
type?: number;
|
||||
shouzimu?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 已选门店 ID 列表 */
|
||||
value?: number[];
|
||||
/** 限定类型:0诊所 1药店;不传则诊所+药店都可搜 */
|
||||
storeType?: 0 | 1;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 外部已选门店详情(打开绑定弹窗时回填名称用)
|
||||
* 仅用于 Tag 展示,不改变 value
|
||||
*/
|
||||
initialItems?: StoreSearchItem[];
|
||||
}>(),
|
||||
{
|
||||
value: () => [],
|
||||
storeType: undefined,
|
||||
placeholder: '输入名称或拼音首拼搜索',
|
||||
disabled: false,
|
||||
initialItems: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:value': [value: number[]];
|
||||
}>();
|
||||
|
||||
const mValue = useVModel(props, 'value', emits, {
|
||||
passive: true,
|
||||
defaultValue: [],
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const searchKeyword = ref('');
|
||||
const loading = ref(false);
|
||||
const showDropdown = ref(false);
|
||||
const highlightIndex = ref(-1);
|
||||
const options = ref<StoreSearchItem[]>([]);
|
||||
/** 已选门店详情(用于 Tag 展示名称) */
|
||||
const selectedMap = ref<Record<number, StoreSearchItem>>({});
|
||||
|
||||
const selectedIds = computed(() =>
|
||||
Array.isArray(mValue.value) ? mValue.value.map(Number) : [],
|
||||
);
|
||||
|
||||
const selectedItems = computed(() =>
|
||||
selectedIds.value.map((id) => {
|
||||
const cached = selectedMap.value[id];
|
||||
return cached || { id, name: `门店${id}` };
|
||||
}),
|
||||
);
|
||||
|
||||
/** 下拉中过滤掉已选 */
|
||||
const visibleOptions = computed(() =>
|
||||
options.value.filter((item) => !selectedIds.value.includes(item.id)),
|
||||
);
|
||||
|
||||
/**
|
||||
* 将初始/回填门店写入 selectedMap,便于 Tag 显示真实名称
|
||||
*/
|
||||
function mergeInitialItems(items: StoreSearchItem[]) {
|
||||
if (!items?.length) return;
|
||||
const next = { ...selectedMap.value };
|
||||
for (const item of items) {
|
||||
if (item?.id) {
|
||||
next[item.id] = item;
|
||||
}
|
||||
}
|
||||
selectedMap.value = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求后端门店搜索;有结果时默认高亮第一项
|
||||
*/
|
||||
async function fetchOptions() {
|
||||
const keyword = searchKeyword.value.trim();
|
||||
if (!keyword) {
|
||||
options.value = [];
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
showDropdown.value = true;
|
||||
try {
|
||||
const res = await searchStoreOption({
|
||||
keyword,
|
||||
...(props.storeType === 0 || props.storeType === 1
|
||||
? { type: props.storeType }
|
||||
: {}),
|
||||
limit: 20,
|
||||
});
|
||||
options.value = res?.items ?? [];
|
||||
// 默认高亮第一项,Enter 可直接选中
|
||||
highlightIndex.value = options.value.length > 0 ? 0 : -1;
|
||||
} catch {
|
||||
options.value = [];
|
||||
highlightIndex.value = -1;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedFetch = useDebounceFn(fetchOptions, 300);
|
||||
|
||||
function handleInput() {
|
||||
debouncedFetch();
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (visibleOptions.value.length > 0) {
|
||||
showDropdown.value = true;
|
||||
if (highlightIndex.value < 0) {
|
||||
highlightIndex.value = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中一项:追加到 value,缓存名称,清空输入继续搜
|
||||
*/
|
||||
function selectOption(item: StoreSearchItem) {
|
||||
if (selectedIds.value.includes(item.id)) return;
|
||||
selectedMap.value = { ...selectedMap.value, [item.id]: item };
|
||||
mValue.value = [...selectedIds.value, item.id];
|
||||
searchKeyword.value = '';
|
||||
options.value = [];
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消某个已选门店
|
||||
*/
|
||||
function removeSelected(id: number) {
|
||||
mValue.value = selectedIds.value.filter((x) => x !== id);
|
||||
const next = { ...selectedMap.value };
|
||||
delete next[id];
|
||||
selectedMap.value = next;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!showDropdown.value || visibleOptions.value.length === 0) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
highlightIndex.value = Math.min(
|
||||
Math.max(highlightIndex.value, 0) + 1,
|
||||
visibleOptions.value.length - 1,
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
highlightIndex.value = Math.max(highlightIndex.value - 1, 0);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
{
|
||||
const idx = highlightIndex.value >= 0 ? highlightIndex.value : 0;
|
||||
const item = visibleOptions.value[idx];
|
||||
if (item) selectOption(item);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
mergeInitialItems(props.initialItems || []);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.storeType,
|
||||
() => {
|
||||
options.value = [];
|
||||
showDropdown.value = false;
|
||||
searchKeyword.value = '';
|
||||
highlightIndex.value = -1;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.initialItems,
|
||||
(items) => mergeInitialItems(items || []),
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="store-multi-search">
|
||||
<Input
|
||||
v-model:value="searchKeyword"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
allow-clear
|
||||
@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>
|
||||
|
||||
<!-- 气泡建议列表 -->
|
||||
<div v-if="showDropdown" class="store-multi-search__dropdown">
|
||||
<Spin :spinning="loading">
|
||||
<Empty
|
||||
v-if="!loading && visibleOptions.length === 0"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
description="暂无匹配门店"
|
||||
class="py-3"
|
||||
/>
|
||||
<div
|
||||
v-for="(item, index) in visibleOptions"
|
||||
:key="item.id"
|
||||
class="store-multi-search__option"
|
||||
:class="{ 'is-active': index === highlightIndex }"
|
||||
@mousedown.prevent="selectOption(item)"
|
||||
>
|
||||
<div class="store-multi-search__option-name">
|
||||
{{ item.name }}【{{ item.id }}】
|
||||
</div>
|
||||
<div v-if="item.shouzimu" class="store-multi-search__option-sub">
|
||||
首拼:{{ item.shouzimu }}
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
<!-- 已选 Tag -->
|
||||
<div v-if="selectedItems.length > 0" class="store-multi-search__tags">
|
||||
<Tag
|
||||
v-for="item in selectedItems"
|
||||
:key="item.id"
|
||||
closable
|
||||
color="blue"
|
||||
@close="() => removeSelected(item.id)"
|
||||
>
|
||||
{{ item.name }}【{{ item.id }}】
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 只用框架主题变量,随亮暗/主题色自动适配 */
|
||||
.store-multi-search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-multi-search__dropdown {
|
||||
position: absolute;
|
||||
z-index: 1050;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.store-multi-search__option {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
background: hsl(var(--accent-hover));
|
||||
}
|
||||
}
|
||||
|
||||
.store-multi-search__option-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.store-multi-search__option-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.store-multi-search__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -24,17 +24,12 @@ interface Props {
|
||||
modelValue: string[];
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 素材库可选类型(透传 GalleryPickLink),默认仅图片
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
maxCount: 9,
|
||||
acceptTypes: () => [1],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -189,7 +184,6 @@ const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.val
|
||||
v-if="showUploadButton"
|
||||
:multiple="multiple"
|
||||
:max-count="galleryRemainCount"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onGallerySelect"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
<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';
|
||||
@@ -23,17 +17,11 @@ 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<{
|
||||
@@ -143,50 +131,26 @@ 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>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-oss-file-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
<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>
|
||||
@@ -5,4 +5,5 @@ export type CustomComponentType =
|
||||
| 'ApiSelect'
|
||||
| 'ApiTreeSelect'
|
||||
| 'IconPicker'
|
||||
| 'StoreMultiSearch'
|
||||
| 'WarehouseAdminDrugSearch';
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<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>
|
||||
@@ -1,300 +0,0 @@
|
||||
<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>
|
||||
@@ -1,119 +0,0 @@
|
||||
<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>
|
||||
@@ -1,348 +0,0 @@
|
||||
<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>
|
||||
@@ -1,522 +0,0 @@
|
||||
<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>
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* 通用拖拽式可视化编辑器类型定义
|
||||
*
|
||||
* 设计目标:把「左侧组件库 → 中间画布拖拽 → 右侧属性面板 → 自动生成 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 }[];
|
||||
/** 图片字段:限定素材库文件类型(如 [1] 只显示图片) */
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 处方溯源展示内容(对齐小程序药师溯源:就诊→开具→审核)
|
||||
* 由审方/业务处方两个 Modal 共用,避免双份漂移
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Timeline, TimelineItem } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
FileTextOutlined,
|
||||
MedicineBoxOutlined,
|
||||
ShopOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
data: Record<string, any> | null;
|
||||
}>();
|
||||
|
||||
const formatTime = (timestamp: unknown) => {
|
||||
if (!timestamp) return '--';
|
||||
if (typeof timestamp === 'string' && timestamp.includes('-')) return timestamp;
|
||||
const n = Number(timestamp);
|
||||
if (!n) return '--';
|
||||
return new Date(n * 1000).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const prescriptionTypeMap: Record<number, string> = {
|
||||
1: '中药处方',
|
||||
2: '西药处方',
|
||||
3: '保健食品',
|
||||
5: '产品服务包',
|
||||
6: '非药品',
|
||||
7: '医疗器械',
|
||||
};
|
||||
|
||||
const statusMap: Record<number, string> = {
|
||||
0: '待审核',
|
||||
1: '已通过',
|
||||
2: '已拒绝',
|
||||
3: '已过期',
|
||||
};
|
||||
|
||||
const prescriptionTypeText = computed(() => {
|
||||
if (!props.data) return '--';
|
||||
return prescriptionTypeMap[props.data.prescription_type] || '未知';
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (!props.data) return '--';
|
||||
return statusMap[props.data.status] || '未知';
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (!props.data) return 'gray';
|
||||
const map: Record<number, string> = {
|
||||
0: 'orange',
|
||||
1: 'green',
|
||||
2: 'red',
|
||||
3: 'gray',
|
||||
};
|
||||
return map[props.data.status] || 'gray';
|
||||
});
|
||||
|
||||
const expireTime = computed(() =>
|
||||
props.data ? formatTime(props.data.auto_expire_time) : '--',
|
||||
);
|
||||
|
||||
const patientSexText = computed(() => {
|
||||
const sex = props.data?.user_patient?.sex;
|
||||
if (sex === 1) return '男';
|
||||
if (sex === 2) return '女';
|
||||
return '--';
|
||||
});
|
||||
|
||||
const doctorInfo = computed(
|
||||
() => props.data?.doctor_info || props.data?.doctorInfo || null,
|
||||
);
|
||||
const pharmacistInfo = computed(
|
||||
() => props.data?.pharmacist_info || props.data?.pharmacistInfo || null,
|
||||
);
|
||||
const userPatient = computed(
|
||||
() => props.data?.user_patient || props.data?.userPatient || null,
|
||||
);
|
||||
const registerInfo = computed(
|
||||
() => props.data?.register || null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="data" class="prescription-source-container">
|
||||
<div class="mb-6 rounded-lg p-6 shadow-md">
|
||||
<div class="mb-4 flex items-center">
|
||||
<ShopOutlined class="mr-2 text-xl text-purple-500" />
|
||||
<h2 class="text-xl font-bold">开具机构</h2>
|
||||
</div>
|
||||
<div v-if="data.store" class="grid grid-cols-1 gap-4">
|
||||
<div class="info-item">
|
||||
<span class="label">机构名称:</span>
|
||||
<span class="value">{{ data.store.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="italic text-gray-500">暂无机构信息</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 rounded-lg p-6 shadow-md">
|
||||
<div class="mb-4 flex items-center">
|
||||
<FileTextOutlined class="mr-2 text-xl text-blue-500" />
|
||||
<h2 class="text-xl font-bold">时间线</h2>
|
||||
</div>
|
||||
<Timeline>
|
||||
<TimelineItem>
|
||||
<div class="font-medium">就诊</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ formatTime(registerInfo?.created_at) }}
|
||||
· {{ userPatient?.name || '--' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm">
|
||||
性别:{{ patientSexText }} · 年龄:{{ userPatient?.age ?? '--' }}
|
||||
</div>
|
||||
<div v-if="data.clinical_diagnose" class="mt-1 text-sm">
|
||||
诊断:{{ data.clinical_diagnose }}
|
||||
</div>
|
||||
</TimelineItem>
|
||||
<TimelineItem>
|
||||
<div class="font-medium">开具</div>
|
||||
<div class="text-sm text-gray-500">{{ data.created_at || '--' }}</div>
|
||||
<div class="mt-1 text-sm">
|
||||
医生:{{ doctorInfo?.name || '--' }} · 科室:{{
|
||||
doctorInfo?.depart?.name || '--'
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-1 text-sm">
|
||||
处方编号:{{ data.prescription_no }} · 类型:{{ prescriptionTypeText }}
|
||||
</div>
|
||||
</TimelineItem>
|
||||
<TimelineItem>
|
||||
<div class="font-medium">审核</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ data.pharmacist_view_time || '--' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm">
|
||||
状态:
|
||||
<span :class="`text-${statusColor}-500`">{{ statusText }}</span>
|
||||
· 药师:{{ pharmacistInfo?.name || '--' }}
|
||||
</div>
|
||||
<div v-if="data.reject_reason" class="mt-1 text-sm text-red-500">
|
||||
驳回原因:{{ data.reject_reason }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="data.cancel_remark && Number(data.status) === 2"
|
||||
class="mt-1 text-sm text-red-500"
|
||||
>
|
||||
驳回原因:{{ data.cancel_remark }}
|
||||
</div>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 rounded-lg p-6 shadow-md">
|
||||
<div class="mb-4 flex items-center">
|
||||
<FileTextOutlined class="mr-2 text-xl text-blue-500" />
|
||||
<h2 class="text-xl font-bold">处方基本信息</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="info-item">
|
||||
<span class="label">处方编号:</span>
|
||||
<span class="value">{{ data.prescription_no }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">处方类型:</span>
|
||||
<span class="value">{{ prescriptionTypeText }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">处方状态:</span>
|
||||
<span :class="`text-${statusColor}-500`" class="value">{{
|
||||
statusText
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">总金额:</span>
|
||||
<span class="value font-bold text-red-500"
|
||||
>¥{{ data.total_pay_price }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">过期时间:</span>
|
||||
<span class="value">{{ expireTime }}</span>
|
||||
</div>
|
||||
<div v-if="data.reject_reason" class="info-item">
|
||||
<span class="label">驳回原因:</span>
|
||||
<span class="value text-red-500">{{ data.reject_reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 rounded-lg p-6 shadow-md">
|
||||
<div class="mb-4 flex items-center">
|
||||
<MedicineBoxOutlined class="mr-2 text-xl text-green-500" />
|
||||
<h2 class="text-xl font-bold">医生信息</h2>
|
||||
</div>
|
||||
<div v-if="doctorInfo" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="info-item">
|
||||
<span class="label">开具医生:</span>
|
||||
<span class="value">{{ doctorInfo.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">开具科室:</span>
|
||||
<span class="value">{{ doctorInfo.depart?.name || '--' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">开具机构:</span>
|
||||
<span class="value">{{ data.store?.name || '--' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">开具时间:</span>
|
||||
<span class="value">{{ data.created_at || '--' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="italic text-gray-500">暂无医生信息</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg p-6 shadow-md">
|
||||
<div class="mb-4 flex items-center">
|
||||
<UserOutlined class="mr-2 text-xl text-amber-500" />
|
||||
<h2 class="text-xl font-bold">患者信息</h2>
|
||||
</div>
|
||||
<div
|
||||
v-if="userPatient"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<div class="info-item">
|
||||
<span class="label">患者姓名:</span>
|
||||
<span class="value">{{ userPatient.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">年龄:</span>
|
||||
<span class="value">{{ userPatient.age }}岁</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">性别:</span>
|
||||
<span class="value">{{ patientSexText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="italic text-gray-500">暂无患者信息</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex h-64 items-center justify-center">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-b-2 border-t-2 border-blue-500"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.prescription-source-container {
|
||||
@apply max-h-[70vh] overflow-auto p-4;
|
||||
}
|
||||
.info-item {
|
||||
@apply flex flex-col rounded-md p-3;
|
||||
}
|
||||
.label {
|
||||
@apply mb-1 text-sm text-gray-500;
|
||||
}
|
||||
.value {
|
||||
@apply font-medium;
|
||||
}
|
||||
</style>
|
||||
511
apps/web-antd/src/components/store-card/StoreCardModal.vue
Normal file
511
apps/web-antd/src/components/store-card/StoreCardModal.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 门店详情弹窗(诊所/药店)
|
||||
* 布局对齐医生档案:顶部统计 + 左侧竖向 Tabs
|
||||
*/
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Image,
|
||||
Select,
|
||||
Spin,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
|
||||
import { getStoreBankCardReportDetail } from '#/views/system/store-bank-card/api';
|
||||
import {
|
||||
getStoreCardApi,
|
||||
getStoreCardStatsApi,
|
||||
getStoreDrugsByTypeApi,
|
||||
openQrCodeApi,
|
||||
} from '#/views/system/store/api';
|
||||
import { getSalespersonList } from '#/views/system/store/api/salesperson';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
const RangePicker = DatePicker.RangePicker;
|
||||
|
||||
const storeId = ref(0);
|
||||
const loading = ref(false);
|
||||
const activeTab = ref('basic');
|
||||
const cardData = ref<any>(null);
|
||||
const searchTime = ref<[Dayjs, Dayjs]>([
|
||||
dayjs().startOf('month'),
|
||||
dayjs(),
|
||||
]);
|
||||
|
||||
const bankLoading = ref(false);
|
||||
const bankDetail = ref<any>(null);
|
||||
|
||||
const drugType = ref(2);
|
||||
const drugLoading = ref(false);
|
||||
const drugList = ref<any[]>([]);
|
||||
|
||||
const promoterLoading = ref(false);
|
||||
const promoterList = ref<any[]>([]);
|
||||
|
||||
const generatingQr = ref(false);
|
||||
|
||||
const store = computed(() => cardData.value?.store ?? {});
|
||||
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 modalTitle = computed(() => {
|
||||
const name = store.value?.name;
|
||||
if (!name) return '门店详情';
|
||||
return isPharmacy.value ? `药店详情 · ${name}` : `诊所详情 · ${name}`;
|
||||
});
|
||||
|
||||
const drugTypeOptions = [
|
||||
{ label: '中药', value: 1 },
|
||||
{ label: '西药', value: 2 },
|
||||
{ label: '保健食品', value: 3 },
|
||||
{ label: '产品服务包', value: 5 },
|
||||
{ label: '非药品', value: 6 },
|
||||
{ label: '医疗器械', value: 7 },
|
||||
];
|
||||
|
||||
const drugColumns = [
|
||||
{ title: '药品名称', dataIndex: 'drug_name', key: 'drug_name', ellipsis: true },
|
||||
{ title: '规格', dataIndex: 'specification', key: 'specification', width: 140 },
|
||||
{ title: '售价', dataIndex: 'price', key: 'price', width: 100 },
|
||||
{ title: '进价', dataIndex: 'buy_price', key: 'buy_price', width: 100 },
|
||||
];
|
||||
|
||||
const promoterColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name' },
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 140 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||
];
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
class: 'w-[900px] xl:w-[1100px]',
|
||||
fullscreenButton: true,
|
||||
footer: false,
|
||||
draggable: true,
|
||||
closeOnClickModal: false,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) return;
|
||||
const data = modalApi.getData<Record<string, any>>() || {};
|
||||
storeId.value = Number(data.storeId || data.id || 0);
|
||||
activeTab.value = 'basic';
|
||||
searchTime.value = [dayjs().startOf('month'), dayjs()];
|
||||
bankDetail.value = null;
|
||||
drugList.value = [];
|
||||
promoterList.value = [];
|
||||
if (storeId.value) {
|
||||
loadCard();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [DoctorModal, DoctorModalApi] = useVbenModal({
|
||||
connectedComponent: DoctorCardModal,
|
||||
});
|
||||
|
||||
function searchTimeParam(): [string, string] {
|
||||
return [
|
||||
searchTime.value[0].format('YYYY-MM-DD 00:00:00'),
|
||||
searchTime.value[1].format('YYYY-MM-DD 23:59:59'),
|
||||
];
|
||||
}
|
||||
|
||||
/** 加载门店卡片概览 */
|
||||
async function loadCard() {
|
||||
if (!storeId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getStoreCardApi({
|
||||
id: storeId.value,
|
||||
search_time: searchTimeParam(),
|
||||
});
|
||||
cardData.value = res;
|
||||
if (isPharmacy.value) {
|
||||
drugType.value = 2;
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载门店详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅刷新统计 */
|
||||
async function refreshStats() {
|
||||
if (!storeId.value) return;
|
||||
try {
|
||||
const res = await getStoreCardStatsApi({
|
||||
id: storeId.value,
|
||||
search_time: searchTimeParam(),
|
||||
});
|
||||
if (cardData.value) {
|
||||
cardData.value = {
|
||||
...cardData.value,
|
||||
stats_summary: res.stats_summary,
|
||||
search_time: res.search_time,
|
||||
};
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '刷新统计失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBank() {
|
||||
if (!storeId.value || bankDetail.value) return;
|
||||
bankLoading.value = true;
|
||||
try {
|
||||
bankDetail.value = await getStoreBankCardReportDetail(storeId.value);
|
||||
} catch {
|
||||
bankDetail.value = null;
|
||||
} finally {
|
||||
bankLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDrugs() {
|
||||
if (!storeId.value) return;
|
||||
drugLoading.value = true;
|
||||
try {
|
||||
const res = await getStoreDrugsByTypeApi(storeId.value, drugType.value);
|
||||
const list = Array.isArray(res) ? res : res?.items || res?.list || [];
|
||||
drugList.value = (list || []).map((item: any) => {
|
||||
const drug = item.drug || {};
|
||||
return {
|
||||
id: item.id,
|
||||
drug_name: drug.drug_name || item.drug_name || '-',
|
||||
specification: drug.specification || item.specification || '-',
|
||||
price: item.price ?? '-',
|
||||
buy_price: item.buy_price ?? '-',
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
drugList.value = [];
|
||||
} finally {
|
||||
drugLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPromoters() {
|
||||
if (!storeId.value || promoterList.value.length > 0) return;
|
||||
promoterLoading.value = true;
|
||||
try {
|
||||
const res = await getSalespersonList({
|
||||
store_id: storeId.value,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
const list = res?.items || res?.list?.items || res || [];
|
||||
promoterList.value = Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
promoterList.value = [];
|
||||
} finally {
|
||||
promoterLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange(key: string | number) {
|
||||
activeTab.value = String(key);
|
||||
if (key === 'bank') loadBank();
|
||||
if (key === 'drugs') loadDrugs();
|
||||
if (key === 'promoters') loadPromoters();
|
||||
}
|
||||
|
||||
function openDoctor(suId: number) {
|
||||
DoctorModalApi.setData({ su_id: suId, hideStoresTab: true, readonly: true });
|
||||
DoctorModalApi.open();
|
||||
}
|
||||
|
||||
async function ensureQrCode() {
|
||||
if (cardData.value?.qr_code || store.value?.qr_code) return;
|
||||
generatingQr.value = true;
|
||||
try {
|
||||
await openQrCodeApi(storeId.value);
|
||||
await loadCard();
|
||||
message.success('二维码已生成');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '生成二维码失败');
|
||||
} finally {
|
||||
generatingQr.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clinicTypeText(v: number) {
|
||||
if (v === 1) return '西医诊所';
|
||||
if (v === 2) return '中医诊所';
|
||||
return '未设置';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="modalTitle">
|
||||
<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>
|
||||
<RangePicker
|
||||
v-model:value="searchTime"
|
||||
format="YYYY-MM-DD"
|
||||
@change="refreshStats"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 顶部统计 -->
|
||||
<div
|
||||
v-if="cardData"
|
||||
class="mb-6 grid grid-cols-1 gap-4 px-1 md:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-col overflow-hidden rounded-xl border border-blue-100 bg-blue-50 p-5 shadow-sm transition-all group hover:shadow-md dark:border-blue-800/50 dark:bg-blue-900/20"
|
||||
>
|
||||
<span class="text-sm font-semibold text-blue-600/80 dark:text-blue-300/80">
|
||||
销售额
|
||||
</span>
|
||||
<div class="mt-2 text-2xl font-bold text-blue-900 dark:text-blue-100">
|
||||
<span class="text-lg">¥</span>
|
||||
{{ Number(stats.sales_amount ?? 0).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex flex-col overflow-hidden rounded-xl border border-emerald-100 bg-emerald-50 p-5 shadow-sm transition-all group hover:shadow-md dark:border-emerald-800/50 dark:bg-emerald-900/20"
|
||||
>
|
||||
<span class="text-sm font-semibold text-emerald-600/80 dark:text-emerald-300/80">
|
||||
利润
|
||||
</span>
|
||||
<div class="mt-2 text-2xl font-bold text-emerald-900 dark:text-emerald-100">
|
||||
<span class="text-lg">¥</span>
|
||||
{{ Number(stats.profit ?? 0).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex flex-col overflow-hidden rounded-xl border border-purple-100 bg-purple-50 p-5 shadow-sm transition-all group hover:shadow-md dark:border-purple-800/50 dark:bg-purple-900/20"
|
||||
>
|
||||
<span class="text-sm font-semibold text-purple-600/80 dark:text-purple-300/80">
|
||||
处方量
|
||||
</span>
|
||||
<div class="mt-2 text-2xl font-bold text-purple-900 dark:text-purple-100">
|
||||
{{ stats.prescription_count ?? 0 }}
|
||||
<span class="text-xs font-normal text-purple-700/60 dark:text-purple-300/60">
|
||||
张
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
:active-key="activeTab"
|
||||
tab-position="left"
|
||||
class="custom-vertical-tabs min-h-[480px]"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<Tabs.TabPane key="basic" tab="门店基本信息">
|
||||
<div class="pl-4">
|
||||
<Descriptions bordered :column="2" size="small">
|
||||
<Descriptions.Item label="名称">
|
||||
{{ store.name || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="ID">{{ store.id }}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
{{ isPharmacy ? '药店' : '诊所' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item v-if="isClinic" label="诊所类型">
|
||||
{{ clinicTypeText(Number(store.clinic_type)) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="联系人">
|
||||
{{ store.contact || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">
|
||||
{{ store.mobile || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="地址" :span="2">
|
||||
{{ store.position || store.address || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="ERP ID">
|
||||
{{ store.erp_id || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="MES ID">
|
||||
{{ store.mes_id || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="银行卡报备">
|
||||
{{ store.bank_card_report_status_text || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="业务员">
|
||||
{{ store.new_admin?.nick_name || '-' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="bank" tab="银行卡">
|
||||
<div class="pl-4">
|
||||
<Spin :spinning="bankLoading">
|
||||
<template v-if="bankDetail">
|
||||
<Descriptions bordered :column="2" size="small">
|
||||
<Descriptions.Item label="户名">
|
||||
{{ bankDetail.bank_user_name || bankDetail.account_name || store.bank_user_name || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卡号">
|
||||
{{ bankDetail.bank_card || bankDetail.card_no || store.bank_card || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开户行">
|
||||
{{ bankDetail.bank_name || store.bank_name || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="联行号">
|
||||
{{ bankDetail.bank_no || store.bank_no || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="报备状态">
|
||||
{{ bankDetail.report_status_text || store.bank_card_report_status_text || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账户类型">
|
||||
{{ bankDetail.bank_account_type ?? store.bank_account_type ?? '-' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</template>
|
||||
<Empty v-else description="暂无银行卡报备信息,展示门店预留信息">
|
||||
<Descriptions bordered :column="2" size="small" class="mt-4 text-left">
|
||||
<Descriptions.Item label="户名">
|
||||
{{ store.bank_user_name || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卡号">
|
||||
{{ store.bank_card || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开户行">
|
||||
{{ store.bank_name || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="联行号">
|
||||
{{ store.bank_no || '-' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Empty>
|
||||
</Spin>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane v-if="isClinic" key="doctors" tab="诊所医生团队">
|
||||
<div class="pl-4">
|
||||
<Empty v-if="doctorList.length === 0" description="暂无医生" />
|
||||
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="item in doctorList"
|
||||
:key="item.su_id"
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-100 p-3 dark:border-slate-700"
|
||||
>
|
||||
<Avatar :src="resolveAvatarUrl(item.doctor?.avatar)" :size="48" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<Button type="link" class="!px-0" @click="openDoctor(item.doctor?.su_id || item.su_id)">
|
||||
{{ item.doctor?.name || '未知医生' }}
|
||||
</Button>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ item.doctor?.mobile || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane v-if="isPharmacy" key="drugs" tab="药店药品">
|
||||
<div class="pl-4">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">药品类型</span>
|
||||
<Select
|
||||
v-model:value="drugType"
|
||||
:options="drugTypeOptions"
|
||||
style="width: 160px"
|
||||
@change="loadDrugs"
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
:columns="drugColumns"
|
||||
:data-source="drugList"
|
||||
:loading="drugLoading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:scroll="{ y: 360 }"
|
||||
/>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="qr" tab="门店二维码">
|
||||
<div class="flex flex-col items-center gap-4 py-6 pl-4">
|
||||
<template v-if="cardData?.qr_code || store.qr_code">
|
||||
<Image
|
||||
:src="cardData?.qr_code || store.qr_code"
|
||||
:width="200"
|
||||
:preview="true"
|
||||
/>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ store.name }} 门店二维码
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Empty description="尚未生成二维码" />
|
||||
<Button type="primary" :loading="generatingQr" @click="ensureQrCode">
|
||||
生成二维码
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="promoters" tab="门店推广员">
|
||||
<div class="pl-4">
|
||||
<Table
|
||||
:columns="promoterColumns"
|
||||
:data-source="promoterList"
|
||||
:loading="promoterLoading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:scroll="{ y: 360 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
{{ record.name || record.nick_name || '-' }}
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<Tag>{{ record.status ?? '-' }}</Tag>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Spin>
|
||||
</Modal>
|
||||
<DoctorModal />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.custom-vertical-tabs .ant-tabs-nav) {
|
||||
width: 140px;
|
||||
}
|
||||
:deep(.custom-vertical-tabs .ant-tabs-tab) {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
:deep(.custom-vertical-tabs .ant-tabs-tab-active) {
|
||||
background-color: var(
|
||||
--ant-primary-color-active-deprecated-f-12,
|
||||
rgba(22, 119, 255, 0.08)
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 处方失效时间展示
|
||||
* - 剩余 ≤2 小时:标红
|
||||
* - 剩余 ≤10 分钟:额外显示倒计时(每秒刷新)
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
/** 失效时间戳(秒)或已格式化的字符串 */
|
||||
autoExpireTime?: number | string | null;
|
||||
}>();
|
||||
|
||||
const nowTs = ref(Math.floor(Date.now() / 1000));
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** 解析为秒级时间戳 */
|
||||
function resolveExpireTs(raw: number | string | null | undefined): number {
|
||||
if (raw == null || raw === '') return 0;
|
||||
if (typeof raw === 'number') {
|
||||
return raw > 1e12 ? Math.floor(raw / 1000) : raw;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
return n > 1e12 ? Math.floor(n / 1000) : n;
|
||||
}
|
||||
const parsed = Date.parse(String(raw).replace(/-/g, '/'));
|
||||
return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : 0;
|
||||
}
|
||||
|
||||
const expireTs = computed(() => resolveExpireTs(props.autoExpireTime));
|
||||
|
||||
const remainSec = computed(() => {
|
||||
if (!expireTs.value) return null;
|
||||
return expireTs.value - nowTs.value;
|
||||
});
|
||||
|
||||
const isUrgent = computed(() => {
|
||||
const r = remainSec.value;
|
||||
return r != null && r > 0 && r <= 2 * 3600;
|
||||
});
|
||||
|
||||
const showCountdown = computed(() => {
|
||||
const r = remainSec.value;
|
||||
return r != null && r > 0 && r <= 10 * 60;
|
||||
});
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!expireTs.value) return '—';
|
||||
const r = remainSec.value ?? 0;
|
||||
if (r <= 0) return '已过期';
|
||||
const d = new Date(expireTs.value * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const base = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
if (showCountdown.value) {
|
||||
const m = Math.floor(r / 60);
|
||||
const s = r % 60;
|
||||
return `${base}(剩余 ${m}:${pad(s)})`;
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
timer = setInterval(() => {
|
||||
nowTs.value = Math.floor(Date.now() / 1000);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.autoExpireTime,
|
||||
() => {
|
||||
nowTs.value = Math.floor(Date.now() / 1000);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(startTimer);
|
||||
onBeforeUnmount(stopTimer);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="expire-time"
|
||||
:class="{
|
||||
'expire-time--urgent': isUrgent || (remainSec != null && remainSec <= 0),
|
||||
'expire-time--countdown': showCountdown,
|
||||
}"
|
||||
>
|
||||
{{ displayText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.expire-time {
|
||||
font-size: 12px;
|
||||
color: inherit;
|
||||
}
|
||||
.expire-time--urgent {
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
}
|
||||
.expire-time--countdown {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 就诊类型标签:线下就诊 / 在线问诊 / 在线复诊
|
||||
* 对应处方、订单字段 is_online
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
/** 就诊渠道:0线下 1在线问诊 2/3在线复诊 */
|
||||
isOnline?: number | null;
|
||||
}>();
|
||||
|
||||
const label = computed(() => {
|
||||
const v = Number(props.isOnline ?? 0);
|
||||
if (v === 1) return { text: '在线问诊', color: 'blue' };
|
||||
if (v === 2 || v === 3) return { text: '在线复诊', color: 'green' };
|
||||
return { text: '线下就诊', color: 'purple' };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tag :color="label.color">{{ label.text }}</Tag>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 列表/卡片内紧凑展示:小程序用户 + 就诊人
|
||||
* 点击后由父级打开 WxUserPatientDrawer
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Avatar, Button } from 'ant-design-vue';
|
||||
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
const props = defineProps<{
|
||||
/** 审方/订单行数据,需含 user、user_patient(或 patient) */
|
||||
row: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [payload: { upId: number; patientName: string }];
|
||||
}>();
|
||||
|
||||
const defaultAvatar = '/img/user-default-avatar.png';
|
||||
|
||||
const user = computed(() => props.row?.user ?? {});
|
||||
const patient = computed(
|
||||
() => props.row?.user_patient ?? props.row?.userPatient ?? {},
|
||||
);
|
||||
|
||||
const upId = computed(() =>
|
||||
Number(patient.value?.id ?? props.row?.up_id ?? 0),
|
||||
);
|
||||
|
||||
const patientName = computed(
|
||||
() => String(patient.value?.name ?? props.row?.patient ?? '') || '—',
|
||||
);
|
||||
|
||||
function userAvatarSrc() {
|
||||
const raw = String(user.value?.avatarurl ?? '').trim();
|
||||
if (!raw) return defaultAvatar;
|
||||
return resolveAvatarUrl(raw) || defaultAvatar;
|
||||
}
|
||||
|
||||
/** 打开就诊人档案抽屉 */
|
||||
function handleOpen() {
|
||||
if (!upId.value) return;
|
||||
emit('open', { upId: upId.value, patientName: patientName.value });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wx-user-patient-cell">
|
||||
<div class="wx-user-patient-cell__row">
|
||||
<Avatar :size="24" :src="userAvatarSrc()" class="shrink-0" />
|
||||
<div class="wx-user-patient-cell__text min-w-0">
|
||||
<span class="text-[11px] text-gray-400">微信用户:</span>
|
||||
<span class="truncate text-xs">{{ user?.nickname || '—' }}</span>
|
||||
<div class="text-[11px] text-gray-500">ID:{{ user?.id ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wx-user-patient-cell__row">
|
||||
<Avatar :size="24" :src="defaultAvatar" class="shrink-0" />
|
||||
<div class="wx-user-patient-cell__text min-w-0">
|
||||
<span class="text-[11px] text-gray-400">就诊人:</span>
|
||||
<Button
|
||||
v-if="upId"
|
||||
class="!h-auto !px-0 !py-0"
|
||||
type="link"
|
||||
@click.stop="handleOpen"
|
||||
>
|
||||
{{ patientName }}
|
||||
</Button>
|
||||
<span v-else class="text-xs">{{ patientName }}</span>
|
||||
<div
|
||||
v-if="patient?.sex || patient?.age"
|
||||
class="text-[11px] text-gray-500"
|
||||
>
|
||||
<template v-if="patient?.sex === 1">男</template>
|
||||
<template v-else-if="patient?.sex === 2">女</template>
|
||||
<template v-if="patient?.age"> · {{ patient.age }}岁</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wx-user-patient-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wx-user-patient-cell__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wx-user-patient-cell__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,451 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 就诊人详情 Modal(患者视角)
|
||||
* 与 Drawer 内容一致:资料 + 挂号/处方/订单;会员管理、订单「就诊人」入口使用
|
||||
*/
|
||||
import { h, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Spin,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
|
||||
import OrderDetail from '#/views/business/order/product-order/components/detail.vue';
|
||||
|
||||
import {
|
||||
getUserPatientOrderListApi,
|
||||
getUserPatientPrescriptionListApi,
|
||||
getUserPatientProfileDetailApi,
|
||||
getUserPatientRegisterListApi,
|
||||
} from './api';
|
||||
import VisitTypeTag from './VisitTypeTag.vue';
|
||||
|
||||
defineOptions({ name: 'WxUserPatientDetailModal' });
|
||||
|
||||
const defaultAvatar = '/img/user-default-avatar.png';
|
||||
|
||||
const upId = ref(0);
|
||||
const loading = ref(false);
|
||||
const activeTab = ref('info');
|
||||
const profile = ref<Record<string, any> | null>(null);
|
||||
|
||||
const registerList = ref<any[]>([]);
|
||||
const prescriptionList = ref<any[]>([]);
|
||||
const orderList = ref<any[]>([]);
|
||||
const registerLoading = ref(false);
|
||||
const prescriptionLoading = ref(false);
|
||||
const orderLoading = ref(false);
|
||||
|
||||
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
|
||||
connectedComponent: PrescriptionDetail,
|
||||
});
|
||||
|
||||
const [OrderDetailModal, OrderDetailModalApi] = useVbenModal({
|
||||
connectedComponent: OrderDetail,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
footer: false,
|
||||
class: 'w-[760px]',
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
profile.value = null;
|
||||
registerList.value = [];
|
||||
prescriptionList.value = [];
|
||||
orderList.value = [];
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{ upId?: number; patientName?: string }>();
|
||||
upId.value = Number(data?.upId || 0);
|
||||
activeTab.value = 'info';
|
||||
const name = String(data?.patientName || '').trim();
|
||||
modalApi.setState({ title: name ? `就诊人:${name}` : '就诊人详情' });
|
||||
if (upId.value > 0) {
|
||||
void loadDetail();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 解析头像 URL */
|
||||
function avatarSrc(raw?: string) {
|
||||
const s = String(raw ?? '').trim();
|
||||
if (!s) return defaultAvatar;
|
||||
return resolveAvatarUrl(s) || defaultAvatar;
|
||||
}
|
||||
|
||||
/** 既往/过敏/家族:0 无,否则展示 history */
|
||||
function historyText(status?: number, history?: string) {
|
||||
if (Number(status) === 0) return '无';
|
||||
const t = String(history || '').trim();
|
||||
return t || '有';
|
||||
}
|
||||
|
||||
/** 肝/肾功能:0 正常,异常时附带指标文案 */
|
||||
function functionText(flag?: number, indexText?: string) {
|
||||
if (Number(flag) === 0) return '正常';
|
||||
const t = String(indexText || '').trim();
|
||||
return t ? `异常 · ${t}` : '异常';
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true;
|
||||
try {
|
||||
profile.value = await getUserPatientProfileDetailApi(upId.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('获取用户信息失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRegisterList() {
|
||||
registerLoading.value = true;
|
||||
try {
|
||||
const res = await getUserPatientRegisterListApi(upId.value);
|
||||
registerList.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('获取挂号记录失败');
|
||||
} finally {
|
||||
registerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrescriptionList() {
|
||||
prescriptionLoading.value = true;
|
||||
try {
|
||||
const res = await getUserPatientPrescriptionListApi(upId.value);
|
||||
prescriptionList.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('获取处方记录失败');
|
||||
} finally {
|
||||
prescriptionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrderList() {
|
||||
orderLoading.value = true;
|
||||
try {
|
||||
const res = await getUserPatientOrderListApi(upId.value);
|
||||
orderList.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('获取订单记录失败');
|
||||
} finally {
|
||||
orderLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tab 切换时懒加载对应列表 */
|
||||
function onTabChange(key: string | number) {
|
||||
const k = String(key);
|
||||
activeTab.value = k;
|
||||
if (k === 'register' && registerList.value.length === 0) {
|
||||
void loadRegisterList();
|
||||
} else if (k === 'prescription' && prescriptionList.value.length === 0) {
|
||||
void loadPrescriptionList();
|
||||
} else if (k === 'order' && orderList.value.length === 0) {
|
||||
void loadOrderList();
|
||||
}
|
||||
}
|
||||
|
||||
function openPrescription(id: number) {
|
||||
PrescriptionDetailModalApi.setData({ values: id });
|
||||
PrescriptionDetailModalApi.open();
|
||||
}
|
||||
|
||||
function openOrder(id: number) {
|
||||
OrderDetailModalApi.setData({ id });
|
||||
OrderDetailModalApi.open();
|
||||
}
|
||||
|
||||
const registerColumns = [
|
||||
{ title: '订单编号', dataIndex: 'order_no', key: 'order_no' },
|
||||
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||
{
|
||||
title: '费用',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
`¥${Number(text || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付',
|
||||
dataIndex: 'is_pay',
|
||||
key: 'is_pay',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
|
||||
text === 1 ? '已支付' : '未支付',
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status_text', key: 'status_text' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
];
|
||||
|
||||
const prescriptionColumns = [
|
||||
{
|
||||
title: '处方编号',
|
||||
dataIndex: 'prescription_no',
|
||||
key: 'prescription_no',
|
||||
customRender: ({ text, record }: { text: string; record: any }) =>
|
||||
h(
|
||||
Button,
|
||||
{ type: 'link', onClick: () => openPrescription(record.id) },
|
||||
() => text,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '就诊类型',
|
||||
dataIndex: 'is_online',
|
||||
key: 'is_online',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
h(VisitTypeTag, { isOnline: text }),
|
||||
},
|
||||
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||
{ title: '诊断', dataIndex: 'clinical_diagnose', key: 'clinical_diagnose' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
customRender: ({ text }: { text: number }) => {
|
||||
const map: Record<number, string> = {
|
||||
0: '待审核',
|
||||
1: '已通过',
|
||||
2: '未通过',
|
||||
3: '无需审核',
|
||||
};
|
||||
return map[text] || '未知';
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
];
|
||||
|
||||
const orderColumns = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'order_no',
|
||||
key: 'order_no',
|
||||
customRender: ({ text, record }: { text: string; record: any }) =>
|
||||
h(Button, { type: 'link', onClick: () => openOrder(record.id) }, () => text),
|
||||
},
|
||||
{
|
||||
title: '就诊类型',
|
||||
dataIndex: 'is_online',
|
||||
key: 'is_online',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
h(VisitTypeTag, { isOnline: text }),
|
||||
},
|
||||
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||
{
|
||||
title: '实付',
|
||||
dataIndex: 'total_pay_price',
|
||||
key: 'total_pay_price',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
`¥${Number(text || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付',
|
||||
dataIndex: 'is_pay',
|
||||
key: 'is_pay',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
|
||||
text === 1 ? '已支付' : '未支付',
|
||||
),
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal>
|
||||
<PrescriptionDetailModal />
|
||||
<OrderDetailModal />
|
||||
<Spin :spinning="loading">
|
||||
<template v-if="profile">
|
||||
<div class="mb-4 flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-slate-800">
|
||||
<Avatar :size="48" :src="avatarSrc(profile.user?.avatarurl)" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">
|
||||
{{ profile.user?.nickname || '—' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
用户 ID:{{ profile.user?.id ?? '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs :active-key="activeTab" @change="onTabChange">
|
||||
<Tabs.TabPane key="info" tab="就诊人信息">
|
||||
<Descriptions :column="2" bordered size="small">
|
||||
<Descriptions.Item label="姓名">
|
||||
{{ profile.patient?.name || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="性别">
|
||||
{{
|
||||
profile.patient?.sex === 1
|
||||
? '男'
|
||||
: profile.patient?.sex === 2
|
||||
? '女'
|
||||
: '—'
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="年龄">
|
||||
{{ profile.patient?.age ? `${profile.patient.age}岁` : '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">
|
||||
<SensitiveText
|
||||
v-if="profile.patient"
|
||||
:record="profile.patient"
|
||||
field="mobile"
|
||||
/>
|
||||
<span v-else>—</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-if="profile.patient?.id_card"
|
||||
label="身份证"
|
||||
:span="2"
|
||||
>
|
||||
<SensitiveText
|
||||
:record="profile.patient"
|
||||
field="id_card"
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<!-- 健康问诊 / 功能异常(与小程序资料 Tab 一致) -->
|
||||
<div class="mt-4">
|
||||
<div class="mb-2 text-sm font-medium text-gray-700">健康信息</div>
|
||||
<Descriptions
|
||||
v-if="profile.health_inquiry"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
>
|
||||
<Descriptions.Item label="既往史">
|
||||
{{
|
||||
historyText(
|
||||
profile.health_inquiry.person_status,
|
||||
profile.health_inquiry.person_history,
|
||||
)
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="过敏史">
|
||||
<span
|
||||
:class="
|
||||
Number(profile.health_inquiry.allergic_status) !== 0
|
||||
? 'text-red-600 font-medium'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{
|
||||
historyText(
|
||||
profile.health_inquiry.allergic_status,
|
||||
profile.health_inquiry.allergic_history,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="家族遗传史">
|
||||
{{
|
||||
historyText(
|
||||
profile.health_inquiry.family_status,
|
||||
profile.health_inquiry.family_history,
|
||||
)
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="肝功能异常">
|
||||
<span
|
||||
:class="
|
||||
Number(profile.health_inquiry.liver_function) !== 0
|
||||
? 'text-red-600 font-medium'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{
|
||||
functionText(
|
||||
profile.health_inquiry.liver_function,
|
||||
profile.health_inquiry.liver_index,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="肾功能异常" :span="2">
|
||||
<span
|
||||
:class="
|
||||
Number(profile.health_inquiry.renal_function) !== 0
|
||||
? 'text-red-600 font-medium'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{
|
||||
functionText(
|
||||
profile.health_inquiry.renal_function,
|
||||
profile.health_inquiry.renal_index,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Empty v-else description="暂无健康问诊记录" />
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="register" tab="挂号记录">
|
||||
<Spin :spinning="registerLoading">
|
||||
<Table
|
||||
v-if="registerList.length"
|
||||
:columns="registerColumns"
|
||||
:data-source="registerList"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
/>
|
||||
<Empty v-else description="暂无挂号记录" />
|
||||
</Spin>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="prescription" tab="处方记录">
|
||||
<Spin :spinning="prescriptionLoading">
|
||||
<Table
|
||||
v-if="prescriptionList.length"
|
||||
:columns="prescriptionColumns"
|
||||
:data-source="prescriptionList"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
/>
|
||||
<Empty v-else description="暂无处方记录" />
|
||||
</Spin>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="order" tab="订单记录">
|
||||
<Spin :spinning="orderLoading">
|
||||
<Table
|
||||
v-if="orderList.length"
|
||||
:columns="orderColumns"
|
||||
:data-source="orderList"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
/>
|
||||
<Empty v-else description="暂无订单记录" />
|
||||
</Spin>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</template>
|
||||
<Empty v-else-if="!loading" description="暂无数据" />
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,359 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 微信用户 + 就诊人档案抽屉
|
||||
* 展示小程序用户基础信息、就诊人信息及挂号/处方/订单记录(跨医生)
|
||||
*/
|
||||
import { h, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Spin,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
|
||||
import OrderDetail from '#/views/business/order/product-order/components/detail.vue';
|
||||
|
||||
import {
|
||||
getUserPatientOrderListApi,
|
||||
getUserPatientPrescriptionListApi,
|
||||
getUserPatientProfileDetailApi,
|
||||
getUserPatientRegisterListApi,
|
||||
} from './api';
|
||||
import VisitTypeTag from './VisitTypeTag.vue';
|
||||
|
||||
defineOptions({ name: 'WxUserPatientDrawer' });
|
||||
|
||||
const defaultAvatar = '/img/user-default-avatar.png';
|
||||
|
||||
const upId = ref(0);
|
||||
const loading = ref(false);
|
||||
const activeTab = ref('info');
|
||||
const profile = ref<Record<string, any> | null>(null);
|
||||
|
||||
const registerList = ref<any[]>([]);
|
||||
const prescriptionList = ref<any[]>([]);
|
||||
const orderList = ref<any[]>([]);
|
||||
const registerLoading = ref(false);
|
||||
const prescriptionLoading = ref(false);
|
||||
const orderLoading = ref(false);
|
||||
|
||||
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
|
||||
connectedComponent: PrescriptionDetail,
|
||||
});
|
||||
|
||||
const [OrderDetailModal, OrderDetailModalApi] = useVbenModal({
|
||||
connectedComponent: OrderDetail,
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
footer: false,
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
profile.value = null;
|
||||
registerList.value = [];
|
||||
prescriptionList.value = [];
|
||||
orderList.value = [];
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<{ upId?: number; patientName?: string }>();
|
||||
upId.value = Number(data?.upId || 0);
|
||||
activeTab.value = 'info';
|
||||
if (upId.value > 0) {
|
||||
void loadDetail();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 解析头像 URL */
|
||||
function avatarSrc(raw?: string) {
|
||||
const s = String(raw ?? '').trim();
|
||||
if (!s) return defaultAvatar;
|
||||
return resolveAvatarUrl(s) || defaultAvatar;
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true;
|
||||
try {
|
||||
profile.value = await getUserPatientProfileDetailApi(upId.value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('获取用户信息失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRegisterList() {
|
||||
registerLoading.value = true;
|
||||
try {
|
||||
const res = await getUserPatientRegisterListApi(upId.value);
|
||||
registerList.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('获取挂号记录失败');
|
||||
} finally {
|
||||
registerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrescriptionList() {
|
||||
prescriptionLoading.value = true;
|
||||
try {
|
||||
const res = await getUserPatientPrescriptionListApi(upId.value);
|
||||
prescriptionList.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('获取处方记录失败');
|
||||
} finally {
|
||||
prescriptionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrderList() {
|
||||
orderLoading.value = true;
|
||||
try {
|
||||
const res = await getUserPatientOrderListApi(upId.value);
|
||||
orderList.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('获取订单记录失败');
|
||||
} finally {
|
||||
orderLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tab 切换时懒加载对应列表 */
|
||||
function onTabChange(key: string | number) {
|
||||
const k = String(key);
|
||||
activeTab.value = k;
|
||||
if (k === 'register' && registerList.value.length === 0) {
|
||||
void loadRegisterList();
|
||||
} else if (k === 'prescription' && prescriptionList.value.length === 0) {
|
||||
void loadPrescriptionList();
|
||||
} else if (k === 'order' && orderList.value.length === 0) {
|
||||
void loadOrderList();
|
||||
}
|
||||
}
|
||||
|
||||
function openPrescription(id: number) {
|
||||
PrescriptionDetailModalApi.setData({ values: id });
|
||||
PrescriptionDetailModalApi.open();
|
||||
}
|
||||
|
||||
function openOrder(id: number) {
|
||||
OrderDetailModalApi.setData({ id });
|
||||
OrderDetailModalApi.open();
|
||||
}
|
||||
|
||||
const registerColumns = [
|
||||
{ title: '订单编号', dataIndex: 'order_no', key: 'order_no' },
|
||||
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||
{
|
||||
title: '费用',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
`¥${Number(text || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付',
|
||||
dataIndex: 'is_pay',
|
||||
key: 'is_pay',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
|
||||
text === 1 ? '已支付' : '未支付',
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status_text', key: 'status_text' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
];
|
||||
|
||||
const prescriptionColumns = [
|
||||
{
|
||||
title: '处方编号',
|
||||
dataIndex: 'prescription_no',
|
||||
key: 'prescription_no',
|
||||
customRender: ({ text, record }: { text: string; record: any }) =>
|
||||
h(
|
||||
Button,
|
||||
{ type: 'link', onClick: () => openPrescription(record.id) },
|
||||
() => text,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '就诊类型',
|
||||
dataIndex: 'is_online',
|
||||
key: 'is_online',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
h(VisitTypeTag, { isOnline: text }),
|
||||
},
|
||||
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||
{ title: '诊断', dataIndex: 'clinical_diagnose', key: 'clinical_diagnose' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
customRender: ({ text }: { text: number }) => {
|
||||
const map: Record<number, string> = {
|
||||
0: '待审核',
|
||||
1: '已通过',
|
||||
2: '未通过',
|
||||
3: '无需审核',
|
||||
};
|
||||
return map[text] || '未知';
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
];
|
||||
|
||||
const orderColumns = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'order_no',
|
||||
key: 'order_no',
|
||||
customRender: ({ text, record }: { text: string; record: any }) =>
|
||||
h(Button, { type: 'link', onClick: () => openOrder(record.id) }, () => text),
|
||||
},
|
||||
{
|
||||
title: '就诊类型',
|
||||
dataIndex: 'is_online',
|
||||
key: 'is_online',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
h(VisitTypeTag, { isOnline: text }),
|
||||
},
|
||||
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
|
||||
{ title: '诊所', dataIndex: 'store', key: 'store' },
|
||||
{
|
||||
title: '实付',
|
||||
dataIndex: 'total_pay_price',
|
||||
key: 'total_pay_price',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
`¥${Number(text || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付',
|
||||
dataIndex: 'is_pay',
|
||||
key: 'is_pay',
|
||||
customRender: ({ text }: { text: number }) =>
|
||||
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
|
||||
text === 1 ? '已支付' : '未支付',
|
||||
),
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer class="w-[720px]" title="微信用户 / 就诊人">
|
||||
<PrescriptionDetailModal />
|
||||
<OrderDetailModal />
|
||||
<Spin :spinning="loading">
|
||||
<template v-if="profile">
|
||||
<!-- 小程序用户 -->
|
||||
<div class="mb-4 flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-slate-800">
|
||||
<Avatar :size="48" :src="avatarSrc(profile.user?.avatarurl)" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">
|
||||
{{ profile.user?.nickname || '—' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
用户 ID:{{ profile.user?.id ?? '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs :active-key="activeTab" @change="onTabChange">
|
||||
<Tabs.TabPane key="info" tab="就诊人信息">
|
||||
<Descriptions :column="2" bordered size="small">
|
||||
<Descriptions.Item label="姓名">
|
||||
{{ profile.patient?.name || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="性别">
|
||||
{{
|
||||
profile.patient?.sex === 1
|
||||
? '男'
|
||||
: profile.patient?.sex === 2
|
||||
? '女'
|
||||
: '—'
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="年龄">
|
||||
{{ profile.patient?.age ? `${profile.patient.age}岁` : '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">
|
||||
<SensitiveText
|
||||
v-if="profile.patient"
|
||||
:record="profile.patient"
|
||||
field="mobile"
|
||||
/>
|
||||
<span v-else>—</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-if="profile.patient?.id_card"
|
||||
label="身份证"
|
||||
:span="2"
|
||||
>
|
||||
<SensitiveText
|
||||
:record="profile.patient"
|
||||
field="id_card"
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="register" tab="挂号记录">
|
||||
<Spin :spinning="registerLoading">
|
||||
<Table
|
||||
v-if="registerList.length"
|
||||
:columns="registerColumns"
|
||||
:data-source="registerList"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
/>
|
||||
<Empty v-else description="暂无挂号记录" />
|
||||
</Spin>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="prescription" tab="处方记录">
|
||||
<Spin :spinning="prescriptionLoading">
|
||||
<Table
|
||||
v-if="prescriptionList.length"
|
||||
:columns="prescriptionColumns"
|
||||
:data-source="prescriptionList"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
/>
|
||||
<Empty v-else description="暂无处方记录" />
|
||||
</Spin>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="order" tab="订单记录">
|
||||
<Spin :spinning="orderLoading">
|
||||
<Table
|
||||
v-if="orderList.length"
|
||||
:columns="orderColumns"
|
||||
:data-source="orderList"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
/>
|
||||
<Empty v-else description="暂无订单记录" />
|
||||
</Spin>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</template>
|
||||
<Empty v-else-if="!loading" description="暂无数据" />
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 某微信用户下的就诊人列表 Modal
|
||||
* 点行再打开就诊人详情 Modal(由父级或本组件内嵌 DetailModal 处理)
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Avatar, Button, Empty, Spin, Table, message } from 'ant-design-vue';
|
||||
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
import { getPatientListByUserApi } from './api';
|
||||
import WxUserPatientDetailModal from './WxUserPatientDetailModal.vue';
|
||||
|
||||
defineOptions({ name: 'WxUserPatientsModal' });
|
||||
|
||||
const defaultAvatar = '/img/user-default-avatar.png';
|
||||
|
||||
const userId = ref(0);
|
||||
const userNickname = ref('');
|
||||
const userAvatar = ref('');
|
||||
const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
|
||||
const [DetailModal, DetailModalApi] = useVbenModal({
|
||||
connectedComponent: WxUserPatientDetailModal,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
footer: false,
|
||||
class: 'w-[640px]',
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
list.value = [];
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{
|
||||
userId?: number;
|
||||
nickname?: string;
|
||||
avatarurl?: string;
|
||||
}>();
|
||||
userId.value = Number(data?.userId || 0);
|
||||
userNickname.value = String(data?.nickname || '');
|
||||
userAvatar.value = String(data?.avatarurl || '');
|
||||
modalApi.setState({
|
||||
title: userNickname.value
|
||||
? `就诊人列表 · ${userNickname.value}`
|
||||
: '就诊人列表',
|
||||
});
|
||||
if (userId.value > 0) {
|
||||
void loadList();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function avatarSrc(raw?: string) {
|
||||
const s = String(raw ?? '').trim();
|
||||
if (!s) return defaultAvatar;
|
||||
return resolveAvatarUrl(s) || defaultAvatar;
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getPatientListByUserApi(userId.value);
|
||||
list.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载就诊人失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开就诊人详情 Modal(与微信用户列表分离) */
|
||||
function openPatientDetail(row: Record<string, any>) {
|
||||
const upId = Number(row.up_id || 0);
|
||||
if (!upId) {
|
||||
message.warning('缺少就诊人信息');
|
||||
return;
|
||||
}
|
||||
DetailModalApi.setData({
|
||||
upId,
|
||||
patientName: row.name || '',
|
||||
});
|
||||
DetailModalApi.open();
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '姓名', key: 'name' },
|
||||
{ title: '性别', key: 'sex', width: 80 },
|
||||
{ title: '手机', key: 'mobile' },
|
||||
{ title: '操作', key: 'action', width: 100 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal>
|
||||
<DetailModal />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Avatar :size="36" :src="avatarSrc(userAvatar)" />
|
||||
<div class="min-w-0 text-sm">
|
||||
<div class="font-medium">{{ userNickname || '—' }}</div>
|
||||
<div class="text-xs text-gray-500">用户 ID:{{ userId || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Spin :spinning="loading">
|
||||
<Table
|
||||
v-if="list.length"
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
:pagination="false"
|
||||
row-key="up_id"
|
||||
size="small"
|
||||
:custom-row="
|
||||
(record) => ({
|
||||
onClick: () => openPatientDetail(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})
|
||||
"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
{{ record.name || '—' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'sex'">
|
||||
<template v-if="record.sex === 1">男</template>
|
||||
<template v-else-if="record.sex === 2">女</template>
|
||||
<template v-else>—</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'mobile'">
|
||||
<SensitiveText :record="record" field="mobile" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" @click.stop="openPatientDetail(record)">
|
||||
详情
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<Empty v-else-if="!loading" description="暂无就诊人" />
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
68
apps/web-antd/src/components/wx-user-patient/api.ts
Normal file
68
apps/web-antd/src/components/wx-user-patient/api.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 就诊人档案 API(跨医生视角,供药师审方等场景)
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'user-patient-profile/';
|
||||
|
||||
/** 用户管理列表(微信用户 + 就诊人,旧接口) */
|
||||
export async function getUserPatientProfileListApi(params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params });
|
||||
}
|
||||
|
||||
/** 会员管理:微信用户分页 */
|
||||
export async function getWxUserListApi(params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}user-list`, { params });
|
||||
}
|
||||
|
||||
/** 某微信用户下的就诊人列表 */
|
||||
export async function getPatientListByUserApi(userId: number) {
|
||||
return requestClient.get<any>(`${prefix}patient-list-by-user`, {
|
||||
params: { user_id: userId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 小程序用户 + 就诊人基础信息 */
|
||||
export async function getUserPatientProfileDetailApi(upId: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, {
|
||||
params: { up_id: upId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 就诊人挂号记录 */
|
||||
export async function getUserPatientRegisterListApi(
|
||||
upId: number,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
return requestClient.get<any>(`${prefix}register-list`, {
|
||||
params: { up_id: upId, ...params },
|
||||
});
|
||||
}
|
||||
|
||||
/** 就诊人处方记录 */
|
||||
export async function getUserPatientPrescriptionListApi(
|
||||
upId: number,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
return requestClient.get<any>(`${prefix}prescription-list`, {
|
||||
params: { up_id: upId, ...params },
|
||||
});
|
||||
}
|
||||
|
||||
/** 就诊人商品订单记录 */
|
||||
export async function getUserPatientOrderListApi(
|
||||
upId: number,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
return requestClient.get<any>(`${prefix}order-list`, {
|
||||
params: { up_id: upId, ...params },
|
||||
});
|
||||
}
|
||||
10
apps/web-antd/src/components/wx-user-patient/index.ts
Normal file
10
apps/web-antd/src/components/wx-user-patient/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 微信用户/就诊人相关组件导出
|
||||
*/
|
||||
export { default as WxUserPatientCell } from './WxUserPatientCell.vue';
|
||||
export { default as WxUserPatientDrawer } from './WxUserPatientDrawer.vue';
|
||||
export { default as WxUserPatientDetailModal } from './WxUserPatientDetailModal.vue';
|
||||
export { default as WxUserPatientsModal } from './WxUserPatientsModal.vue';
|
||||
export { default as VisitTypeTag } from './VisitTypeTag.vue';
|
||||
export { default as PrescriptionExpireTime } from './PrescriptionExpireTime.vue';
|
||||
export * from './api';
|
||||
@@ -1,15 +1,16 @@
|
||||
import { computed, onMounted, ref, toValue, type MaybeRefOrGetter } from 'vue';
|
||||
import { computed, onMounted, ref } 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;
|
||||
@@ -17,13 +18,7 @@ export interface FileGalleryTab {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件库筛选(类型 tab + 分组 + 关键字)
|
||||
* @param storageKeyInput localStorage 键;支持动态 getter,便于按 acceptTypes 隔离记忆
|
||||
*/
|
||||
export function useFileGalleryFilter(
|
||||
storageKeyInput: MaybeRefOrGetter<string> = 'file-picker-active-type',
|
||||
) {
|
||||
export function useFileGalleryFilter(storageKey: string) {
|
||||
const fileTypes = ref<FileTypeItem[]>([]);
|
||||
const activeType = ref<number>(ALL_TYPE_VALUE);
|
||||
const activeGroupId = ref<number>(ALL_GROUP_VALUE);
|
||||
@@ -32,10 +27,6 @@ export function useFileGalleryFilter(
|
||||
|
||||
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) => ({
|
||||
@@ -57,7 +48,7 @@ export function useFileGalleryFilter(
|
||||
]);
|
||||
|
||||
function restoreActiveType() {
|
||||
const saved = localStorage.getItem(resolveStorageKey());
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved === null) {
|
||||
activeType.value = ALL_TYPE_VALUE;
|
||||
return;
|
||||
@@ -73,7 +64,7 @@ export function useFileGalleryFilter(
|
||||
|
||||
function setActiveType(value: number) {
|
||||
activeType.value = value;
|
||||
localStorage.setItem(resolveStorageKey(), String(value));
|
||||
localStorage.setItem(storageKey, String(value));
|
||||
}
|
||||
|
||||
function setActiveGroupId(value: number) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
addWestPrescription,
|
||||
checkChineseMedicineConflictApi,
|
||||
getCurrentStoreTypeApi,
|
||||
getPrescriptionTypeOptionsApi,
|
||||
getDrugUseList,
|
||||
getMyStoreListApi,
|
||||
getPatientItem,
|
||||
@@ -54,6 +55,16 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
// 处方状态
|
||||
const myStoreId = ref(0);
|
||||
const activeCategory = ref(2);
|
||||
/** 后端下发的处方类型列表(含可选 icon / icon_text) */
|
||||
const categories = ref([
|
||||
{ label: '中药', value: 1, icon: '', icon_text: '' },
|
||||
{ label: '西(中成)药', value: 2, icon: '', icon_text: '' },
|
||||
{ label: '保健食品', value: 3, icon: '', icon_text: '' },
|
||||
{ label: '产品服务包', value: 5, icon: '', icon_text: '' },
|
||||
{ label: '非药品', value: 6, icon: '', icon_text: '' },
|
||||
{ label: '医疗器械', value: 7, icon: '', icon_text: '' },
|
||||
]);
|
||||
const prescriptionTypeDefault = ref(2);
|
||||
const diagnosis = ref('');
|
||||
const medicalAdvice = ref('');
|
||||
const treatmentPrice = ref(0);
|
||||
@@ -192,6 +203,33 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
return calcItemMarginPercent(drug.price, drug.buy_price);
|
||||
};
|
||||
|
||||
/**
|
||||
* 拉取后端处方类型列表与默认选中
|
||||
*/
|
||||
const loadPrescriptionTypeOptions = async (registerId?: number | string) => {
|
||||
try {
|
||||
const params: { register_id?: number; store_id?: number } = {};
|
||||
const rid = Number(registerId || currentRegisterId.value);
|
||||
if (rid) params.register_id = rid;
|
||||
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 || '',
|
||||
}));
|
||||
}
|
||||
const def = Number(res?.default);
|
||||
if (def) prescriptionTypeDefault.value = def;
|
||||
} catch (error) {
|
||||
console.error('加载处方类型失败:', error);
|
||||
message.warning('处方类型加载失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
// localStorage同步方法
|
||||
const syncToLocalStorage = () => {
|
||||
try {
|
||||
@@ -260,6 +298,12 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
// 设置当前注册ID
|
||||
currentRegisterId.value = registerId;
|
||||
|
||||
// 先拉类型选项,再决定默认 Tab(无本地记忆时用后端 default)
|
||||
if (!isInitialized.value) {
|
||||
await initializeBasicData();
|
||||
}
|
||||
await loadPrescriptionTypeOptions(registerId);
|
||||
|
||||
// 恢复之前保存的 activeCategory
|
||||
const savedCategory = localStorage.getItem(
|
||||
`${storagePrefix.value}activeCategory${registerId}`
|
||||
@@ -278,17 +322,14 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
activeCategory.value = 1;
|
||||
} else if (westData && JSON.parse(westData).length > 0) {
|
||||
activeCategory.value = 2;
|
||||
} else if (prescriptionTypeDefault.value) {
|
||||
activeCategory.value = prescriptionTypeDefault.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载localStorage数据
|
||||
loadFromLocalStorage();
|
||||
|
||||
// 如果基础数据未初始化,先初始化基础数据
|
||||
if (!isInitialized.value) {
|
||||
await initializeBasicData();
|
||||
}
|
||||
|
||||
await fetchStoreSeeRate();
|
||||
|
||||
// 获取患者信息(只在需要时调用,如 PrescriptionModal)
|
||||
@@ -561,7 +602,9 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
use_frequency: drugUseFrequency.value.find(
|
||||
(item) => item.id === data.drug.frequency_id,
|
||||
),
|
||||
unit: drugUnit.value.find((item) => item.id === data.drug.unit_id),
|
||||
unit:
|
||||
data.drug?.unit ||
|
||||
drugUnit.value.find((item) => item.id === data.drug.unit_id),
|
||||
price: data.price,
|
||||
buy_price: data.buy_price,
|
||||
way_id: data.drug?.way_id,
|
||||
@@ -571,6 +614,8 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
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,
|
||||
select_number: (() => {
|
||||
@@ -656,6 +701,11 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
if (data) {
|
||||
newDrugInfo.value.price = data.price;
|
||||
newDrugInfo.value.name = data.drug.drug_name;
|
||||
// 选药后立刻带上真实单位,供新行数量后缀展示
|
||||
newDrugInfo.value.unit =
|
||||
data.drug?.unit ||
|
||||
drugUnit.value.find((item) => item.id === data.drug?.unit_id);
|
||||
newDrugInfo.value.unit_id = data.drug?.unit_id;
|
||||
nextTick(() => {
|
||||
const input = document.querySelector(
|
||||
'.new-number-input input',
|
||||
@@ -691,7 +741,9 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
use_frequency: drugUseFrequency.value.find(
|
||||
(item) => item.id === data.drug.frequency_id,
|
||||
),
|
||||
unit: drugUnit.value.find((item) => item.id === data.drug.unit_id),
|
||||
unit:
|
||||
data.drug?.unit ||
|
||||
drugUnit.value.find((item) => item.id === data.drug.unit_id),
|
||||
price: data.price,
|
||||
buy_price: data.buy_price,
|
||||
way_id: data.drug?.way_id,
|
||||
@@ -701,6 +753,8 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
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,
|
||||
};
|
||||
@@ -912,6 +966,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
doctorSecondSignValue = 0,
|
||||
customSendMode: number = 0,
|
||||
customStoreId: number | null = null,
|
||||
warehouseMap: Record<number, number> | null = null,
|
||||
) => {
|
||||
if (currentDrugs.value.length === 0) {
|
||||
message.error('请选择药品');
|
||||
@@ -986,6 +1041,13 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
// 诊所选择参数(转诊挂号时自动使用承接方诊所ID)
|
||||
send_mode: finalSendMode,
|
||||
custom_store_id: finalSendMode === 1 ? finalStoreId : null,
|
||||
// 在线复诊手选仓:列表格式避免数字键对象序列化丢失
|
||||
warehouse_map: warehouseMap
|
||||
? Object.entries(warehouseMap).map(([drug_id, warehouse_id]) => ({
|
||||
drug_id: Number(drug_id),
|
||||
warehouse_id: Number(warehouse_id),
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const res = await response;
|
||||
@@ -1016,7 +1078,9 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
userStore.currentUser.doctor_id,
|
||||
);
|
||||
resetForm();
|
||||
|
||||
// 发送成功后清空本挂号下全部分类草稿(含另一分类与 activeCategory)
|
||||
clearLocalPrescriptionCache();
|
||||
|
||||
// 返回包含转诊信息的响应数据
|
||||
return {
|
||||
success: true,
|
||||
@@ -1031,6 +1095,33 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除当前挂号会话的处方 localStorage 草稿
|
||||
* 原因:resetForm 只会把当前分类写成空数组,另一分类与 activeCategory 会残留,再次打开仍会回填
|
||||
*/
|
||||
const clearLocalPrescriptionCache = () => {
|
||||
const registerId = currentRegisterId.value;
|
||||
if (!registerId) return;
|
||||
try {
|
||||
// 与小程序 PrescriptionStorage.clearAllPrescriptionData 对齐的分类集合
|
||||
const categories = [1, 2, 3, 5, 6, 7];
|
||||
categories.forEach((cat) => {
|
||||
localStorage.removeItem(
|
||||
`${storagePrefix.value}prescriptionData_${cat}_${registerId}`,
|
||||
);
|
||||
// 清理历史双横线孤儿 key(旧版弹窗 `${prefix}-prescriptionData_`)
|
||||
localStorage.removeItem(
|
||||
`${storagePrefix.value}-prescriptionData_${cat}_${registerId}`,
|
||||
);
|
||||
});
|
||||
localStorage.removeItem(
|
||||
`${storagePrefix.value}activeCategory${registerId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('清除处方本地缓存失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
updateCurrentDrugs([]);
|
||||
diagnosis.value = '';
|
||||
@@ -1063,6 +1154,11 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
} else {
|
||||
currentDrugs.value.splice(0, currentDrugs.value.length);
|
||||
}
|
||||
// 切换后聚焦 Tab 栏当前项
|
||||
nextTick(() => {
|
||||
const el = document.getElementById(`rx-modal-tab-${categoryValue}`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
});
|
||||
};
|
||||
|
||||
// 工具函数
|
||||
@@ -1102,6 +1198,8 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
myStoreList,
|
||||
myStoreId,
|
||||
activeCategory,
|
||||
categories,
|
||||
prescriptionTypeDefault,
|
||||
diagnosis,
|
||||
medicalAdvice,
|
||||
treatmentPrice,
|
||||
@@ -1138,6 +1236,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
initializePrescription,
|
||||
initializeForModal,
|
||||
initializeBasicData,
|
||||
loadPrescriptionTypeOptions,
|
||||
resetInitializationState,
|
||||
getDrugList,
|
||||
addProducts,
|
||||
@@ -1165,6 +1264,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
checkChineseMedicineConflict,
|
||||
sendPrescription,
|
||||
resetForm,
|
||||
clearLocalPrescriptionCache,
|
||||
changeCategory,
|
||||
getProcessRuleListData,
|
||||
splitString,
|
||||
|
||||
15
apps/web-antd/src/utils/formatStoreNameWithHu.ts
Normal file
15
apps/web-antd/src/utils/formatStoreNameWithHu.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 在线处方诊所名追加「(互)」:is_online 为 1/2/3(问诊/复诊等),线下 0 不加
|
||||
*/
|
||||
export function formatStoreNameWithHu(
|
||||
storeName: string | null | undefined,
|
||||
isOnline: number | string | null | undefined,
|
||||
): string {
|
||||
const name = String(storeName ?? '').trim();
|
||||
if (!name) return '';
|
||||
const online = Number(isOnline);
|
||||
if (online === 1 || online === 2 || online === 3) {
|
||||
return name.endsWith('(互)') ? name : `${name}(互)`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
66
apps/web-antd/src/utils/matchExpressByTrackingNo.ts
Normal file
66
apps/web-antd/src/utils/matchExpressByTrackingNo.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 根据运单号前缀推断快递公司(用于发货表单自动选中)
|
||||
* 匹配关键字同时支持公司名与 code(如 shunfeng)
|
||||
*/
|
||||
|
||||
export type ExpressMatchOption = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
/** 常见单号前缀 → 公司名 / code 关键字(按前缀长度降序匹配,避免短前缀误伤) */
|
||||
const PREFIX_RULES: Array<{ prefixes: string[]; keywords: string[] }> = [
|
||||
{ prefixes: ['SF'], keywords: ['顺丰', 'shunfeng', 'sf'] },
|
||||
{ prefixes: ['ZTO', 'ZT'], keywords: ['中通', 'zhongtong', 'zto'] },
|
||||
{ prefixes: ['STO'], keywords: ['申通', 'shentong', 'sto'] },
|
||||
{ prefixes: ['YTO', 'YT'], keywords: ['圆通', 'yuantong', 'yto'] },
|
||||
{ prefixes: ['YD'], keywords: ['韵达', 'yunda', 'yd'] },
|
||||
{ prefixes: ['JD'], keywords: ['京东', 'jd', 'jingdong'] },
|
||||
{ prefixes: ['JT'], keywords: ['极兔', 'jitu', 'jt'] },
|
||||
{ prefixes: ['EMS', 'E'], keywords: ['ems', '邮政'] },
|
||||
{ prefixes: ['HHTT', 'HT'], keywords: ['百世', 'baishi', 'huitong'] },
|
||||
{ prefixes: ['UC'], keywords: ['优速', 'uc'] },
|
||||
{ prefixes: ['DBL'], keywords: ['德邦', 'debang', 'dbl'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* 按运单号前缀在 options 中找最可能的快递公司
|
||||
* @returns 匹配到的 option,未匹配返回 null
|
||||
*/
|
||||
export function matchExpressByTrackingNo(
|
||||
trackingNo: string | undefined | null,
|
||||
options: ExpressMatchOption[],
|
||||
): ExpressMatchOption | null {
|
||||
const no = String(trackingNo || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/\s+/g, '');
|
||||
if (!no || !Array.isArray(options) || !options.length) {
|
||||
return null;
|
||||
}
|
||||
const sortedRules = [...PREFIX_RULES].sort(
|
||||
(a, b) =>
|
||||
Math.max(...b.prefixes.map((p) => p.length)) -
|
||||
Math.max(...a.prefixes.map((p) => p.length)),
|
||||
);
|
||||
let matchedKeywords: string[] | null = null;
|
||||
for (const rule of sortedRules) {
|
||||
if (rule.prefixes.some((p) => no.startsWith(p))) {
|
||||
matchedKeywords = rule.keywords;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matchedKeywords) {
|
||||
return null;
|
||||
}
|
||||
const keywords = matchedKeywords.map((k) => k.toLowerCase());
|
||||
for (const opt of options) {
|
||||
const name = String(opt.name || '').toLowerCase();
|
||||
const code = String(opt.code || '').toLowerCase();
|
||||
if (keywords.some((k) => name.includes(k) || code === k || code.includes(k))) {
|
||||
return opt;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { Avatar, Button, Image } from 'ant-design-vue';
|
||||
// 勿用 message 作 toast 名:会覆盖 props.message,导致模板 message_type 全失效
|
||||
import { Avatar, Button, Image, message as antdMessage } from 'ant-design-vue';
|
||||
|
||||
import { usePrescriptionStore } from '#/store/prescription';
|
||||
import { getChatMessageRegisterInfoApi } from '#/views/business/chat/api';
|
||||
import { useChatStore } from '#/views/business/chat/stores/chat';
|
||||
import { sendMessage } from '#/views/business/chat/utils/request';
|
||||
import { getDrugsByRegisterId as getDrugsByRegisterIdPharmacy } from '#/views/doctor/online-consultation/api/index';
|
||||
import { getDrugsByRegisterId as getDrugsByRegisterIdClinic } from '#/views/doctor/online-consultation-clinic/api/index';
|
||||
|
||||
// 辅助函数:按需解析消息内容
|
||||
const getParsedContent = (messageType, messageContent) => {
|
||||
@@ -81,10 +86,22 @@ const chatUserStore = chatUseUserStore();
|
||||
const userStore = useUserStore();
|
||||
const chatStore = useChatStore();
|
||||
const themeStore = useThemeStore();
|
||||
const route = useRoute();
|
||||
const prescriptionStore = usePrescriptionStore();
|
||||
|
||||
/** 诊所接诊模块用 clinic API / 存储前缀,药店用 pharmacy */
|
||||
const isClinicModule = computed(() => {
|
||||
return route.path.includes('online-consultation-clinic');
|
||||
});
|
||||
const getDrugsByRegisterId = computed(() => {
|
||||
return isClinicModule.value ? getDrugsByRegisterIdClinic : getDrugsByRegisterIdPharmacy;
|
||||
});
|
||||
|
||||
// 转接方查看抽屉相关
|
||||
const showTransferDrawer = ref(false);
|
||||
const transferRegisterId = ref(null);
|
||||
/** 挂号卡「添加到处方单」请求中,防重复点击 */
|
||||
const registerAddToRxLoading = ref(false);
|
||||
|
||||
const [RefusalOfTreatmentModals, RefusalOfTreatmentModalApi] = useVbenModal({
|
||||
connectedComponent: RefusalOfTreatmentModal,
|
||||
@@ -182,17 +199,109 @@ const registerCardDisplay = computed(() => {
|
||||
return base;
|
||||
});
|
||||
|
||||
const registerDrugNamesLine = computed(() => {
|
||||
/**
|
||||
* 挂号卡片药品行:药名 + 规格 + 数量 + 缩略图(对齐小程序,去掉底部「各 X 盒」)
|
||||
*/
|
||||
const registerCardDrugRows = computed(() => {
|
||||
const pc = registerCardDisplay.value;
|
||||
if (!pc || typeof pc !== 'object') return '';
|
||||
if (!pc || typeof pc !== 'object') return [];
|
||||
const fallbackQty =
|
||||
pc.number != null && pc.number !== '' ? Number(pc.number) : 1;
|
||||
const arr = pc.selected_western_drugs;
|
||||
if (Array.isArray(arr) && arr.length) {
|
||||
return arr.map((d) => d?.name).filter(Boolean).join('、');
|
||||
return arr.map((d, idx) => {
|
||||
const q = d?.quantity != null ? Number(d.quantity) : fallbackQty;
|
||||
return {
|
||||
key: String(d?.drug_id || d?.id || idx),
|
||||
name: d?.name || '',
|
||||
specification: d?.specification || d?.spec || d?.drug_spec || '',
|
||||
image: d?.image || d?.drug_image || '',
|
||||
quantity: q >= 1 ? q : 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (pc.drug?.name) return pc.drug.name;
|
||||
return '';
|
||||
if (pc.drug?.name) {
|
||||
return [
|
||||
{
|
||||
key: String(pc.drug.drug_id || pc.drug.id || 0),
|
||||
name: pc.drug.name,
|
||||
specification: pc.drug.specification || pc.drug.spec || '',
|
||||
image: pc.drug.image || pc.drug.drug_image || '',
|
||||
quantity: fallbackQty >= 1 ? fallbackQty : 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
/**
|
||||
* 是否展示「添加到处方单」:有选药、接诊中、患者侧卡片(对齐小程序 canAddExperienceToRx && !isMine)
|
||||
*/
|
||||
const canAddRegisterDrugsToRx = computed(() => {
|
||||
return (
|
||||
registerCardDrugRows.value.length > 0 &&
|
||||
Number(registerCardDisplay.value?.status) === 2 &&
|
||||
!props.isSent
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 将本条挂号卡对应选药写入处方单(复用 getDrugsByRegisterId + prescriptionStore)
|
||||
*/
|
||||
const handleAddRegisterDrugsToPrescription = async () => {
|
||||
if (registerAddToRxLoading.value) return;
|
||||
const cardRegisterId = registerCardDisplay.value?.id;
|
||||
const registerId =
|
||||
prescriptionStore.currentRegisterId ||
|
||||
chatStore.currentFriend?.register_id ||
|
||||
cardRegisterId;
|
||||
if (!registerId) {
|
||||
antdMessage.warning('请先接诊患者');
|
||||
return;
|
||||
}
|
||||
if (!prescriptionStore.currentRegisterId) {
|
||||
const storagePrefix = isClinicModule.value
|
||||
? 'onlineConsultationClinic-'
|
||||
: 'onlineConsultation-';
|
||||
prescriptionStore.initializePrescription(registerId, false, storagePrefix);
|
||||
}
|
||||
let storeId = prescriptionStore.myStoreId;
|
||||
if (!storeId || storeId === 0) {
|
||||
if (prescriptionStore.myStoreList && prescriptionStore.myStoreList.length > 0) {
|
||||
storeId = prescriptionStore.myStoreList[0].id;
|
||||
} else {
|
||||
antdMessage.warning('请先选择诊所');
|
||||
return;
|
||||
}
|
||||
}
|
||||
registerAddToRxLoading.value = true;
|
||||
try {
|
||||
const res = await getDrugsByRegisterId.value(registerId, storeId);
|
||||
const drugList = res?.result || res?.data || res || [];
|
||||
if (!drugList || drugList.length === 0) {
|
||||
antdMessage.warning('未找到药品信息');
|
||||
return;
|
||||
}
|
||||
let addedCount = 0;
|
||||
for (const drugData of drugList) {
|
||||
if (prescriptionStore.addProducts(drugData)) {
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
if (addedCount > 0) {
|
||||
antdMessage.success(`已成功添加 ${addedCount} 个药品到处方单`);
|
||||
emit('open-prescription', registerId);
|
||||
} else {
|
||||
antdMessage.warning('所有药品已在处方中');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加药品失败:', error);
|
||||
antdMessage.error('添加药品失败,请重试');
|
||||
} finally {
|
||||
registerAddToRxLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const isFollowUpDrugAssistant = computed(() => {
|
||||
if (props.message.message_type !== 11) return false;
|
||||
const pc = parsedContent.value;
|
||||
@@ -617,9 +726,9 @@ const getSexText = (sex) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主诉 / 所选药品 / 数量(与小程序挂号卡对齐) -->
|
||||
<!-- 主诉 / 所选药品(逐药:缩略图 + 规格 + 数量,对齐小程序) -->
|
||||
<div
|
||||
v-if="registerCardDisplay.chief_complaint || registerDrugNamesLine || (registerCardDisplay.number != null && registerCardDisplay.number !== '')"
|
||||
v-if="registerCardDisplay.chief_complaint || 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">
|
||||
@@ -628,19 +737,34 @@ const getSexText = (sex) => {
|
||||
registerCardDisplay.chief_complaint
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="registerDrugNamesLine">
|
||||
<span class="font-medium text-gray-600 dark:text-gray-300">所选药品:</span>
|
||||
<span class="text-gray-800 dark:text-gray-100">{{
|
||||
registerDrugNamesLine
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="registerCardDisplay.number != null && registerCardDisplay.number !== ''"
|
||||
>
|
||||
<span class="font-medium text-gray-600 dark:text-gray-300">数量:</span>
|
||||
<span class="text-gray-800 dark:text-gray-100"
|
||||
>各 {{ registerCardDisplay.number }} 盒</span
|
||||
<div v-if="registerCardDrugRows.length" class="space-y-2">
|
||||
<div class="font-medium text-gray-600 dark:text-gray-300">所选药品:</div>
|
||||
<div
|
||||
v-for="row in registerCardDrugRows"
|
||||
:key="row.key"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<Image
|
||||
v-if="row.image"
|
||||
:src="row.image"
|
||||
:width="36"
|
||||
:height="36"
|
||||
class="register-drug-thumb flex-shrink-0 overflow-hidden rounded"
|
||||
:preview="{ src: row.image }"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-gray-800 dark:text-gray-100">{{ row.name || '药品' }}</div>
|
||||
<div
|
||||
v-if="row.specification"
|
||||
class="text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{{ row.specification }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="flex-shrink-0 font-medium text-gray-700 dark:text-gray-200"
|
||||
>×{{ row.quantity }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -680,14 +804,20 @@ const getSexText = (sex) => {
|
||||
拒诊
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 查看详情按钮 -->
|
||||
<!-- <div class="flex items-center justify-center mt-2">-->
|
||||
<!-- <div class="flex items-center text-blue-500 dark:text-blue-300 hover:text-blue-600 dark:hover:text-blue-200 transition-colors cursor-pointer">-->
|
||||
<!-- <span class="text-sm font-medium">查看详情</span>-->
|
||||
<!-- <i class="fas fa-chevron-right ml-1 text-xs"></i>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- 接诊中且有选药:导入到处方单(对齐小程序挂号卡) -->
|
||||
<div v-if="canAddRegisterDrugsToRx" class="mt-2">
|
||||
<Button
|
||||
:loading="registerAddToRxLoading"
|
||||
block
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
@click.stop="handleAddRegisterDrugsToPrescription"
|
||||
>
|
||||
<i class="fas fa-plus-circle mr-1"></i>
|
||||
添加到处方单
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间信息 -->
|
||||
|
||||
@@ -56,6 +56,8 @@ import InfoModal from '#/components/modal/InfoModal.vue';
|
||||
import { DrugSearchSelect } from '#/components/drug-search-select';
|
||||
import { formatPriceDisplay } from '#/utils/formatPrice';
|
||||
import ChineseMedicineConfig from '#/views/doctor/doctor-reception/components/ChineseMedicineConfig.vue';
|
||||
import { getDeliveryWarehouseOptionsByDrugs } from '#/views/doctor/doctor-reception/api';
|
||||
import WarehouseSelectModal from './WarehouseSelectModal.vue';
|
||||
|
||||
const splitString = (input: string) => input.split(',');
|
||||
|
||||
@@ -75,18 +77,15 @@ const commonPrescriptionName = ref('');
|
||||
/** 是否正在保存常用方 */
|
||||
const isSavingCommonPrescription = ref(false);
|
||||
|
||||
const categories = [
|
||||
{ label: '中药', value: 1 },
|
||||
{ label: '中成(西)药', value: 2 },
|
||||
{ label: '保健食品', value: 3 },
|
||||
{ label: '产品服务包', value: 5 },
|
||||
{ label: '非药品', value: 6 },
|
||||
{ label: '医疗器械', value: 7 },
|
||||
];
|
||||
|
||||
// 使用 Pinia store
|
||||
const prescriptionStore = usePrescriptionStore();
|
||||
|
||||
/** 处方类型来自后端下发(store.categories) */
|
||||
const categories = computed(() => prescriptionStore.categories);
|
||||
|
||||
const rxSwipeStartX = ref(0);
|
||||
const rxSwipeStartY = ref(0);
|
||||
|
||||
const allowInsuranceCategory = computed(
|
||||
() => Number(prescriptionStore.registerStoreInfo?.allow_insurance_category ?? 0) === 1,
|
||||
);
|
||||
@@ -131,6 +130,8 @@ const [Modal, modalApi] = useVbenModal({
|
||||
storagePrefix,
|
||||
);
|
||||
prescriptionStore.loadFromLocalStorage();
|
||||
// 开方弹窗打开时再拉一次类型,确保 Network 可见且数据最新
|
||||
await prescriptionStore.loadPrescriptionTypeOptions(modalData.value.registerId);
|
||||
// 获取挂号诊所信息
|
||||
await prescriptionStore.fetchRegisterStoreInfo();
|
||||
|
||||
@@ -210,6 +211,130 @@ const handleCheckChineseMedicineConflict = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 药店开方待选仓时暂存发送参数
|
||||
const pendingSendParams = ref<{
|
||||
doctorSecondSignValue: number;
|
||||
sendMode: number;
|
||||
customStoreId: number | null;
|
||||
} | null>(null);
|
||||
|
||||
const [WarehouseSelectModals, warehouseSelectModalApi] = useVbenModal({
|
||||
connectedComponent: WarehouseSelectModal,
|
||||
});
|
||||
|
||||
/**
|
||||
* 在线复诊发送前:有仓药绑定时弹卡片选仓(默认最低价)
|
||||
* @returns true=已拦截发送等待选仓;false=无需选仓可继续发送
|
||||
*/
|
||||
const tryPharmacyWarehouseSelect = async (
|
||||
doctorSecondSignValue: number,
|
||||
sendMode: number,
|
||||
customStoreId: number | null,
|
||||
): Promise<boolean> => {
|
||||
const drugs = prescriptionStore.currentDrugs || [];
|
||||
const drugIds: number[] = [];
|
||||
const needQtyMap: Record<number, number> = {};
|
||||
for (const d of drugs) {
|
||||
const id = Number(d?.id ?? 0);
|
||||
const qty = Number(d?.select_number ?? d?.number ?? 0);
|
||||
if (id <= 0 || qty <= 0) {
|
||||
continue;
|
||||
}
|
||||
drugIds.push(id);
|
||||
needQtyMap[id] = (needQtyMap[id] || 0) + qty;
|
||||
}
|
||||
if (drugIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const res = await getDeliveryWarehouseOptionsByDrugs({
|
||||
drug_ids: drugIds,
|
||||
need_qty_map: needQtyMap,
|
||||
});
|
||||
const map = (res?.result ?? res ?? {}) as Record<string, any[]>;
|
||||
const rows: Array<{
|
||||
drug_id: number;
|
||||
drug_name: string;
|
||||
options: Array<{
|
||||
warehouse_id: number;
|
||||
warehouse_name: string;
|
||||
quote: string;
|
||||
available_stock: number;
|
||||
}>;
|
||||
}> = [];
|
||||
for (const d of drugs) {
|
||||
const id = Number(d?.id ?? 0);
|
||||
if (id <= 0) {
|
||||
continue;
|
||||
}
|
||||
const options = map[String(id)] || map[id] || [];
|
||||
if (!Array.isArray(options) || options.length === 0) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
drug_id: id,
|
||||
// 优先用仓选项接口带回的药品信息,避免处方草稿未存规格/图
|
||||
drug_name: String(
|
||||
options[0]?.drug_name ||
|
||||
d?.drug_name ||
|
||||
d?.name ||
|
||||
`药品#${id}`,
|
||||
),
|
||||
image: String(
|
||||
options[0]?.image ||
|
||||
d?.image ||
|
||||
d?._image ||
|
||||
d?.drug?.image ||
|
||||
'',
|
||||
),
|
||||
specification: String(
|
||||
options[0]?.specification ||
|
||||
d?.specification ||
|
||||
d?.drug?.specification ||
|
||||
'',
|
||||
),
|
||||
options: options.map((o) => ({
|
||||
warehouse_id: Number(o.warehouse_id),
|
||||
warehouse_name: String(o.warehouse_name || ''),
|
||||
quote: String(o.quote ?? '0'),
|
||||
available_stock: Number(o.available_stock ?? 0),
|
||||
// 保留药品展示字段,供弹窗从 options[0] 回退读取
|
||||
drug_name: String(o.drug_name || ''),
|
||||
image: String(o.image || ''),
|
||||
specification: String(o.specification || ''),
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
pendingSendParams.value = {
|
||||
doctorSecondSignValue,
|
||||
sendMode,
|
||||
customStoreId,
|
||||
};
|
||||
warehouseSelectModalApi.setData({ rows });
|
||||
warehouseSelectModalApi.open();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载配送仓库失败');
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const onWarehouseSelected = async (warehouseMap: Record<number, number>) => {
|
||||
if (!pendingSendParams.value) return;
|
||||
const { doctorSecondSignValue, sendMode, customStoreId } =
|
||||
pendingSendParams.value;
|
||||
pendingSendParams.value = null;
|
||||
await executeSendPrescription(
|
||||
doctorSecondSignValue,
|
||||
sendMode,
|
||||
customStoreId,
|
||||
warehouseMap,
|
||||
);
|
||||
};
|
||||
|
||||
// 处方发送前的确认流程
|
||||
const handleSendPrescription = async (doctorSecondSignValue = 0) => {
|
||||
// 确保基础数据已初始化(包括当前诊所列表)
|
||||
@@ -219,14 +344,25 @@ const handleSendPrescription = async (doctorSecondSignValue = 0) => {
|
||||
await prescriptionStore.fetchRegisterStoreInfo();
|
||||
|
||||
const storeInfo = prescriptionStore.registerStoreInfo;
|
||||
|
||||
let sendMode = 0;
|
||||
let customStoreId: number | null = null;
|
||||
if (storeInfo?.is_from_transfer === 1 && storeInfo?.delegate_store_id) {
|
||||
await executeSendPrescription(doctorSecondSignValue, 1, storeInfo.delegate_store_id);
|
||||
sendMode = 1;
|
||||
customStoreId = storeInfo.delegate_store_id;
|
||||
} else if (storeInfo?.store_id) {
|
||||
await executeSendPrescription(doctorSecondSignValue, 1, storeInfo.store_id);
|
||||
} else {
|
||||
await executeSendPrescription(doctorSecondSignValue, 0, null);
|
||||
sendMode = 1;
|
||||
customStoreId = storeInfo.store_id;
|
||||
}
|
||||
// 有绑仓药品时先卡片选仓,再发送
|
||||
const needSelect = await tryPharmacyWarehouseSelect(
|
||||
doctorSecondSignValue,
|
||||
sendMode,
|
||||
customStoreId,
|
||||
);
|
||||
if (needSelect) {
|
||||
return;
|
||||
}
|
||||
await executeSendPrescription(doctorSecondSignValue, sendMode, customStoreId);
|
||||
};
|
||||
|
||||
// 执行发送处方
|
||||
@@ -234,11 +370,13 @@ const executeSendPrescription = async (
|
||||
doctorSecondSignValue: number,
|
||||
sendMode: number,
|
||||
customStoreId: number | null,
|
||||
warehouseMap: Record<number, number> | null = null,
|
||||
) => {
|
||||
const result = await prescriptionStore.sendPrescription(
|
||||
doctorSecondSignValue,
|
||||
sendMode,
|
||||
customStoreId,
|
||||
warehouseMap,
|
||||
);
|
||||
|
||||
// 检查返回结果,可能是boolean或包含转诊信息的对象
|
||||
@@ -307,6 +445,26 @@ const tabChange = async (id: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 记录左右滑起点 */
|
||||
const onRxPanelPointerDown = (e: PointerEvent) => {
|
||||
rxSwipeStartX.value = e.clientX;
|
||||
rxSwipeStartY.value = e.clientY;
|
||||
};
|
||||
|
||||
/** 左右滑切换相邻处方类型 */
|
||||
const onRxPanelPointerUp = (e: PointerEvent) => {
|
||||
const dx = e.clientX - rxSwipeStartX.value;
|
||||
const dy = e.clientY - rxSwipeStartY.value;
|
||||
if (Math.abs(dx) < 80 || Math.abs(dx) <= Math.abs(dy)) return;
|
||||
const list = categories.value || [];
|
||||
if (list.length < 2) return;
|
||||
const idx = list.findIndex((c) => Number(c.value) === Number(prescriptionStore.activeCategory));
|
||||
if (idx < 0) return;
|
||||
const nextIdx = dx < 0 ? idx + 1 : idx - 1;
|
||||
if (nextIdx < 0 || nextIdx >= list.length) return;
|
||||
tabChange(list[nextIdx].value);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测并显示转诊提示
|
||||
* @description 当切换到中药处方时,如果当前诊所为西医诊所,显示转诊提示
|
||||
@@ -467,6 +625,7 @@ async function handleSelectCommonPrescription(data: any, type: number) {
|
||||
frequency_id: recipe.frequency_id,
|
||||
unit_id: recipe.unit_id,
|
||||
image: recipe.image,
|
||||
specification: recipe.specification || recipe.drug?.specification || '',
|
||||
instruction: recipe.instruction,
|
||||
type: recipe.type,
|
||||
select_number: recipe.select_number || 1,
|
||||
@@ -490,6 +649,7 @@ async function handleSelectCommonPrescription(data: any, type: number) {
|
||||
price: recipe.price || 0,
|
||||
buy_price: recipe.buy_price,
|
||||
way_id: recipe.way_id || 0,
|
||||
specification: recipe.specification || recipe.drug?.specification || '',
|
||||
select_number: 1,
|
||||
};
|
||||
// 检查是否已存在
|
||||
@@ -581,6 +741,12 @@ function handleSimpleProductSelect(drug: any) {
|
||||
number: 1,
|
||||
price: drug._price || drug.price,
|
||||
image: drug._image || drug.drug?.image || drug.image,
|
||||
// 规格(选仓弹窗等处展示)
|
||||
specification:
|
||||
drug._specification ||
|
||||
drug.drug?.specification ||
|
||||
drug.specification ||
|
||||
'',
|
||||
instruction: drug.drug?.instruction || drug.instruction || '',
|
||||
type: drug.drug?.type || drug.type || prescriptionStore.activeCategory,
|
||||
select_number: 1,
|
||||
@@ -874,16 +1040,30 @@ const cancelSaveCommonPrescription = () => {
|
||||
<div class="drug-categories sticky top-0 bg-white dark:bg-[#151515] z-10 py-2 border-b mb-4">
|
||||
<button
|
||||
v-for="categoryItem in categories"
|
||||
:id="`rx-modal-tab-${categoryItem.value}`"
|
||||
:key="categoryItem.value"
|
||||
:class="{
|
||||
active: prescriptionStore.activeCategory === categoryItem.value,
|
||||
}"
|
||||
@click="tabChange(categoryItem.value)"
|
||||
>
|
||||
{{ categoryItem.label }}
|
||||
<img
|
||||
v-if="categoryItem.icon"
|
||||
class="tab-type-icon"
|
||||
:src="categoryItem.icon"
|
||||
alt=""
|
||||
/>
|
||||
<span>{{ categoryItem.label }}</span>
|
||||
<span v-if="categoryItem.icon_text" class="tab-type-badge">{{ categoryItem.icon_text }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rx-swipe-panel"
|
||||
@pointerdown="onRxPanelPointerDown"
|
||||
@pointerup="onRxPanelPointerUp"
|
||||
>
|
||||
|
||||
<!-- 费用类型选择和添加商品按钮 -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<RadioGroup v-if="allowInsuranceCategory" v-model:value="prescriptionStore.category">
|
||||
@@ -920,6 +1100,13 @@ const cancelSaveCommonPrescription = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中药:顶部固定展示已选种数 -->
|
||||
<div
|
||||
v-if="prescriptionStore.activeCategory === 1"
|
||||
class="mb-3 mt-2 text-base font-medium text-gray-700"
|
||||
>
|
||||
已选 {{ prescriptionStore.currentDrugs.length }} 种药
|
||||
</div>
|
||||
<!-- 中药药品列表 -->
|
||||
<div
|
||||
v-if="prescriptionStore.activeCategory === 1"
|
||||
@@ -977,7 +1164,7 @@ const cancelSaveCommonPrescription = () => {
|
||||
prescriptionStore.updateChineseNumberGoNewDrug($event)
|
||||
"
|
||||
>
|
||||
<template #addonAfter> g</template>
|
||||
<template #addonAfter>{{ drug.unit?.name || 'g' }}</template>
|
||||
</InputNumber>
|
||||
|
||||
<Select
|
||||
@@ -1056,7 +1243,7 @@ const cancelSaveCommonPrescription = () => {
|
||||
prescriptionStore.selectDrugByNewDrugInfo($event, true)
|
||||
"
|
||||
>
|
||||
<template #addonAfter> g</template>
|
||||
<template #addonAfter>{{ prescriptionStore.newDrugInfo.unit?.name || 'g' }}</template>
|
||||
</InputNumber>
|
||||
|
||||
<Select
|
||||
@@ -1428,6 +1615,7 @@ const cancelSaveCommonPrescription = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 以下是各种弹窗组件,位置不动 -->
|
||||
@@ -1505,6 +1693,8 @@ const cancelSaveCommonPrescription = () => {
|
||||
<PrescriptionDetailModal />
|
||||
<!-- 常用方选择弹窗 -->
|
||||
<CommonPrescriptionModals />
|
||||
<!-- 药店开方选配送仓库 -->
|
||||
<WarehouseSelectModals @confirm="onWarehouseSelected" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1548,6 +1738,8 @@ const cancelSaveCommonPrescription = () => {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
/* margin: 1rem 0; 已在类中通过 sticky 处理 */
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.drug-categories button {
|
||||
@@ -1558,6 +1750,11 @@ const cancelSaveCommonPrescription = () => {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
transition: all 0.3s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dark .drug-categories button {
|
||||
@@ -1571,6 +1768,25 @@ const cancelSaveCommonPrescription = () => {
|
||||
background: #455cda;
|
||||
}
|
||||
|
||||
.tab-type-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.tab-type-badge {
|
||||
font-size: 11px;
|
||||
color: #e6a23c;
|
||||
background: #fdf6ec;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rx-swipe-panel {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.selected-drugs {
|
||||
margin: 1rem 0;
|
||||
border: 1px solid #eee;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 在线复诊开方:按药品分步卡片选配送仓库
|
||||
* 默认选中 options[0](与后端 auto 一致:quote ASC 最低价)
|
||||
* 样式使用主题 token,适配亮/暗色
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Image, message } from 'ant-design-vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', map: Record<number, number>): void;
|
||||
}>();
|
||||
|
||||
type WarehouseOption = {
|
||||
warehouse_id: number;
|
||||
warehouse_name: string;
|
||||
quote: string;
|
||||
available_stock: number;
|
||||
/** 接口可能在 option 上带药品展示字段,供 row 回退 */
|
||||
drug_name?: string;
|
||||
image?: string;
|
||||
specification?: string;
|
||||
};
|
||||
|
||||
type DrugRow = {
|
||||
drug_id: number;
|
||||
drug_name: string;
|
||||
image?: string;
|
||||
specification?: string;
|
||||
options: WarehouseOption[];
|
||||
warehouse_id?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 row 或 options[0] 取药品展示字段(接口规格在 option 上)
|
||||
*/
|
||||
function resolveDrugMeta(row: DrugRow) {
|
||||
const first = row.options?.[0];
|
||||
return {
|
||||
drug_name: row.drug_name || first?.drug_name || '',
|
||||
image: row.image || first?.image || '',
|
||||
specification: row.specification || first?.specification || '',
|
||||
};
|
||||
}
|
||||
|
||||
const drugRows = ref<DrugRow[]>([]);
|
||||
/** 当前步骤下标(0-based) */
|
||||
const stepIndex = ref(0);
|
||||
|
||||
const totalSteps = computed(() => drugRows.value.length);
|
||||
const currentRow = computed(() => drugRows.value[stepIndex.value] || null);
|
||||
/** 当前步药品展示:名/图/规格,含 option 回退 */
|
||||
const currentDrugMeta = computed(() =>
|
||||
currentRow.value
|
||||
? resolveDrugMeta(currentRow.value)
|
||||
: { drug_name: '', image: '', specification: '' },
|
||||
);
|
||||
const isFirstStep = computed(() => stepIndex.value <= 0);
|
||||
const isLastStep = computed(
|
||||
() => stepIndex.value >= Math.max(totalSteps.value - 1, 0),
|
||||
);
|
||||
const canGoNext = computed(() => !!currentRow.value?.warehouse_id);
|
||||
|
||||
/** 点击卡片选中当前步骤药品的配送仓 */
|
||||
function selectWarehouse(warehouseId: number) {
|
||||
const row = currentRow.value;
|
||||
if (!row) return;
|
||||
row.warehouse_id = warehouseId;
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
if (!isFirstStep.value) {
|
||||
stepIndex.value -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (!canGoNext.value) {
|
||||
message.error('请先选择配送仓库');
|
||||
return;
|
||||
}
|
||||
if (!isLastStep.value) {
|
||||
stepIndex.value += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 汇总 warehouse_map 并确认 */
|
||||
function confirmAll() {
|
||||
if (!canGoNext.value) {
|
||||
message.error('请先选择配送仓库');
|
||||
return;
|
||||
}
|
||||
const map: Record<number, number> = {};
|
||||
for (const row of drugRows.value) {
|
||||
if (!row.warehouse_id) {
|
||||
message.error(`请为【${row.drug_name}】选择配送仓库`);
|
||||
return;
|
||||
}
|
||||
map[row.drug_id] = row.warehouse_id;
|
||||
}
|
||||
emit('confirm', map);
|
||||
modalApi.close();
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
footer: false,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<{ rows: DrugRow[] }>();
|
||||
// 默认选中每药 options[0](最低 quote);规格/图/名可从 option 回退
|
||||
drugRows.value = (data?.rows || []).map((row) => {
|
||||
const meta = resolveDrugMeta(row);
|
||||
return {
|
||||
...row,
|
||||
warehouse_id: row.options?.[0]?.warehouse_id,
|
||||
drug_name: meta.drug_name || `药品#${row.drug_id}`,
|
||||
image: meta.image,
|
||||
specification: meta.specification,
|
||||
};
|
||||
});
|
||||
stepIndex.value = 0;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="选择配送仓库" class="w-[640px]">
|
||||
<div v-if="currentRow" class="space-y-4 py-2">
|
||||
<div class="text-muted-foreground text-sm">
|
||||
第 {{ stepIndex + 1 }} / {{ totalSteps }} 个药品
|
||||
</div>
|
||||
<!-- 当前药品信息:图 / 名 / 规格 -->
|
||||
<div
|
||||
class="border-border bg-muted/30 flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<Image
|
||||
v-if="currentDrugMeta.image"
|
||||
:src="currentDrugMeta.image"
|
||||
:width="64"
|
||||
:height="64"
|
||||
class="shrink-0 rounded object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="bg-accent text-muted-foreground flex h-16 w-16 shrink-0 items-center justify-center rounded text-xs"
|
||||
>
|
||||
暂无图
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="text-foreground truncate text-base font-medium"
|
||||
:title="currentDrugMeta.drug_name"
|
||||
>
|
||||
{{ currentDrugMeta.drug_name }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-sm">
|
||||
规格:{{ currentDrugMeta.specification || '--' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 仓卡片 -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="opt in currentRow.options"
|
||||
:key="opt.warehouse_id"
|
||||
type="button"
|
||||
class="min-w-[140px] rounded-lg border px-3 py-2 text-left transition-colors"
|
||||
:class="
|
||||
currentRow.warehouse_id === opt.warehouse_id
|
||||
? 'border-primary bg-primary/10 ring-primary ring-1'
|
||||
: 'border-border hover:border-primary/50'
|
||||
"
|
||||
@click="selectWarehouse(opt.warehouse_id)"
|
||||
>
|
||||
<div class="text-foreground text-sm font-medium">
|
||||
{{ opt.warehouse_name }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 text-xs">
|
||||
供货价 {{ opt.quote }} · 库存 {{ opt.available_stock }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground py-6">当前药品无需选择配送仓库</div>
|
||||
<!-- 自定义底栏:上一步 / 下一步 / 确认 -->
|
||||
<div class="border-border mt-4 flex justify-end gap-2 border-t pt-3">
|
||||
<Button v-if="!isFirstStep" @click="goPrev">上一步</Button>
|
||||
<Button
|
||||
v-if="!isLastStep"
|
||||
type="primary"
|
||||
:disabled="!canGoNext"
|
||||
@click="goNext"
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
<Button v-else type="primary" :disabled="!canGoNext" @click="confirmAll">
|
||||
确认
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -57,7 +57,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`${isUpdate === true ? '编辑' : '新增'}快递公司`" class="w-[30%]">
|
||||
<Modal :title="`${isUpdate === true ? '编辑' : '新增'}快递公司`" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -19,7 +19,8 @@ export async function getOrderTraceApi(params: {
|
||||
export async function getOrderLedgerDetailApi(params: {
|
||||
order_id: number;
|
||||
order_type: number;
|
||||
scope?: 'all' | 'platform' | 'store';
|
||||
/** 平台端可按受益方类型筛选:全部/门店/平台/供应商/配送仓库 */
|
||||
scope?: 'all' | 'platform' | 'store' | 'supplier' | 'warehouse';
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}ledger-detail`, { params });
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ import ReconciliationDetailTable from './reconciliation-detail-table.vue';
|
||||
|
||||
defineOptions({ name: 'OrderTraceDrawer' });
|
||||
|
||||
type LedgerScope = 'all' | 'platform' | 'store';
|
||||
/** 分账明细 scope:与后端 allowed_scopes 对齐 */
|
||||
type LedgerScope = 'all' | 'platform' | 'store' | 'supplier' | 'warehouse';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
@@ -57,7 +58,8 @@ const ledgerColumns = [
|
||||
{ title: '药品编号', dataIndex: 'drug_number', key: 'drug_number' },
|
||||
{ title: '药品规格', dataIndex: 'specification', key: 'specification' },
|
||||
{ title: '分账对象', dataIndex: 'user_type_txt', key: 'user_type_txt' },
|
||||
{ title: '门店', dataIndex: ['store', 'name'], key: 'store_name' },
|
||||
// 受益方名称:门店/供应商/配送仓库实体名,平台为「平台」
|
||||
{ title: '受益方名称', dataIndex: 'party_name', key: 'party_name' },
|
||||
{ title: '分账金额', dataIndex: 'money', key: 'money' },
|
||||
{ title: '结算状态', dataIndex: 'status_txt', key: 'status_txt' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
@@ -360,6 +362,8 @@ watch(isPlatformAdmin, (val) => {
|
||||
<Tabs.TabPane key="all" tab="全部" />
|
||||
<Tabs.TabPane key="store" tab="门店" />
|
||||
<Tabs.TabPane key="platform" tab="平台" />
|
||||
<Tabs.TabPane key="supplier" tab="供应商" />
|
||||
<Tabs.TabPane key="warehouse" tab="配送仓库" />
|
||||
</Tabs>
|
||||
|
||||
<Tabs v-model:active-key="ledgerSubTab" type="card">
|
||||
@@ -389,7 +393,8 @@ watch(isPlatformAdmin, (val) => {
|
||||
v-else-if="
|
||||
column.key === 'drug_name' ||
|
||||
column.key === 'drug_number' ||
|
||||
column.key === 'specification'
|
||||
column.key === 'specification' ||
|
||||
column.key === 'party_name'
|
||||
"
|
||||
>
|
||||
{{ formatDrugCell(text) }}
|
||||
@@ -429,7 +434,8 @@ watch(isPlatformAdmin, (val) => {
|
||||
v-else-if="
|
||||
column.key === 'drug_name' ||
|
||||
column.key === 'drug_number' ||
|
||||
column.key === 'specification'
|
||||
column.key === 'specification' ||
|
||||
column.key === 'party_name'
|
||||
"
|
||||
>
|
||||
{{ formatDrugCell(text) }}
|
||||
|
||||
@@ -1,81 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
/**
|
||||
* 业务处方页处方溯源弹窗
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { Timeline, TimelineItem } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
FileTextOutlined,
|
||||
MedicineBoxOutlined,
|
||||
ShopOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import PrescriptionSourceContent from '#/components/prescription-source/PrescriptionSourceContent.vue';
|
||||
|
||||
import { getPrescriptionSourceApi } from '../api';
|
||||
|
||||
defineOptions({ name: 'PrescriptionSource' });
|
||||
|
||||
// 处方溯源信息
|
||||
const data = ref();
|
||||
|
||||
// 格式化时间戳
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return '--';
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// 处方类型
|
||||
const prescriptionTypeMap = {
|
||||
1: '西药处方',
|
||||
2: '中成药处方',
|
||||
3: '中药处方',
|
||||
};
|
||||
|
||||
// 处方状态
|
||||
const statusMap = {
|
||||
0: '待审核',
|
||||
1: '已审核',
|
||||
2: '已驳回',
|
||||
3: '已过期',
|
||||
};
|
||||
|
||||
// 计算属性:处方类型文本
|
||||
const prescriptionTypeText = computed(() => {
|
||||
return data.value
|
||||
? prescriptionTypeMap[data.value.prescription_type] || '未知'
|
||||
: '--';
|
||||
});
|
||||
|
||||
// 计算属性:处方状态文本
|
||||
const statusText = computed(() => {
|
||||
return data.value ? statusMap[data.value.status] || '未知' : '--';
|
||||
});
|
||||
|
||||
// 计算属性:状态颜色
|
||||
const statusColor = computed(() => {
|
||||
if (!data.value) return 'gray';
|
||||
|
||||
const statusColors = {
|
||||
0: 'orange',
|
||||
1: 'green',
|
||||
2: 'red',
|
||||
3: 'gray',
|
||||
};
|
||||
|
||||
return statusColors[data.value.status] || 'gray';
|
||||
});
|
||||
|
||||
// 计算属性:过期时间
|
||||
const expireTime = computed(() => {
|
||||
return data.value ? formatTime(data.value.auto_expire_time) : '--';
|
||||
});
|
||||
const data = ref<Record<string, any> | null>(null);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
@@ -87,226 +24,20 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
const { id } = modalApi.getData<Record<string, any>>();
|
||||
if (isOpen && id) {
|
||||
getPrescriptionSourceApi({ id }).then((res) => {
|
||||
const payload = modalApi.getData<Record<string, any>>();
|
||||
if (isOpen && payload?.id) {
|
||||
getPrescriptionSourceApi({ id: payload.id }).then((res) => {
|
||||
data.value = res;
|
||||
});
|
||||
} else {
|
||||
data.value = null; // Reset data when modal is closed or id is missing
|
||||
data.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[80%]" title="处方溯源">
|
||||
<div v-if="data" class="prescription-source-container">
|
||||
<!-- 药店信息 -->
|
||||
<div
|
||||
class="mb-6 transform rounded-lg p-6 shadow-md transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="mb-4 flex items-center">
|
||||
<ShopOutlined class="mr-2 text-xl text-purple-500" />
|
||||
<h2 class="text-xl font-bold">药店信息</h2>
|
||||
</div>
|
||||
<div v-if="data.store" class="grid grid-cols-1 gap-4">
|
||||
<div class="info-item">
|
||||
<span class="label">药店名称:</span>
|
||||
<span class="value">{{ data.store.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="italic text-gray-500">暂无药店信息</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间线 -->
|
||||
<div
|
||||
class="mb-6 transform rounded-lg p-6 shadow-md transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="mb-4 flex items-center">
|
||||
<FileTextOutlined class="mr-2 text-xl text-blue-500" />
|
||||
<h2 class="text-xl font-bold">时间线</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 mt-5">
|
||||
<Timeline>
|
||||
<TimelineItem v-if="data.pharmacist_view_time">
|
||||
{{ data.pharmacist_view_time }}
|
||||
<template v-if="data.pharmacist_info">
|
||||
【{{ data.pharmacist_info.name }}】 审核
|
||||
</template>
|
||||
<template v-else> -- </template>
|
||||
</TimelineItem>
|
||||
<TimelineItem v-else>
|
||||
{{ statusText }}
|
||||
{{ data.cancel_remark }}
|
||||
</TimelineItem>
|
||||
<TimelineItem>
|
||||
{{ data.created_at }}
|
||||
<template v-if="data.doctor_info">
|
||||
【{{ data.doctor_info.name }}】 开方,诊断:{{
|
||||
data.clinical_diagnose
|
||||
}}
|
||||
</template>
|
||||
<template v-else> -- </template>
|
||||
</TimelineItem>
|
||||
<TimelineItem>
|
||||
{{ data.register.created_at }}
|
||||
<template v-if="data.doctor_info">
|
||||
【{{ data.user_patient.name }}】 挂号
|
||||
</template>
|
||||
<template v-else> -- </template>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 处方基本信息 -->
|
||||
<div
|
||||
class="mb-6 transform rounded-lg p-6 shadow-md transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="mb-4 flex items-center">
|
||||
<FileTextOutlined class="mr-2 text-xl text-blue-500" />
|
||||
<h2 class="text-xl font-bold">处方基本信息</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="info-item">
|
||||
<span class="label">处方编号:</span>
|
||||
<span class="value">{{ data.prescription_no }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">处方类型:</span>
|
||||
<span class="value">{{ prescriptionTypeText }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">处方状态:</span>
|
||||
<span :class="`text-${statusColor}-500`" class="value">{{
|
||||
statusText
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">总金额:</span>
|
||||
<span class="value font-bold text-red-500">¥{{ data.total_pay_price }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">过期时间:</span>
|
||||
<span class="value">{{ expireTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 医生信息 -->
|
||||
<div
|
||||
class="mb-6 transform rounded-lg p-6 shadow-md transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="mb-4 flex items-center">
|
||||
<MedicineBoxOutlined class="mr-2 text-xl text-green-500" />
|
||||
<h2 class="text-xl font-bold">医生信息</h2>
|
||||
</div>
|
||||
<div
|
||||
v-if="data.doctor_info"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||
>
|
||||
<div class="info-item">
|
||||
<span class="label">医生姓名:</span>
|
||||
<span class="value">{{ data.doctor_info.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">所属科室:</span>
|
||||
<span class="value">{{
|
||||
data.doctor_info.depart?.name || '--'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="italic text-gray-500">暂无医生信息</div>
|
||||
</div>
|
||||
|
||||
<!-- 患者信息 -->
|
||||
<div
|
||||
class="transform rounded-lg p-6 shadow-md transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="mb-4 flex items-center">
|
||||
<UserOutlined class="mr-2 text-xl text-amber-500" />
|
||||
<h2 class="text-xl font-bold">患者信息</h2>
|
||||
</div>
|
||||
<div
|
||||
v-if="data.user_patient"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<div class="info-item">
|
||||
<span class="label">患者姓名:</span>
|
||||
<span class="value">{{ data.user_patient.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">年龄:</span>
|
||||
<span class="value">{{ data.user_patient.age }}岁</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">性别:</span>
|
||||
<span class="value">{{
|
||||
data.user_patient.sex === 1 ? '男' : '女'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="italic text-gray-500">暂无患者信息</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-else class="flex h-64 items-center justify-center">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-b-2 border-t-2 border-blue-500"
|
||||
></div>
|
||||
</div>
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="处方溯源">
|
||||
<PrescriptionSourceContent :data="data" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.prescription-source-container {
|
||||
@apply max-h-[70vh] overflow-auto p-4;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
@apply flex flex-col rounded-md p-3 transition-all duration-300;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply mb-1 text-sm text-gray-500;
|
||||
}
|
||||
|
||||
.value {
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
/* 添加动感效果 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-source-container > div {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.prescription-source-container > div:nth-child(1) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.prescription-source-container > div:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.prescription-source-container > div:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.prescription-source-container > div:nth-child(4) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
{ field: 'prescription_no', align: 'left', title: '处方订单号' },
|
||||
{ field: 'doctor_info.name', align: 'left', title: '开方医生' },
|
||||
{ field: 'user_patient.name', align: 'left', title: '就诊人名称' },
|
||||
{ field: 'store.name', align: 'left', title: '开方诊所' },
|
||||
{ field: 'store.name', align: 'left', title: '开方诊所', slots: { default: 'store-name' } },
|
||||
{ field: 'status', title: '状态', slots: { default: 'status' } },
|
||||
{
|
||||
field: 'is_online',
|
||||
|
||||
@@ -1,44 +1,75 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
Page,
|
||||
useVbenModal,
|
||||
} from '@vben/common-ui';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Tag } from 'ant-design-vue';
|
||||
import { Button, Tabs, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
|
||||
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
|
||||
import PrescriptionSource from './components/source.vue';
|
||||
|
||||
import { getPrescriptionListApi } from './api';
|
||||
import PrescriptionSource from './components/source.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import { gridOptions as baseGridOptions } from './config/table';
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
/** 审核状态 Tab:0待审核 / 1已通过 / 2已拒绝 */
|
||||
const statusTab = ref('0');
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
|
||||
connectedComponent: StoreCardModal,
|
||||
});
|
||||
|
||||
function openStoreCard(storeId?: number) {
|
||||
if (!storeId) return;
|
||||
StoreCardModalApi.setData({ storeId: Number(storeId) });
|
||||
StoreCardModalApi.open();
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions,
|
||||
gridOptions: {
|
||||
...baseGridOptions,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getPrescriptionListApi({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
status: Number(statusTab.value),
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
watch(statusTab, () => {
|
||||
gridApi.query();
|
||||
});
|
||||
|
||||
function onStatusTabChange(key: string | number) {
|
||||
statusTab.value = String(key);
|
||||
}
|
||||
|
||||
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
|
||||
connectedComponent: PrescriptionDetail,
|
||||
});
|
||||
@@ -46,40 +77,52 @@ const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
|
||||
const [PrescriptionSourceModal, PrescriptionSourceModalApi] = useVbenModal({
|
||||
connectedComponent: PrescriptionSource,
|
||||
});
|
||||
const openPrescriptionDetail = (values) => {
|
||||
// 打开西药处方模态框逻辑
|
||||
PrescriptionDetailModalApi.setData({
|
||||
values,
|
||||
});
|
||||
|
||||
const openPrescriptionDetail = (values: number) => {
|
||||
PrescriptionDetailModalApi.setData({ values });
|
||||
PrescriptionDetailModalApi.open();
|
||||
};
|
||||
const openPrescriptionSourceModal = (id) => {
|
||||
// 打开西药处方模态框逻辑
|
||||
PrescriptionSourceModalApi.setData({
|
||||
id,
|
||||
});
|
||||
|
||||
const openPrescriptionSourceModal = (id: number) => {
|
||||
PrescriptionSourceModalApi.setData({ id });
|
||||
PrescriptionSourceModalApi.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="订单管理">
|
||||
<Page auto-content-height title="处方管理">
|
||||
<PrescriptionDetailModal />
|
||||
<PrescriptionSourceModal />
|
||||
<StoreCardModalComp />
|
||||
<div class="mb-3">
|
||||
<Tabs :active-key="statusTab" @change="onStatusTabChange">
|
||||
<Tabs.TabPane key="0" tab="待审核" />
|
||||
<Tabs.TabPane key="1" tab="已通过" />
|
||||
<Tabs.TabPane key="2" tab="已拒绝" />
|
||||
</Tabs>
|
||||
</div>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[]"
|
||||
:drop-down-actions="[]"
|
||||
>
|
||||
<TableAction :actions="[]" :drop-down-actions="[]">
|
||||
<template #more>
|
||||
<Button style="margin-left: 16px">
|
||||
批量操作
|
||||
<Icon icon="ant-design:down-outlined" />
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #store-name="{ row }">
|
||||
<Button
|
||||
v-if="row.store?.id || row.store_id"
|
||||
type="link"
|
||||
size="small"
|
||||
class="!px-0"
|
||||
@click="openStoreCard(row.store?.id || row.store_id)"
|
||||
>
|
||||
{{ row.store?.name || '—' }}
|
||||
</Button>
|
||||
<span v-else>{{ row.store?.name || '—' }}</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<div>
|
||||
<Tag v-if="row.prescription_type === 1" color="orange">中药</Tag>
|
||||
@@ -104,11 +147,12 @@ const openPrescriptionSourceModal = (id) => {
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<Tag v-if="row.status === 0" color="red">待审核</Tag>
|
||||
<Tag v-else-if="row.status === 1" color="green">通过审核</Tag>
|
||||
<Tag v-else-if="row.status === 2" color="red">拒绝审核:{{ row.reject_reason }}</Tag>
|
||||
<Tag v-else-if="row.status === 1" color="green">已通过</Tag>
|
||||
<Tag v-else-if="row.status === 2" color="red"
|
||||
>已拒绝:{{ row.reject_reason }}</Tag
|
||||
>
|
||||
<Tag v-else-if="row.status === 3" color="purple">无需审核</Tag>
|
||||
<Tag v-else-if="row.status === 4" color="green">无需审核</Tag>
|
||||
<!-- <p>{{ row.created_at }}</p>-->
|
||||
</div>
|
||||
</template>
|
||||
<template #is-online="{ row }">
|
||||
@@ -124,19 +168,16 @@ const openPrescriptionSourceModal = (id) => {
|
||||
label: '查看处方',
|
||||
type: 'link',
|
||||
icon: 'marketeq:eye',
|
||||
// auth: ['超级订单', 'sys:user:save'],
|
||||
onClick: openPrescriptionDetail.bind(null, row.id),
|
||||
},
|
||||
{
|
||||
label: '处方溯源',
|
||||
type: 'link',
|
||||
icon: 'marketeq:eye',
|
||||
// auth: ['超级订单', 'sys:user:save'],
|
||||
onClick: openPrescriptionSourceModal.bind(null, row.id),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
]"
|
||||
:drop-down-actions="[]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
@@ -147,28 +188,4 @@ const openPrescriptionSourceModal = (id) => {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.custom-list-item {
|
||||
background-color: rgba(64, 158, 255, 0.04);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #409eff;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -79,6 +79,39 @@ export async function expressDetailByOrderId(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`express-detail/detail-by-order`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改已发货运单:快递单号 / 查件手机号 / 快递公司
|
||||
*/
|
||||
export async function updateExpressNosApi(data: {
|
||||
express_no_id: number;
|
||||
express_no?: string;
|
||||
mobile?: string;
|
||||
express_company_code?: string;
|
||||
}) {
|
||||
return requestClient.post<any>(`express-detail/update-express-nos`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管:将历史订单级运单同步到分包裹 shipment
|
||||
*/
|
||||
export async function syncLegacyShipmentApi(orderId: number) {
|
||||
return requestClient.post<any>(`${prefix}sync-legacy-shipment`, {
|
||||
order_id: orderId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单页改仓:将 from 仓名下未出库明细切到 to 仓(0=萧康医药本仓库)
|
||||
*/
|
||||
export async function changeDeliveryWarehouseApi(data: {
|
||||
order_id: number;
|
||||
from_warehouse_id?: number;
|
||||
to_warehouse_id?: number;
|
||||
items?: Array<{ order_item_id: number; to_warehouse_id: number }>;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}change-delivery-warehouse`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出订单数据(后端 Excel,旧接口保留)
|
||||
*/
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 订单列表:下单用户 / 医生 / 就诊人信息单元
|
||||
* 下单用户 → 该用户就诊人列表 Modal;就诊人 → 详情 Modal
|
||||
*/
|
||||
import { Avatar, Button } from 'ant-design-vue';
|
||||
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
row: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
openDoctor: [row: Record<string, any>];
|
||||
/** 点下单微信用户 → 打开就诊人列表 */
|
||||
openUserPatients: [
|
||||
payload: { userId: number; nickname: string; avatarurl: string },
|
||||
];
|
||||
/** 点就诊人 → 打开详情 */
|
||||
openPatient: [payload: { upId: number; patientName: string }];
|
||||
}>();
|
||||
|
||||
/** 默认头像路径 */
|
||||
@@ -36,26 +46,74 @@ function getDoctorAvatarSrc(row: Record<string, any>) {
|
||||
* 格式化就诊人年龄副文本
|
||||
*/
|
||||
function formatPatientAge(row: Record<string, any>) {
|
||||
const age = row.patient_age;
|
||||
const age = row.patient_age ?? row.user_patient?.age ?? row.userPatient?.age;
|
||||
if (age == null || age === '') return '';
|
||||
return `${age}岁`;
|
||||
}
|
||||
|
||||
/** 解析就诊人 ID */
|
||||
function resolveUpId(row: Record<string, any>) {
|
||||
return Number(
|
||||
row.up_id ||
|
||||
row.user_patient?.id ||
|
||||
row.userPatient?.id ||
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** 解析微信用户 ID */
|
||||
function resolveUserId(row: Record<string, any>) {
|
||||
return Number(row.user?.id || row.user_id || 0);
|
||||
}
|
||||
|
||||
function patientName(row: Record<string, any>) {
|
||||
return (
|
||||
row.user_patient?.name ||
|
||||
row.userPatient?.name ||
|
||||
row.patient ||
|
||||
'—'
|
||||
);
|
||||
}
|
||||
|
||||
/** 打开该微信用户下的就诊人列表 */
|
||||
function handleOpenUser() {
|
||||
const userId = resolveUserId(props.row);
|
||||
if (!userId) return;
|
||||
emit('openUserPatients', {
|
||||
userId,
|
||||
nickname: String(props.row.user?.nickname || ''),
|
||||
avatarurl: String(props.row.user?.avatarurl || ''),
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开就诊人详情 Modal */
|
||||
function handleOpenPatient() {
|
||||
const upId = resolveUpId(props.row);
|
||||
if (!upId) return;
|
||||
emit('openPatient', {
|
||||
upId,
|
||||
patientName: String(patientName(props.row)),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="order-user-info">
|
||||
<!-- 行1:下单微信用户 -->
|
||||
<div class="order-user-info__row">
|
||||
<Avatar
|
||||
:size="24"
|
||||
:src="getUserAvatarSrc(row)"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<Avatar :size="24" :src="getUserAvatarSrc(row)" class="shrink-0" />
|
||||
<div class="order-user-info__text min-w-0">
|
||||
<span class="text-[11px] leading-tight text-gray-400 dark:text-slate-500">
|
||||
下单用户:
|
||||
</span>
|
||||
<span class="truncate text-xs text-gray-700 dark:text-slate-200">
|
||||
<Button
|
||||
v-if="resolveUserId(row)"
|
||||
class="order-user-info__link-btn !h-auto !px-0 !py-0"
|
||||
type="link"
|
||||
@click.stop="handleOpenUser"
|
||||
>
|
||||
{{ row.user?.nickname || '—' }}
|
||||
</Button>
|
||||
<span v-else class="truncate text-xs text-gray-700 dark:text-slate-200">
|
||||
{{ row.user?.nickname || '—' }}
|
||||
</span>
|
||||
<div class="text-[11px] text-gray-500 dark:text-slate-400">
|
||||
@@ -63,14 +121,9 @@ function formatPatientAge(row: Record<string, any>) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 行2:开方医生(可点击查看档案) -->
|
||||
<div class="order-user-info__row">
|
||||
<Avatar
|
||||
:size="24"
|
||||
:src="getDoctorAvatarSrc(row)"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<div class="order-user-info__text min-w-0" >
|
||||
<Avatar :size="24" :src="getDoctorAvatarSrc(row)" class="shrink-0" />
|
||||
<div class="order-user-info__text min-w-0">
|
||||
<span class="text-[11px] leading-tight text-gray-400 dark:text-slate-500">
|
||||
医生:
|
||||
</span>
|
||||
@@ -82,28 +135,25 @@ function formatPatientAge(row: Record<string, any>) {
|
||||
>
|
||||
{{ row.doctor.name }}
|
||||
</Button>
|
||||
<!-- <template v-if="row.doctor?.name">-->
|
||||
<!-- <Button-->
|
||||
<!-- v-if="row.doctor?.name"-->
|
||||
<!-- class="order-user-info__doctor-btn !h-auto !px-0 !py-0 dark:!text-blue-400"-->
|
||||
<!-- type="link"-->
|
||||
<!-- @click="emit('openDoctor', row)"-->
|
||||
<!-- >-->
|
||||
<!-- {{ row.doctor.name }}-->
|
||||
<!-- </Button>-->
|
||||
<!-- </template>-->
|
||||
<div v-else class="text-xs text-gray-700 dark:text-slate-200">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 行3:就诊人(与上两行保持 Avatar + 文本区结构) -->
|
||||
<div class="order-user-info__row">
|
||||
<Avatar :size="24" :src="defaultAvatar" class="shrink-0" />
|
||||
<div class="order-user-info__text min-w-0">
|
||||
<span class="text-[11px] leading-tight text-gray-400 dark:text-slate-500">
|
||||
就诊人:
|
||||
</span>
|
||||
<span class="truncate text-xs text-gray-700 dark:text-slate-200">
|
||||
{{ row.patient || '—' }}
|
||||
<Button
|
||||
v-if="resolveUpId(row)"
|
||||
class="order-user-info__link-btn !h-auto !px-0 !py-0"
|
||||
type="link"
|
||||
@click.stop="handleOpenPatient"
|
||||
>
|
||||
{{ patientName(row) }}
|
||||
</Button>
|
||||
<span v-else class="truncate text-xs text-gray-700 dark:text-slate-200">
|
||||
{{ patientName(row) }}
|
||||
</span>
|
||||
<div
|
||||
v-if="formatPatientAge(row)"
|
||||
@@ -123,26 +173,23 @@ function formatPatientAge(row: Record<string, any>) {
|
||||
gap: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.order-user-info__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.order-user-info__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.order-user-info__doctor-btn {
|
||||
.order-user-info__doctor-btn,
|
||||
.order-user-info__link-btn {
|
||||
font-size: 12px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
//display: block;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { Button, Card, Descriptions, Image, Space, Tag, Timeline } from 'ant-design-vue';
|
||||
import { Button, Card, Descriptions, Image, Space, Tabs, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
|
||||
|
||||
@@ -11,15 +12,16 @@ import OrderPricePercentAdjustDrawer from '#/views/business/order/components/Ord
|
||||
import { getOrderPriceAdjustConfig, adjustOrderPercent } from '#/api/order/priceAdjust';
|
||||
import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
|
||||
import { formatPriceDiscountLabel, normalizeQuickOptions } from '#/utils/pricePercentAdjust';
|
||||
|
||||
import { expressDetailByOrderId, getOrderInfo } from '../api';
|
||||
import { expressDetailByOrderId, getOrderInfo, syncLegacyShipmentApi } from '../api';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
|
||||
import LogisticsModal from './logistics-modal.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'DetailModal',
|
||||
});
|
||||
|
||||
const userStore = useUserStore();
|
||||
const gridApi = ref();
|
||||
// 订单信息
|
||||
const data = ref();
|
||||
@@ -27,6 +29,20 @@ const data = ref();
|
||||
const expressDetail = ref({});
|
||||
// 订单发货方式
|
||||
const deliveryMethod = ref(-1);
|
||||
const syncingLegacy = ref(false);
|
||||
|
||||
/**
|
||||
* 超管可见:上门快递且订单有运单时,可手动把历史运单同步到 shipment
|
||||
* 与后端一致:仅 role_id=1(SUPPER_ADMIN)
|
||||
*/
|
||||
const canSyncLegacyShipment = computed(() => {
|
||||
const roleId = Number(
|
||||
userStore.userInfo?.role_id ?? userStore.userInfo?.roles?.id,
|
||||
);
|
||||
if (roleId !== 1) return false;
|
||||
if (Number(data.value?.delivery_method) !== 0) return false;
|
||||
return Number(data.value?.express_no_id) > 0;
|
||||
});
|
||||
|
||||
const [TraceDrawer, traceDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: OrderTraceDrawer,
|
||||
@@ -36,6 +52,11 @@ const [PercentAdjustDrawer, percentAdjustDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: OrderPricePercentAdjustDrawer,
|
||||
});
|
||||
|
||||
/** 详情内「查看物流动态」:独立弹窗只拉物流接口 */
|
||||
const [LogisticsModalComp, logisticsModalApi] = useVbenModal({
|
||||
connectedComponent: LogisticsModal,
|
||||
});
|
||||
|
||||
const priceAdjustMeta = ref({
|
||||
scope: 'sale_only' as 'both' | 'sale_only',
|
||||
quickOptions: [] as QuickDiscountOption[],
|
||||
@@ -114,7 +135,10 @@ const [Modal, modalApi] = useVbenModal({
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (isOpen) {
|
||||
const { values, id } = modalApi.getData<Record<string, any>>();
|
||||
const modalData = modalApi.getData<Record<string, any>>() || {};
|
||||
const { values, id, onShipPackage: shipFn } = modalData;
|
||||
// 列表传入:按 warehouse_id 打开发货弹窗(平台包=0)
|
||||
onShipPackage.value = typeof shipFn === 'function' ? shipFn : null;
|
||||
if (id) {
|
||||
getOrderInfo(id).then((res) => {
|
||||
data.value = res;
|
||||
@@ -127,6 +151,8 @@ const [Modal, modalApi] = useVbenModal({
|
||||
deliveryMethod.value = data.value.delivery_method;
|
||||
getExpressDetail();
|
||||
}
|
||||
} else {
|
||||
onShipPackage.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -138,26 +164,67 @@ async function getExpressDetail() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取物流信息标签颜色
|
||||
* @param status
|
||||
* 多包裹:优先物流接口 packages(门店角色已按开关抹仓名),
|
||||
* 避免订单详情未抹名数据盖过物流结果。
|
||||
*/
|
||||
function getColor(status: any) {
|
||||
switch (status) {
|
||||
case '在途': {
|
||||
return '';
|
||||
}
|
||||
case '揽收': {
|
||||
return 'orange';
|
||||
}
|
||||
case '派件': {
|
||||
return 'blue';
|
||||
}
|
||||
case '签收': {
|
||||
return 'green';
|
||||
}
|
||||
default: {
|
||||
return '';
|
||||
}
|
||||
const packageTabs = computed(() => {
|
||||
const fromExpress = Array.isArray((expressDetail.value as any)?.packages)
|
||||
? (expressDetail.value as any).packages
|
||||
: [];
|
||||
if (fromExpress.length) return fromExpress;
|
||||
const fromOrder = Array.isArray(data.value?.packages) ? data.value.packages : [];
|
||||
return fromOrder;
|
||||
});
|
||||
const activePkg = ref('0');
|
||||
/** 详情内「发本包」回调(由列表传入时仅平台包裹可发) */
|
||||
const onShipPackage = ref<null | ((warehouseId: number) => void)>(null);
|
||||
|
||||
/**
|
||||
* Tab 文案:有仓名才拼括号;空串不兜底「平台/仓」
|
||||
*/
|
||||
function packageTabLabel(pkg: Record<string, any>, idx: number): string {
|
||||
const no = pkg.package_no || idx + 1;
|
||||
const name = String(pkg.warehouse_name || '').trim();
|
||||
return name ? `包裹${no}(${name})` : `包裹${no}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收件人姓名脱敏:保留首字,其余用 *(与物流动态弹窗一致)
|
||||
*/
|
||||
function maskExpressName(name: unknown): string {
|
||||
const str = String(name || '').trim();
|
||||
if (!str) {
|
||||
return '-';
|
||||
}
|
||||
if (str.length === 1) {
|
||||
return `${str}*`;
|
||||
}
|
||||
return str.slice(0, 1) + '*'.repeat(str.length - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开独立物流动态弹窗(完整轨迹)
|
||||
*/
|
||||
function openLogisticsModal() {
|
||||
if (!data.value?.id) return;
|
||||
logisticsModalApi.setData({ order_id: data.value.id });
|
||||
logisticsModalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管手动同步历史运单到分包裹表,成功后刷新详情与物流
|
||||
*/
|
||||
async function syncLegacyShipment() {
|
||||
if (!data.value?.id || syncingLegacy.value) return;
|
||||
syncingLegacy.value = true;
|
||||
try {
|
||||
const res = await syncLegacyShipmentApi(Number(data.value.id));
|
||||
message.success(res?.message || '同步成功');
|
||||
await reloadOrder();
|
||||
deliveryMethod.value = data.value?.delivery_method;
|
||||
await getExpressDetail();
|
||||
} finally {
|
||||
syncingLegacy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +263,7 @@ function prescriptionStatusColor() {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[80%]" title="订单详情">
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="订单详情">
|
||||
<div v-if="data" class="flex flex-col gap-4">
|
||||
<Space>
|
||||
<Button type="primary" size="small" @click="openOrderTrace">
|
||||
@@ -249,7 +316,7 @@ function prescriptionStatusColor() {
|
||||
{{ data.trans_expenses }} 元
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="是否免邮">
|
||||
{{ data.free_ship === 1 ? '是' : '否' }}
|
||||
{{ data.free_ship === 0 ? '是' : '否' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag v-if="data.status === 0" color="red">待支付</Tag>
|
||||
@@ -350,14 +417,14 @@ function prescriptionStatusColor() {
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-400">
|
||||
供货价:{{
|
||||
item.buy_price != null && item.buy_price !== ''
|
||||
? `${item.buy_price} 元/g`
|
||||
? `${item.buy_price} 元/${item.drug?.unit?.name || item.unit?.name || 'g'}`
|
||||
: '—'
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-400">
|
||||
售价:{{
|
||||
item.buy_price != null && item.buy_price !== ''
|
||||
? `${item.price} 元/g`
|
||||
? `${item.price} 元/${item.drug?.unit?.name || item.unit?.name || 'g'}`
|
||||
: '—'
|
||||
}}
|
||||
</div>
|
||||
@@ -383,7 +450,7 @@ function prescriptionStatusColor() {
|
||||
</div>
|
||||
<div>
|
||||
<h4>{{ item.drug_name }}</h4>
|
||||
<p>规格: {{ item.drug.specification || 'g' }}</p>
|
||||
<p>规格: {{ item.drug.specification || item.drug?.unit?.name || 'g' }}</p>
|
||||
<p>数量: {{ item.number }}</p>
|
||||
<p>单价: {{ item.price }} 元</p>
|
||||
<p>
|
||||
@@ -428,45 +495,114 @@ function prescriptionStatusColor() {
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
<div v-if="deliveryMethod === 0">
|
||||
<div class="mt-4">
|
||||
<h3>物流信息</h3>
|
||||
<Descriptions
|
||||
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
|
||||
bordered
|
||||
class="mt-4"
|
||||
>
|
||||
<Descriptions.Item label="物流公司">
|
||||
{{ expressDetail.express_company_name }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">
|
||||
{{ expressDetail.express_no }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最新状态">
|
||||
{{ expressDetail.state_txt }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<h3>物流追踪</h3>
|
||||
<Timeline class="mt-4">
|
||||
<Timeline.Item
|
||||
v-for="(detail, index) in expressDetail.detail"
|
||||
:key="index"
|
||||
<div v-if="deliveryMethod === 0" class="mt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="mb-0">物流信息</h3>
|
||||
<Space>
|
||||
<Button
|
||||
v-if="canSyncLegacyShipment"
|
||||
type="link"
|
||||
size="small"
|
||||
:loading="syncingLegacy"
|
||||
@click="syncLegacyShipment"
|
||||
>
|
||||
<Tag :color="getColor(detail.status)">
|
||||
{{ detail.status }}
|
||||
</Tag>
|
||||
<p>{{ detail.detail_at }}</p>
|
||||
<p>{{ detail.detail }}</p>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
同步物流包裹
|
||||
</Button>
|
||||
<Button type="link" size="small" @click="openLogisticsModal">
|
||||
查看物流动态
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<!-- 详情内仅保留包裹发货状态;完整轨迹进独立「物流动态」弹窗 -->
|
||||
<Tabs
|
||||
v-if="packageTabs.length"
|
||||
v-model:active-key="activePkg"
|
||||
type="card"
|
||||
class="mt-4"
|
||||
>
|
||||
<Tabs.TabPane
|
||||
v-for="(pkg, idx) in packageTabs"
|
||||
:key="String(idx)"
|
||||
:tab="packageTabLabel(pkg, idx)"
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex justify-end"
|
||||
v-if="Number(pkg.is_send) === 0 && Number(pkg.warehouse_id) === 0 && onShipPackage"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="onShipPackage(Number(pkg.warehouse_id) || 0)"
|
||||
>
|
||||
发本包裹
|
||||
</Button>
|
||||
</div>
|
||||
<template v-if="Number(pkg.is_send) === 1 && pkg.express">
|
||||
<Descriptions
|
||||
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
|
||||
bordered
|
||||
>
|
||||
<Descriptions.Item label="收件人">
|
||||
{{
|
||||
maskExpressName(
|
||||
(expressDetail as any).express_name || data?.express_name,
|
||||
)
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">
|
||||
<SensitiveText :record="pkg.express" field="mobile" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="物流公司">
|
||||
{{ pkg.express.express_company_name || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">
|
||||
{{ pkg.express.express_no || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最新状态">
|
||||
{{ pkg.express.state_txt || '-' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</template>
|
||||
<div v-else class="text-gray-400 py-2">
|
||||
本包裹尚未发货
|
||||
<span v-if="Array.isArray(pkg.items) && pkg.items.length">
|
||||
({{ pkg.items.map((p: any) => p.drug_name || p.name).filter(Boolean).join('、') }})
|
||||
</span>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
<Descriptions
|
||||
v-else
|
||||
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
|
||||
bordered
|
||||
class="mt-4"
|
||||
>
|
||||
<Descriptions.Item label="收件人">
|
||||
{{
|
||||
maskExpressName(
|
||||
(expressDetail as any).express_name || data?.express_name,
|
||||
)
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">
|
||||
<SensitiveText :record="expressDetail" field="mobile" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="物流公司">
|
||||
{{ expressDetail.express_company_name || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">
|
||||
{{ expressDetail.express_no || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最新状态">
|
||||
{{ expressDetail.state_txt || '-' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<TraceDrawer />
|
||||
<PercentAdjustDrawer />
|
||||
<LogisticsModalComp />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -494,6 +630,14 @@ function prescriptionStatusColor() {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.space-x-4 > * + * {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 独立「物流动态」弹窗:只请求 express-detail,不展示整单其它信息。
|
||||
* 仓名为空时 Tab 仅显示「包裹N」,与后端诊所开关抹名约定一致。
|
||||
* 已发货包裹展示脱敏收件人/查件手机号,并支持修改手机号与快递单号。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Form,
|
||||
FormItem,
|
||||
Input,
|
||||
Modal as AModal,
|
||||
Spin,
|
||||
Tabs,
|
||||
Tag,
|
||||
Timeline,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import ExpressCompanySelect from '#/components/form/components/express-company-select.vue';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
|
||||
import { expressDetailByOrderId, updateExpressNosApi } from '../api';
|
||||
|
||||
defineOptions({
|
||||
name: 'ProductOrderLogisticsModal',
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const expressDetail = ref<Record<string, any>>({});
|
||||
const activePkg = ref('0');
|
||||
const orderId = ref<number | null>(null);
|
||||
|
||||
/** 改运单弹窗 */
|
||||
const editVisible = ref(false);
|
||||
const editSubmitting = ref(false);
|
||||
const editForm = reactive({
|
||||
express_no_id: 0,
|
||||
express_no: '',
|
||||
mobile: '',
|
||||
express_company_code: '' as string | undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* 多包裹列表:以物流接口 packages 为准(门店角色已按开关抹仓名)
|
||||
*/
|
||||
const packageTabs = computed(() => {
|
||||
const pkgs = expressDetail.value?.packages;
|
||||
return Array.isArray(pkgs) ? pkgs : [];
|
||||
});
|
||||
|
||||
/**
|
||||
* Tab 文案:有仓名才拼括号,空串不兜底「平台/仓」
|
||||
*/
|
||||
function packageTabLabel(pkg: Record<string, any>, idx: number): string {
|
||||
const no = pkg.package_no || idx + 1;
|
||||
const name = String(pkg.warehouse_name || '').trim();
|
||||
return name ? `包裹${no}(${name})` : `包裹${no}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收件人姓名脱敏:保留首字,其余用 *
|
||||
*/
|
||||
function maskExpressName(name: unknown): string {
|
||||
const str = String(name || '').trim();
|
||||
if (!str) {
|
||||
return '-';
|
||||
}
|
||||
if (str.length === 1) {
|
||||
return `${str}*`;
|
||||
}
|
||||
return str.slice(0, 1) + '*'.repeat(str.length - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析运单 ID:优先 express_no_id,其次 express.id
|
||||
*/
|
||||
function resolveExpressNoId(
|
||||
express: Record<string, any> | null | undefined,
|
||||
fallbackId?: number,
|
||||
): number {
|
||||
const fromExpress = Number(express?.id || 0);
|
||||
const fromFallback = Number(fallbackId || 0);
|
||||
return fromExpress > 0 ? fromExpress : fromFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 物流状态 Tag 颜色
|
||||
*/
|
||||
function getColor(status: any) {
|
||||
switch (status) {
|
||||
case '在途': {
|
||||
return '';
|
||||
}
|
||||
case '揽收': {
|
||||
return 'orange';
|
||||
}
|
||||
case '派件': {
|
||||
return 'blue';
|
||||
}
|
||||
case '签收': {
|
||||
return 'green';
|
||||
}
|
||||
default: {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExpress(id: number, keepActive = false) {
|
||||
loading.value = true;
|
||||
try {
|
||||
expressDetail.value = (await expressDetailByOrderId({ order_id: id })) || {};
|
||||
if (!keepActive) {
|
||||
activePkg.value = '0';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开改运单表单(包裹或旧单运单共用)
|
||||
*/
|
||||
function openEditExpress(payload: {
|
||||
express_no_id: number;
|
||||
express_no?: string;
|
||||
mobile?: string;
|
||||
express_company_code?: string;
|
||||
}) {
|
||||
const id = Number(payload.express_no_id || 0);
|
||||
if (id <= 0) {
|
||||
message.warning('缺少运单信息,无法修改');
|
||||
return;
|
||||
}
|
||||
editForm.express_no_id = id;
|
||||
editForm.express_no = String(payload.express_no || '');
|
||||
editForm.mobile = String(payload.mobile || '');
|
||||
editForm.express_company_code = payload.express_company_code || undefined;
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交修改运单号 / 查件手机号 / 快递公司
|
||||
*/
|
||||
async function submitEditExpress() {
|
||||
if (!editForm.express_no?.trim()) {
|
||||
message.warning('请输入快递单号');
|
||||
return;
|
||||
}
|
||||
if (!editForm.express_company_code) {
|
||||
message.warning('请选择快递公司');
|
||||
return;
|
||||
}
|
||||
if (!editForm.mobile?.trim()) {
|
||||
message.warning('请输入手机号');
|
||||
return;
|
||||
}
|
||||
editSubmitting.value = true;
|
||||
try {
|
||||
await updateExpressNosApi({
|
||||
express_no_id: editForm.express_no_id,
|
||||
express_no: editForm.express_no.trim(),
|
||||
mobile: editForm.mobile.trim(),
|
||||
express_company_code: editForm.express_company_code,
|
||||
});
|
||||
message.success('修改成功');
|
||||
editVisible.value = false;
|
||||
if (orderId.value) {
|
||||
await loadExpress(orderId.value, true);
|
||||
}
|
||||
} finally {
|
||||
editSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
showConfirmButton: false,
|
||||
cancelText: '关闭',
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const payload = modalApi.getData<Record<string, any>>() || {};
|
||||
const id = Number(payload.order_id || payload.id || 0);
|
||||
orderId.value = id || null;
|
||||
expressDetail.value = {};
|
||||
editVisible.value = false;
|
||||
if (id) {
|
||||
loadExpress(id);
|
||||
}
|
||||
} else {
|
||||
orderId.value = null;
|
||||
expressDetail.value = {};
|
||||
activePkg.value = '0';
|
||||
editVisible.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[640px]" title="物流动态">
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="!orderId" class="text-gray-400 py-6 text-center">
|
||||
缺少订单信息
|
||||
</div>
|
||||
<template v-else>
|
||||
<Tabs
|
||||
v-if="packageTabs.length"
|
||||
v-model:active-key="activePkg"
|
||||
type="card"
|
||||
>
|
||||
<Tabs.TabPane
|
||||
v-for="(pkg, idx) in packageTabs"
|
||||
:key="String(idx)"
|
||||
:tab="packageTabLabel(pkg, idx)"
|
||||
>
|
||||
<template v-if="Number(pkg.is_send) === 1 && pkg.express">
|
||||
<Descriptions
|
||||
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
|
||||
bordered
|
||||
>
|
||||
<Descriptions.Item label="收件人">
|
||||
{{ maskExpressName(expressDetail.express_name) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<SensitiveText :record="pkg.express" field="mobile" />
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
class="!px-0"
|
||||
@click="
|
||||
openEditExpress({
|
||||
express_no_id: resolveExpressNoId(
|
||||
pkg.express,
|
||||
pkg.express_no_id,
|
||||
),
|
||||
express_no: pkg.express.express_no,
|
||||
mobile: pkg.express.mobile,
|
||||
express_company_code: pkg.express.express_company_code,
|
||||
})
|
||||
"
|
||||
>
|
||||
修改
|
||||
</Button>
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="物流公司">
|
||||
{{ pkg.express.express_company_name || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span>{{ pkg.express.express_no || '-' }}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
class="!px-0"
|
||||
@click="
|
||||
openEditExpress({
|
||||
express_no_id: resolveExpressNoId(
|
||||
pkg.express,
|
||||
pkg.express_no_id,
|
||||
),
|
||||
express_no: pkg.express.express_no,
|
||||
mobile: pkg.express.mobile,
|
||||
express_company_code: pkg.express.express_company_code,
|
||||
})
|
||||
"
|
||||
>
|
||||
修改
|
||||
</Button>
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最新状态">
|
||||
{{ pkg.express.state_txt || '-' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div class="mt-4">
|
||||
<h4 class="mb-2">物流追踪</h4>
|
||||
<Timeline
|
||||
v-if="Array.isArray(pkg.express.detail) && pkg.express.detail.length"
|
||||
>
|
||||
<Timeline.Item
|
||||
v-for="(detail, dIdx) in pkg.express.detail"
|
||||
:key="dIdx"
|
||||
>
|
||||
<Tag :color="getColor(detail.status)">{{ detail.status }}</Tag>
|
||||
<p>{{ detail.detail_at }}</p>
|
||||
<p>{{ detail.detail }}</p>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
<div v-else class="text-gray-400">暂无物流轨迹</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-gray-400 py-2">本包裹尚未发货</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
<template v-else>
|
||||
<Descriptions
|
||||
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
|
||||
bordered
|
||||
>
|
||||
<Descriptions.Item label="收件人">
|
||||
{{ maskExpressName(expressDetail.express_name) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<SensitiveText :record="expressDetail" field="mobile" />
|
||||
<Button
|
||||
v-if="resolveExpressNoId(expressDetail, expressDetail.express_no_id)"
|
||||
type="link"
|
||||
size="small"
|
||||
class="!px-0"
|
||||
@click="
|
||||
openEditExpress({
|
||||
express_no_id: resolveExpressNoId(
|
||||
expressDetail,
|
||||
expressDetail.express_no_id,
|
||||
),
|
||||
express_no: expressDetail.express_no,
|
||||
mobile: expressDetail.mobile,
|
||||
express_company_code: expressDetail.express_company_code,
|
||||
})
|
||||
"
|
||||
>
|
||||
修改
|
||||
</Button>
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="物流公司">
|
||||
{{ expressDetail.express_company_name || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span>{{ expressDetail.express_no || '-' }}</span>
|
||||
<Button
|
||||
v-if="resolveExpressNoId(expressDetail, expressDetail.express_no_id)"
|
||||
type="link"
|
||||
size="small"
|
||||
class="!px-0"
|
||||
@click="
|
||||
openEditExpress({
|
||||
express_no_id: resolveExpressNoId(
|
||||
expressDetail,
|
||||
expressDetail.express_no_id,
|
||||
),
|
||||
express_no: expressDetail.express_no,
|
||||
mobile: expressDetail.mobile,
|
||||
express_company_code: expressDetail.express_company_code,
|
||||
})
|
||||
"
|
||||
>
|
||||
修改
|
||||
</Button>
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最新状态">
|
||||
{{ expressDetail.state_txt || '-' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div class="mt-4">
|
||||
<h4 class="mb-2">物流追踪</h4>
|
||||
<Timeline
|
||||
v-if="Array.isArray(expressDetail.detail) && expressDetail.detail.length"
|
||||
>
|
||||
<Timeline.Item
|
||||
v-for="(detail, index) in expressDetail.detail"
|
||||
:key="index"
|
||||
>
|
||||
<Tag :color="getColor(detail.status)">{{ detail.status }}</Tag>
|
||||
<p>{{ detail.detail_at }}</p>
|
||||
<p>{{ detail.detail }}</p>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
<div v-else class="text-gray-400">暂无物流轨迹</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</Spin>
|
||||
</Modal>
|
||||
|
||||
<!-- 修改运单号 / 查件手机号:与发货表单字段对齐,改单号时可重匹配公司 -->
|
||||
<AModal
|
||||
v-model:open="editVisible"
|
||||
title="修改运单信息"
|
||||
:confirm-loading="editSubmitting"
|
||||
destroy-on-close
|
||||
@ok="submitEditExpress"
|
||||
>
|
||||
<Form layout="vertical" class="mt-2">
|
||||
<FormItem label="快递单号" required>
|
||||
<Input
|
||||
v-model:value="editForm.express_no"
|
||||
placeholder="请输入快递单号"
|
||||
allow-clear
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="快递公司" required>
|
||||
<ExpressCompanySelect
|
||||
v-model:value="editForm.express_company_code"
|
||||
placeholder="请选择快递公司"
|
||||
:tracking-no="editForm.express_no"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="手机号" required>
|
||||
<Input
|
||||
v-model:value="editForm.mobile"
|
||||
placeholder="请输入查件手机号"
|
||||
allow-clear
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</AModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -27,9 +27,16 @@ const [Modal, modalApi] = useVbenModal({
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (e.valid) {
|
||||
const values = await formApi.getValues();
|
||||
// 仅提交合法非负整数 warehouse_id;脏值不传,由后端按角色默认平台包 0
|
||||
const wid = Number(values.warehouse_id);
|
||||
const payload: Record<string, any> = { ...values };
|
||||
if (Number.isFinite(wid) && wid >= 0 && String(values.warehouse_id) !== '[object Object]') {
|
||||
payload.warehouse_id = wid;
|
||||
} else {
|
||||
delete payload.warehouse_id;
|
||||
}
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
const submitApi = sendOrder;
|
||||
submitApi(values)
|
||||
sendOrder(payload)
|
||||
.then(() => {
|
||||
message.success('发货成功');
|
||||
gridApi.value?.reload();
|
||||
@@ -45,7 +52,9 @@ const [Modal, modalApi] = useVbenModal({
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (isOpen) {
|
||||
const { values, update } = modalApi.getData<Record<string, any>>();
|
||||
const { values, update } = modalApi.getData<Record<string, any>>() || {};
|
||||
// 先重置,避免上次发货残留的 warehouse_id
|
||||
formApi.resetForm();
|
||||
if (values) {
|
||||
orderNo.value = values.order_no;
|
||||
isUpdate.value = update;
|
||||
@@ -56,7 +65,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`订单:【 ${orderNo} 】发货`" class="w-[30%]">
|
||||
<Modal :title="`订单:【 ${orderNo} 】发货`" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -54,7 +54,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<template>
|
||||
<Modal
|
||||
title="退款"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
|
||||
@@ -22,6 +22,17 @@ export const modalFormProps: VbenFormProps = {
|
||||
triggerFields: ['oreder_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
// 分包裹发货:0=平台包;>0=配送仓(默认 0)
|
||||
component: 'VbenInput',
|
||||
fieldName: 'warehouse_id',
|
||||
label: '发货方',
|
||||
defaultValue: 0,
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['warehouse_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
@@ -40,6 +51,16 @@ export const modalFormProps: VbenFormProps = {
|
||||
formItemClass: 'col-span-6',
|
||||
label: '快递公司',
|
||||
rules: 'required',
|
||||
// 单号变化时传入 trackingNo,组件按前缀自动匹配快递公司
|
||||
dependencies: {
|
||||
triggerFields: ['express_no'],
|
||||
componentProps(values) {
|
||||
return {
|
||||
placeholder: '请选择快递公司',
|
||||
trackingNo: values.express_no,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import {getOrderStatusOption} from "#/views/business/order/product-order/api";
|
||||
import {getStoreOption} from "#/views/system/store/api";
|
||||
import { getOrderStatusOption } from '#/views/business/order/product-order/api';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
// 默认展开
|
||||
@@ -18,22 +17,13 @@ export const formOptions: VbenFormProps = {
|
||||
label: '订单号',
|
||||
},
|
||||
{
|
||||
component: 'ApiSelect',
|
||||
// 门店多选:名称/拼音首拼气泡选择,诊所+药店
|
||||
component: 'StoreMultiSearch',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
filterOption: true,
|
||||
// showSearch: true,
|
||||
// 菜单接口转options格式
|
||||
afterFetch: (data: { id: number; name: string }[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: `${item.name}【${item.id}】`,
|
||||
value: item.id,
|
||||
}));
|
||||
},
|
||||
api: getStoreOption,
|
||||
placeholder: '请选择',
|
||||
placeholder: '输入名称或拼音首拼',
|
||||
},
|
||||
fieldName: 'store_id',
|
||||
defaultValue: [],
|
||||
fieldName: 'store_ids',
|
||||
label: '门店',
|
||||
},
|
||||
{
|
||||
@@ -41,8 +31,6 @@ export const formOptions: VbenFormProps = {
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
filterOption: true,
|
||||
// showSearch: true,
|
||||
// 菜单接口转options格式
|
||||
afterFetch: (data: { id: number; name: string }[]) => {
|
||||
return data.map((item: any) => ({
|
||||
label: item.name,
|
||||
@@ -117,21 +105,16 @@ export const formOptions: VbenFormProps = {
|
||||
{
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD', // 确保格式与你需要的一致
|
||||
format: 'YYYY-MM-DD',
|
||||
},
|
||||
defaultValue: [dayjs().startOf('month'), dayjs()],
|
||||
fieldName: 'search_time',
|
||||
label: '时间范围',
|
||||
},
|
||||
],
|
||||
// 控制表单是否显示折叠按钮
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: '查询',
|
||||
},
|
||||
// 是否在字段值改变时提交表单
|
||||
// submitOnChange: true,
|
||||
// submitOnChange: true,
|
||||
// 按下回车时是否提交表单
|
||||
submitOnEnter: false,
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
},
|
||||
columns: [
|
||||
{ type: 'expand', width: 80, slots: { content: 'expand-content' } },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 50 },
|
||||
{
|
||||
field: 'order_no',
|
||||
align: 'left',
|
||||
@@ -37,6 +37,12 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
width: 200,
|
||||
slots: { default: 'express-info' },
|
||||
},
|
||||
{
|
||||
field: 'delivery_warehouses',
|
||||
title: '配送仓库',
|
||||
width: 160,
|
||||
slots: { default: 'delivery-warehouses' },
|
||||
},
|
||||
{
|
||||
field: 'delivery_method',
|
||||
title: '订单类型&邮寄方式&订单状态',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
@@ -15,7 +15,18 @@ import {
|
||||
import { useUserStore } from '@vben/stores';
|
||||
import { SvgCakeIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Image, message, Modal as AntdModal, Popconfirm, Popover, Space, Switch, Table, Tag } from 'ant-design-vue';
|
||||
import {
|
||||
Button,
|
||||
Image,
|
||||
message,
|
||||
Modal as AntdModal,
|
||||
Popconfirm,
|
||||
Popover,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -24,12 +35,14 @@ import { TableAction } from '#/components/table-action';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import {
|
||||
cancelOrderApi,
|
||||
changeDeliveryWarehouseApi,
|
||||
getOrderInfo,
|
||||
getOrderList,
|
||||
getVerifyRecentOrderAmounts,
|
||||
saleAmountApi,
|
||||
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 ChinaErpSyncLogDrawer from '#/views/business/order/components/china-erp-sync-log-drawer.vue';
|
||||
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
|
||||
@@ -39,9 +52,15 @@ import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
|
||||
import { normalizeQuickOptions } from '#/utils/pricePercentAdjust';
|
||||
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
|
||||
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
|
||||
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
|
||||
import {
|
||||
WxUserPatientDetailModal,
|
||||
WxUserPatientsModal,
|
||||
} from '#/components/wx-user-patient';
|
||||
|
||||
import DetailModal from './components/detail.vue';
|
||||
import OrderUserInfoCell from './components/cells/OrderUserInfoCell.vue';
|
||||
import LogisticsModal from './components/logistics-modal.vue';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import ProductOrderExportModal from './components/ProductOrderExportModal.vue';
|
||||
import Refund from './components/refund.vue';
|
||||
@@ -120,8 +139,8 @@ function buildFormOptionsFromRoute(): VbenFormProps {
|
||||
const rawTimeScope = route.query.time_scope;
|
||||
const timeScope = Array.isArray(rawTimeScope) ? rawTimeScope[0] : rawTimeScope;
|
||||
const schema = (formOptions.schema ?? []).map((item) => {
|
||||
if (item.fieldName === 'store_id') {
|
||||
return { ...item, defaultValue: Number(storeId) };
|
||||
if (item.fieldName === 'store_ids') {
|
||||
return { ...item, defaultValue: [Number(storeId)] };
|
||||
}
|
||||
if (item.fieldName === 'search_time') {
|
||||
if (timeScope === 'month') {
|
||||
@@ -167,6 +186,10 @@ const [FormModal, formModalApi] = useVbenModal({
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
connectedComponent: DetailModal,
|
||||
});
|
||||
/** 独立物流动态弹窗:只拉 express-detail,与订单详情解耦 */
|
||||
const [LogisticsModalComp, logisticsModalApi] = useVbenModal({
|
||||
connectedComponent: LogisticsModal,
|
||||
});
|
||||
const [RefundModal, RefundModalApi] = useVbenModal({
|
||||
connectedComponent: Refund,
|
||||
});
|
||||
@@ -175,22 +198,51 @@ const [ExportModal, exportModalApi] = useVbenModal({
|
||||
connectedComponent: ProductOrderExportModal,
|
||||
});
|
||||
|
||||
const infoModal = (data = {}) => {
|
||||
const infoModal = (data: Record<string, any> = {}) => {
|
||||
modalApi.setData({
|
||||
// 表单值
|
||||
// 带 id 走详情接口拿到 packages;values 作首屏兜底
|
||||
id: data?.id,
|
||||
values: data,
|
||||
gridApi,
|
||||
// 详情内「发本包裹」:关详情后打开发货弹窗(仅平台包会传 0)
|
||||
onShipPackage: (warehouseId: number) => {
|
||||
modalApi.close();
|
||||
wareSend(data, warehouseId);
|
||||
},
|
||||
});
|
||||
modalApi.open();
|
||||
};
|
||||
|
||||
const wareSend = (data = {}) => {
|
||||
/**
|
||||
* 打开物流动态:仅传 order_id,弹窗内单独请求物流接口
|
||||
*/
|
||||
const openLogisticsModal = (data: Record<string, any> = {}) => {
|
||||
logisticsModalApi.setData({
|
||||
order_id: data?.id,
|
||||
});
|
||||
logisticsModalApi.open();
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开发货弹窗。
|
||||
* 列表不传 warehouse_id(后端平台默认 0);详情按包发时仅传合法非负整数。
|
||||
* 注意:勿用 bind(null,row),table-action 的 click 会把事件当成第 2 参污染 warehouse_id。
|
||||
*/
|
||||
const wareSend = (data: Record<string, any> = {}, warehouseId?: number) => {
|
||||
const values: Record<string, any> = {
|
||||
order_id: data?.id,
|
||||
order_no: data?.order_no,
|
||||
};
|
||||
if (
|
||||
warehouseId !== undefined &&
|
||||
warehouseId !== null &&
|
||||
Number.isFinite(Number(warehouseId)) &&
|
||||
Number(warehouseId) >= 0
|
||||
) {
|
||||
values.warehouse_id = Number(warehouseId);
|
||||
}
|
||||
formModalApi.setData({
|
||||
// 表单值
|
||||
values: {
|
||||
order_id: data?.id,
|
||||
order_no: data?.order_no,
|
||||
},
|
||||
values,
|
||||
gridApi,
|
||||
});
|
||||
formModalApi.open();
|
||||
@@ -205,12 +257,15 @@ const collapseAll = () => {
|
||||
const overviewItems = ref<AnalysisOverviewItem[]>([]);
|
||||
const income = ref(0);
|
||||
const total = ref(0);
|
||||
/** 按当前列表筛选条件刷新顶部销售金额/收益(门店参数后端字段名为 id) */
|
||||
/** 按当前列表筛选条件刷新顶部销售金额/收益(支持门店多选 store_ids) */
|
||||
const saleAmount = (formValues?: Record<string, any>) => {
|
||||
const values = formValues ?? gridApi.formApi.latestSubmissionValues ?? {};
|
||||
saleAmountApi({
|
||||
search_time: values.search_time,
|
||||
id: values.store_id,
|
||||
store_ids: values.store_ids,
|
||||
id: Array.isArray(values.store_ids) && values.store_ids.length === 1
|
||||
? values.store_ids[0]
|
||||
: undefined,
|
||||
}).then((res) => {
|
||||
income.value = res.income;
|
||||
total.value = res.total;
|
||||
@@ -241,6 +296,24 @@ const [DoctorCardModals, DoctorCardModalApi] = useVbenModal({
|
||||
connectedComponent: DoctorCardModal,
|
||||
});
|
||||
|
||||
const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
|
||||
connectedComponent: StoreCardModal,
|
||||
});
|
||||
|
||||
function openStoreCard(storeId?: number) {
|
||||
if (!storeId) return;
|
||||
StoreCardModalApi.setData({ storeId: Number(storeId) });
|
||||
StoreCardModalApi.open();
|
||||
}
|
||||
|
||||
const [PatientsModal, PatientsModalApi] = useVbenModal({
|
||||
connectedComponent: WxUserPatientsModal,
|
||||
});
|
||||
|
||||
const [PatientDetailModal, PatientDetailModalApi] = useVbenModal({
|
||||
connectedComponent: WxUserPatientDetailModal,
|
||||
});
|
||||
|
||||
/** 从订单列表以只读模式打开医生档案 */
|
||||
function showOrderDoctorCard(row: Record<string, any>) {
|
||||
const doctor = row.doctor;
|
||||
@@ -256,6 +329,37 @@ function showOrderDoctorCard(row: Record<string, any>) {
|
||||
DoctorCardModalApi.open();
|
||||
}
|
||||
|
||||
/** 点下单用户 → 该用户下就诊人列表 Modal */
|
||||
function openOrderUserPatients(payload: {
|
||||
userId: number;
|
||||
nickname: string;
|
||||
avatarurl: string;
|
||||
}) {
|
||||
if (!payload?.userId) {
|
||||
message.warning('缺少用户信息');
|
||||
return;
|
||||
}
|
||||
PatientsModalApi.setData({
|
||||
userId: payload.userId,
|
||||
nickname: payload.nickname,
|
||||
avatarurl: payload.avatarurl,
|
||||
});
|
||||
PatientsModalApi.open();
|
||||
}
|
||||
|
||||
/** 点就诊人 → 详情 Modal */
|
||||
function openOrderPatient(payload: { upId: number; patientName: string }) {
|
||||
if (!payload?.upId) {
|
||||
message.warning('缺少就诊人信息');
|
||||
return;
|
||||
}
|
||||
PatientDetailModalApi.setData({
|
||||
upId: payload.upId,
|
||||
patientName: payload.patientName,
|
||||
});
|
||||
PatientDetailModalApi.open();
|
||||
}
|
||||
|
||||
const [TraceDrawer, traceDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: OrderTraceDrawer,
|
||||
});
|
||||
@@ -450,6 +554,173 @@ async function handleCancelOrder(row: Record<string, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 订单页改仓弹窗状态(按药分别选仓) */
|
||||
const changeWhOpen = ref(false);
|
||||
const changeWhSubmitting = ref(false);
|
||||
const changeWhLoading = ref(false);
|
||||
const changeWhOrderId = ref(0);
|
||||
const changeWhFromId = ref(0);
|
||||
const changeWhFromName = ref('');
|
||||
/** 弹窗内药品行:每药独立选 to_warehouse_id */
|
||||
const changeWhDrugRows = ref<
|
||||
Array<{
|
||||
order_item_id: number;
|
||||
drug_id: number;
|
||||
drug_name: string;
|
||||
image: string;
|
||||
specification: string;
|
||||
number: number;
|
||||
from_warehouse_id: number;
|
||||
to_warehouse_id: number;
|
||||
options: Array<{
|
||||
warehouse_id: number;
|
||||
warehouse_name: string;
|
||||
quote: string | null;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const changeWhPendingCount = computed(
|
||||
() =>
|
||||
changeWhDrugRows.value.filter(
|
||||
(r) => Number(r.to_warehouse_id) !== Number(r.from_warehouse_id),
|
||||
).length,
|
||||
);
|
||||
|
||||
/**
|
||||
* 为单药构造仓卡片:本仓库 + 该药 options(排除不可用)
|
||||
*/
|
||||
function buildDrugWarehouseCards(
|
||||
drugOptions: any[],
|
||||
_fromWarehouseId: number,
|
||||
): Array<{
|
||||
warehouse_id: number;
|
||||
warehouse_name: string;
|
||||
quote: string | null;
|
||||
disabled?: boolean;
|
||||
}> {
|
||||
const local = {
|
||||
warehouse_id: 0,
|
||||
warehouse_name: '萧康医药本仓库',
|
||||
quote: null as string | null,
|
||||
};
|
||||
const seen = new Set<number>([0]);
|
||||
const cards = [local];
|
||||
for (const opt of drugOptions || []) {
|
||||
const id = Number(opt?.warehouse_id ?? 0);
|
||||
if (id <= 0 || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
cards.push({
|
||||
warehouse_id: id,
|
||||
warehouse_name: String(opt?.warehouse_name ?? `仓库#${id}`),
|
||||
quote: String(opt?.quote ?? '0'),
|
||||
});
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击仓库 Tag:打开按药改仓弹窗
|
||||
*/
|
||||
async function openChangeWarehouse(
|
||||
row: Record<string, any>,
|
||||
wh: { id: number; name: string },
|
||||
) {
|
||||
changeWhOrderId.value = Number(row.id ?? 0);
|
||||
changeWhFromId.value = Number(wh?.id ?? 0);
|
||||
changeWhFromName.value = String(wh?.name ?? '');
|
||||
changeWhDrugRows.value = [];
|
||||
changeWhOpen.value = true;
|
||||
changeWhLoading.value = true;
|
||||
try {
|
||||
const items = Array.isArray(row.product_order_items)
|
||||
? row.product_order_items
|
||||
: [];
|
||||
const fromId = changeWhFromId.value;
|
||||
const scoped = items.filter((it: any) => {
|
||||
if (
|
||||
it?.delivery_warehouse_id === undefined ||
|
||||
it?.delivery_warehouse_id === null
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const wid = Number(it.delivery_warehouse_id ?? 0);
|
||||
return fromId > 0 ? wid === fromId : wid <= 0;
|
||||
});
|
||||
const list = scoped.length ? scoped : items;
|
||||
const drugIds = [
|
||||
...new Set(
|
||||
list
|
||||
.map((it: any) => Number(it?.drug_id ?? 0))
|
||||
.filter((id: number) => id > 0),
|
||||
),
|
||||
];
|
||||
if (list.length === 0) {
|
||||
message.warning('该仓库下没有可改仓的药品');
|
||||
return;
|
||||
}
|
||||
const res =
|
||||
drugIds.length > 0
|
||||
? await getDeliveryWarehouseOptionsByDrugs({ drug_ids: drugIds })
|
||||
: {};
|
||||
const map = (res || {}) as Record<string, any[]>;
|
||||
changeWhDrugRows.value = list.map((it: any) => {
|
||||
const drugId = Number(it?.drug_id ?? 0);
|
||||
const itemId = Number(it?.id ?? 0);
|
||||
const opts = map[String(drugId)] || map[drugId] || [];
|
||||
const fromWid = Number(it?.delivery_warehouse_id ?? fromId ?? 0);
|
||||
return {
|
||||
order_item_id: itemId,
|
||||
drug_id: drugId,
|
||||
drug_name: String(it?.drug_name || it?.name || `药品#${drugId}`),
|
||||
image: String(it?.image || it?.drug?.image || ''),
|
||||
specification: String(
|
||||
it?.specification || it?.drug?.specification || '',
|
||||
),
|
||||
number: Number(it?.number ?? it?.select_number ?? 1),
|
||||
from_warehouse_id: fromWid,
|
||||
// 默认仍为当前仓,用户需主动点选目标仓
|
||||
to_warehouse_id: fromWid,
|
||||
options: buildDrugWarehouseCards(opts, fromWid),
|
||||
};
|
||||
});
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载配送仓库失败');
|
||||
} finally {
|
||||
changeWhLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 确认按药改仓 */
|
||||
async function submitChangeWarehouse() {
|
||||
const items = changeWhDrugRows.value
|
||||
.filter((r) => Number(r.to_warehouse_id) !== Number(r.from_warehouse_id))
|
||||
.map((r) => ({
|
||||
order_item_id: r.order_item_id,
|
||||
to_warehouse_id: Number(r.to_warehouse_id),
|
||||
}));
|
||||
if (items.length === 0) {
|
||||
message.warning('请至少为一种药品选择新的配送仓库');
|
||||
return;
|
||||
}
|
||||
changeWhSubmitting.value = true;
|
||||
try {
|
||||
await changeDeliveryWarehouseApi({
|
||||
order_id: changeWhOrderId.value,
|
||||
from_warehouse_id: changeWhFromId.value,
|
||||
items,
|
||||
});
|
||||
message.success(`改仓成功(${items.length} 种药品)`);
|
||||
changeWhOpen.value = false;
|
||||
await gridApi.query();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '改仓失败');
|
||||
} finally {
|
||||
changeWhSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFreeShipping = async (row: any, checked: boolean) => {
|
||||
try {
|
||||
const res = await updateFreeShipping({ id: row.id, is_free_shipping: checked ? 1 : 0 });
|
||||
@@ -511,6 +782,7 @@ const openOrderAmountVerify = () => {
|
||||
<Page auto-content-height title="订单管理">
|
||||
<FormModal />
|
||||
<Modal />
|
||||
<LogisticsModalComp />
|
||||
<AntdModal
|
||||
v-model:open="verifyOpen"
|
||||
title="近10分钟订单金额校验"
|
||||
@@ -560,6 +832,9 @@ const openOrderAmountVerify = () => {
|
||||
</AntdModal>
|
||||
<RefundModal />
|
||||
<DoctorCardModals />
|
||||
<StoreCardModalComp />
|
||||
<PatientsModal />
|
||||
<PatientDetailModal />
|
||||
<ExportModal />
|
||||
<PrescriptionDetailModal />
|
||||
<TraceDrawer />
|
||||
@@ -607,7 +882,12 @@ const openOrderAmountVerify = () => {
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #order-user-info="{ row }">
|
||||
<OrderUserInfoCell :row="row" @open-doctor="showOrderDoctorCard" />
|
||||
<OrderUserInfoCell
|
||||
:row="row"
|
||||
@open-doctor="showOrderDoctorCard"
|
||||
@open-user-patients="openOrderUserPatients"
|
||||
@open-patient="openOrderPatient"
|
||||
/>
|
||||
</template>
|
||||
<template #order-store="{ row }">
|
||||
<div class="leading-snug">
|
||||
@@ -618,7 +898,18 @@ const openOrderAmountVerify = () => {
|
||||
<Tag v-else color="default">线下就诊</Tag>
|
||||
</div>
|
||||
<div class="font-medium">{{ row.order_no }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.store?.name || '—' }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<Button
|
||||
v-if="row.store?.id"
|
||||
class="!h-auto max-w-[xxx] whitespace-normal break-words !px-0 text-left"
|
||||
size="small"
|
||||
type="link"
|
||||
@click="openStoreCard(row.store.id)"
|
||||
>
|
||||
{{ row.store?.name || '—' }}
|
||||
</Button>
|
||||
<span v-else>{{ row.store?.name || '—' }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">下单时间: {{ row.created_at || '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -643,6 +934,24 @@ const openOrderAmountVerify = () => {
|
||||
<div class="text-xs text-gray-500">{{ formatExpressAddress(row) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 患者配送仓库:点击 Tag 可改仓(本仓库 / 配送仓) -->
|
||||
<template #delivery-warehouses="{ row }">
|
||||
<div
|
||||
v-if="Array.isArray(row.delivery_warehouses) && row.delivery_warehouses.length"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<Tag
|
||||
v-for="wh in row.delivery_warehouses"
|
||||
:key="wh.id"
|
||||
color="processing"
|
||||
class="cursor-pointer"
|
||||
@click="openChangeWarehouse(row, wh)"
|
||||
>
|
||||
{{ wh.name }}
|
||||
</Tag>
|
||||
</div>
|
||||
<span v-else class="text-[hsl(var(--muted-foreground))]">-</span>
|
||||
</template>
|
||||
<template #price-info="{ row }">
|
||||
<div class="space-y-0.5 text-sm leading-snug">
|
||||
<div>
|
||||
@@ -958,6 +1267,19 @@ const openOrderAmountVerify = () => {
|
||||
// auth: ['order', 'sys:role:detail'],
|
||||
onClick: infoModal.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '物流动态',
|
||||
type: 'link',
|
||||
icon: 'mdi:truck-delivery-outline',
|
||||
size: 'small',
|
||||
// 上门快递且已支付、非取消/待支付:可单独查看物流
|
||||
ifShow:
|
||||
row.delivery_method === 0 &&
|
||||
row.is_pay === 1 &&
|
||||
row.status !== 0 &&
|
||||
row.status !== 9,
|
||||
onClick: openLogisticsModal.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '溯源',
|
||||
type: 'link',
|
||||
@@ -986,11 +1308,7 @@ const openOrderAmountVerify = () => {
|
||||
type: 'link',
|
||||
icon: 'ri:send-plane-fill',
|
||||
auth: ['Super Admin','Admin'],
|
||||
onClick: wareSend.bind(null, row),
|
||||
// popConfirm: {
|
||||
// title: '确定发货吗?',
|
||||
// confirm: wareSend.bind(null, row),
|
||||
// },
|
||||
onClick: () => wareSend(row),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
@@ -1102,14 +1420,14 @@ const openOrderAmountVerify = () => {
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-400">
|
||||
供货价:{{
|
||||
item.buy_price != null && item.buy_price !== ''
|
||||
? `${item.buy_price} 元/g`
|
||||
? `${item.buy_price} 元/${item.drug?.unit?.name || item.unit?.name || 'g'}`
|
||||
: '—'
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-400">
|
||||
售价:{{
|
||||
item.buy_price != null && item.buy_price !== ''
|
||||
? `${item.price} 元/g`
|
||||
? `${item.price} 元/${item.drug?.unit?.name || item.unit?.name || 'g'}`
|
||||
: '—'
|
||||
}}
|
||||
</div>
|
||||
@@ -1128,6 +1446,86 @@ const openOrderAmountVerify = () => {
|
||||
</template>
|
||||
</Grid>
|
||||
<PercentAdjustDrawer />
|
||||
<!-- 订单改仓:按药分别选仓 -->
|
||||
<AntdModal
|
||||
v-model:open="changeWhOpen"
|
||||
title="修改配送仓库"
|
||||
:confirm-loading="changeWhSubmitting"
|
||||
:ok-button-props="{ disabled: changeWhPendingCount <= 0 }"
|
||||
destroy-on-close
|
||||
width="720px"
|
||||
@ok="submitChangeWarehouse"
|
||||
>
|
||||
<div class="text-muted-foreground mb-3 text-sm">
|
||||
当前仓库:{{ changeWhFromName || '—' }}
|
||||
<span v-if="!changeWhLoading" class="ml-2">
|
||||
· 将改仓 {{ changeWhPendingCount }} 种药品
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="changeWhLoading" class="text-muted-foreground py-4 text-center">
|
||||
加载可选仓库…
|
||||
</div>
|
||||
<div v-else class="change-wh-drug-list space-y-4">
|
||||
<div
|
||||
v-for="row in changeWhDrugRows"
|
||||
:key="row.order_item_id"
|
||||
class="border-border rounded-lg border p-3"
|
||||
>
|
||||
<div class="mb-2 flex items-start gap-3">
|
||||
<img
|
||||
v-if="row.image"
|
||||
:src="row.image"
|
||||
alt=""
|
||||
class="h-12 w-12 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="bg-muted text-muted-foreground flex h-12 w-12 shrink-0 items-center justify-center rounded text-xs"
|
||||
>
|
||||
无图
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-foreground truncate font-medium">
|
||||
{{ row.drug_name }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-0.5 text-xs">
|
||||
规格:{{ row.specification || '--' }} · 数量 {{ row.number }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="opt in row.options"
|
||||
:key="`${row.order_item_id}-${opt.warehouse_id}`"
|
||||
type="button"
|
||||
class="min-w-[120px] rounded-lg border px-3 py-2 text-left transition-colors"
|
||||
:class="
|
||||
row.to_warehouse_id === opt.warehouse_id
|
||||
? 'border-primary bg-primary/10 ring-primary ring-1'
|
||||
: 'border-border hover:border-primary/50'
|
||||
"
|
||||
@click="row.to_warehouse_id = opt.warehouse_id"
|
||||
>
|
||||
<div class="text-foreground text-sm font-medium">
|
||||
{{ opt.warehouse_name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="opt.quote != null"
|
||||
class="text-muted-foreground mt-1 text-xs"
|
||||
>
|
||||
供货价 {{ opt.quote }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="changeWhDrugRows.length === 0"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
暂无可改仓药品
|
||||
</div>
|
||||
</div>
|
||||
</AntdModal>
|
||||
</Page>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -54,7 +54,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<template>
|
||||
<Modal
|
||||
title="退款"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const gridOptions: VxeGridProps<RegisterOrderItem> = {
|
||||
{ field: 'doctor_info.name', title: '开方医生' },
|
||||
{ field: 'doctor_info.depart.name', title: '科室' },
|
||||
{ field: 'user_patient.name', title: '就诊人名称' },
|
||||
{ field: 'store.name', title: '开方诊所' },
|
||||
{ field: 'store.name', title: '开方诊所', slots: { default: 'store-name' } },
|
||||
{ field: 'salesperson', title: '推广员', slots: { default: 'salesperson' } },
|
||||
{ field: 'type', title: '挂号类型', slots: { default: 'type' } },
|
||||
{ field: 'prescription', title: '处方', width: 240, slots: { default: 'prescription'} },
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button, Image, message, Modal as AntdModal, Tag } from "ant-design-vue"
|
||||
|
||||
import { useVbenVxeGrid } from "#/adapter/vxe-table";
|
||||
import { TableAction } from "#/components/table-action";
|
||||
import StoreCardModal from "#/components/store-card/StoreCardModal.vue";
|
||||
import { simulatePayApi } from "#/views/business/order/api/order-ops";
|
||||
import PrescriptionDetail from "#/views/doctor/doctor-reception/components/PrescriptionDetail.vue";
|
||||
|
||||
@@ -38,6 +39,16 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridEvents
|
||||
});
|
||||
|
||||
const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
|
||||
connectedComponent: StoreCardModal,
|
||||
});
|
||||
|
||||
function openStoreCard(storeId?: number) {
|
||||
if (!storeId) return;
|
||||
StoreCardModalApi.setData({ storeId: Number(storeId) });
|
||||
StoreCardModalApi.open();
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
@@ -108,6 +119,7 @@ function formatRegisterPrice(price: unknown) {
|
||||
<Page auto-content-height title="订单管理">
|
||||
<PrescriptionDetailModal />
|
||||
<RefundModal />
|
||||
<StoreCardModalComp />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction :actions="[]" :drop-down-actions="[]">
|
||||
@@ -119,6 +131,18 @@ function formatRegisterPrice(price: unknown) {
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #store-name="{ row }">
|
||||
<Button
|
||||
v-if="row.store?.id || row.store_id"
|
||||
type="link"
|
||||
size="small"
|
||||
class="!px-0"
|
||||
@click="openStoreCard(row.store?.id || row.store_id)"
|
||||
>
|
||||
{{ row.store?.name || "—" }}
|
||||
</Button>
|
||||
<span v-else>{{ row.store?.name || "—" }}</span>
|
||||
</template>
|
||||
<template #prescription="{ row }">
|
||||
<div v-for="item in row.prescription" :key="item.id">
|
||||
<Button type="link" @click="openPrescriptionDetail(item.id)">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
// 后端路由前缀:patient-call-log-dict(routes/admin.php 中 cc_auto_route_register 注册)
|
||||
const prefix = 'patient-call-log-dict/';
|
||||
|
||||
/** 列表(分页 + 搜索 name/type/status) */
|
||||
export async function getPatientCallLogDictList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
/** 下拉:可选 type 过滤(type=1 结果 / type=2 标签) */
|
||||
export async function getPatientCallLogDictOption(data?: any) {
|
||||
return requestClient.get<any>(`${prefix}option`, { params: data });
|
||||
}
|
||||
|
||||
/** 详情 */
|
||||
export async function getPatientCallLogDictInfo(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
/** 新增 */
|
||||
export async function createPatientCallLogDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
/** 更新 */
|
||||
export async function updatePatientCallLogDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
/** 删除(软删;ids 数组) */
|
||||
export async function deletePatientCallLogDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import {
|
||||
createPatientCallLogDict,
|
||||
updatePatientCallLogDict,
|
||||
} from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (e.valid) {
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
const submitApi = isUpdate.value
|
||||
? updatePatientCallLogDict
|
||||
: createPatientCallLogDict;
|
||||
submitApi(values)
|
||||
.then(() => {
|
||||
message.success('保存成功');
|
||||
gridApi.value?.reload();
|
||||
modalApi.close();
|
||||
})
|
||||
.finally(() => {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
});
|
||||
}
|
||||
});
|
||||
await formApi.validateAndSubmitForm();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (isOpen) {
|
||||
const { values, update } = modalApi.getData<Record<string, any>>();
|
||||
if (values && update) {
|
||||
// 编辑:回填后端返回的字段
|
||||
isUpdate.value = true;
|
||||
formApi.setValues({
|
||||
id: values.id || '',
|
||||
type: values.type ?? 1,
|
||||
name: values.name || '',
|
||||
value: values.value || '',
|
||||
color: values.color || '#6acdbb',
|
||||
sort: values.sort ?? 0,
|
||||
status: values.status ?? 1,
|
||||
});
|
||||
} else {
|
||||
// 新增:默认值
|
||||
isUpdate.value = false;
|
||||
formApi.resetForm();
|
||||
formApi.setValues({
|
||||
id: '',
|
||||
type: 1,
|
||||
name: '',
|
||||
value: '',
|
||||
color: '#6acdbb',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`${isUpdate === true ? '编辑' : '新增'}回访字典`" class="w-[50%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
formItemClass: 'col-span-6',
|
||||
defaultValue: '',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
// 1 回访结果 / 2 回访标签;区分后续列表渲染
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
placeholder: '请选择类型',
|
||||
options: [
|
||||
{ label: '回访结果', value: 1 },
|
||||
{ label: '回访标签', value: 2 },
|
||||
],
|
||||
},
|
||||
fieldName: 'type',
|
||||
label: '类型',
|
||||
rules: 'required',
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入展示名(如 接通/关怀)',
|
||||
},
|
||||
fieldName: 'name',
|
||||
label: '名称',
|
||||
rules: 'required',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
// 小程序存储的英文/拼音 value;与历史记录匹配
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入存储值(如 connected/care)',
|
||||
},
|
||||
fieldName: 'value',
|
||||
label: '存储值',
|
||||
rules: 'required',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
// hex 颜色,用于小程序彩色 tag
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入 hex 颜色,如 #6acdbb',
|
||||
},
|
||||
fieldName: 'color',
|
||||
label: '颜色',
|
||||
defaultValue: '#6acdbb',
|
||||
},
|
||||
{
|
||||
component: 'VbenInputNumber',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 0,
|
||||
},
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
placeholder: '请选择状态',
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
],
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
formItemClass: 'col-span-6',
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
// 列表搜索:按名称模糊、类型精确、状态精确
|
||||
export const formOptions: VbenFormProps = {
|
||||
layout: 'inline',
|
||||
showResetButton: true,
|
||||
showSubmitButton: true,
|
||||
schemas: [
|
||||
{
|
||||
fieldName: 'name',
|
||||
component: 'Input',
|
||||
label: '名称',
|
||||
componentProps: {
|
||||
placeholder: '请输入名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
component: 'Select',
|
||||
label: '类型',
|
||||
componentProps: {
|
||||
placeholder: '请选择类型',
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '回访结果', value: 1 },
|
||||
{ label: '回访标签', value: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
component: 'Select',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
placeholder: '请选择状态',
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { getPatientCallLogDictList } from '../api';
|
||||
|
||||
interface RowType {
|
||||
id: number;
|
||||
type: number;
|
||||
type_txt: string;
|
||||
name: string;
|
||||
value: string;
|
||||
color: string;
|
||||
sort: number;
|
||||
status: number;
|
||||
status_txt: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const gridOptions: VxeGridProps<RowType> = {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
labelField: '',
|
||||
},
|
||||
columnConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
rowConfig: {
|
||||
useKey: true,
|
||||
isHover: true,
|
||||
isCurrent: true,
|
||||
},
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'type_txt', align: 'left', title: '类型', width: 110 },
|
||||
{ field: 'name', align: 'left', title: '名称', minWidth: 140 },
|
||||
{ field: 'value', align: 'left', title: '存储值', minWidth: 140 },
|
||||
{
|
||||
field: 'color',
|
||||
align: 'left',
|
||||
title: '颜色',
|
||||
width: 120,
|
||||
// 用色块+hex 直观预览,避免放函数调用导致渲染异常
|
||||
slots: { default: 'color' },
|
||||
},
|
||||
{ field: 'sort', align: 'left', title: '排序', width: 90 },
|
||||
{ field: 'status_txt', align: 'left', title: '状态', width: 90 },
|
||||
{ field: 'created_at', title: '创建时间', width: 180 },
|
||||
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 160 },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getPatientCallLogDictList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
print: false,
|
||||
export: false,
|
||||
zoom: true,
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
},
|
||||
custom: {
|
||||
icon: 'vxe-icon-menu',
|
||||
},
|
||||
},
|
||||
showOverflow: false,
|
||||
};
|
||||
124
apps/web-antd/src/views/business/patient-call-log-dict/index.vue
Normal file
124
apps/web-antd/src/views/business/patient-call-log-dict/index.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { deletePatientCallLogDict } from './api';
|
||||
import DictModal from './components/modal.vue';
|
||||
import { formOptions as searchFormOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
const records = gridApi.grid.getCheckboxRecords();
|
||||
hasTopTableDropDownActions.value = records.length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: searchFormOptions,
|
||||
gridOptions,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
const [DictFormModal, dictFormModalApi] = useVbenModal({
|
||||
connectedComponent: DictModal,
|
||||
});
|
||||
|
||||
const showDictModal = (data = {}, isUpdate = false) => {
|
||||
dictFormModalApi.setData({
|
||||
values: data,
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
});
|
||||
dictFormModalApi.open();
|
||||
};
|
||||
|
||||
const deleteDictApi = (row: any) => {
|
||||
let ids: (string | number)[] = [];
|
||||
if (row) {
|
||||
ids.push(row);
|
||||
} else {
|
||||
ids = gridApi.grid.getCheckboxRecords().map((item: any) => item.id);
|
||||
}
|
||||
deletePatientCallLogDict({ ids }).then(() => {
|
||||
message.success('删除成功!');
|
||||
gridApi.query();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="回访字典管理">
|
||||
<DictFormModal />
|
||||
|
||||
<div class="p-4">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: () => showDictModal({}, false),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<Button
|
||||
v-if="hasTopTableDropDownActions"
|
||||
danger
|
||||
type="primary"
|
||||
@click="deleteDictApi()"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- 颜色列:色块 + hex 直观预览 -->
|
||||
<template #color="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block h-4 w-4 rounded"
|
||||
:style="{ background: row.color, border: '1px solid #e5e7eb' }"
|
||||
/>
|
||||
<span>{{ row.color }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
onClick: () => showDictModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
color: 'error',
|
||||
popConfirm: {
|
||||
title: '确定要删除该项吗?',
|
||||
onConfirm: () => deleteDictApi(row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 分类仓储合并列:分区/分类 + 配送仓绑定 + 总仓是否入库
|
||||
* Tags 打开绑定列表;「新增绑定」直接走新增表单
|
||||
*/
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
defineProps<{
|
||||
row: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
category: [row: Record<string, any>];
|
||||
bind: [row: Record<string, any>];
|
||||
bindCreate: [row: Record<string, any>];
|
||||
stockIn: [row: Record<string, any>];
|
||||
}>();
|
||||
</script>
|
||||
<template>
|
||||
<div class="category-warehouse-cell">
|
||||
<div class="category-warehouse-cell__row">
|
||||
<span class="category-warehouse-cell__label">分区</span>
|
||||
<a
|
||||
class="text-primary cursor-pointer hover:underline"
|
||||
@click.stop="emit('category', row)"
|
||||
>
|
||||
{{ row.zone_name || '未设置' }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="category-warehouse-cell__row">
|
||||
<span class="category-warehouse-cell__label">分类</span>
|
||||
<a
|
||||
class="text-primary cursor-pointer hover:underline"
|
||||
@click.stop="emit('category', row)"
|
||||
>
|
||||
{{ row.category_name || '未设置' }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="category-warehouse-cell__row">
|
||||
<span class="category-warehouse-cell__label">配送仓</span>
|
||||
<div class="category-warehouse-cell__wh">
|
||||
<a
|
||||
class="text-primary cursor-pointer hover:underline"
|
||||
@click.stop="emit('bind', row)"
|
||||
>
|
||||
<template
|
||||
v-if="row.delivery_warehouses && row.delivery_warehouses.length"
|
||||
>
|
||||
<Tag
|
||||
v-for="w in row.delivery_warehouses"
|
||||
:key="w.id"
|
||||
class="mb-1 mr-1"
|
||||
color="blue"
|
||||
>
|
||||
{{ w.name }}
|
||||
</Tag>
|
||||
</template>
|
||||
<span v-else class="category-warehouse-cell__muted">未绑定</span>
|
||||
</a>
|
||||
<a
|
||||
class="text-primary cursor-pointer hover:underline category-warehouse-cell__add"
|
||||
@click.stop="emit('bindCreate', row)"
|
||||
>
|
||||
新增绑定
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="category-warehouse-cell__row">
|
||||
<span class="category-warehouse-cell__label">入库</span>
|
||||
<Tag v-if="row.in_central_warehouse" color="green">已入库</Tag>
|
||||
<a v-else @click.stop="emit('stockIn', row)">
|
||||
<Tag color="orange" class="cursor-pointer">未入库</Tag>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.category-warehouse-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.category-warehouse-cell__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.category-warehouse-cell__label {
|
||||
flex-shrink: 0;
|
||||
width: 42px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.category-warehouse-cell__muted {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.category-warehouse-cell__wh {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.category-warehouse-cell__add {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 总仓售价列:已入库可点开调价;未入库提示并可触发入库
|
||||
*/
|
||||
defineProps<{
|
||||
row: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
adjust: [row: Record<string, any>];
|
||||
stockIn: [row: Record<string, any>];
|
||||
}>();
|
||||
|
||||
function formatPrice(v: unknown) {
|
||||
const n = Number(v);
|
||||
if (!(n >= 0) || Number.isNaN(n)) return '-';
|
||||
return `¥${n.toFixed(2)}`;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<a
|
||||
v-if="row.in_central_warehouse"
|
||||
class="text-primary cursor-pointer hover:underline"
|
||||
@click.stop="emit('adjust', row)"
|
||||
>
|
||||
{{ formatPrice(row.central_price) }}
|
||||
</a>
|
||||
<a
|
||||
v-else
|
||||
class="cursor-pointer"
|
||||
style="color: hsl(var(--muted-foreground))"
|
||||
@click.stop="emit('stockIn', row)"
|
||||
>
|
||||
未入库
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 总仓入库弹窗(从药品列表「未入库」打开)
|
||||
* 药品已预填,只填供货价/销售价后 createWarehouseDrugManagement
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createWarehouseDrugManagement } from '#/views/business/warehouse-drug-management/admin/api';
|
||||
|
||||
const drugId = ref(0);
|
||||
const drugName = ref('');
|
||||
const productType = ref(2);
|
||||
const productGridApi = ref();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
showDefaultActions: false,
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'drug_id',
|
||||
label: '药品ID',
|
||||
componentProps: { disabled: true },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'drug_name',
|
||||
label: '药品名称',
|
||||
componentProps: { disabled: true },
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'market_price',
|
||||
label: '供货价格',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 4,
|
||||
class: 'w-full',
|
||||
placeholder: '请输入供货价格',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'price',
|
||||
label: '销售价格',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 4,
|
||||
class: 'w-full',
|
||||
placeholder: '请输入销售价格',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const result = await formApi.validate();
|
||||
if (!result.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
try {
|
||||
// 总仓入库接口必填:drug_id / market_price / price(type 由药品本身决定)
|
||||
await createWarehouseDrugManagement({
|
||||
drug_id: drugId.value,
|
||||
market_price: values.market_price,
|
||||
price: values.price,
|
||||
});
|
||||
message.success('入库成功');
|
||||
productGridApi.value?.query?.();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) return;
|
||||
const data = modalApi.getData<Record<string, any>>() || {};
|
||||
drugId.value = Number(data.drug_id || data.values?.id || 0);
|
||||
drugName.value =
|
||||
data.drug_name || data.values?.drug_name || `药品#${drugId.value}`;
|
||||
productType.value = Number(data.productType || data.type || 2);
|
||||
productGridApi.value = data.gridApi;
|
||||
formApi.setValues({
|
||||
drug_id: drugId.value,
|
||||
drug_name: drugName.value,
|
||||
market_price: undefined,
|
||||
price: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`总仓入库 - ${drugName}`" class="w-[420px]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 商品信息合并列:图片 + 名称/ID/拼音/发布时间 + 说明书预览链接
|
||||
* 五类商品列表复用,避免每页各写一套 slot
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Image } from 'ant-design-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
row: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const previewVisible = ref(false);
|
||||
|
||||
const instructionUrl = computed(() => {
|
||||
const url = props.row?.instruction;
|
||||
return typeof url === 'string' && url.trim() ? url.trim() : '';
|
||||
});
|
||||
|
||||
function openInstruction() {
|
||||
if (!instructionUrl.value) return;
|
||||
previewVisible.value = true;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="product-info-cell">
|
||||
<Image
|
||||
v-if="row.image"
|
||||
:src="row.image"
|
||||
:width="40"
|
||||
:height="40"
|
||||
class="product-info-cell__img"
|
||||
/>
|
||||
<div v-else class="product-info-cell__img product-info-cell__img--empty">
|
||||
无图
|
||||
</div>
|
||||
<div class="product-info-cell__body">
|
||||
<div class="product-info-cell__name">{{ row.drug_name || '-' }}</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="row.created_at">发布时间:{{ row.created_at }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="instructionUrl"
|
||||
class="product-info-cell__link text-primary"
|
||||
@click.stop="openInstruction"
|
||||
>
|
||||
说明书
|
||||
</a>
|
||||
<!-- 隐藏 Image 仅用于说明书预览 -->
|
||||
<Image
|
||||
v-if="instructionUrl"
|
||||
:src="instructionUrl"
|
||||
:style="{ display: 'none' }"
|
||||
:preview="{
|
||||
visible: previewVisible,
|
||||
onVisibleChange: (v: boolean) => (previewVisible = v),
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.product-info-cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.product-info-cell__img {
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.product-info-cell__img--empty {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
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));
|
||||
border-radius: 4px;
|
||||
}
|
||||
.product-info-cell__body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.product-info-cell__name {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
.product-info-cell__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.product-info-cell__link {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.product-info-cell__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -80,7 +80,7 @@ const handleChange = (info: { file: UploadFile }) => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[30%]" title="上传Excel">
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" 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>
|
||||
|
||||
@@ -123,7 +123,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}中药`"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
|
||||
@@ -102,7 +102,7 @@ const handleChange = (info: { file: UploadFile }) => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[30%]" title="上传Excel">
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
|
||||
<!-- 显示文件选择状态提示 -->
|
||||
<div
|
||||
v-if="isFileSelected"
|
||||
|
||||
@@ -142,7 +142,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}保健食品`"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
|
||||
@@ -8,114 +8,86 @@ import { getHealthFoodList } from '../api';
|
||||
* 表格行数据类型定义
|
||||
*/
|
||||
interface RowType {
|
||||
id: string; // 保健食品ID
|
||||
name: string; // 保健食品名称
|
||||
logo: string; // 保健食品Logo
|
||||
introduce: string; // 保健食品介绍
|
||||
created_at: string; // 创建时间
|
||||
zone_id: number; // 分区ID
|
||||
zone_name: string; // 分区名称
|
||||
category_id: number; // 分类ID
|
||||
category_name: string; // 分类名称
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
introduce: string;
|
||||
created_at: string;
|
||||
zone_id: number;
|
||||
zone_name: string;
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保健食品管理表格配置
|
||||
* 定义表格的列、分页、查询等配置
|
||||
* 列结构对齐西药:商品信息/分类仓储/总仓售价合并列 + 页内特有列
|
||||
*/
|
||||
export const gridOptions: VxeGridProps<RowType> = {
|
||||
// 复选框配置
|
||||
checkboxConfig: {
|
||||
highlight: true, // 高亮选中行
|
||||
labelField: '', // 标签字段
|
||||
highlight: true,
|
||||
labelField: '',
|
||||
},
|
||||
// 列配置
|
||||
columnConfig: {
|
||||
useKey: true, // 使用key作为列的唯一标识
|
||||
useKey: true,
|
||||
},
|
||||
// 行配置
|
||||
rowConfig: {
|
||||
useKey: true, // 使用key作为行的唯一标识
|
||||
useKey: true,
|
||||
},
|
||||
// 表格列定义数组
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 }, // 复选框列
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 }, // ID列
|
||||
{ field: 'drug_name', align: 'left', title: '保健食品名称' }, // 保健食品名称列
|
||||
{ field: 'pinyin_simple', title: '拼音' }, // 拼音列
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{
|
||||
field: 'drug_name',
|
||||
align: 'left',
|
||||
title: '商品信息',
|
||||
minWidth: 260,
|
||||
slots: { default: 'product_info' },
|
||||
},
|
||||
{
|
||||
field: 'zone_name',
|
||||
title: '分区',
|
||||
width: 100,
|
||||
slots: { default: 'zone' }, // 使用插槽显示可点击的分区链接
|
||||
title: '分类仓储',
|
||||
minWidth: 200,
|
||||
slots: { default: 'category_warehouse' },
|
||||
},
|
||||
{
|
||||
field: 'category_name',
|
||||
title: '分类',
|
||||
width: 120,
|
||||
slots: { default: 'category' }, // 使用插槽显示可点击的分类链接
|
||||
field: 'central_price',
|
||||
title: '总仓售价',
|
||||
width: 110,
|
||||
slots: { default: 'central_price' },
|
||||
},
|
||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' } }, // 供应商列,使用插槽自定义显示
|
||||
{
|
||||
field: 'image',
|
||||
align: 'left',
|
||||
title: '产品图片',
|
||||
slots: { default: 'image' }, // 使用插槽显示图片
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
field: 'instruction',
|
||||
align: 'left',
|
||||
title: '说明书',
|
||||
slots: { default: 'instruction' }, // 使用插槽显示说明书
|
||||
width: 130,
|
||||
},
|
||||
// 注意:已移除function列(主要功能),因为保健食品不需要功效字段
|
||||
{ field: 'specification', title: '规格' }, // 规格列
|
||||
{ field: 'status', title: '状态', slots: { default: 'status' } }, // 状态列,使用插槽显示标签
|
||||
{ field: 'created_at', title: '发布时间' }, // 发布时间列
|
||||
{ type: 'html', title: '操作', width: 230, slots: { default: 'action' } }, // 操作列,使用插槽显示操作按钮
|
||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
|
||||
{ field: 'specification', title: '规格', width: 120 },
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
|
||||
],
|
||||
// 保持数据源
|
||||
keepSource: true,
|
||||
// 分页配置
|
||||
pagerConfig: {},
|
||||
// 代理配置:用于数据请求
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
// 请求后端接口方法
|
||||
// 当表格需要加载数据时,会调用此方法
|
||||
query: async ({ page }, formValues) => {
|
||||
// 调用保健食品列表查询API
|
||||
return await getHealthFoodList({
|
||||
page: page.currentPage, // 当前页码
|
||||
pageSize: page.pageSize, // 每页条数
|
||||
...formValues, // 合并搜索表单的值
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
// 表格高度:自动适应
|
||||
height: 'auto',
|
||||
// 是否显示边框
|
||||
border: false,
|
||||
// 工具栏配置
|
||||
toolbarConfig: {
|
||||
// 是否显示搜索表单控制按钮
|
||||
// @ts-ignore 正式环境时有完整的类型声明
|
||||
search: true,
|
||||
refresh: true, // 显示刷新按钮
|
||||
print: false, // 不显示打印按钮
|
||||
export: false, // 不显示导出按钮(使用自定义导出按钮)
|
||||
zoom: true, // 显示最大化最小化按钮
|
||||
refresh: true,
|
||||
print: false,
|
||||
export: false,
|
||||
zoom: true,
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons', // 自定义工具栏按钮插槽
|
||||
buttons: 'toolbar-buttons',
|
||||
},
|
||||
custom: {
|
||||
// 自定义列-图标
|
||||
icon: 'vxe-icon-menu',
|
||||
},
|
||||
},
|
||||
// 是否显示溢出内容
|
||||
showOverflow: false,
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ref } from 'vue';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
// 导入Ant Design Vue组件
|
||||
import { Button, Image, message, Tag } from 'ant-design-vue';
|
||||
import { Button, message, Tag } from 'ant-design-vue';
|
||||
|
||||
// 导入Vben VxeGrid适配器
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
@@ -17,20 +17,27 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
// 导入文件下载工具
|
||||
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 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 FormModalDemo from './components/modal.vue';
|
||||
// 导入Excel上传组件
|
||||
import ExcelUpload from './components/ExcelUpload.vue';
|
||||
// 导入分类设置弹窗组件
|
||||
import CategoryModal from './components/CategoryModal.vue';
|
||||
// 导入搜索表单配置
|
||||
import { formOptions } from './config/search';
|
||||
// 导入表格配置
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
/** 保健食品产品类型 */
|
||||
const PRODUCT_TYPE = 3;
|
||||
|
||||
// 控制顶部表格下拉操作按钮的显示状态
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
@@ -74,6 +81,78 @@ const [CategoryModalComp, categoryModalApi] = useVbenModal({
|
||||
connectedComponent: CategoryModal,
|
||||
});
|
||||
|
||||
const [BindListModalComp, bindListModalApi] = useVbenModal({
|
||||
connectedComponent: BindListModal,
|
||||
});
|
||||
|
||||
const [CentralStockInModalComp, centralStockInModalApi] = useVbenModal({
|
||||
connectedComponent: CentralStockInModal,
|
||||
});
|
||||
|
||||
const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
|
||||
connectedComponent: WarehouseDrugPriceModal,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开某产品的配送仓绑定列表(带总仓售价供费用封顶/默认平台费)
|
||||
* @param autoCreate 为 true 时打开后立刻弹出新增绑定表单
|
||||
*/
|
||||
function openBindList(row: any, autoCreate = false) {
|
||||
const price = resolveCentralPrice(row);
|
||||
bindListModalApi.setData({
|
||||
drug_id: row.id,
|
||||
drug_name: row.drug_name,
|
||||
specification: row.specification,
|
||||
image: row.image,
|
||||
central_price: price,
|
||||
values: row,
|
||||
gridApi,
|
||||
autoCreate,
|
||||
});
|
||||
bindListModalApi.open();
|
||||
}
|
||||
|
||||
/** 列上「新增绑定」:打开列表并自动弹出新增表单 */
|
||||
function openBindCreate(row: any) {
|
||||
openBindList(row, true);
|
||||
}
|
||||
|
||||
/** 未入总仓时打开入库弹窗 */
|
||||
function openStockIn(row: any) {
|
||||
if (row.in_central_warehouse) {
|
||||
message.info('该药品已在总仓库');
|
||||
return;
|
||||
}
|
||||
centralStockInModalApi.setData({
|
||||
drug_id: row.id,
|
||||
drug_name: row.drug_name,
|
||||
productType: PRODUCT_TYPE,
|
||||
values: row,
|
||||
gridApi,
|
||||
});
|
||||
centralStockInModalApi.open();
|
||||
}
|
||||
|
||||
/** 已入库:打开总仓调价弹窗 */
|
||||
function openCentralPriceAdjust(row: any) {
|
||||
const cwd = row.central_warehouse_drug;
|
||||
if (!cwd?.id) {
|
||||
message.warning('未找到总仓药品记录');
|
||||
return;
|
||||
}
|
||||
warehousePriceModalApi.setData({
|
||||
update: true,
|
||||
listDrugType: PRODUCT_TYPE,
|
||||
gridApi,
|
||||
values: {
|
||||
id: cwd.id,
|
||||
price: cwd.price ?? row.central_price,
|
||||
market_price: cwd.market_price ?? row.central_market_price,
|
||||
},
|
||||
});
|
||||
warehousePriceModalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开分类设置弹窗
|
||||
* @param row - 当前行数据
|
||||
@@ -155,6 +234,9 @@ const openExcelUploadModal = () => {
|
||||
<ExcelUploadModal />
|
||||
<FormModal />
|
||||
<CategoryModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
<WarehousePriceModalComp />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
@@ -199,27 +281,28 @@ const openExcelUploadModal = () => {
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<Image :src="row.image" height="30" width="30" />
|
||||
<template #product_info="{ row }">
|
||||
<ProductInfoCell :row="row" />
|
||||
</template>
|
||||
<template #category_warehouse="{ row }">
|
||||
<CategoryWarehouseCell
|
||||
:row="row"
|
||||
@category="openCategoryModal"
|
||||
@bind="openBindList"
|
||||
@bind-create="openBindCreate"
|
||||
@stock-in="openStockIn"
|
||||
/>
|
||||
</template>
|
||||
<template #central_price="{ row }">
|
||||
<CentralPriceCell
|
||||
:row="row"
|
||||
@adjust="openCentralPriceAdjust"
|
||||
@stock-in="openStockIn"
|
||||
/>
|
||||
</template>
|
||||
<template #supplier="{ row }">
|
||||
{{ row.supplier?.name || row.source }}
|
||||
</template>
|
||||
<template #zone="{ row }">
|
||||
<a class="text-primary cursor-pointer hover:underline" @click="openCategoryModal(row)">
|
||||
{{ row.zone_name || '未设置' }}
|
||||
</a>
|
||||
</template>
|
||||
<template #category="{ row }">
|
||||
<a class="text-primary cursor-pointer hover:underline" @click="openCategoryModal(row)">
|
||||
{{ row.category_name || '未设置' }}
|
||||
</a>
|
||||
</template>
|
||||
<template #instruction="{ row }">
|
||||
<div class="div" style="width: 120px; height: 120px; overflow: scroll">
|
||||
<Image :src="row.instruction" height="30" width="30" />
|
||||
</div>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||
</template>
|
||||
@@ -234,6 +317,13 @@ const openExcelUploadModal = () => {
|
||||
size: 'small',
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '绑定配送仓',
|
||||
type: 'link',
|
||||
icon: 'mdi:warehouse',
|
||||
size: 'small',
|
||||
onClick: openBindList.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
|
||||
@@ -73,7 +73,7 @@ const handleChange = (info: { file: UploadFile }) => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[30%]" title="上传Excel">
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
|
||||
<div
|
||||
v-if="isFileSelected"
|
||||
class="mb-4 rounded border border-green-200 bg-green-50 p-3"
|
||||
|
||||
@@ -96,7 +96,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}医疗器械`"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
|
||||
@@ -14,6 +14,7 @@ interface RowType {
|
||||
category_name: string;
|
||||
}
|
||||
|
||||
/** 医疗器械表格:合并商品信息/分类仓储/总仓售价列 */
|
||||
export const gridOptions: VxeGridProps<RowType> = {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
@@ -27,34 +28,30 @@ 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: 'pinyin_simple', title: '拼音' },
|
||||
{
|
||||
field: 'drug_name',
|
||||
align: 'left',
|
||||
title: '商品信息',
|
||||
minWidth: 260,
|
||||
slots: { default: 'product_info' },
|
||||
},
|
||||
{
|
||||
field: 'zone_name',
|
||||
title: '分区',
|
||||
width: 100,
|
||||
slots: { default: 'zone' },
|
||||
title: '分类仓储',
|
||||
minWidth: 200,
|
||||
slots: { default: 'category_warehouse' },
|
||||
},
|
||||
{
|
||||
field: 'category_name',
|
||||
title: '分类',
|
||||
width: 120,
|
||||
slots: { default: 'category' },
|
||||
field: 'central_price',
|
||||
title: '总仓售价',
|
||||
width: 110,
|
||||
slots: { default: 'central_price' },
|
||||
},
|
||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' } },
|
||||
{
|
||||
field: 'image',
|
||||
align: 'left',
|
||||
title: '产品图片',
|
||||
slots: { default: 'image' },
|
||||
width: 130,
|
||||
},
|
||||
{ field: 'function', title: '器械功能' },
|
||||
{ field: 'specification', title: '规格型号' },
|
||||
{ field: 'status', title: '状态', slots: { default: 'status' } },
|
||||
{ field: 'created_at', title: '发布时间' },
|
||||
{ type: 'html', title: '操作', width: 230, slots: { default: 'action' } },
|
||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
|
||||
{ field: 'function', title: '器械功能', width: 200 },
|
||||
{ field: 'specification', title: '规格型号', width: 120 },
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
@@ -87,11 +84,3 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
},
|
||||
showOverflow: false,
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,18 @@ import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Image, message, Tag } from 'ant-design-vue';
|
||||
import { Button, message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
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 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 FormModalDemo from './components/modal.vue';
|
||||
@@ -18,6 +25,9 @@ import CategoryModal from './components/CategoryModal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
/** 医疗器械产品类型 */
|
||||
const PRODUCT_TYPE = 7;
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
@@ -49,6 +59,78 @@ const [CategoryModalComp, categoryModalApi] = useVbenModal({
|
||||
connectedComponent: CategoryModal,
|
||||
});
|
||||
|
||||
const [BindListModalComp, bindListModalApi] = useVbenModal({
|
||||
connectedComponent: BindListModal,
|
||||
});
|
||||
|
||||
const [CentralStockInModalComp, centralStockInModalApi] = useVbenModal({
|
||||
connectedComponent: CentralStockInModal,
|
||||
});
|
||||
|
||||
const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
|
||||
connectedComponent: WarehouseDrugPriceModal,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开某产品的配送仓绑定列表(带总仓售价供费用封顶/默认平台费)
|
||||
* @param autoCreate 为 true 时打开后立刻弹出新增绑定表单
|
||||
*/
|
||||
function openBindList(row: any, autoCreate = false) {
|
||||
const price = resolveCentralPrice(row);
|
||||
bindListModalApi.setData({
|
||||
drug_id: row.id,
|
||||
drug_name: row.drug_name,
|
||||
specification: row.specification,
|
||||
image: row.image,
|
||||
central_price: price,
|
||||
values: row,
|
||||
gridApi,
|
||||
autoCreate,
|
||||
});
|
||||
bindListModalApi.open();
|
||||
}
|
||||
|
||||
/** 列上「新增绑定」:打开列表并自动弹出新增表单 */
|
||||
function openBindCreate(row: any) {
|
||||
openBindList(row, true);
|
||||
}
|
||||
|
||||
/** 未入总仓时打开入库弹窗 */
|
||||
function openStockIn(row: any) {
|
||||
if (row.in_central_warehouse) {
|
||||
message.info('该药品已在总仓库');
|
||||
return;
|
||||
}
|
||||
centralStockInModalApi.setData({
|
||||
drug_id: row.id,
|
||||
drug_name: row.drug_name,
|
||||
productType: PRODUCT_TYPE,
|
||||
values: row,
|
||||
gridApi,
|
||||
});
|
||||
centralStockInModalApi.open();
|
||||
}
|
||||
|
||||
/** 已入库:打开总仓调价弹窗 */
|
||||
function openCentralPriceAdjust(row: any) {
|
||||
const cwd = row.central_warehouse_drug;
|
||||
if (!cwd?.id) {
|
||||
message.warning('未找到总仓药品记录');
|
||||
return;
|
||||
}
|
||||
warehousePriceModalApi.setData({
|
||||
update: true,
|
||||
listDrugType: PRODUCT_TYPE,
|
||||
gridApi,
|
||||
values: {
|
||||
id: cwd.id,
|
||||
price: cwd.price ?? row.central_price,
|
||||
market_price: cwd.market_price ?? row.central_market_price,
|
||||
},
|
||||
});
|
||||
warehousePriceModalApi.open();
|
||||
}
|
||||
|
||||
const openCategoryModal = (row: any) => {
|
||||
categoryModalApi.setData({
|
||||
row,
|
||||
@@ -99,6 +181,9 @@ const openExcelUploadModal = () => {
|
||||
<ExcelUploadModal />
|
||||
<FormModal />
|
||||
<CategoryModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
<WarehousePriceModalComp />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
@@ -143,22 +228,28 @@ const openExcelUploadModal = () => {
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<Image :src="row.image" height="30" width="30" />
|
||||
<template #product_info="{ row }">
|
||||
<ProductInfoCell :row="row" />
|
||||
</template>
|
||||
<template #category_warehouse="{ row }">
|
||||
<CategoryWarehouseCell
|
||||
:row="row"
|
||||
@category="openCategoryModal"
|
||||
@bind="openBindList"
|
||||
@bind-create="openBindCreate"
|
||||
@stock-in="openStockIn"
|
||||
/>
|
||||
</template>
|
||||
<template #central_price="{ row }">
|
||||
<CentralPriceCell
|
||||
:row="row"
|
||||
@adjust="openCentralPriceAdjust"
|
||||
@stock-in="openStockIn"
|
||||
/>
|
||||
</template>
|
||||
<template #supplier="{ row }">
|
||||
{{ row.supplier?.name || row.source }}
|
||||
</template>
|
||||
<template #zone="{ row }">
|
||||
<a class="text-primary cursor-pointer hover:underline" @click="openCategoryModal(row)">
|
||||
{{ row.zone_name || '未设置' }}
|
||||
</a>
|
||||
</template>
|
||||
<template #category="{ row }">
|
||||
<a class="text-primary cursor-pointer hover:underline" @click="openCategoryModal(row)">
|
||||
{{ row.category_name || '未设置' }}
|
||||
</a>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||
</template>
|
||||
@@ -173,6 +264,13 @@ const openExcelUploadModal = () => {
|
||||
size: 'small',
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '绑定配送仓',
|
||||
type: 'link',
|
||||
icon: 'mdi:warehouse',
|
||||
size: 'small',
|
||||
onClick: openBindList.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
|
||||
@@ -73,7 +73,7 @@ const handleChange = (info: { file: UploadFile }) => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[30%]" title="上传Excel">
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
|
||||
<div
|
||||
v-if="isFileSelected"
|
||||
class="mb-4 rounded border border-green-200 bg-green-50 p-3"
|
||||
|
||||
@@ -96,7 +96,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}非药品`"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
|
||||
@@ -14,6 +14,7 @@ interface RowType {
|
||||
category_name: string;
|
||||
}
|
||||
|
||||
/** 非药品表格:合并商品信息/分类仓储/总仓售价列 */
|
||||
export const gridOptions: VxeGridProps<RowType> = {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
@@ -27,34 +28,30 @@ 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: 'pinyin_simple', title: '拼音' },
|
||||
{
|
||||
field: 'drug_name',
|
||||
align: 'left',
|
||||
title: '商品信息',
|
||||
minWidth: 260,
|
||||
slots: { default: 'product_info' },
|
||||
},
|
||||
{
|
||||
field: 'zone_name',
|
||||
title: '分区',
|
||||
width: 100,
|
||||
slots: { default: 'zone' },
|
||||
title: '分类仓储',
|
||||
minWidth: 200,
|
||||
slots: { default: 'category_warehouse' },
|
||||
},
|
||||
{
|
||||
field: 'category_name',
|
||||
title: '分类',
|
||||
width: 120,
|
||||
slots: { default: 'category' },
|
||||
field: 'central_price',
|
||||
title: '总仓售价',
|
||||
width: 110,
|
||||
slots: { default: 'central_price' },
|
||||
},
|
||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' } },
|
||||
{
|
||||
field: 'image',
|
||||
align: 'left',
|
||||
title: '产品图片',
|
||||
slots: { default: 'image' },
|
||||
width: 130,
|
||||
},
|
||||
{ field: 'function', title: '产品功能' },
|
||||
{ field: 'specification', title: '规格' },
|
||||
{ field: 'status', title: '状态', slots: { default: 'status' } },
|
||||
{ field: 'created_at', title: '发布时间' },
|
||||
{ type: 'html', title: '操作', width: 230, slots: { default: 'action' } },
|
||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
|
||||
{ field: 'function', title: '产品功能', width: 200 },
|
||||
{ field: 'specification', title: '规格', width: 120 },
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
@@ -87,11 +84,3 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
},
|
||||
showOverflow: false,
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,18 @@ import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Image, message, Tag } from 'ant-design-vue';
|
||||
import { Button, message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
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 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 FormModalDemo from './components/modal.vue';
|
||||
@@ -18,6 +25,9 @@ import CategoryModal from './components/CategoryModal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
/** 非药品产品类型 */
|
||||
const PRODUCT_TYPE = 6;
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
@@ -49,6 +59,78 @@ const [CategoryModalComp, categoryModalApi] = useVbenModal({
|
||||
connectedComponent: CategoryModal,
|
||||
});
|
||||
|
||||
const [BindListModalComp, bindListModalApi] = useVbenModal({
|
||||
connectedComponent: BindListModal,
|
||||
});
|
||||
|
||||
const [CentralStockInModalComp, centralStockInModalApi] = useVbenModal({
|
||||
connectedComponent: CentralStockInModal,
|
||||
});
|
||||
|
||||
const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
|
||||
connectedComponent: WarehouseDrugPriceModal,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开某产品的配送仓绑定列表(带总仓售价供费用封顶/默认平台费)
|
||||
* @param autoCreate 为 true 时打开后立刻弹出新增绑定表单
|
||||
*/
|
||||
function openBindList(row: any, autoCreate = false) {
|
||||
const price = resolveCentralPrice(row);
|
||||
bindListModalApi.setData({
|
||||
drug_id: row.id,
|
||||
drug_name: row.drug_name,
|
||||
specification: row.specification,
|
||||
image: row.image,
|
||||
central_price: price,
|
||||
values: row,
|
||||
gridApi,
|
||||
autoCreate,
|
||||
});
|
||||
bindListModalApi.open();
|
||||
}
|
||||
|
||||
/** 列上「新增绑定」:打开列表并自动弹出新增表单 */
|
||||
function openBindCreate(row: any) {
|
||||
openBindList(row, true);
|
||||
}
|
||||
|
||||
/** 未入总仓时打开入库弹窗 */
|
||||
function openStockIn(row: any) {
|
||||
if (row.in_central_warehouse) {
|
||||
message.info('该药品已在总仓库');
|
||||
return;
|
||||
}
|
||||
centralStockInModalApi.setData({
|
||||
drug_id: row.id,
|
||||
drug_name: row.drug_name,
|
||||
productType: PRODUCT_TYPE,
|
||||
values: row,
|
||||
gridApi,
|
||||
});
|
||||
centralStockInModalApi.open();
|
||||
}
|
||||
|
||||
/** 已入库:打开总仓调价弹窗 */
|
||||
function openCentralPriceAdjust(row: any) {
|
||||
const cwd = row.central_warehouse_drug;
|
||||
if (!cwd?.id) {
|
||||
message.warning('未找到总仓药品记录');
|
||||
return;
|
||||
}
|
||||
warehousePriceModalApi.setData({
|
||||
update: true,
|
||||
listDrugType: PRODUCT_TYPE,
|
||||
gridApi,
|
||||
values: {
|
||||
id: cwd.id,
|
||||
price: cwd.price ?? row.central_price,
|
||||
market_price: cwd.market_price ?? row.central_market_price,
|
||||
},
|
||||
});
|
||||
warehousePriceModalApi.open();
|
||||
}
|
||||
|
||||
const openCategoryModal = (row: any) => {
|
||||
categoryModalApi.setData({
|
||||
row,
|
||||
@@ -99,6 +181,9 @@ const openExcelUploadModal = () => {
|
||||
<ExcelUploadModal />
|
||||
<FormModal />
|
||||
<CategoryModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
<WarehousePriceModalComp />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
@@ -143,22 +228,28 @@ const openExcelUploadModal = () => {
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<Image :src="row.image" height="30" width="30" />
|
||||
<template #product_info="{ row }">
|
||||
<ProductInfoCell :row="row" />
|
||||
</template>
|
||||
<template #category_warehouse="{ row }">
|
||||
<CategoryWarehouseCell
|
||||
:row="row"
|
||||
@category="openCategoryModal"
|
||||
@bind="openBindList"
|
||||
@bind-create="openBindCreate"
|
||||
@stock-in="openStockIn"
|
||||
/>
|
||||
</template>
|
||||
<template #central_price="{ row }">
|
||||
<CentralPriceCell
|
||||
:row="row"
|
||||
@adjust="openCentralPriceAdjust"
|
||||
@stock-in="openStockIn"
|
||||
/>
|
||||
</template>
|
||||
<template #supplier="{ row }">
|
||||
{{ row.supplier?.name || row.source }}
|
||||
</template>
|
||||
<template #zone="{ row }">
|
||||
<a class="text-primary cursor-pointer hover:underline" @click="openCategoryModal(row)">
|
||||
{{ row.zone_name || '未设置' }}
|
||||
</a>
|
||||
</template>
|
||||
<template #category="{ row }">
|
||||
<a class="text-primary cursor-pointer hover:underline" @click="openCategoryModal(row)">
|
||||
{{ row.category_name || '未设置' }}
|
||||
</a>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||
</template>
|
||||
@@ -173,6 +264,13 @@ const openExcelUploadModal = () => {
|
||||
size: 'small',
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '绑定配送仓',
|
||||
type: 'link',
|
||||
icon: 'mdi:warehouse',
|
||||
size: 'small',
|
||||
onClick: openBindList.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
|
||||
@@ -66,7 +66,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}产品服务包`"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
|
||||
@@ -14,6 +14,7 @@ interface RowType {
|
||||
category_name: string;
|
||||
}
|
||||
|
||||
/** 产品服务包表格:合并商品信息/分类仓储/总仓售价列 */
|
||||
export const gridOptions: VxeGridProps<RowType> = {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
@@ -27,47 +28,35 @@ 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: 'pinyin_simple', title: '拼音' },
|
||||
{
|
||||
field: 'drug_name',
|
||||
align: 'left',
|
||||
title: '商品信息',
|
||||
minWidth: 260,
|
||||
slots: { default: 'product_info' },
|
||||
},
|
||||
{
|
||||
field: 'zone_name',
|
||||
title: '分区',
|
||||
width: 100,
|
||||
slots: { default: 'zone' },
|
||||
title: '分类仓储',
|
||||
minWidth: 200,
|
||||
slots: { default: 'category_warehouse' },
|
||||
},
|
||||
{
|
||||
field: 'category_name',
|
||||
title: '分类',
|
||||
width: 120,
|
||||
slots: { default: 'category' },
|
||||
field: 'central_price',
|
||||
title: '总仓售价',
|
||||
width: 110,
|
||||
slots: { default: 'central_price' },
|
||||
},
|
||||
{ field: 'supplier.name', title: '供应商' },
|
||||
{
|
||||
field: 'image',
|
||||
align: 'left',
|
||||
title: '产品图片',
|
||||
slots: { default: 'image' },
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
field: 'instruction',
|
||||
align: 'left',
|
||||
title: '说明书',
|
||||
slots: { default: 'instruction' },
|
||||
width: 130,
|
||||
},
|
||||
{ field: 'function', title: '主要功能' },
|
||||
{ field: 'specification', title: '规格' },
|
||||
{ field: 'status', title: '状态', slots: { default: 'status' } },
|
||||
{ field: 'created_at', title: '发布时间' },
|
||||
{ type: 'html', title: '操作', slots: { default: 'action' } },
|
||||
{ field: 'supplier.name', title: '供应商', width: 130 },
|
||||
{ field: 'function', title: '主要功能', width: 200 },
|
||||
{ field: 'specification', title: '规格', width: 120 },
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
// 请求后端接口方法
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getServicePackList({
|
||||
page: page.currentPage,
|
||||
@@ -80,19 +69,16 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
height: 'auto',
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
// 是否显示搜索表单控制按钮
|
||||
// @ts-ignore 正式环境时有完整的类型声明
|
||||
search: true,
|
||||
refresh: true, // 刷新
|
||||
print: false, // 打印
|
||||
export: false, // 导出
|
||||
// custom: true, // 自定义列
|
||||
zoom: true, // 最大化最小化
|
||||
refresh: true,
|
||||
print: false,
|
||||
export: false,
|
||||
zoom: true,
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
},
|
||||
custom: {
|
||||
// 自定义列-图标
|
||||
icon: 'vxe-icon-menu',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,10 +5,17 @@ import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Image, message, Tag } from 'ant-design-vue';
|
||||
import { Button, 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 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 FormModalDemo from './components/modal.vue';
|
||||
@@ -16,6 +23,9 @@ import ZoneCategoryModal from './components/ZoneCategoryModal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
/** 产品服务包产品类型 */
|
||||
const PRODUCT_TYPE = 5;
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
@@ -45,6 +55,79 @@ const [ZoneCategoryModalComp, zoneCategoryModalApi] = useVbenModal({
|
||||
connectedComponent: ZoneCategoryModal,
|
||||
});
|
||||
|
||||
const [BindListModalComp, bindListModalApi] = useVbenModal({
|
||||
connectedComponent: BindListModal,
|
||||
});
|
||||
|
||||
const [CentralStockInModalComp, centralStockInModalApi] = useVbenModal({
|
||||
connectedComponent: CentralStockInModal,
|
||||
});
|
||||
|
||||
const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
|
||||
connectedComponent: WarehouseDrugPriceModal,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开某产品的配送仓绑定列表(带总仓售价供费用封顶/默认平台费)
|
||||
* @param autoCreate 为 true 时打开后立刻弹出新增绑定表单
|
||||
*/
|
||||
function openBindList(row: any, autoCreate = false) {
|
||||
const price = resolveCentralPrice(row);
|
||||
bindListModalApi.setData({
|
||||
drug_id: row.id,
|
||||
drug_name: row.drug_name,
|
||||
specification: row.specification,
|
||||
image: row.image,
|
||||
central_price: price,
|
||||
values: row,
|
||||
gridApi,
|
||||
autoCreate,
|
||||
});
|
||||
bindListModalApi.open();
|
||||
}
|
||||
|
||||
/** 列上「新增绑定」:打开列表并自动弹出新增表单 */
|
||||
function openBindCreate(row: any) {
|
||||
openBindList(row, true);
|
||||
}
|
||||
|
||||
/** 未入总仓时打开入库弹窗 */
|
||||
function openStockIn(row: any) {
|
||||
if (row.in_central_warehouse) {
|
||||
message.info('该药品已在总仓库');
|
||||
return;
|
||||
}
|
||||
centralStockInModalApi.setData({
|
||||
drug_id: row.id,
|
||||
drug_name: row.drug_name,
|
||||
productType: PRODUCT_TYPE,
|
||||
values: row,
|
||||
gridApi,
|
||||
});
|
||||
centralStockInModalApi.open();
|
||||
}
|
||||
|
||||
/** 已入库:打开总仓调价弹窗 */
|
||||
function openCentralPriceAdjust(row: any) {
|
||||
const cwd = row.central_warehouse_drug;
|
||||
if (!cwd?.id) {
|
||||
message.warning('未找到总仓药品记录');
|
||||
return;
|
||||
}
|
||||
warehousePriceModalApi.setData({
|
||||
update: true,
|
||||
listDrugType: PRODUCT_TYPE,
|
||||
gridApi,
|
||||
values: {
|
||||
id: cwd.id,
|
||||
price: cwd.price ?? row.central_price,
|
||||
market_price: cwd.market_price ?? row.central_market_price,
|
||||
},
|
||||
});
|
||||
warehousePriceModalApi.open();
|
||||
}
|
||||
|
||||
/** 打开服务包分区/分类设置弹窗(ZoneCategoryModal) */
|
||||
const openZoneCategoryModal = (row: any) => {
|
||||
zoneCategoryModalApi.setData({
|
||||
row,
|
||||
@@ -81,6 +164,9 @@ const deleteApi = (row: any) => {
|
||||
<Page auto-content-height title="产品服务包管理">
|
||||
<FormModal />
|
||||
<ZoneCategoryModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
<WarehousePriceModalComp />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
@@ -114,23 +200,24 @@ const deleteApi = (row: any) => {
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #zone="{ row }">
|
||||
<a class="text-primary cursor-pointer hover:underline" @click="openZoneCategoryModal(row)">
|
||||
{{ row.zone_name || '未设置' }}
|
||||
</a>
|
||||
<template #product_info="{ row }">
|
||||
<ProductInfoCell :row="row" />
|
||||
</template>
|
||||
<template #category="{ row }">
|
||||
<a class="text-primary cursor-pointer hover:underline" @click="openZoneCategoryModal(row)">
|
||||
{{ row.category_name || '未设置' }}
|
||||
</a>
|
||||
<template #category_warehouse="{ row }">
|
||||
<CategoryWarehouseCell
|
||||
:row="row"
|
||||
@category="openZoneCategoryModal"
|
||||
@bind="openBindList"
|
||||
@bind-create="openBindCreate"
|
||||
@stock-in="openStockIn"
|
||||
/>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<Image :src="row.image" height="30" width="30" />
|
||||
</template>
|
||||
<template #instruction="{ row }">
|
||||
<div class="div" style="width: 120px; height: 120px; overflow: hidden">
|
||||
<Image :src="row.instruction" height="30" width="30" />
|
||||
</div>
|
||||
<template #central_price="{ row }">
|
||||
<CentralPriceCell
|
||||
:row="row"
|
||||
@adjust="openCentralPriceAdjust"
|
||||
@stock-in="openStockIn"
|
||||
/>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||
@@ -147,6 +234,13 @@ const deleteApi = (row: any) => {
|
||||
// auth: ['service-pack', 'sys:role:detail'],
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '绑定配送仓',
|
||||
type: 'link',
|
||||
icon: 'mdi:warehouse',
|
||||
size: 'small',
|
||||
onClick: openBindList.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
|
||||
@@ -80,7 +80,7 @@ const handleChange = (info: { file: UploadFile }) => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal class="w-[30%]" title="上传Excel">
|
||||
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
|
||||
<!-- 新增:显示文件选择状态 -->
|
||||
<div
|
||||
v-if="isFileSelected"
|
||||
|
||||
@@ -152,7 +152,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}西(中成)药`"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
|
||||
@@ -27,60 +27,53 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
},
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'drug_name', align: 'left', title: '药品名称', width: 120 },
|
||||
{ field: 'pinyin_simple', title: '拼音', width: 120 },
|
||||
{
|
||||
field: 'drug_name',
|
||||
align: 'left',
|
||||
title: '商品信息',
|
||||
minWidth: 260,
|
||||
slots: { default: 'product_info' },
|
||||
},
|
||||
{
|
||||
field: 'zone_name',
|
||||
title: '分区',
|
||||
width: 100,
|
||||
slots: { default: 'zone' },
|
||||
title: '分类仓储',
|
||||
minWidth: 200,
|
||||
slots: { default: 'category_warehouse' },
|
||||
},
|
||||
{
|
||||
field: 'category_name',
|
||||
title: '分类',
|
||||
width: 120,
|
||||
slots: { default: 'category' },
|
||||
field: 'central_price',
|
||||
title: '总仓售价',
|
||||
width: 110,
|
||||
slots: { default: 'central_price' },
|
||||
},
|
||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' },width: 130 },
|
||||
{
|
||||
field: 'image',
|
||||
align: 'left',
|
||||
title: '产品图片',
|
||||
slots: { default: 'image' },
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
field: 'instruction',
|
||||
align: 'left',
|
||||
title: '说明书',
|
||||
slots: { default: 'instruction' },
|
||||
width: 130,
|
||||
},
|
||||
{ field: 'function', title: '主要功能', width: 240 },
|
||||
{ field: 'specification', title: '规格', width: 130 },
|
||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
|
||||
{ field: 'function', title: '主要功能', width: 200 },
|
||||
{ field: 'specification', title: '规格', width: 120 },
|
||||
{
|
||||
field: 'is_otc',
|
||||
title: '处方药',
|
||||
width: 100,
|
||||
slots: { default: 'is_otc' },
|
||||
},
|
||||
{ field: 'status', title: '状态', slots: { default: 'status' } },
|
||||
{ field: 'created_at', title: '发布时间', width: 240 },
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{
|
||||
field: 'min_adjust_price',
|
||||
title: '最低调整金额',
|
||||
width: 130,
|
||||
width: 120,
|
||||
slots: { default: 'min_adjust_price' },
|
||||
},
|
||||
{ field: 'erp_qty_factor', title: 'ERP抓取数量倍数', width: 150, slots: { default: 'erp_qty_factor' } },
|
||||
{ type: 'html', title: '操作', fixed: 'right', width: 280, slots: { default: 'action' } },
|
||||
{
|
||||
field: 'erp_qty_factor',
|
||||
title: 'ERP抓取数量倍数',
|
||||
width: 140,
|
||||
slots: { default: 'erp_qty_factor' },
|
||||
},
|
||||
{ type: 'html', title: '操作', fixed: 'right', width: 340, slots: { default: 'action' } },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
// 请求后端接口方法
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getWesternMedicineList({
|
||||
page: page.currentPage,
|
||||
@@ -90,25 +83,19 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
// exportConfig: {
|
||||
// api: passApplicationApi,
|
||||
// },
|
||||
height: 'auto',
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
// 是否显示搜索表单控制按钮
|
||||
// @ts-ignore 正式环境时有完整的类型声明
|
||||
// @ts-ignore
|
||||
search: true,
|
||||
refresh: true, // 刷新
|
||||
print: false, // 打印
|
||||
export: false, // 导出
|
||||
// custom: true, // 自定义列
|
||||
zoom: true, // 最大化最小化
|
||||
refresh: true,
|
||||
print: false,
|
||||
export: false,
|
||||
zoom: true,
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
},
|
||||
custom: {
|
||||
// 自定义列-图标
|
||||
icon: 'vxe-icon-menu',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,11 +5,18 @@ import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Image, message, Tag } from 'ant-design-vue';
|
||||
import { Button, message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
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 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 FormModalDemo from './components/modal.vue';
|
||||
@@ -20,6 +27,9 @@ import ErpQtyFactorModal from './components/ErpQtyFactorModal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
/** 西药(含中成药列表)产品类型 */
|
||||
const PRODUCT_TYPE = 2;
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
@@ -61,6 +71,78 @@ const [ErpQtyFactorModalComp, erpQtyFactorModalApi] = useVbenModal({
|
||||
connectedComponent: ErpQtyFactorModal,
|
||||
});
|
||||
|
||||
const [BindListModalComp, bindListModalApi] = useVbenModal({
|
||||
connectedComponent: BindListModal,
|
||||
});
|
||||
|
||||
const [CentralStockInModalComp, centralStockInModalApi] = useVbenModal({
|
||||
connectedComponent: CentralStockInModal,
|
||||
});
|
||||
|
||||
const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
|
||||
connectedComponent: WarehouseDrugPriceModal,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开某药的配送仓绑定列表(带总仓售价供费用封顶/默认平台费)
|
||||
* @param autoCreate 为 true 时打开后立刻弹出新增绑定表单
|
||||
*/
|
||||
function openBindList(row: any, autoCreate = false) {
|
||||
const price = resolveCentralPrice(row);
|
||||
bindListModalApi.setData({
|
||||
drug_id: row.id,
|
||||
drug_name: row.drug_name,
|
||||
specification: row.specification,
|
||||
image: row.image,
|
||||
central_price: price,
|
||||
values: row,
|
||||
gridApi,
|
||||
autoCreate,
|
||||
});
|
||||
bindListModalApi.open();
|
||||
}
|
||||
|
||||
/** 列上「新增绑定」:打开列表并自动弹出新增表单 */
|
||||
function openBindCreate(row: any) {
|
||||
openBindList(row, true);
|
||||
}
|
||||
|
||||
/** 未入总仓时打开入库弹窗 */
|
||||
function openStockIn(row: any) {
|
||||
if (row.in_central_warehouse) {
|
||||
message.info('该药品已在总仓库');
|
||||
return;
|
||||
}
|
||||
centralStockInModalApi.setData({
|
||||
drug_id: row.id,
|
||||
drug_name: row.drug_name,
|
||||
productType: PRODUCT_TYPE,
|
||||
values: row,
|
||||
gridApi,
|
||||
});
|
||||
centralStockInModalApi.open();
|
||||
}
|
||||
|
||||
/** 已入库:打开总仓调价弹窗 */
|
||||
function openCentralPriceAdjust(row: any) {
|
||||
const cwd = row.central_warehouse_drug;
|
||||
if (!cwd?.id) {
|
||||
message.warning('未找到总仓药品记录');
|
||||
return;
|
||||
}
|
||||
warehousePriceModalApi.setData({
|
||||
update: true,
|
||||
listDrugType: PRODUCT_TYPE,
|
||||
gridApi,
|
||||
values: {
|
||||
id: cwd.id,
|
||||
price: cwd.price ?? row.central_price,
|
||||
market_price: cwd.market_price ?? row.central_market_price,
|
||||
},
|
||||
});
|
||||
warehousePriceModalApi.open();
|
||||
}
|
||||
|
||||
const openCategoryModal = (row: any) => {
|
||||
categoryModalApi.setData({
|
||||
row,
|
||||
@@ -138,6 +220,9 @@ const openExcelUploadModal = () => {
|
||||
<CategoryModalComp />
|
||||
<MinPriceModalComp />
|
||||
<ErpQtyFactorModalComp />
|
||||
<BindListModalComp />
|
||||
<CentralStockInModalComp />
|
||||
<WarehousePriceModalComp />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
@@ -186,27 +271,28 @@ const openExcelUploadModal = () => {
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<Image :src="row.image" height="30" width="30" />
|
||||
<template #product_info="{ row }">
|
||||
<ProductInfoCell :row="row" />
|
||||
</template>
|
||||
<template #category_warehouse="{ row }">
|
||||
<CategoryWarehouseCell
|
||||
:row="row"
|
||||
@category="openCategoryModal"
|
||||
@bind="openBindList"
|
||||
@bind-create="openBindCreate"
|
||||
@stock-in="openStockIn"
|
||||
/>
|
||||
</template>
|
||||
<template #central_price="{ row }">
|
||||
<CentralPriceCell
|
||||
:row="row"
|
||||
@adjust="openCentralPriceAdjust"
|
||||
@stock-in="openStockIn"
|
||||
/>
|
||||
</template>
|
||||
<template #supplier="{ row }">
|
||||
{{ row.supplier?.name || row.source }}
|
||||
</template>
|
||||
<template #zone="{ row }">
|
||||
<a class="text-primary cursor-pointer hover:underline" @click="openCategoryModal(row)">
|
||||
{{ row.zone_name || '未设置' }}
|
||||
</a>
|
||||
</template>
|
||||
<template #category="{ row }">
|
||||
<a class="text-primary cursor-pointer hover:underline" @click="openCategoryModal(row)">
|
||||
{{ row.category_name || '未设置' }}
|
||||
</a>
|
||||
</template>
|
||||
<template #instruction="{ row }">
|
||||
<div class="div" style="width: 120px; height: 120px; overflow: scroll">
|
||||
<Image :src="row.instruction" height="30" width="30" />
|
||||
</div>
|
||||
</template>
|
||||
<template #is_otc="{ row }">
|
||||
<Tag :color="row.is_otc === 0 ? 'red' : 'green'">
|
||||
{{ row.is_otc === 0 ? '处方药' : '非处方药' }}
|
||||
@@ -246,6 +332,13 @@ const openExcelUploadModal = () => {
|
||||
// auth: ['western-medicine', 'sys:role:detail'],
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '绑定配送仓',
|
||||
type: 'link',
|
||||
icon: 'mdi:warehouse',
|
||||
size: 'small',
|
||||
onClick: openBindList.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '设置底价',
|
||||
type: 'link',
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface ChineseDrugItem {
|
||||
price: number;
|
||||
buy_price?: number;
|
||||
way_id: number;
|
||||
unit_id?: number;
|
||||
unit?: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -123,6 +125,8 @@ const newDrugInfo = ref<{
|
||||
number?: number;
|
||||
price?: number;
|
||||
way_id?: number;
|
||||
unit_id?: number;
|
||||
unit?: { id: number; name: string } | null;
|
||||
}>({});
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -180,6 +184,10 @@ function selectNewDrug(drugId: number) {
|
||||
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();
|
||||
@@ -204,6 +212,8 @@ function addChineseDrug() {
|
||||
number: newDrugInfo.value.number,
|
||||
price: newDrugInfo.value.price || 0,
|
||||
way_id: newDrugInfo.value.way_id || 0,
|
||||
unit_id: newDrugInfo.value.unit_id || 0,
|
||||
unit: newDrugInfo.value.unit || null,
|
||||
});
|
||||
newDrugInfo.value = {};
|
||||
chineseSearchResults.value = [];
|
||||
@@ -244,6 +254,8 @@ function changeChineseDrug(index: number, drugId: number) {
|
||||
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;
|
||||
@@ -287,6 +299,8 @@ function loadDrugs(recipes: any[], dosage?: number, dayDosage?: number, drugPric
|
||||
price: zeroPrice ? 0 : (configured?.sell_price ?? recipe.price ?? 0),
|
||||
buy_price: zeroPrice ? 0 : (configured?.buy_price ?? recipe.buy_price ?? 0),
|
||||
way_id: recipe.way_id || 0,
|
||||
unit_id: recipe.unit_id || 0,
|
||||
unit: recipe.unit || null,
|
||||
};
|
||||
});
|
||||
if (dosage !== undefined) dosageLocal.value = dosage;
|
||||
@@ -404,7 +418,7 @@ defineExpose({
|
||||
<div v-if="structureReadonly" class="chinese-drug-readonly">
|
||||
<span class="chinese-drug-name-text">{{ drug.drug_name }}</span>
|
||||
<span class="chinese-drug-comma">,</span>
|
||||
<span>{{ drug.number }}g</span>
|
||||
<span>{{ drug.number }}{{ drug.unit?.name || 'g' }}</span>
|
||||
<span class="chinese-drug-comma">,</span>
|
||||
<span>{{ getWayName(drug.way_id) }}</span>
|
||||
</div>
|
||||
@@ -439,7 +453,7 @@ defineExpose({
|
||||
style="width: 60px"
|
||||
@change="onDrugNumberChange"
|
||||
/>
|
||||
<span class="chinese-drug-unit">g</span>
|
||||
<span class="chinese-drug-unit">{{ drug.unit?.name || 'g' }}</span>
|
||||
<span class="chinese-drug-comma">,</span>
|
||||
<Select
|
||||
v-model:value="drug.way_id"
|
||||
@@ -535,7 +549,7 @@ defineExpose({
|
||||
style="width: 60px"
|
||||
@keydown="(e) => handleChineseKeydown(e, true)"
|
||||
/>
|
||||
<span class="chinese-drug-unit">g</span>
|
||||
<span class="chinese-drug-unit">{{ newDrugInfo.unit?.name || 'g' }}</span>
|
||||
<span class="chinese-drug-comma">,</span>
|
||||
<Select
|
||||
v-model:value="newDrugInfo.way_id"
|
||||
|
||||
@@ -100,7 +100,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="编辑药方" class="w-[80%]">
|
||||
<Modal title="编辑药方" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
|
||||
<div v-if="prescriptionType === 'chinese'">
|
||||
<ChineseDrugEditor
|
||||
ref="chineseDrugEditorRef"
|
||||
|
||||
@@ -178,7 +178,7 @@ function bindPrescriptionTypeChange() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}特色方`" class="w-[80%]">
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}特色方`" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
|
||||
<Form />
|
||||
<div v-if="prescriptionType === 'chinese'" class="mt-4 border-t pt-4">
|
||||
<ChineseDrugEditor
|
||||
|
||||
@@ -58,7 +58,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}轮播图`"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
|
||||
177
apps/web-antd/src/views/business/user-patient/index.vue
Normal file
177
apps/web-antd/src/views/business/user-patient/index.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 会员管理:微信用户列表 → 就诊人列表 Modal → 就诊人详情 Modal
|
||||
* 菜单 component 路径:/business/user-patient/index
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Avatar, Button, Input, Space, Table, message } from 'ant-design-vue';
|
||||
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import { WxUserPatientsModal } from '#/components/wx-user-patient';
|
||||
import { getWxUserListApi } from '#/components/wx-user-patient/api';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
defineOptions({ name: 'UserPatientManage' });
|
||||
|
||||
const defaultAvatar = '/img/user-default-avatar.png';
|
||||
const loading = ref(false);
|
||||
const keyword = ref('');
|
||||
const list = ref<any[]>([]);
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const [PatientsModal, PatientsModalApi] = useVbenModal({
|
||||
connectedComponent: WxUserPatientsModal,
|
||||
});
|
||||
|
||||
function avatarSrc(raw?: string) {
|
||||
const s = String(raw ?? '').trim();
|
||||
if (!s) return defaultAvatar;
|
||||
return resolveAvatarUrl(s) || defaultAvatar;
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 无关键字时不带 keyword,避免被序列化成 "undefined"
|
||||
const params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
keyword?: string;
|
||||
} = {
|
||||
page: pagination.value.current,
|
||||
pageSize: pagination.value.pageSize,
|
||||
};
|
||||
const kw = keyword.value.trim();
|
||||
if (kw) {
|
||||
params.keyword = kw;
|
||||
}
|
||||
const res = await getWxUserListApi(params);
|
||||
list.value = res?.items ?? [];
|
||||
pagination.value.total = Number(res?.total || 0);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('加载会员列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
pagination.value.current = 1;
|
||||
void loadList();
|
||||
}
|
||||
|
||||
function onPageChange(page: number, pageSize: number) {
|
||||
pagination.value.current = page;
|
||||
pagination.value.pageSize = pageSize;
|
||||
void loadList();
|
||||
}
|
||||
|
||||
/** 点微信用户 → 打开该用户下就诊人列表 Modal */
|
||||
function openPatients(row: Record<string, any>) {
|
||||
const userId = Number(row.id || 0);
|
||||
if (!userId) {
|
||||
message.warning('缺少用户信息');
|
||||
return;
|
||||
}
|
||||
PatientsModalApi.setData({
|
||||
userId,
|
||||
nickname: row.nickname || '',
|
||||
avatarurl: row.avatarurl || '',
|
||||
});
|
||||
PatientsModalApi.open();
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '微信用户', key: 'user', width: 180 },
|
||||
{ title: '用户手机', key: 'mobile', width: 140 },
|
||||
{ title: '就诊人', key: 'patients' },
|
||||
{ title: '用户 ID', key: 'id', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 100 },
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
void loadList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="会员管理">
|
||||
<PatientsModal />
|
||||
<div class="mb-3 flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
placeholder="昵称 / 手机 / 就诊人"
|
||||
style="width: 280px"
|
||||
@press-enter="onSearch"
|
||||
/>
|
||||
<Button type="primary" @click="onSearch">查询</Button>
|
||||
</div>
|
||||
<Table
|
||||
:loading="loading"
|
||||
:data-source="list"
|
||||
:columns="columns"
|
||||
:pagination="{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
onChange: onPageChange,
|
||||
}"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
:custom-row="
|
||||
(record) => ({
|
||||
onClick: () => openPatients(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})
|
||||
"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'user'">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar :size="24" :src="avatarSrc(record.avatarurl)" />
|
||||
<div class="min-w-0 truncate text-sm">{{ record.nickname || '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'mobile'">
|
||||
<SensitiveText :record="record" field="mobile" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'patients'">
|
||||
<div
|
||||
v-if="record.patients?.length"
|
||||
class="flex flex-col gap-1 text-sm leading-snug"
|
||||
>
|
||||
<div
|
||||
v-for="p in record.patients"
|
||||
:key="p.up_id"
|
||||
class="flex flex-wrap items-center gap-x-2"
|
||||
>
|
||||
<span>{{ p.name || '—' }}</span>
|
||||
<SensitiveText :record="p" field="mobile" />
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'id'">
|
||||
{{ record.id ?? '—' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Space>
|
||||
<Button type="link" @click.stop="openPatients(record)">
|
||||
就诊人
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -132,7 +132,7 @@ const exportWarehouseDrugManagementTemplate = () => {
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="importType === 1 ? '上传Excel' : titles" class="w-[30%]">
|
||||
<Modal :title="importType === 1 ? '上传Excel' : titles" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
|
||||
<Button
|
||||
v-if="importType === 1"
|
||||
type="link"
|
||||
|
||||
@@ -112,7 +112,7 @@ const exportWarehouseDrugManagementStoreTemplate = () => {
|
||||
<template>
|
||||
<Modal
|
||||
:title="importType === 1 ? '上传Excel' : '批量修改价格'"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Button
|
||||
v-if="importType === 1"
|
||||
|
||||
@@ -126,7 +126,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}仓库药品`"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
|
||||
@@ -115,7 +115,7 @@ const exportWarehouseDrugManagementStoreTemplate = () => {
|
||||
<template>
|
||||
<Modal
|
||||
:title="importType === 1 ? '上传Excel' : '批量修改价格'"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Button
|
||||
v-if="importType === 1"
|
||||
|
||||
@@ -126,7 +126,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}仓库药品`"
|
||||
class="w-[30%]"
|
||||
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
|
||||
@@ -2,6 +2,18 @@ import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'doctor-reception/';
|
||||
|
||||
/**
|
||||
* 按药品查询可选配送仓库(药店开方选仓)
|
||||
*/
|
||||
export async function getDeliveryWarehouseOptionsByDrugs(data: {
|
||||
drug_ids: number[] | string;
|
||||
need_qty_map?: Record<number | string, number>;
|
||||
}) {
|
||||
return requestClient.get<any>('delivery-warehouse-drug/options-by-drugs', {
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 医生应用特色方到开方区
|
||||
*/
|
||||
@@ -92,6 +104,26 @@ export async function getCurrentStoreTypeApi(params?: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 开方处方类型选项(含默认选中、可选 icon / icon_text)
|
||||
*/
|
||||
export async function getPrescriptionTypeOptionsApi(params?: {
|
||||
register_id?: number;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.get<{
|
||||
default: number;
|
||||
list: Array<{
|
||||
value: number;
|
||||
label: string;
|
||||
icon?: string;
|
||||
icon_text?: string;
|
||||
}>;
|
||||
}>(`${prefix}prescription-type-options`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 接诊
|
||||
*/
|
||||
|
||||
@@ -142,7 +142,8 @@ function getDrugNames(recipes: any[], nameField = 'drug_name', showNumber = fals
|
||||
return recipes.map((item) => {
|
||||
const name = item[nameField] || item.name;
|
||||
if (showNumber && item.number) {
|
||||
return `${name}(${item.number}g)`;
|
||||
const unit = item.unit?.name || item.unit_name || 'g';
|
||||
return `${name}(${item.number}${unit})`;
|
||||
}
|
||||
return name;
|
||||
}).join('、');
|
||||
|
||||
@@ -1,46 +1,132 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
/**
|
||||
* 处方详情弹窗
|
||||
* - 常规查看:仅展示/打印
|
||||
* - auditMode:底部显示「通过审方 / 拒绝」,供药师审方页使用
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
import { Button, Spin, message } from 'ant-design-vue';
|
||||
|
||||
import { getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api';
|
||||
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
|
||||
import { formatStoreNameWithHu } from '#/utils/formatStoreNameWithHu';
|
||||
import { passApi } from '#/views/pharmacist/audit-prescription/api';
|
||||
import RejectionReason from '#/views/pharmacist/audit-prescription/components/modal.vue';
|
||||
|
||||
const item = ref<Record<string, any>>({});
|
||||
const loading = ref(false);
|
||||
const passing = ref(false);
|
||||
/** 药师审方场景标识 */
|
||||
const auditMode = ref(false);
|
||||
/** 审核成功后回调(刷新列表) */
|
||||
const onAudited = ref<(() => void) | null>(null);
|
||||
|
||||
/** 顶栏诊所名:在线渠道追加(互);无诊所名时兜底空串 */
|
||||
const clinicTitleName = computed(() =>
|
||||
formatStoreNameWithHu(
|
||||
item.value?.store?.name || item.value?.content?.store?.name || '',
|
||||
item.value?.is_online,
|
||||
),
|
||||
);
|
||||
|
||||
/** 是否展示底部审方按钮:审方模式且待审核 */
|
||||
const showAuditActions = computed(
|
||||
() => auditMode.value && Number(item.value?.status) === 0,
|
||||
);
|
||||
|
||||
const [RejectionReasonModal, RejectionReasonModalApi] = useVbenModal({
|
||||
connectedComponent: RejectionReason,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
// 默认隐藏确认按钮;审方模式用自定义 footer
|
||||
showConfirmButton: false,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const { values } = modalApi.getData<Record<string, any>>();
|
||||
if (values) {
|
||||
if (typeof values === 'number') {
|
||||
getPrescriptionInfoApi(values).then((res) => {
|
||||
item.value = res;
|
||||
if (!isOpen) {
|
||||
item.value = {};
|
||||
auditMode.value = false;
|
||||
onAudited.value = null;
|
||||
loading.value = false;
|
||||
passing.value = false;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<Record<string, any>>() || {};
|
||||
auditMode.value = !!data.auditMode;
|
||||
onAudited.value =
|
||||
typeof data.onAudited === 'function' ? data.onAudited : null;
|
||||
const { values } = data;
|
||||
if (values) {
|
||||
if (typeof values === 'number') {
|
||||
loading.value = true;
|
||||
getPrescriptionInfoApi(values)
|
||||
.then((res) => {
|
||||
item.value = res || {};
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
} else {
|
||||
item.value = values;
|
||||
}
|
||||
} else {
|
||||
item.value = values;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function handleWindowPrint(ele, fileName) {
|
||||
// 获取要打印的元素
|
||||
/**
|
||||
* 通过审方:调用审核接口,成功后关弹窗并通知父页刷新
|
||||
*/
|
||||
/**
|
||||
* 通过审方:先回调刷新列表(保留搜索/分页),再关弹窗
|
||||
* 必须先 onAudited 再 close,否则 onOpenChange(false) 会清空回调
|
||||
*/
|
||||
async function handlePass() {
|
||||
const id = item.value?.id;
|
||||
if (!id || passing.value) return;
|
||||
passing.value = true;
|
||||
try {
|
||||
await passApi({ id });
|
||||
message.success('通过成功');
|
||||
const refresh = onAudited.value;
|
||||
refresh?.();
|
||||
modalApi.close();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
passing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开拒绝原因弹窗;提交成功后先刷新列表再关闭详情
|
||||
*/
|
||||
function handleReject() {
|
||||
const id = item.value?.id;
|
||||
if (!id) return;
|
||||
RejectionReasonModalApi.setData({
|
||||
values: id,
|
||||
onSuccess: () => {
|
||||
const refresh = onAudited.value;
|
||||
refresh?.();
|
||||
modalApi.close();
|
||||
},
|
||||
});
|
||||
RejectionReasonModalApi.open();
|
||||
}
|
||||
|
||||
function handleWindowPrint(_ele, fileName) {
|
||||
const printBox = document.querySelector('.print-box');
|
||||
if (!printBox) {
|
||||
console.error('找不到具有 "print-box" 类的元素');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建一个隐藏的iframe
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.position = 'fixed';
|
||||
iframe.style.right = '0';
|
||||
@@ -49,10 +135,7 @@ function handleWindowPrint(ele, fileName) {
|
||||
iframe.style.height = '0';
|
||||
iframe.style.border = '0';
|
||||
document.body.append(iframe);
|
||||
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
|
||||
// 写入HTML结构
|
||||
iframeDoc.open();
|
||||
iframeDoc.write(`
|
||||
<!DOCTYPE html>
|
||||
@@ -65,41 +148,33 @@ function handleWindowPrint(ele, fileName) {
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
// 复制原页面的所有样式
|
||||
const styles = document.querySelectorAll('style, link[rel="stylesheet"]');
|
||||
styles.forEach((style) => {
|
||||
if (style.tagName === 'LINK') {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = style.href; // 使用绝对路径
|
||||
link.href = style.href;
|
||||
iframeDoc.head.append(link);
|
||||
} else {
|
||||
iframeDoc.head.append(style.cloneNode(true));
|
||||
}
|
||||
});
|
||||
|
||||
iframeDoc.close();
|
||||
|
||||
// 加载完成后触发打印
|
||||
iframe.contentWindow.addEventListener('load', () => {
|
||||
iframe.contentWindow.print();
|
||||
// 打印后移除iframe
|
||||
setTimeout(() => {
|
||||
iframe.remove();
|
||||
}, 1000); // 确保打印对话框已弹出
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// 解密方法示例(需要根据实际加密方式实现)
|
||||
const decrypt = (str: string) => str; // 简单base64解码示例
|
||||
const decrypt = (str: string) => str;
|
||||
|
||||
const getImageSource = (imageString) => {
|
||||
if (imageString && imageString.includes('http')) {
|
||||
return imageString; // 直接使用HTTP URL
|
||||
} else {
|
||||
return `data:image/jpeg;base64,${imageString}`; // 使用Base64格式
|
||||
return imageString;
|
||||
}
|
||||
return `data:image/jpeg;base64,${imageString}`;
|
||||
};
|
||||
|
||||
function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
@@ -116,192 +191,216 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[60%]" title="中药处方详情">
|
||||
<Page>
|
||||
<Button type="primary" @click="handleWindowPrint('#demo', '处方')">
|
||||
打印处方
|
||||
</Button>
|
||||
<div class="prescription-container print-box">
|
||||
<!-- 头部信息 -->
|
||||
<div class="prescription-header">
|
||||
<div class="header-top">
|
||||
<span>处方编号: {{ item.prescription_no }}</span>
|
||||
<div class="prescription-type">普通处方</div>
|
||||
</div>
|
||||
<h2 class="clinic-name">{{ item.content?.patient.name }} 处方笺</h2>
|
||||
<div class="prescription-date">开具日期: {{ item.created_at }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 患者信息 -->
|
||||
<div class="patient-info">
|
||||
<div class="info-row">
|
||||
<span>姓名: {{ decrypt(item.content?.patient.name) }}</span>
|
||||
<span>性别: {{ item.content?.patient.sex === 1 ? '男' : '女' }}</span>
|
||||
<span>年龄: {{ item.content?.patient.age }}</span>
|
||||
<span>类别: {{ item.content?.category }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>科室: {{ item.content?.doctor.depart?.name }}</span>
|
||||
<span>诊断: {{ item.content?.clinical_diagnose }}</span>
|
||||
</div>
|
||||
<template v-if="item.online_tcm_print?.show">
|
||||
<div v-if="item.online_tcm_print.tcm_syndrome" class="info-row">
|
||||
<span>中医证候: {{ item.online_tcm_print.tcm_syndrome }}</span>
|
||||
<Modal class="w-[60%]" title="处方详情">
|
||||
<RejectionReasonModal />
|
||||
<Spin :spinning="loading">
|
||||
<Page>
|
||||
<Button type="primary" @click="handleWindowPrint('#demo', '处方')">
|
||||
打印处方
|
||||
</Button>
|
||||
<div class="prescription-container print-box">
|
||||
<div class="prescription-header">
|
||||
<div class="header-top">
|
||||
<span>处方编号: {{ item.prescription_no }}</span>
|
||||
<div class="prescription-type">普通处方</div>
|
||||
</div>
|
||||
<div v-if="item.online_tcm_print.tcm_method" class="info-row">
|
||||
<span>中医治法: {{ item.online_tcm_print.tcm_method }}</span>
|
||||
<h2 class="clinic-name">{{ clinicTitleName || '诊所' }} 处方笺</h2>
|
||||
<div class="prescription-date">开具日期: {{ item.created_at }}</div>
|
||||
</div>
|
||||
<div class="patient-info">
|
||||
<div class="info-row">
|
||||
<span>姓名: {{ decrypt(item.content?.patient?.name) }}</span>
|
||||
<span>
|
||||
性别: {{ item.content?.patient?.sex === 1 ? '男' : '女' }}
|
||||
</span>
|
||||
<span>年龄: {{ item.content?.patient?.age }}</span>
|
||||
<span>类别: {{ item.content?.category }}</span>
|
||||
</div>
|
||||
<div v-if="item.online_tcm_print.tcm_disease" class="info-row">
|
||||
<span>中医疾病: {{ item.online_tcm_print.tcm_disease }}</span>
|
||||
<div class="info-row">
|
||||
<span>科室: {{ item.content?.doctor?.depart?.name }}</span>
|
||||
<span>诊断: {{ item.content?.clinical_diagnose }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 药品列表 -->
|
||||
<div class="medicine-list">
|
||||
<div class="rp-title">Rp</div>
|
||||
<div
|
||||
v-for="(recipe, index) in item.content?.repice"
|
||||
:key="index"
|
||||
class="recipe-item"
|
||||
>
|
||||
<!-- <div class="medicine-item" v-for="drug in JSON.parse(recipe.content)" :key="drug?.id">-->
|
||||
<div v-if="item.prescription_type === 1" class="w-full">
|
||||
<div
|
||||
v-for="drug in JSON.parse(recipe.content)"
|
||||
:key="drug?.id"
|
||||
class="w-1/3"
|
||||
style="display: inline-block"
|
||||
>
|
||||
<div class="medicine-item">
|
||||
<!-- {{ drug }}-->
|
||||
<span class="drug-name">{{
|
||||
drug?.name || drug?.drug_name
|
||||
}}</span>
|
||||
<span class="drug-quantity mr-5">{{ drug?.number }} /g</span>
|
||||
</div>
|
||||
<!-- <div class="">煎服方法: {{ drug.use_way?.name || '煎服' }}</div>-->
|
||||
<div>
|
||||
方法:
|
||||
<span class="preparation-info">{{
|
||||
drug.use_way?.name || drug.useWay || '煎服'
|
||||
}}</span>
|
||||
</div>
|
||||
<template v-if="item.online_tcm_print?.show">
|
||||
<div v-if="item.online_tcm_print.tcm_syndrome" class="info-row">
|
||||
<span>中医证候: {{ item.online_tcm_print.tcm_syndrome }}</span>
|
||||
</div>
|
||||
<div class="preparation-info">
|
||||
<p>
|
||||
用法:每日{{ recipe.consumption }}次,共{{ recipe.dosage }}剂
|
||||
</p>
|
||||
<p>
|
||||
使用方式:{{
|
||||
`${recipe.process_rule_note},${recipe.process_rule}`
|
||||
}}
|
||||
</p>
|
||||
<div v-if="item.online_tcm_print.tcm_method" class="info-row">
|
||||
<span>中医治法: {{ item.online_tcm_print.tcm_method }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="item.prescription_type === 2 || item.prescription_type === 3 || item.prescription_type === 5 || item.prescription_type === 6 || item.prescription_type === 7">
|
||||
<template
|
||||
v-for="drug in [parseRecipeContent(recipe.content)]"
|
||||
:key="`west-${index}`"
|
||||
>
|
||||
<div class="medicine-item">
|
||||
<span class="drug-name">{{
|
||||
drug?.name || drug?.drug_name
|
||||
}}<span
|
||||
v-if="drug?.specification"
|
||||
class="drug-spec"
|
||||
>{{ drug.specification }}</span></span>
|
||||
<span class="drug-quantity">{{ drug?.number
|
||||
}}{{ drug?.unit?.name }}</span>
|
||||
<div v-if="drug?.useWay" class="usage-info">
|
||||
{{ drug.useWay }}
|
||||
<div v-if="item.online_tcm_print.tcm_disease" class="info-row">
|
||||
<span>中医疾病: {{ item.online_tcm_print.tcm_disease }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="medicine-list">
|
||||
<div class="rp-title">Rp</div>
|
||||
<div
|
||||
v-for="(recipe, index) in item.content?.repice"
|
||||
:key="index"
|
||||
class="recipe-item"
|
||||
>
|
||||
<div v-if="item.prescription_type === 1" class="w-full">
|
||||
<div
|
||||
v-for="drug in JSON.parse(recipe.content)"
|
||||
:key="drug?.id"
|
||||
class="w-1/3"
|
||||
style="display: inline-block"
|
||||
>
|
||||
<div class="medicine-item">
|
||||
<span class="drug-name">{{
|
||||
drug?.name || drug?.drug_name
|
||||
}}</span>
|
||||
<span class="drug-quantity mr-5">{{ drug?.number }} /{{ drug?.unit?.name || drug?.use_unit?.name || 'g' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
方法:
|
||||
<span class="preparation-info">{{
|
||||
drug.use_way?.name || drug.useWay || '煎服'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="preparation-info">
|
||||
使用方法: {{ recipe.instruction }}
|
||||
<div class="preparation-info">
|
||||
<p>
|
||||
用法:每日{{ recipe.consumption }}次,共{{ recipe.dosage }}剂
|
||||
</p>
|
||||
<p>
|
||||
使用方式:{{
|
||||
`${recipe.process_rule_note},${recipe.process_rule}`
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
item.prescription_type === 2 ||
|
||||
item.prescription_type === 3 ||
|
||||
item.prescription_type === 5 ||
|
||||
item.prescription_type === 6 ||
|
||||
item.prescription_type === 7
|
||||
"
|
||||
>
|
||||
<template
|
||||
v-for="drug in [parseRecipeContent(recipe.content)]"
|
||||
:key="`west-${index}`"
|
||||
>
|
||||
<div class="medicine-item">
|
||||
<span class="drug-name"
|
||||
>{{ drug?.name || drug?.drug_name
|
||||
}}<span
|
||||
v-if="drug?.specification"
|
||||
class="drug-spec"
|
||||
>{{ drug.specification }}</span
|
||||
></span
|
||||
>
|
||||
<span class="drug-quantity"
|
||||
>{{ drug?.number }}{{ drug?.unit?.name }}</span
|
||||
>
|
||||
<div v-if="drug?.useWay" class="usage-info">
|
||||
{{ drug.useWay }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="preparation-info">
|
||||
使用方法: {{ recipe.instruction }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 医嘱及签名 -->
|
||||
<div class="footer-section">
|
||||
<div class="medical-advice">
|
||||
<label>医嘱:</label>
|
||||
{{ formatDoctorOrderText(item) }}
|
||||
</div>
|
||||
<div class="signature-area">
|
||||
<div class="signature">
|
||||
<label>开方医生:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="getImageSource(item.doctor_info.identity_info.sign_image)"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block; "
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor.name }}</span>
|
||||
<div class="footer-section">
|
||||
<div class="medical-advice">
|
||||
<label>医嘱:</label>
|
||||
{{ formatDoctorOrderText(item) }}
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>审核药师:</label>
|
||||
<img
|
||||
v-if="item.pharmacist_info?.identity?.sign_image"
|
||||
:src="getImageSource(item.pharmacist_info.identity.sign_image)"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.pharmacist_info?.name }}</span>
|
||||
<div class="signature-area">
|
||||
<div class="signature">
|
||||
<label>开方医生:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="
|
||||
getImageSource(item.doctor_info.identity_info.sign_image)
|
||||
"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor?.name }}</span>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>审核药师:</label>
|
||||
<img
|
||||
v-if="item.pharmacist_info?.identity?.sign_image"
|
||||
:src="
|
||||
getImageSource(item.pharmacist_info.identity.sign_image)
|
||||
"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.pharmacist_info?.name }}</span>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>调配人:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="
|
||||
getImageSource(item.doctor_info.identity_info.sign_image)
|
||||
"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor?.name }}</span>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>核对人:</label>
|
||||
<img
|
||||
v-if="item.pharmacist_info?.identity?.sign_image"
|
||||
:src="
|
||||
getImageSource(item.pharmacist_info.identity.sign_image)
|
||||
"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.pharmacist_info?.name }}</span>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>发药人:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="
|
||||
getImageSource(item.doctor_info.identity_info.sign_image)
|
||||
"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor?.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>调配人:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="getImageSource(item.doctor_info.identity_info.sign_image)"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor.name }}</span>
|
||||
<div class="price-info"></div>
|
||||
<div class="price-info">总价: ¥{{ item.total_pay_price }}</div>
|
||||
<div
|
||||
v-if="item.status === 2"
|
||||
class="validity"
|
||||
style="color: red; font-weight: bold"
|
||||
>
|
||||
该处方未通过审核
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>核对人:</label>
|
||||
<img
|
||||
v-if="item.pharmacist_info?.identity?.sign_image"
|
||||
:src="getImageSource(item.pharmacist_info.identity.sign_image)"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.pharmacist_info?.name }}</span>
|
||||
<div v-else-if="item.auto_expire_txt === ''" class="validity">
|
||||
处方有效期: {{ item.valid_hours }}小时
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>发药人:</label>
|
||||
<img
|
||||
v-if="item.doctor_info?.identity_info?.sign_image"
|
||||
:src="getImageSource(item.doctor_info.identity_info.sign_image)"
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor.name }}</span>
|
||||
<div v-else class="validity" style="color: red">
|
||||
{{ item.auro_expire_txt }}
|
||||
</div>
|
||||
<!-- <div class="signature">-->
|
||||
<!-- <label>加工费:</label>-->
|
||||
|
||||
<!-- <span v-if="item.prescription_type === 1">¥{{ item.content.repice[0].process_price }}</span>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
<div class="price-info"></div>
|
||||
<div class="price-info">总价: ¥{{ item.total_pay_price }}</div>
|
||||
<div v-if="item.status === 2" class="validity" style="color: red; font-weight: bold">
|
||||
该处方未通过审核
|
||||
</div>
|
||||
<div v-else-if="item.auto_expire_txt === ''" class="validity">
|
||||
处方有效期: {{ item.valid_hours }}小时
|
||||
</div>
|
||||
<div v-else class="validity" style="color: red">
|
||||
{{ item.auro_expire_txt }}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</Spin>
|
||||
<template v-if="showAuditActions" #footer>
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
<Button @click="modalApi.close()">取消</Button>
|
||||
<Button danger :disabled="passing" @click="handleReject">拒绝</Button>
|
||||
<Button type="primary" :loading="passing" @click="handlePass">
|
||||
通过审方
|
||||
</Button>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -313,111 +412,97 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
padding: 20px;
|
||||
font-family: 'SimSun', serif;
|
||||
}
|
||||
|
||||
.prescription-header {
|
||||
border-bottom: 2px solid #000;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.prescription-type {
|
||||
border: 1px solid #666;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.clinic-name {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.patient-info .info-row {
|
||||
.prescription-date {
|
||||
text-align: right;
|
||||
}
|
||||
.patient-info {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.medicine-list {
|
||||
margin: 20px 0 200px 0;
|
||||
border-top: 1px solid #ccc;
|
||||
padding-top: 15px;
|
||||
min-height: 200px;
|
||||
border-top: 1px dashed #666;
|
||||
border-bottom: 1px dashed #666;
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.rp-title {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.recipe-item {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.recipe-item {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.medicine-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 8px 0;
|
||||
padding: 4px 0;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.chinese-item {
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.drug-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drug-spec {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-left: 8px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.preparation-info {
|
||||
color: #666;
|
||||
margin-top: 12px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.medical-advice {
|
||||
color: #c00;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.signature-area {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.signature {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.price-info {
|
||||
margin-top: 20px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.validity {
|
||||
.drug-spec {
|
||||
margin-left: 6px;
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
margin-top: 25px;
|
||||
}
|
||||
.usage-info {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.preparation-info {
|
||||
margin-top: 8px;
|
||||
color: #333;
|
||||
}
|
||||
.footer-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.medical-advice {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.signature-area {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 24px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.signature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.price-info {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.validity {
|
||||
text-align: right;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -71,16 +71,22 @@ const setVisible = (value: boolean, instruction = '') => {
|
||||
previewImage.value = instruction;
|
||||
};
|
||||
|
||||
// 本地存储键名(支持前缀区分不同场景)
|
||||
// 本地存储键名:与 prescription store 一致(prefix 已含尾部 `-`,勿再拼 `-`)
|
||||
const storageKey = computed(() => {
|
||||
const prefix = storagePrefix.value ? `${storagePrefix.value}-` : '';
|
||||
return `${prefix}prescriptionData_${type.value}_${activePatientId.value}`;
|
||||
return `${storagePrefix.value}prescriptionData_${type.value}_${activePatientId.value}`;
|
||||
});
|
||||
|
||||
// 获取当前患者的药品数据
|
||||
/**
|
||||
* 同步已选药品到 selectList
|
||||
* chat 模式以 Pinia currentDrugs 为准,避免双横线孤儿缓存回填
|
||||
*/
|
||||
const getCurrentDrugs = () => {
|
||||
try {
|
||||
// 从localStorage获取数据并解析
|
||||
if (isChatMode.value) {
|
||||
const drugs = prescriptionStore.currentDrugs;
|
||||
selectList.value = Array.isArray(drugs) ? [...drugs] : [];
|
||||
return;
|
||||
}
|
||||
const storedData = localStorage.getItem(storageKey.value);
|
||||
selectList.value = storedData ? JSON.parse(storedData) : [];
|
||||
} catch (error) {
|
||||
@@ -167,6 +173,8 @@ function addProducts(data: any) {
|
||||
number: 1,
|
||||
price: data.price,
|
||||
image: data.drug.image,
|
||||
// 规格(选仓弹窗等处展示)
|
||||
specification: data.drug.specification || data.specification || '',
|
||||
instruction: data.drug.instruction,
|
||||
type: data.drug.type,
|
||||
select_number: 1,
|
||||
@@ -316,7 +324,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
// 提示成功
|
||||
message.success('保存成功');
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
onOpenChange: async (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
// 获取回调函数
|
||||
const data = modalApi.getData();
|
||||
@@ -334,8 +342,15 @@ const [Modal, modalApi] = useVbenModal({
|
||||
activePatientId.value = activePatient_id;
|
||||
if (isChat === 'chat') {
|
||||
isChatMode.value = true;
|
||||
// 使用轻量级初始化,不获取患者信息
|
||||
initializeForModal(activePatient_id);
|
||||
await initializeForModal(
|
||||
activePatient_id,
|
||||
prefix || 'onlineConsultation-',
|
||||
);
|
||||
// 弹窗类型与 store tab 对齐,避免读到其它分类的已选
|
||||
prescriptionStore.activeCategory = type.value;
|
||||
prescriptionStore.loadFromLocalStorage();
|
||||
const drugs = prescriptionStore.currentDrugs;
|
||||
selectList.value = Array.isArray(drugs) ? [...drugs] : [];
|
||||
} else {
|
||||
isChatMode.value = false;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,13 @@ const userStore = useUserStore();
|
||||
const searchKey = ref('');
|
||||
// 药品类型:1-中药,2-西药
|
||||
const type = ref(1);
|
||||
/** 弹窗标题:中药时展示已选种数 */
|
||||
const modalTitle = computed(() => {
|
||||
if (Number(type.value) === 1) {
|
||||
return `选择中药(${selectList.value.length}种)`;
|
||||
}
|
||||
return '商品列表';
|
||||
});
|
||||
// 当前药品回调函数
|
||||
const currentDrugsWestern = ref();
|
||||
// 当前患者ID
|
||||
@@ -77,16 +84,22 @@ const setVisible = (value, instruction = '') => {
|
||||
previewImage.value = instruction;
|
||||
};
|
||||
|
||||
// 本地存储键名(支持前缀区分不同场景)
|
||||
// 本地存储键名:与 prescription store 一致(prefix 已含尾部 `-`,勿再拼 `-`)
|
||||
const storageKey = computed(() => {
|
||||
const prefix = storagePrefix.value ? `${storagePrefix.value}-` : '';
|
||||
return `${prefix}prescriptionData_${type.value}_${activePatientId.value}`;
|
||||
return `${storagePrefix.value}prescriptionData_${type.value}_${activePatientId.value}`;
|
||||
});
|
||||
|
||||
// 获取当前患者的药品数据
|
||||
/**
|
||||
* 同步已选药品到 selectList
|
||||
* chat 模式以 Pinia currentDrugs 为准(发送后已清空),避免孤儿 localStorage 回填
|
||||
*/
|
||||
const getCurrentDrugs = () => {
|
||||
try {
|
||||
// 从localStorage获取数据并解析
|
||||
if (ChatTypeCheck.value === 'chat') {
|
||||
const drugs = prescriptionStore.currentDrugs;
|
||||
selectList.value = Array.isArray(drugs) ? [...drugs] : [];
|
||||
return;
|
||||
}
|
||||
const storedData = localStorage.getItem(storageKey.value);
|
||||
selectList.value = storedData ? JSON.parse(storedData) : [];
|
||||
} catch (error) {
|
||||
@@ -116,6 +129,13 @@ const getDrugListByWesternModal = debounce(async (searchText = '') => {
|
||||
|
||||
drugList.value = res.map((item) => {
|
||||
const matched = selectList.value.find((v) => v.index_id === item.id);
|
||||
const unitId = matched?.unit_id || item.drug?.unit_id;
|
||||
// 优先接口返回的 unit,再回退本地字典,保证中药网格展示真实单位
|
||||
const unitObj =
|
||||
matched?.unit ||
|
||||
item.drug?.unit ||
|
||||
item.unit ||
|
||||
drugUnit.value.find((u) => u.id === unitId);
|
||||
return {
|
||||
...item,
|
||||
select_number: matched?.select_number || 0,
|
||||
@@ -127,7 +147,8 @@ const getDrugListByWesternModal = debounce(async (searchText = '') => {
|
||||
frequency_id: matched?.frequency_id || item.drug.frequency_id,
|
||||
type_id: matched?.type_id || item.drug.type_id,
|
||||
time_id: matched?.time_id || item.drug.time_id,
|
||||
unit_id: matched?.unit_id || item.drug.unit_id,
|
||||
unit_id: unitId,
|
||||
unit: unitObj,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -184,8 +205,10 @@ function addProducts(data) {
|
||||
use_frequency: drugUseFrequency.value.find(
|
||||
(item) => item.id === data.drug.frequency_id,
|
||||
),
|
||||
// 药品单位信息
|
||||
unit: drugUnit.value.find((item) => item.id === data.drug.unit_id),
|
||||
// 药品单位:优先用选药接口返回的 unit,再回退本地字典
|
||||
unit:
|
||||
data.drug?.unit ||
|
||||
drugUnit.value.find((item) => item.id === data.drug.unit_id),
|
||||
// 药品价格
|
||||
price: data.price,
|
||||
// 药品使用方式ID
|
||||
@@ -202,6 +225,8 @@ function addProducts(data) {
|
||||
unit_id: data.drug.unit_id,
|
||||
// 药品图片
|
||||
image: data.drug.image,
|
||||
// 规格(选仓弹窗等处展示)
|
||||
specification: data.drug.specification || data.specification || '',
|
||||
// 药品说明书
|
||||
instruction: data.drug.instruction,
|
||||
// 药品类型
|
||||
@@ -376,7 +401,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
// 提示成功
|
||||
message.success('保存成功');
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
onOpenChange: async (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
// 获取回调函数
|
||||
const data = modalApi.getData();
|
||||
@@ -394,8 +419,18 @@ const [Modal, modalApi] = useVbenModal({
|
||||
activePatientId.value = activePatient_id;
|
||||
if (isChat === 'chat') {
|
||||
ChatTypeCheck.value = isChat;
|
||||
// 使用轻量级初始化,不获取患者信息
|
||||
initializeForModal(activePatient_id);
|
||||
// 传入 prefix,与 store 读写同一套 key;完成后用 store 回填已选
|
||||
await initializeForModal(
|
||||
activePatient_id,
|
||||
prefix || 'onlineConsultation-',
|
||||
);
|
||||
// 弹窗类型可能与 initialize 恢复的 tab 不一致,强制对齐后再取已选
|
||||
prescriptionStore.activeCategory = type.value;
|
||||
prescriptionStore.loadFromLocalStorage();
|
||||
const drugs = prescriptionStore.currentDrugs;
|
||||
selectList.value = Array.isArray(drugs) ? [...drugs] : [];
|
||||
} else {
|
||||
ChatTypeCheck.value = '';
|
||||
}
|
||||
// 获取药品列表
|
||||
getDrugListByWesternModal();
|
||||
@@ -557,7 +592,7 @@ function updateProductNumber(id, number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[60%]" title="商品列表">
|
||||
<Modal class="w-[60%]" :title="modalTitle">
|
||||
<!-- 图片预览组件 -->
|
||||
<Image
|
||||
:preview="{
|
||||
@@ -733,7 +768,7 @@ function updateProductNumber(id, number) {
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-red-500">¥{{ item.price }}</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">/g</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">/{{ item.drug?.unit?.name || item.unit?.name || 'g' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -767,7 +802,7 @@ function updateProductNumber(id, number) {
|
||||
class="flex-1 !bg-transparent !border-0 shadow-none text-center"
|
||||
@change="updateProductNumber(item.id, item.drug.number)"
|
||||
/>
|
||||
<span class="text-gray-400 dark:text-gray-500 text-xs px-1">g</span>
|
||||
<span class="text-gray-400 dark:text-gray-500 text-xs px-1">{{ item.drug?.unit?.name || item.unit?.name || 'g' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 加减按钮 (Action) -->
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user