Compare commits
2 Commits
dev/messag
...
5ff6f0d837
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ff6f0d837 | ||
|
|
8eca0bd84d |
@@ -1,233 +1,10 @@
|
|||||||
---
|
---
|
||||||
description: 萧康云医管理后台(Vben Admin v5 + Vue 3 + TS + Ant Design Vue)代码规范
|
description:
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# 基础规范(所有项目通用)
|
|
||||||
|
|
||||||
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
|
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
|
||||||
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于 2 行
|
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于2行
|
||||||
3. 有封装好的方法、组件需要复用,不要重复造轮子
|
3. 有封装好的方法、组件需要复用,不要重复造轮子
|
||||||
4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(记得使用 v-if 而不是 v-show)
|
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面(记得使用v-if而不是v-show)
|
||||||
5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化
|
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` 数组
|
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export type ComponentType =
|
|||||||
| 'DatePicker'
|
| 'DatePicker'
|
||||||
| 'DefaultButton'
|
| 'DefaultButton'
|
||||||
| 'Divider'
|
| 'Divider'
|
||||||
| 'GalleryPickLink' // OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型)
|
|
||||||
| 'IconPicker'
|
| 'IconPicker'
|
||||||
| 'Input'
|
| 'Input'
|
||||||
| 'InputNumber'
|
| 'InputNumber'
|
||||||
|
|||||||
@@ -6,19 +6,20 @@
|
|||||||
* - 支持中药/西药类型切换
|
* - 支持中药/西药类型切换
|
||||||
* - 下拉选项展示:药品图片、名称、供应商、规格、价格
|
* - 下拉选项展示:药品图片、名称、供应商、规格、价格
|
||||||
* - 选择时返回完整药品数据(包含默认用法字段)
|
* - 选择时返回完整药品数据(包含默认用法字段)
|
||||||
|
* - isCard:单选确认场景(如仓绑药)选中后以内嵌卡片展示已选药品
|
||||||
* @author 系统
|
* @author 系统
|
||||||
* @date 2024
|
* @date 2024
|
||||||
*/
|
*/
|
||||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
import { SearchOutlined, LoadingOutlined } from '@ant-design/icons-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 { debounce } from 'lodash-es';
|
||||||
|
|
||||||
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
|
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';
|
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
|
||||||
|
|
||||||
// ==================== Props 定义 ====================
|
// ==================== Props 定义 ====================
|
||||||
@@ -29,12 +30,17 @@ interface Props {
|
|||||||
* 1-中药,2-西药
|
* 1-中药,2-西药
|
||||||
*/
|
*/
|
||||||
type?: number;
|
type?: number;
|
||||||
|
/**
|
||||||
|
* 多药品类型(优先于 type;有值时支持空关键词拉全量)
|
||||||
|
* 例如 [2,4] 表示西药+中成药
|
||||||
|
*/
|
||||||
|
types?: number[];
|
||||||
/**
|
/**
|
||||||
* 占位提示文本
|
* 占位提示文本
|
||||||
*/
|
*/
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/**
|
/**
|
||||||
* 诊所ID
|
* 诊所ID(接诊/开方必传;仓药绑定等平台场景可省略,默认 0 走主库 types 检索)
|
||||||
*/
|
*/
|
||||||
storeId?: number;
|
storeId?: number;
|
||||||
/**
|
/**
|
||||||
@@ -45,14 +51,21 @@ interface Props {
|
|||||||
* 是否禁用
|
* 是否禁用
|
||||||
*/
|
*/
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否以卡片展示已选药品(仓绑药等单选确认场景;开方默认 false)
|
||||||
|
*/
|
||||||
|
isCard?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
type: 1,
|
type: 1,
|
||||||
|
types: () => [],
|
||||||
placeholder: '输入药品名称搜索',
|
placeholder: '输入药品名称搜索',
|
||||||
storeId: 2,
|
// 默认 0:平台无门店;诊所场景由调用方传 :store-id
|
||||||
|
storeId: 0,
|
||||||
registerId: undefined,
|
registerId: undefined,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
isCard: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================== Emits 定义 ====================
|
// ==================== Emits 定义 ====================
|
||||||
@@ -97,10 +110,21 @@ const containerRef = ref<HTMLElement | null>(null);
|
|||||||
*/
|
*/
|
||||||
const highlightIndex = ref(-1);
|
const highlightIndex = ref(-1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isCard 模式下已选药品(用于输入框下方卡片)
|
||||||
|
*/
|
||||||
|
const selectedDrug = ref<any | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索框引用,便于「重新选择」时聚焦
|
||||||
|
*/
|
||||||
|
const inputRef = ref<{ focus?: () => void } | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析下拉展示图:优先真图,无图用统一占位
|
||||||
|
*/
|
||||||
function resolveDropdownImage(item: any): string {
|
function resolveDropdownImage(item: any): string {
|
||||||
if (item._image) return item._image;
|
return item._image || DRUG_PLACEHOLDER_IMAGE;
|
||||||
if (props.type === 1) return TCM_PLACEHOLDER_IMAGE;
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 方法定义 ====================
|
// ==================== 方法定义 ====================
|
||||||
@@ -108,10 +132,12 @@ function resolveDropdownImage(item: any): string {
|
|||||||
/**
|
/**
|
||||||
* 搜索药品
|
* 搜索药品
|
||||||
* @param keyword 搜索关键词
|
* @param keyword 搜索关键词
|
||||||
* @description 调用后端API搜索药品,支持按名称和拼音搜索
|
* @description 调用后端API搜索药品,支持按名称和拼音搜索;
|
||||||
|
* 传入 types 时允许空关键词拉取该类型全量列表(用于下拉展开)
|
||||||
*/
|
*/
|
||||||
const searchDrugs = debounce(async (keyword: string) => {
|
const searchDrugs = debounce(async (keyword: string) => {
|
||||||
if (!keyword || keyword.length < 1) {
|
// 单类型模式:空关键词清空;多类型模式:空关键词仍请求后端拉全量
|
||||||
|
if ((!keyword || keyword.length < 1) && props.types.length === 0) {
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
return;
|
return;
|
||||||
@@ -121,37 +147,41 @@ const searchDrugs = debounce(async (keyword: string) => {
|
|||||||
showDropdown.value = true;
|
showDropdown.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// types 优先:有多类型时只传 types,不传 type,避免后端被单 type 覆盖
|
||||||
|
const typeParams =
|
||||||
|
props.types.length > 0
|
||||||
|
? { types: props.types }
|
||||||
|
: { type: props.type };
|
||||||
const res = await getProductListDoctorReception({
|
const res = await getProductListDoctorReception({
|
||||||
name: keyword,
|
name: keyword || '',
|
||||||
type: props.type,
|
...typeParams,
|
||||||
store_id: props.storeId,
|
store_id: props.storeId,
|
||||||
...(props.registerId ? { register_id: props.registerId } : {}),
|
...(props.registerId ? { register_id: props.registerId } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理返回数据
|
// 处理返回数据(兼容门店 relation 与平台主库打平结构)
|
||||||
if (Array.isArray(res)) {
|
if (Array.isArray(res)) {
|
||||||
searchResults.value = res.map((item: any) => ({
|
searchResults.value = res.map((item: any) => {
|
||||||
// 保留原始数据
|
const drug = item.drug || {};
|
||||||
...item,
|
return {
|
||||||
// 提取常用字段便于访问
|
...item,
|
||||||
_id: item.id,
|
_id: item.id,
|
||||||
_drugId: item.drug_id || item.drug?.id,
|
_drugId: item.drug_id || drug.id || item.id,
|
||||||
_drugName: item.drug?.drug_name || item.drug_name || '',
|
_drugName: drug.drug_name || item.drug_name || '',
|
||||||
_image: item.drug?.image || '',
|
_image: drug.image || item.image || '',
|
||||||
_specification: item.drug?.specification || '',
|
_specification: drug.specification || item.specification || '',
|
||||||
_supplier: item.drug?.supplier?.name || '',
|
_supplier: drug.supplier?.name || item.supplier?.name || '',
|
||||||
_price: item.price || 0,
|
_price: Number(item.price ?? 0),
|
||||||
// 默认用法字段
|
_timeId: drug.time_id || 0,
|
||||||
_timeId: item.drug?.time_id || 0,
|
_typeId: drug.type_id || 0,
|
||||||
_typeId: item.drug?.type_id || 0,
|
_frequencyId: drug.frequency_id || 0,
|
||||||
_frequencyId: item.drug?.frequency_id || 0,
|
_unitId: drug.unit_id || 0,
|
||||||
_unitId: item.drug?.unit_id || 0,
|
_number: drug.number || 1,
|
||||||
_number: item.drug?.number || 1,
|
_useNum: drug.useNum,
|
||||||
// 用法名称
|
_useType: drug.useType,
|
||||||
_useNum: item.drug?.useNum,
|
_useFrequency: drug.useFrequency,
|
||||||
_useType: item.drug?.useType,
|
};
|
||||||
_useFrequency: item.drug?.useFrequency,
|
});
|
||||||
}));
|
|
||||||
} else {
|
} else {
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
}
|
}
|
||||||
@@ -176,8 +206,14 @@ function handleInput(e: Event) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理输入框获得焦点
|
* 处理输入框获得焦点
|
||||||
|
* @description 多类型模式聚焦即拉取列表并展开;单类型模式仅在已有结果时展示下拉
|
||||||
*/
|
*/
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
|
if (props.types.length > 0) {
|
||||||
|
searchDrugs('');
|
||||||
|
showDropdown.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (searchResults.value.length > 0) {
|
if (searchResults.value.length > 0) {
|
||||||
showDropdown.value = true;
|
showDropdown.value = true;
|
||||||
}
|
}
|
||||||
@@ -186,15 +222,36 @@ function handleFocus() {
|
|||||||
/**
|
/**
|
||||||
* 处理选中药品
|
* 处理选中药品
|
||||||
* @param drug 选中的药品数据
|
* @param drug 选中的药品数据
|
||||||
|
* @description isCard 时保留 selectedDrug 用于卡片展示;否则清空选中态
|
||||||
*/
|
*/
|
||||||
function handleSelectDrug(drug: any) {
|
function handleSelectDrug(drug: any) {
|
||||||
emit('select', drug);
|
emit('select', drug);
|
||||||
|
|
||||||
// 清空搜索状态
|
|
||||||
searchKeyword.value = '';
|
searchKeyword.value = '';
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
highlightIndex.value = -1;
|
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 +318,7 @@ watch(
|
|||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
searchKeyword.value = '';
|
searchKeyword.value = '';
|
||||||
|
selectedDrug.value = null;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@@ -269,6 +327,7 @@ watch(
|
|||||||
<div ref="containerRef" class="drug-search-select">
|
<div ref="containerRef" class="drug-search-select">
|
||||||
<!-- 搜索输入框 -->
|
<!-- 搜索输入框 -->
|
||||||
<Input
|
<Input
|
||||||
|
ref="inputRef"
|
||||||
v-model:value="searchKeyword"
|
v-model:value="searchKeyword"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -283,6 +342,49 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</Input>
|
</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">
|
<div v-if="showDropdown" class="drug-dropdown">
|
||||||
<!-- 加载中 -->
|
<!-- 加载中 -->
|
||||||
@@ -321,12 +423,11 @@ watch(
|
|||||||
<div v-else class="drug-item__img-placeholder">无图</div>
|
<div v-else class="drug-item__img-placeholder">无图</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 药品信息 -->
|
<!-- 药品信息:药名 / ID / 规格 / 供应商 -->
|
||||||
<div class="drug-item__info">
|
<div class="drug-item__info">
|
||||||
<!-- 药品名称 -->
|
|
||||||
<div class="drug-item__name">{{ item._drugName }}</div>
|
<div class="drug-item__name">{{ item._drugName }}</div>
|
||||||
<!-- 规格和供应商 -->
|
|
||||||
<div class="drug-item__meta">
|
<div class="drug-item__meta">
|
||||||
|
<span class="drug-item__id">ID:{{ item._drugId }}</span>
|
||||||
<span v-if="item._specification" class="drug-item__spec">
|
<span v-if="item._specification" class="drug-item__spec">
|
||||||
规格:{{ item._specification }}
|
规格:{{ item._specification }}
|
||||||
</span>
|
</span>
|
||||||
@@ -336,9 +437,12 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 价格 -->
|
<!-- 价格:无价(平台主库常见)展示「暂无」 -->
|
||||||
<div class="drug-item__price">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,6 +451,7 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
/* 只用框架主题变量,随亮暗/主题色自动适配,不写 .dark 硬编码覆盖 */
|
||||||
.drug-search-select {
|
.drug-search-select {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -359,8 +464,8 @@ watch(
|
|||||||
right: 0;
|
right: 0;
|
||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
background: #fff;
|
background: hsl(var(--card));
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 8px;
|
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);
|
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;
|
max-height: 400px;
|
||||||
@@ -373,7 +478,7 @@ watch(
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
color: #999;
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +492,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
background: #d9d9d9;
|
background: hsl(var(--border));
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +512,25 @@ watch(
|
|||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&--active {
|
&--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 {
|
&__image {
|
||||||
@@ -421,7 +544,7 @@ watch(
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #f0f0f0;
|
border: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
&__img-placeholder {
|
&__img-placeholder {
|
||||||
@@ -430,10 +553,10 @@ watch(
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #f5f5f5;
|
background: hsl(var(--accent));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #bbb;
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
&__info {
|
&__info {
|
||||||
@@ -445,7 +568,7 @@ watch(
|
|||||||
&__name {
|
&__name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
color: hsl(var(--foreground));
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -457,57 +580,31 @@ watch(
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
&__id {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
&__spec {
|
&__spec {
|
||||||
color: #666;
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
&__supplier {
|
&__supplier {
|
||||||
color: #1890ff;
|
color: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
&__price {
|
&__price {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ff4d4f;
|
color: hsl(var(--destructive));
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 暗色模式适配 */
|
&--empty {
|
||||||
.dark {
|
font-size: 13px;
|
||||||
.drug-dropdown {
|
font-weight: 400;
|
||||||
background: #1f2937;
|
color: hsl(var(--muted-foreground));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
|||||||
import { useVModel } from '@vueuse/core';
|
import { useVModel } from '@vueuse/core';
|
||||||
import { Empty, Input, Popover, Spin } from 'ant-design-vue';
|
import { Empty, Input, Popover, Spin } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { matchExpressByTrackingNo } from '#/utils/matchExpressByTrackingNo';
|
||||||
import { getExpressCompaniesOption } from '#/views/business/express/express-company/api';
|
import { getExpressCompaniesOption } from '#/views/business/express/express-company/api';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -15,6 +16,8 @@ const props = defineProps<{
|
|||||||
value?: string;
|
value?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** 运单号:变化时按前缀自动匹配快递公司(用户手动改选后不再覆盖) */
|
||||||
|
trackingNo?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
@@ -34,6 +37,8 @@ const loading = ref(false);
|
|||||||
const searchKeyword = ref('');
|
const searchKeyword = ref('');
|
||||||
const options = ref<ExpressCompanyOption[]>([]);
|
const options = ref<ExpressCompanyOption[]>([]);
|
||||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
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();
|
const q = keyword.trim().toLowerCase();
|
||||||
@@ -59,17 +64,36 @@ const displayText = computed(() => {
|
|||||||
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) 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() {
|
async function loadOptions() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getExpressCompaniesOption({});
|
const res = await getExpressCompaniesOption({});
|
||||||
options.value = Array.isArray(res) ? res : [];
|
options.value = Array.isArray(res) ? res : [];
|
||||||
|
tryAutoMatchByTrackingNo();
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectOption(item: ExpressCompanyOption) {
|
function selectOption(item: ExpressCompanyOption) {
|
||||||
|
userPicked.value = true;
|
||||||
mValue.value = item.code;
|
mValue.value = item.code;
|
||||||
open.value = false;
|
open.value = false;
|
||||||
searchKeyword.value = '';
|
searchKeyword.value = '';
|
||||||
@@ -77,6 +101,7 @@ function selectOption(item: ExpressCompanyOption) {
|
|||||||
|
|
||||||
function clearSelection(e: Event) {
|
function clearSelection(e: Event) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
userPicked.value = true;
|
||||||
mValue.value = undefined;
|
mValue.value = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +124,14 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.trackingNo,
|
||||||
|
() => {
|
||||||
|
// 单号变化时允许再自动匹配(除非用户已改选为不一致公司)
|
||||||
|
tryAutoMatchByTrackingNo();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadOptions();
|
loadOptions();
|
||||||
});
|
});
|
||||||
@@ -177,7 +210,7 @@ onMounted(() => {
|
|||||||
.clear-btn {
|
.clear-btn {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #86909c;
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
@@ -203,10 +236,11 @@ onMounted(() => {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
.express-item:hover,
|
.express-item:hover,
|
||||||
.express-item.active {
|
.express-item.active {
|
||||||
background: #f2f3f5;
|
background: hsl(var(--accent));
|
||||||
}
|
}
|
||||||
.express-meta {
|
.express-meta {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -214,11 +248,11 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.express-name {
|
.express-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #1d2129;
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
.express-sub {
|
.express-sub {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #86909c;
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
.express-empty {
|
.express-empty {
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
@@ -228,5 +262,8 @@ onMounted(() => {
|
|||||||
<style>
|
<style>
|
||||||
.express-company-select-popover .ant-popover-inner {
|
.express-company-select-popover .ant-popover-inner {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,17 +8,11 @@ const props = withDefaults(
|
|||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/**
|
|
||||||
* 限制可选择的文件类型(透传给 image-gallery-picker,按 xk_file_type.value 过滤)
|
|
||||||
* 例如图片:[1];文件:[6];语音:[3]
|
|
||||||
*/
|
|
||||||
acceptTypes?: number[];
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
multiple: false,
|
multiple: false,
|
||||||
maxCount: 1,
|
maxCount: 1,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
acceptTypes: () => [] as number[],
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -47,7 +41,6 @@ function onSelect(urls: string[]) {
|
|||||||
v-model:open="galleryOpen"
|
v-model:open="galleryOpen"
|
||||||
:multiple="multiple"
|
:multiple="multiple"
|
||||||
:max-count="maxCount"
|
:max-count="maxCount"
|
||||||
:accept-types="acceptTypes"
|
|
||||||
@select="onSelect"
|
@select="onSelect"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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';
|
import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue';
|
||||||
|
|
||||||
@@ -14,18 +14,10 @@ const props = withDefaults(
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
/**
|
|
||||||
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
|
|
||||||
* - 不传:显示全部类型(默认行为,保持向后兼容)
|
|
||||||
* - 传数组:只展示指定类型 tab,且自动锁定到第一个匹配的类型
|
|
||||||
* 例如图片:[1];图片+视频:[1, 2];文件:[6]
|
|
||||||
*/
|
|
||||||
acceptTypes?: number[];
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
multiple: false,
|
multiple: false,
|
||||||
maxCount: 1,
|
maxCount: 1,
|
||||||
acceptTypes: () => [] as number[],
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -42,14 +34,6 @@ const pageSize = ref(24);
|
|||||||
const pageSizeOptions = ['12', '24', '48'];
|
const pageSizeOptions = ['12', '24', '48'];
|
||||||
const selected = ref<string[]>([]);
|
const selected = ref<string[]>([]);
|
||||||
|
|
||||||
/** 按 acceptTypes 隔离 localStorage,避免图片/视频选择器互相污染 */
|
|
||||||
const storageKey = computed(
|
|
||||||
() =>
|
|
||||||
`file-picker-type-${
|
|
||||||
props.acceptTypes?.length ? props.acceptTypes.join('-') : 'all'
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeType,
|
activeType,
|
||||||
keyword,
|
keyword,
|
||||||
@@ -58,55 +42,14 @@ const {
|
|||||||
buildListParams,
|
buildListParams,
|
||||||
fileTypes,
|
fileTypes,
|
||||||
typesLoading,
|
typesLoading,
|
||||||
} = useFileGalleryFilter(() => storageKey.value);
|
} = useFileGalleryFilter('file-picker-active-type');
|
||||||
|
|
||||||
/**
|
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
* 实际渲染的 tabs(按 acceptTypes 过滤)
|
|
||||||
*/
|
|
||||||
const visibleTabs = computed(() => {
|
|
||||||
if (!props.acceptTypes || props.acceptTypes.length === 0) {
|
|
||||||
return tabs.value;
|
|
||||||
}
|
|
||||||
return tabs.value.filter(
|
|
||||||
(tab) => tab.value !== -1 && props.acceptTypes.includes(Number(tab.value)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 强制锁定到 acceptTypes 首项(打开弹窗 / 类型字典就绪时调用)
|
|
||||||
*/
|
|
||||||
function lockAcceptType() {
|
|
||||||
const arr = props.acceptTypes;
|
|
||||||
if (!arr || arr.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (activeType.value !== arr[0]) {
|
|
||||||
setActiveType(arr[0]!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否允许拉列表:有 acceptTypes 时需等类型字典加载完且 visibleTabs 非空
|
|
||||||
*/
|
|
||||||
function canLoadList() {
|
|
||||||
if (typesLoading.value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (props.acceptTypes && props.acceptTypes.length > 0) {
|
|
||||||
return visibleTabs.value.length > 0;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!canLoadList()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getFileGalleryList(
|
const res = await getFileGalleryList(buildListParams(page.value, pageSize.value));
|
||||||
buildListParams(page.value, pageSize.value),
|
|
||||||
);
|
|
||||||
const data = (res as any)?.data ?? res;
|
const data = (res as any)?.data ?? res;
|
||||||
items.value = data?.items ?? [];
|
items.value = data?.items ?? [];
|
||||||
total.value = data?.total ?? 0;
|
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(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
selected.value = [];
|
selected.value = [];
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
// 打开时再次按 acceptTypes 重锁,避免沿用其它选择器的视频 tab
|
if (!typesLoading.value) {
|
||||||
lockAcceptType();
|
void load();
|
||||||
void load();
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(typesLoading, (val) => {
|
watch(typesLoading, (val) => {
|
||||||
if (!val && props.open) {
|
if (!val && props.open) {
|
||||||
lockAcceptType();
|
|
||||||
void load();
|
void load();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -216,11 +143,6 @@ function onSearch(value: string) {
|
|||||||
function itemIcon(item: FileGalleryItem) {
|
function itemIcon(item: FileGalleryItem) {
|
||||||
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -233,7 +155,7 @@ function isImageItem(item: FileGalleryItem) {
|
|||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
>
|
>
|
||||||
<Tabs :active-key="String(activeType)" size="small" @change="onTabChange">
|
<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>
|
<template #tab>
|
||||||
<span class="tab-label">
|
<span class="tab-label">
|
||||||
<MIcon v-if="tab.icon" :icon="tab.icon" size="12" />
|
<MIcon v-if="tab.icon" :icon="tab.icon" size="12" />
|
||||||
@@ -254,7 +176,7 @@ function isImageItem(item: FileGalleryItem) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Spin :spinning="loading || typesLoading">
|
<Spin :spinning="loading">
|
||||||
<div v-if="items.length" class="gallery-grid">
|
<div v-if="items.length" class="gallery-grid">
|
||||||
<div
|
<div
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
@@ -263,17 +185,10 @@ function isImageItem(item: FileGalleryItem) {
|
|||||||
:class="{ active: isSelected(item.url) }"
|
:class="{ active: isSelected(item.url) }"
|
||||||
@click="toggleSelect(item.url)"
|
@click="toggleSelect(item.url)"
|
||||||
>
|
>
|
||||||
<img
|
<img v-if="item.type === 0" :src="item.url" alt="" class="gallery-thumb" />
|
||||||
v-if="isImageItem(item)"
|
|
||||||
:src="item.url"
|
|
||||||
alt=""
|
|
||||||
class="gallery-thumb"
|
|
||||||
/>
|
|
||||||
<div v-else class="gallery-file-icon">
|
<div v-else class="gallery-file-icon">
|
||||||
<MIcon :icon="itemIcon(item)" size="28" />
|
<MIcon :icon="itemIcon(item)" size="28" />
|
||||||
<span class="file-name">{{
|
<span class="file-name">{{ item.file_name || item.original_name || '未命名' }}</span>
|
||||||
item.file_name || item.original_name || '未命名'
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="gallery-meta">
|
<div class="gallery-meta">
|
||||||
<div class="file-title">
|
<div class="file-title">
|
||||||
|
|||||||
@@ -24,17 +24,12 @@ interface Props {
|
|||||||
modelValue: string[];
|
modelValue: string[];
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
/**
|
|
||||||
* 素材库可选类型(透传 GalleryPickLink),默认仅图片
|
|
||||||
*/
|
|
||||||
acceptTypes?: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
modelValue: () => [],
|
modelValue: () => [],
|
||||||
multiple: true,
|
multiple: true,
|
||||||
maxCount: 9,
|
maxCount: 9,
|
||||||
acceptTypes: () => [1],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -189,7 +184,6 @@ const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.val
|
|||||||
v-if="showUploadButton"
|
v-if="showUploadButton"
|
||||||
:multiple="multiple"
|
:multiple="multiple"
|
||||||
:max-count="galleryRemainCount"
|
:max-count="galleryRemainCount"
|
||||||
:accept-types="acceptTypes"
|
|
||||||
@select="onGallerySelect"
|
@select="onGallerySelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
|
||||||
* OSS 文件上传(单文件 URL 字符串)
|
|
||||||
* 可选:从素材库选择(acceptTypes 过滤,如文件=6、语音=3)
|
|
||||||
*/
|
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { preferences } from '@vben/preferences';
|
import { preferences } from '@vben/preferences';
|
||||||
import { Button, Upload } from 'ant-design-vue';
|
import { Button, Upload } from 'ant-design-vue';
|
||||||
|
|
||||||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
|
||||||
import { Icon } from '#/components/icon';
|
import { Icon } from '#/components/icon';
|
||||||
import { uploadFile } from '#/api/core/upload';
|
import { uploadFile } from '#/api/core/upload';
|
||||||
import { uploadToOss } from '#/utils/oss-upload';
|
import { uploadToOss } from '#/utils/oss-upload';
|
||||||
@@ -23,17 +17,11 @@ interface FileItem {
|
|||||||
interface Props {
|
interface Props {
|
||||||
modelValue?: string;
|
modelValue?: string;
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
/** 素材库类型过滤,如 [6] 文件、[3] 语音 */
|
|
||||||
acceptTypes?: number[];
|
|
||||||
/** input accept,如 .pdf,.doc 或 audio/* */
|
|
||||||
accept?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
modelValue: '',
|
modelValue: '',
|
||||||
maxCount: 1,
|
maxCount: 1,
|
||||||
acceptTypes: () => [6],
|
|
||||||
accept: '.pdf,.doc,.docx,.zip,.rar,.txt,application/pdf,audio/*,*/*',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -143,50 +131,26 @@ function handleRemove() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onGallerySelect(urls: string[]) {
|
|
||||||
const url = urls[0] || '';
|
|
||||||
if (!url) return;
|
|
||||||
syncFileListFromValue(url);
|
|
||||||
updateModelValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
const showUploadButton = computed(
|
const showUploadButton = computed(
|
||||||
() => fileList.value.filter((item) => item.status !== 'error').length === 0,
|
() => fileList.value.filter((item) => item.status !== 'error').length === 0,
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="upload-oss-file-wrap">
|
<Upload
|
||||||
<Upload
|
:file-list="fileList"
|
||||||
:file-list="fileList"
|
:before-upload="beforeUpload"
|
||||||
:before-upload="beforeUpload"
|
:custom-request="customRequest"
|
||||||
:custom-request="customRequest"
|
:max-count="maxCount"
|
||||||
:max-count="maxCount"
|
list-type="text"
|
||||||
list-type="text"
|
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,image/*,application/pdf"
|
||||||
:accept="accept"
|
@remove="handleRemove"
|
||||||
@remove="handleRemove"
|
>
|
||||||
>
|
<Button v-if="showUploadButton">
|
||||||
<Button v-if="showUploadButton">
|
上传文件
|
||||||
上传文件
|
<template #icon>
|
||||||
<template #icon>
|
<Icon icon="ant-design:cloud-upload-outlined" />
|
||||||
<Icon icon="ant-design:cloud-upload-outlined" />
|
</template>
|
||||||
</template>
|
</Button>
|
||||||
</Button>
|
</Upload>
|
||||||
</Upload>
|
|
||||||
<GalleryPickLink
|
|
||||||
v-if="showUploadButton"
|
|
||||||
:max-count="1"
|
|
||||||
:accept-types="acceptTypes"
|
|
||||||
@select="onGallerySelect"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 type { FileTypeItem } from '#/api/core/file-gallery';
|
||||||
import { getFileTypes } from '#/api/core/file-gallery';
|
import { getFileTypes } from '#/api/core/file-gallery';
|
||||||
import type { FileGroupOptionItem } from '#/api/core/file-group';
|
import type { FileGroupOptionItem } from '#/api/core/file-group';
|
||||||
import { useFileGroupCache } from '#/composables/use-file-group-cache';
|
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_TYPE_VALUE = -1;
|
||||||
|
|
||||||
export const ALL_GROUP_VALUE = 0;
|
export const ALL_GROUP_VALUE = 0;
|
||||||
|
|
||||||
|
import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon';
|
||||||
|
|
||||||
export interface FileGalleryTab {
|
export interface FileGalleryTab {
|
||||||
key: string;
|
key: string;
|
||||||
value: number;
|
value: number;
|
||||||
@@ -17,13 +18,7 @@ export interface FileGalleryTab {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function useFileGalleryFilter(storageKey: string) {
|
||||||
* 文件库筛选(类型 tab + 分组 + 关键字)
|
|
||||||
* @param storageKeyInput localStorage 键;支持动态 getter,便于按 acceptTypes 隔离记忆
|
|
||||||
*/
|
|
||||||
export function useFileGalleryFilter(
|
|
||||||
storageKeyInput: MaybeRefOrGetter<string> = 'file-picker-active-type',
|
|
||||||
) {
|
|
||||||
const fileTypes = ref<FileTypeItem[]>([]);
|
const fileTypes = ref<FileTypeItem[]>([]);
|
||||||
const activeType = ref<number>(ALL_TYPE_VALUE);
|
const activeType = ref<number>(ALL_TYPE_VALUE);
|
||||||
const activeGroupId = ref<number>(ALL_GROUP_VALUE);
|
const activeGroupId = ref<number>(ALL_GROUP_VALUE);
|
||||||
@@ -32,10 +27,6 @@ export function useFileGalleryFilter(
|
|||||||
|
|
||||||
const { groupOptions, loadGroupOptions } = useFileGroupCache();
|
const { groupOptions, loadGroupOptions } = useFileGroupCache();
|
||||||
|
|
||||||
function resolveStorageKey() {
|
|
||||||
return toValue(storageKeyInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabs = computed<FileGalleryTab[]>(() => [
|
const tabs = computed<FileGalleryTab[]>(() => [
|
||||||
{ key: 'all', value: ALL_TYPE_VALUE, label: '全部', icon: ALL_TYPE_ICON },
|
{ key: 'all', value: ALL_TYPE_VALUE, label: '全部', icon: ALL_TYPE_ICON },
|
||||||
...fileTypes.value.map((item) => ({
|
...fileTypes.value.map((item) => ({
|
||||||
@@ -57,7 +48,7 @@ export function useFileGalleryFilter(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function restoreActiveType() {
|
function restoreActiveType() {
|
||||||
const saved = localStorage.getItem(resolveStorageKey());
|
const saved = localStorage.getItem(storageKey);
|
||||||
if (saved === null) {
|
if (saved === null) {
|
||||||
activeType.value = ALL_TYPE_VALUE;
|
activeType.value = ALL_TYPE_VALUE;
|
||||||
return;
|
return;
|
||||||
@@ -73,7 +64,7 @@ export function useFileGalleryFilter(
|
|||||||
|
|
||||||
function setActiveType(value: number) {
|
function setActiveType(value: number) {
|
||||||
activeType.value = value;
|
activeType.value = value;
|
||||||
localStorage.setItem(resolveStorageKey(), String(value));
|
localStorage.setItem(storageKey, String(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveGroupId(value: number) {
|
function setActiveGroupId(value: number) {
|
||||||
|
|||||||
@@ -912,6 +912,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
|||||||
doctorSecondSignValue = 0,
|
doctorSecondSignValue = 0,
|
||||||
customSendMode: number = 0,
|
customSendMode: number = 0,
|
||||||
customStoreId: number | null = null,
|
customStoreId: number | null = null,
|
||||||
|
warehouseMap: Record<number, number> | null = null,
|
||||||
) => {
|
) => {
|
||||||
if (currentDrugs.value.length === 0) {
|
if (currentDrugs.value.length === 0) {
|
||||||
message.error('请选择药品');
|
message.error('请选择药品');
|
||||||
@@ -986,6 +987,8 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
|||||||
// 诊所选择参数(转诊挂号时自动使用承接方诊所ID)
|
// 诊所选择参数(转诊挂号时自动使用承接方诊所ID)
|
||||||
send_mode: finalSendMode,
|
send_mode: finalSendMode,
|
||||||
custom_store_id: finalSendMode === 1 ? finalStoreId : null,
|
custom_store_id: finalSendMode === 1 ? finalStoreId : null,
|
||||||
|
// 药店按药选配送仓映射 drug_id -> warehouse_id
|
||||||
|
warehouse_map: warehouseMap || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await response;
|
const res = await response;
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -56,6 +56,8 @@ import InfoModal from '#/components/modal/InfoModal.vue';
|
|||||||
import { DrugSearchSelect } from '#/components/drug-search-select';
|
import { DrugSearchSelect } from '#/components/drug-search-select';
|
||||||
import { formatPriceDisplay } from '#/utils/formatPrice';
|
import { formatPriceDisplay } from '#/utils/formatPrice';
|
||||||
import ChineseMedicineConfig from '#/views/doctor/doctor-reception/components/ChineseMedicineConfig.vue';
|
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(',');
|
const splitString = (input: string) => input.split(',');
|
||||||
|
|
||||||
@@ -210,6 +212,77 @@ const handleCheckChineseMedicineConflict = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 药店开方待选仓时暂存发送参数
|
||||||
|
const pendingSendParams = ref<{
|
||||||
|
doctorSecondSignValue: number;
|
||||||
|
sendMode: number;
|
||||||
|
customStoreId: number | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [WarehouseSelectModals, warehouseSelectModalApi] = useVbenModal({
|
||||||
|
connectedComponent: WarehouseSelectModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 药店在线问诊:若药品有配送仓绑定,则先弹选仓再发送
|
||||||
|
*/
|
||||||
|
const tryPharmacyWarehouseSelect = async (
|
||||||
|
doctorSecondSignValue: number,
|
||||||
|
sendMode: number,
|
||||||
|
customStoreId: number | null,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const storagePrefix =
|
||||||
|
modalData.value?.storagePrefix || 'onlineConsultation-';
|
||||||
|
// 诊所在线复诊前缀不走手动选仓(后端自动按报价)
|
||||||
|
if (storagePrefix === 'onlineConsultationClinic-') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const drugs = prescriptionStore.currentDrugs || [];
|
||||||
|
const drugIds = drugs.map((d: any) => Number(d.id)).filter((id: number) => id > 0);
|
||||||
|
if (drugIds.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const needQtyMap: Record<number, number> = {};
|
||||||
|
drugs.forEach((d: any) => {
|
||||||
|
needQtyMap[Number(d.id)] = Number(d.select_number || d.number || 1);
|
||||||
|
});
|
||||||
|
const optionsMap = await getDeliveryWarehouseOptionsByDrugs({
|
||||||
|
drug_ids: drugIds.join(','),
|
||||||
|
need_qty_map: needQtyMap,
|
||||||
|
});
|
||||||
|
const rows: any[] = [];
|
||||||
|
Object.keys(optionsMap || {}).forEach((drugId) => {
|
||||||
|
const list = optionsMap[drugId] || [];
|
||||||
|
if (!list.length) return;
|
||||||
|
const drug = drugs.find((d: any) => Number(d.id) === Number(drugId));
|
||||||
|
rows.push({
|
||||||
|
drug_id: Number(drugId),
|
||||||
|
drug_name: drug?.drug_name || drug?.name || `药品#${drugId}`,
|
||||||
|
options: list,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pendingSendParams.value = { doctorSecondSignValue, sendMode, customStoreId };
|
||||||
|
warehouseSelectModalApi.setData({ rows });
|
||||||
|
warehouseSelectModalApi.open();
|
||||||
|
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) => {
|
const handleSendPrescription = async (doctorSecondSignValue = 0) => {
|
||||||
// 确保基础数据已初始化(包括当前诊所列表)
|
// 确保基础数据已初始化(包括当前诊所列表)
|
||||||
@@ -219,14 +292,24 @@ const handleSendPrescription = async (doctorSecondSignValue = 0) => {
|
|||||||
await prescriptionStore.fetchRegisterStoreInfo();
|
await prescriptionStore.fetchRegisterStoreInfo();
|
||||||
|
|
||||||
const storeInfo = prescriptionStore.registerStoreInfo;
|
const storeInfo = prescriptionStore.registerStoreInfo;
|
||||||
|
let sendMode = 0;
|
||||||
|
let customStoreId: number | null = null;
|
||||||
if (storeInfo?.is_from_transfer === 1 && storeInfo?.delegate_store_id) {
|
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) {
|
} else if (storeInfo?.store_id) {
|
||||||
await executeSendPrescription(doctorSecondSignValue, 1, storeInfo.store_id);
|
sendMode = 1;
|
||||||
} else {
|
customStoreId = storeInfo.store_id;
|
||||||
await executeSendPrescription(doctorSecondSignValue, 0, null);
|
|
||||||
}
|
}
|
||||||
|
const needSelect = await tryPharmacyWarehouseSelect(
|
||||||
|
doctorSecondSignValue,
|
||||||
|
sendMode,
|
||||||
|
customStoreId,
|
||||||
|
);
|
||||||
|
if (needSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await executeSendPrescription(doctorSecondSignValue, sendMode, customStoreId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 执行发送处方
|
// 执行发送处方
|
||||||
@@ -234,11 +317,13 @@ const executeSendPrescription = async (
|
|||||||
doctorSecondSignValue: number,
|
doctorSecondSignValue: number,
|
||||||
sendMode: number,
|
sendMode: number,
|
||||||
customStoreId: number | null,
|
customStoreId: number | null,
|
||||||
|
warehouseMap: Record<number, number> | null = null,
|
||||||
) => {
|
) => {
|
||||||
const result = await prescriptionStore.sendPrescription(
|
const result = await prescriptionStore.sendPrescription(
|
||||||
doctorSecondSignValue,
|
doctorSecondSignValue,
|
||||||
sendMode,
|
sendMode,
|
||||||
customStoreId,
|
customStoreId,
|
||||||
|
warehouseMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 检查返回结果,可能是boolean或包含转诊信息的对象
|
// 检查返回结果,可能是boolean或包含转诊信息的对象
|
||||||
@@ -1505,6 +1590,8 @@ const cancelSaveCommonPrescription = () => {
|
|||||||
<PrescriptionDetailModal />
|
<PrescriptionDetailModal />
|
||||||
<!-- 常用方选择弹窗 -->
|
<!-- 常用方选择弹窗 -->
|
||||||
<CommonPrescriptionModals />
|
<CommonPrescriptionModals />
|
||||||
|
<!-- 药店开方选配送仓库 -->
|
||||||
|
<WarehouseSelectModals @confirm="onWarehouseSelected" />
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 药店开方:按药品选择配送仓库
|
||||||
|
* 交互尽量简单:每个药品一个下拉,确认后回传 warehouse_map
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message, Select } from 'ant-design-vue';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'confirm', map: Record<number, number>): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const drugRows = ref<
|
||||||
|
Array<{
|
||||||
|
drug_id: number;
|
||||||
|
drug_name: string;
|
||||||
|
options: Array<{
|
||||||
|
warehouse_id: number;
|
||||||
|
warehouse_name: string;
|
||||||
|
quote: string;
|
||||||
|
available_stock: number;
|
||||||
|
}>;
|
||||||
|
warehouse_id?: number;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
onConfirm() {
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
if (isOpen) {
|
||||||
|
const data = modalApi.getData<{ rows: typeof drugRows.value }>();
|
||||||
|
drugRows.value = (data?.rows || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
warehouse_id: row.options?.[0]?.warehouse_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canConfirm = computed(() =>
|
||||||
|
drugRows.value.every((row) => !!row.warehouse_id),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal title="选择配送仓库" class="w-[520px]" :confirm-disabled="!canConfirm">
|
||||||
|
<div class="space-y-3 py-2">
|
||||||
|
<div
|
||||||
|
v-for="row in drugRows"
|
||||||
|
:key="row.drug_id"
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<div class="w-40 shrink-0 truncate" :title="row.drug_name">
|
||||||
|
{{ row.drug_name }}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
v-model:value="row.warehouse_id"
|
||||||
|
class="flex-1"
|
||||||
|
placeholder="请选择配送仓库"
|
||||||
|
:options="
|
||||||
|
row.options.map((o) => ({
|
||||||
|
label: `${o.warehouse_name}(报价${o.quote},库存${o.available_stock})`,
|
||||||
|
value: o.warehouse_id,
|
||||||
|
}))
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="drugRows.length === 0" class="text-gray-400">
|
||||||
|
当前药品无需选择配送仓库
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -19,7 +19,8 @@ export async function getOrderTraceApi(params: {
|
|||||||
export async function getOrderLedgerDetailApi(params: {
|
export async function getOrderLedgerDetailApi(params: {
|
||||||
order_id: number;
|
order_id: number;
|
||||||
order_type: number;
|
order_type: number;
|
||||||
scope?: 'all' | 'platform' | 'store';
|
/** 平台端可按受益方类型筛选:全部/门店/平台/供应商/配送仓库 */
|
||||||
|
scope?: 'all' | 'platform' | 'store' | 'supplier' | 'warehouse';
|
||||||
}) {
|
}) {
|
||||||
return requestClient.get<any>(`${prefix}ledger-detail`, { params });
|
return requestClient.get<any>(`${prefix}ledger-detail`, { params });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ import ReconciliationDetailTable from './reconciliation-detail-table.vue';
|
|||||||
|
|
||||||
defineOptions({ name: 'OrderTraceDrawer' });
|
defineOptions({ name: 'OrderTraceDrawer' });
|
||||||
|
|
||||||
type LedgerScope = 'all' | 'platform' | 'store';
|
/** 分账明细 scope:与后端 allowed_scopes 对齐 */
|
||||||
|
type LedgerScope = 'all' | 'platform' | 'store' | 'supplier' | 'warehouse';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
@@ -57,7 +58,8 @@ const ledgerColumns = [
|
|||||||
{ title: '药品编号', dataIndex: 'drug_number', key: 'drug_number' },
|
{ title: '药品编号', dataIndex: 'drug_number', key: 'drug_number' },
|
||||||
{ title: '药品规格', dataIndex: 'specification', key: 'specification' },
|
{ title: '药品规格', dataIndex: 'specification', key: 'specification' },
|
||||||
{ title: '分账对象', dataIndex: 'user_type_txt', key: 'user_type_txt' },
|
{ 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: 'money', key: 'money' },
|
||||||
{ title: '结算状态', dataIndex: 'status_txt', key: 'status_txt' },
|
{ title: '结算状态', dataIndex: 'status_txt', key: 'status_txt' },
|
||||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
|
||||||
@@ -360,6 +362,8 @@ watch(isPlatformAdmin, (val) => {
|
|||||||
<Tabs.TabPane key="all" tab="全部" />
|
<Tabs.TabPane key="all" tab="全部" />
|
||||||
<Tabs.TabPane key="store" tab="门店" />
|
<Tabs.TabPane key="store" tab="门店" />
|
||||||
<Tabs.TabPane key="platform" tab="平台" />
|
<Tabs.TabPane key="platform" tab="平台" />
|
||||||
|
<Tabs.TabPane key="supplier" tab="供应商" />
|
||||||
|
<Tabs.TabPane key="warehouse" tab="配送仓库" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Tabs v-model:active-key="ledgerSubTab" type="card">
|
<Tabs v-model:active-key="ledgerSubTab" type="card">
|
||||||
@@ -389,7 +393,8 @@ watch(isPlatformAdmin, (val) => {
|
|||||||
v-else-if="
|
v-else-if="
|
||||||
column.key === 'drug_name' ||
|
column.key === 'drug_name' ||
|
||||||
column.key === 'drug_number' ||
|
column.key === 'drug_number' ||
|
||||||
column.key === 'specification'
|
column.key === 'specification' ||
|
||||||
|
column.key === 'party_name'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ formatDrugCell(text) }}
|
{{ formatDrugCell(text) }}
|
||||||
@@ -429,7 +434,8 @@ watch(isPlatformAdmin, (val) => {
|
|||||||
v-else-if="
|
v-else-if="
|
||||||
column.key === 'drug_name' ||
|
column.key === 'drug_name' ||
|
||||||
column.key === 'drug_number' ||
|
column.key === 'drug_number' ||
|
||||||
column.key === 'specification'
|
column.key === 'specification' ||
|
||||||
|
column.key === 'party_name'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ formatDrugCell(text) }}
|
{{ formatDrugCell(text) }}
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ function prescriptionStatusColor() {
|
|||||||
{{ data.trans_expenses }} 元
|
{{ data.trans_expenses }} 元
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="是否免邮">
|
<Descriptions.Item label="是否免邮">
|
||||||
{{ data.free_ship === 1 ? '是' : '否' }}
|
{{ data.free_ship === 0 ? '是' : '否' }}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
<Tag v-if="data.status === 0" color="red">待支付</Tag>
|
<Tag v-if="data.status === 0" color="red">待支付</Tag>
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ export const modalFormProps: VbenFormProps = {
|
|||||||
formItemClass: 'col-span-6',
|
formItemClass: 'col-span-6',
|
||||||
label: '快递公司',
|
label: '快递公司',
|
||||||
rules: 'required',
|
rules: 'required',
|
||||||
|
// 单号变化时传入 trackingNo,组件按前缀自动匹配快递公司
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: ['express_no'],
|
||||||
|
componentProps(values) {
|
||||||
|
return {
|
||||||
|
placeholder: '请选择快递公司',
|
||||||
|
trackingNo: values.express_no,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: 'Textarea',
|
component: 'Textarea',
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
width: 200,
|
width: 200,
|
||||||
slots: { default: 'express-info' },
|
slots: { default: 'express-info' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'delivery_warehouses',
|
||||||
|
title: '配送仓库',
|
||||||
|
width: 160,
|
||||||
|
slots: { default: 'delivery-warehouses' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'delivery_method',
|
field: 'delivery_method',
|
||||||
title: '订单类型&邮寄方式&订单状态',
|
title: '订单类型&邮寄方式&订单状态',
|
||||||
|
|||||||
@@ -643,6 +643,22 @@ const openOrderAmountVerify = () => {
|
|||||||
<div class="text-xs text-gray-500">{{ formatExpressAddress(row) }}</div>
|
<div class="text-xs text-gray-500">{{ formatExpressAddress(row) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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"
|
||||||
|
>
|
||||||
|
{{ wh.name }}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-[hsl(var(--muted-foreground))]">-</span>
|
||||||
|
</template>
|
||||||
<template #price-info="{ row }">
|
<template #price-info="{ row }">
|
||||||
<div class="space-y-0.5 text-sm leading-snug">
|
<div class="space-y-0.5 text-sm leading-snug">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -8,114 +8,86 @@ import { getHealthFoodList } from '../api';
|
|||||||
* 表格行数据类型定义
|
* 表格行数据类型定义
|
||||||
*/
|
*/
|
||||||
interface RowType {
|
interface RowType {
|
||||||
id: string; // 保健食品ID
|
id: string;
|
||||||
name: string; // 保健食品名称
|
name: string;
|
||||||
logo: string; // 保健食品Logo
|
logo: string;
|
||||||
introduce: string; // 保健食品介绍
|
introduce: string;
|
||||||
created_at: string; // 创建时间
|
created_at: string;
|
||||||
zone_id: number; // 分区ID
|
zone_id: number;
|
||||||
zone_name: string; // 分区名称
|
zone_name: string;
|
||||||
category_id: number; // 分类ID
|
category_id: number;
|
||||||
category_name: string; // 分类名称
|
category_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保健食品管理表格配置
|
* 保健食品管理表格配置
|
||||||
* 定义表格的列、分页、查询等配置
|
* 列结构对齐西药:商品信息/分类仓储/总仓售价合并列 + 页内特有列
|
||||||
*/
|
*/
|
||||||
export const gridOptions: VxeGridProps<RowType> = {
|
export const gridOptions: VxeGridProps<RowType> = {
|
||||||
// 复选框配置
|
|
||||||
checkboxConfig: {
|
checkboxConfig: {
|
||||||
highlight: true, // 高亮选中行
|
highlight: true,
|
||||||
labelField: '', // 标签字段
|
labelField: '',
|
||||||
},
|
},
|
||||||
// 列配置
|
|
||||||
columnConfig: {
|
columnConfig: {
|
||||||
useKey: true, // 使用key作为列的唯一标识
|
useKey: true,
|
||||||
},
|
},
|
||||||
// 行配置
|
|
||||||
rowConfig: {
|
rowConfig: {
|
||||||
useKey: true, // 使用key作为行的唯一标识
|
useKey: true,
|
||||||
},
|
},
|
||||||
// 表格列定义数组
|
|
||||||
columns: [
|
columns: [
|
||||||
{ type: 'checkbox', width: 60 }, // 复选框列
|
{ type: 'checkbox', width: 60 },
|
||||||
{ field: 'id', align: 'left', title: 'ID', width: 100 }, // ID列
|
{
|
||||||
{ field: 'drug_name', align: 'left', title: '保健食品名称' }, // 保健食品名称列
|
field: 'drug_name',
|
||||||
{ field: 'pinyin_simple', title: '拼音' }, // 拼音列
|
align: 'left',
|
||||||
|
title: '商品信息',
|
||||||
|
minWidth: 260,
|
||||||
|
slots: { default: 'product_info' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'zone_name',
|
field: 'zone_name',
|
||||||
title: '分区',
|
title: '分类仓储',
|
||||||
width: 100,
|
minWidth: 200,
|
||||||
slots: { default: 'zone' }, // 使用插槽显示可点击的分区链接
|
slots: { default: 'category_warehouse' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'category_name',
|
field: 'central_price',
|
||||||
title: '分类',
|
title: '总仓售价',
|
||||||
width: 120,
|
width: 110,
|
||||||
slots: { default: 'category' }, // 使用插槽显示可点击的分类链接
|
slots: { default: 'central_price' },
|
||||||
},
|
},
|
||||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' } }, // 供应商列,使用插槽自定义显示
|
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
|
||||||
{
|
{ field: 'specification', title: '规格', width: 120 },
|
||||||
field: 'image',
|
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||||
align: 'left',
|
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
|
||||||
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' } }, // 操作列,使用插槽显示操作按钮
|
|
||||||
],
|
],
|
||||||
// 保持数据源
|
|
||||||
keepSource: true,
|
keepSource: true,
|
||||||
// 分页配置
|
|
||||||
pagerConfig: {},
|
pagerConfig: {},
|
||||||
// 代理配置:用于数据请求
|
|
||||||
proxyConfig: {
|
proxyConfig: {
|
||||||
ajax: {
|
ajax: {
|
||||||
// 请求后端接口方法
|
|
||||||
// 当表格需要加载数据时,会调用此方法
|
|
||||||
query: async ({ page }, formValues) => {
|
query: async ({ page }, formValues) => {
|
||||||
// 调用保健食品列表查询API
|
|
||||||
return await getHealthFoodList({
|
return await getHealthFoodList({
|
||||||
page: page.currentPage, // 当前页码
|
page: page.currentPage,
|
||||||
pageSize: page.pageSize, // 每页条数
|
pageSize: page.pageSize,
|
||||||
...formValues, // 合并搜索表单的值
|
...formValues,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 表格高度:自动适应
|
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
// 是否显示边框
|
|
||||||
border: false,
|
border: false,
|
||||||
// 工具栏配置
|
|
||||||
toolbarConfig: {
|
toolbarConfig: {
|
||||||
// 是否显示搜索表单控制按钮
|
|
||||||
// @ts-ignore 正式环境时有完整的类型声明
|
// @ts-ignore 正式环境时有完整的类型声明
|
||||||
search: true,
|
search: true,
|
||||||
refresh: true, // 显示刷新按钮
|
refresh: true,
|
||||||
print: false, // 不显示打印按钮
|
print: false,
|
||||||
export: false, // 不显示导出按钮(使用自定义导出按钮)
|
export: false,
|
||||||
zoom: true, // 显示最大化最小化按钮
|
zoom: true,
|
||||||
slots: {
|
slots: {
|
||||||
buttons: 'toolbar-buttons', // 自定义工具栏按钮插槽
|
buttons: 'toolbar-buttons',
|
||||||
},
|
},
|
||||||
custom: {
|
custom: {
|
||||||
// 自定义列-图标
|
|
||||||
icon: 'vxe-icon-menu',
|
icon: 'vxe-icon-menu',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 是否显示溢出内容
|
|
||||||
showOverflow: false,
|
showOverflow: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ref } from 'vue';
|
|||||||
import { Page, useVbenModal } from '@vben/common-ui';
|
import { Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
// 导入Ant Design Vue组件
|
// 导入Ant Design Vue组件
|
||||||
import { Button, Image, message, Tag } from 'ant-design-vue';
|
import { Button, message, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
// 导入Vben VxeGrid适配器
|
// 导入Vben VxeGrid适配器
|
||||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
@@ -17,20 +17,27 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
|||||||
import { TableAction } from '#/components/table-action';
|
import { TableAction } from '#/components/table-action';
|
||||||
// 导入文件下载工具
|
// 导入文件下载工具
|
||||||
import { downloadByData } from '#/util/tool';
|
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
|
// 导入保健食品管理相关API
|
||||||
import { deleteHealthFood, exportHealthFoodApi } from './api';
|
import { deleteHealthFood, exportHealthFoodApi } from './api';
|
||||||
// 导入表单弹窗组件
|
|
||||||
import FormModalDemo from './components/modal.vue';
|
import FormModalDemo from './components/modal.vue';
|
||||||
// 导入Excel上传组件
|
|
||||||
import ExcelUpload from './components/ExcelUpload.vue';
|
import ExcelUpload from './components/ExcelUpload.vue';
|
||||||
// 导入分类设置弹窗组件
|
|
||||||
import CategoryModal from './components/CategoryModal.vue';
|
import CategoryModal from './components/CategoryModal.vue';
|
||||||
// 导入搜索表单配置
|
// 导入搜索表单配置
|
||||||
import { formOptions } from './config/search';
|
import { formOptions } from './config/search';
|
||||||
// 导入表格配置
|
// 导入表格配置
|
||||||
import { gridOptions } from './config/table';
|
import { gridOptions } from './config/table';
|
||||||
|
|
||||||
|
/** 保健食品产品类型 */
|
||||||
|
const PRODUCT_TYPE = 3;
|
||||||
|
|
||||||
// 控制顶部表格下拉操作按钮的显示状态
|
// 控制顶部表格下拉操作按钮的显示状态
|
||||||
const hasTopTableDropDownActions = ref(false);
|
const hasTopTableDropDownActions = ref(false);
|
||||||
|
|
||||||
@@ -74,6 +81,78 @@ const [CategoryModalComp, categoryModalApi] = useVbenModal({
|
|||||||
connectedComponent: CategoryModal,
|
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 - 当前行数据
|
* @param row - 当前行数据
|
||||||
@@ -155,6 +234,9 @@ const openExcelUploadModal = () => {
|
|||||||
<ExcelUploadModal />
|
<ExcelUploadModal />
|
||||||
<FormModal />
|
<FormModal />
|
||||||
<CategoryModalComp />
|
<CategoryModalComp />
|
||||||
|
<BindListModalComp />
|
||||||
|
<CentralStockInModalComp />
|
||||||
|
<WarehousePriceModalComp />
|
||||||
<Grid>
|
<Grid>
|
||||||
<template #toolbar-buttons>
|
<template #toolbar-buttons>
|
||||||
<TableAction
|
<TableAction
|
||||||
@@ -199,27 +281,28 @@ const openExcelUploadModal = () => {
|
|||||||
</template>
|
</template>
|
||||||
</TableAction>
|
</TableAction>
|
||||||
</template>
|
</template>
|
||||||
<template #image="{ row }">
|
<template #product_info="{ row }">
|
||||||
<Image :src="row.image" height="30" width="30" />
|
<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>
|
||||||
<template #supplier="{ row }">
|
<template #supplier="{ row }">
|
||||||
{{ row.supplier?.name || row.source }}
|
{{ row.supplier?.name || row.source }}
|
||||||
</template>
|
</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 }">
|
<template #status="{ row }">
|
||||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -234,6 +317,13 @@ const openExcelUploadModal = () => {
|
|||||||
size: 'small',
|
size: 'small',
|
||||||
onClick: showModal.bind(null, row, true),
|
onClick: showModal.bind(null, row, true),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '绑定配送仓',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'mdi:warehouse',
|
||||||
|
size: 'small',
|
||||||
|
onClick: openBindList.bind(null, row),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '删除',
|
label: '删除',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface RowType {
|
|||||||
category_name: string;
|
category_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 医疗器械表格:合并商品信息/分类仓储/总仓售价列 */
|
||||||
export const gridOptions: VxeGridProps<RowType> = {
|
export const gridOptions: VxeGridProps<RowType> = {
|
||||||
checkboxConfig: {
|
checkboxConfig: {
|
||||||
highlight: true,
|
highlight: true,
|
||||||
@@ -27,34 +28,30 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
{ type: 'checkbox', width: 60 },
|
{ type: 'checkbox', width: 60 },
|
||||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
{
|
||||||
{ field: 'drug_name', align: 'left', title: '器械名称' },
|
field: 'drug_name',
|
||||||
{ field: 'pinyin_simple', title: '拼音' },
|
align: 'left',
|
||||||
|
title: '商品信息',
|
||||||
|
minWidth: 260,
|
||||||
|
slots: { default: 'product_info' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'zone_name',
|
field: 'zone_name',
|
||||||
title: '分区',
|
title: '分类仓储',
|
||||||
width: 100,
|
minWidth: 200,
|
||||||
slots: { default: 'zone' },
|
slots: { default: 'category_warehouse' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'category_name',
|
field: 'central_price',
|
||||||
title: '分类',
|
title: '总仓售价',
|
||||||
width: 120,
|
width: 110,
|
||||||
slots: { default: 'category' },
|
slots: { default: 'central_price' },
|
||||||
},
|
},
|
||||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' } },
|
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
|
||||||
{
|
{ field: 'function', title: '器械功能', width: 200 },
|
||||||
field: 'image',
|
{ field: 'specification', title: '规格型号', width: 120 },
|
||||||
align: 'left',
|
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||||
title: '产品图片',
|
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
|
||||||
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' } },
|
|
||||||
],
|
],
|
||||||
keepSource: true,
|
keepSource: true,
|
||||||
pagerConfig: {},
|
pagerConfig: {},
|
||||||
@@ -87,11 +84,3 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
},
|
},
|
||||||
showOverflow: false,
|
showOverflow: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import { ref } from 'vue';
|
|||||||
|
|
||||||
import { Page, useVbenModal } from '@vben/common-ui';
|
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 { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import { TableAction } from '#/components/table-action';
|
import { TableAction } from '#/components/table-action';
|
||||||
import { downloadByData } from '#/util/tool';
|
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 { deleteMedicalDevice, exportMedicalDeviceApi } from './api';
|
||||||
import FormModalDemo from './components/modal.vue';
|
import FormModalDemo from './components/modal.vue';
|
||||||
@@ -18,6 +25,9 @@ import CategoryModal from './components/CategoryModal.vue';
|
|||||||
import { formOptions } from './config/search';
|
import { formOptions } from './config/search';
|
||||||
import { gridOptions } from './config/table';
|
import { gridOptions } from './config/table';
|
||||||
|
|
||||||
|
/** 医疗器械产品类型 */
|
||||||
|
const PRODUCT_TYPE = 7;
|
||||||
|
|
||||||
const hasTopTableDropDownActions = ref(false);
|
const hasTopTableDropDownActions = ref(false);
|
||||||
|
|
||||||
const gridEvents: VxeGridListeners<any> = {
|
const gridEvents: VxeGridListeners<any> = {
|
||||||
@@ -49,6 +59,78 @@ const [CategoryModalComp, categoryModalApi] = useVbenModal({
|
|||||||
connectedComponent: CategoryModal,
|
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) => {
|
const openCategoryModal = (row: any) => {
|
||||||
categoryModalApi.setData({
|
categoryModalApi.setData({
|
||||||
row,
|
row,
|
||||||
@@ -99,6 +181,9 @@ const openExcelUploadModal = () => {
|
|||||||
<ExcelUploadModal />
|
<ExcelUploadModal />
|
||||||
<FormModal />
|
<FormModal />
|
||||||
<CategoryModalComp />
|
<CategoryModalComp />
|
||||||
|
<BindListModalComp />
|
||||||
|
<CentralStockInModalComp />
|
||||||
|
<WarehousePriceModalComp />
|
||||||
<Grid>
|
<Grid>
|
||||||
<template #toolbar-buttons>
|
<template #toolbar-buttons>
|
||||||
<TableAction
|
<TableAction
|
||||||
@@ -143,22 +228,28 @@ const openExcelUploadModal = () => {
|
|||||||
</template>
|
</template>
|
||||||
</TableAction>
|
</TableAction>
|
||||||
</template>
|
</template>
|
||||||
<template #image="{ row }">
|
<template #product_info="{ row }">
|
||||||
<Image :src="row.image" height="30" width="30" />
|
<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>
|
||||||
<template #supplier="{ row }">
|
<template #supplier="{ row }">
|
||||||
{{ row.supplier?.name || row.source }}
|
{{ row.supplier?.name || row.source }}
|
||||||
</template>
|
</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 }">
|
<template #status="{ row }">
|
||||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -173,6 +264,13 @@ const openExcelUploadModal = () => {
|
|||||||
size: 'small',
|
size: 'small',
|
||||||
onClick: showModal.bind(null, row, true),
|
onClick: showModal.bind(null, row, true),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '绑定配送仓',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'mdi:warehouse',
|
||||||
|
size: 'small',
|
||||||
|
onClick: openBindList.bind(null, row),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '删除',
|
label: '删除',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface RowType {
|
|||||||
category_name: string;
|
category_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 非药品表格:合并商品信息/分类仓储/总仓售价列 */
|
||||||
export const gridOptions: VxeGridProps<RowType> = {
|
export const gridOptions: VxeGridProps<RowType> = {
|
||||||
checkboxConfig: {
|
checkboxConfig: {
|
||||||
highlight: true,
|
highlight: true,
|
||||||
@@ -27,34 +28,30 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
{ type: 'checkbox', width: 60 },
|
{ type: 'checkbox', width: 60 },
|
||||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
{
|
||||||
{ field: 'drug_name', align: 'left', title: '产品名称' },
|
field: 'drug_name',
|
||||||
{ field: 'pinyin_simple', title: '拼音' },
|
align: 'left',
|
||||||
|
title: '商品信息',
|
||||||
|
minWidth: 260,
|
||||||
|
slots: { default: 'product_info' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'zone_name',
|
field: 'zone_name',
|
||||||
title: '分区',
|
title: '分类仓储',
|
||||||
width: 100,
|
minWidth: 200,
|
||||||
slots: { default: 'zone' },
|
slots: { default: 'category_warehouse' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'category_name',
|
field: 'central_price',
|
||||||
title: '分类',
|
title: '总仓售价',
|
||||||
width: 120,
|
width: 110,
|
||||||
slots: { default: 'category' },
|
slots: { default: 'central_price' },
|
||||||
},
|
},
|
||||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' } },
|
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
|
||||||
{
|
{ field: 'function', title: '产品功能', width: 200 },
|
||||||
field: 'image',
|
{ field: 'specification', title: '规格', width: 120 },
|
||||||
align: 'left',
|
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||||
title: '产品图片',
|
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
|
||||||
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' } },
|
|
||||||
],
|
],
|
||||||
keepSource: true,
|
keepSource: true,
|
||||||
pagerConfig: {},
|
pagerConfig: {},
|
||||||
@@ -87,11 +84,3 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
},
|
},
|
||||||
showOverflow: false,
|
showOverflow: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import { ref } from 'vue';
|
|||||||
|
|
||||||
import { Page, useVbenModal } from '@vben/common-ui';
|
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 { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import { TableAction } from '#/components/table-action';
|
import { TableAction } from '#/components/table-action';
|
||||||
import { downloadByData } from '#/util/tool';
|
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 { deleteNonDrug, exportNonDrugApi } from './api';
|
||||||
import FormModalDemo from './components/modal.vue';
|
import FormModalDemo from './components/modal.vue';
|
||||||
@@ -18,6 +25,9 @@ import CategoryModal from './components/CategoryModal.vue';
|
|||||||
import { formOptions } from './config/search';
|
import { formOptions } from './config/search';
|
||||||
import { gridOptions } from './config/table';
|
import { gridOptions } from './config/table';
|
||||||
|
|
||||||
|
/** 非药品产品类型 */
|
||||||
|
const PRODUCT_TYPE = 6;
|
||||||
|
|
||||||
const hasTopTableDropDownActions = ref(false);
|
const hasTopTableDropDownActions = ref(false);
|
||||||
|
|
||||||
const gridEvents: VxeGridListeners<any> = {
|
const gridEvents: VxeGridListeners<any> = {
|
||||||
@@ -49,6 +59,78 @@ const [CategoryModalComp, categoryModalApi] = useVbenModal({
|
|||||||
connectedComponent: CategoryModal,
|
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) => {
|
const openCategoryModal = (row: any) => {
|
||||||
categoryModalApi.setData({
|
categoryModalApi.setData({
|
||||||
row,
|
row,
|
||||||
@@ -99,6 +181,9 @@ const openExcelUploadModal = () => {
|
|||||||
<ExcelUploadModal />
|
<ExcelUploadModal />
|
||||||
<FormModal />
|
<FormModal />
|
||||||
<CategoryModalComp />
|
<CategoryModalComp />
|
||||||
|
<BindListModalComp />
|
||||||
|
<CentralStockInModalComp />
|
||||||
|
<WarehousePriceModalComp />
|
||||||
<Grid>
|
<Grid>
|
||||||
<template #toolbar-buttons>
|
<template #toolbar-buttons>
|
||||||
<TableAction
|
<TableAction
|
||||||
@@ -143,22 +228,28 @@ const openExcelUploadModal = () => {
|
|||||||
</template>
|
</template>
|
||||||
</TableAction>
|
</TableAction>
|
||||||
</template>
|
</template>
|
||||||
<template #image="{ row }">
|
<template #product_info="{ row }">
|
||||||
<Image :src="row.image" height="30" width="30" />
|
<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>
|
||||||
<template #supplier="{ row }">
|
<template #supplier="{ row }">
|
||||||
{{ row.supplier?.name || row.source }}
|
{{ row.supplier?.name || row.source }}
|
||||||
</template>
|
</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 }">
|
<template #status="{ row }">
|
||||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -173,6 +264,13 @@ const openExcelUploadModal = () => {
|
|||||||
size: 'small',
|
size: 'small',
|
||||||
onClick: showModal.bind(null, row, true),
|
onClick: showModal.bind(null, row, true),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '绑定配送仓',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'mdi:warehouse',
|
||||||
|
size: 'small',
|
||||||
|
onClick: openBindList.bind(null, row),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '删除',
|
label: '删除',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface RowType {
|
|||||||
category_name: string;
|
category_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 产品服务包表格:合并商品信息/分类仓储/总仓售价列 */
|
||||||
export const gridOptions: VxeGridProps<RowType> = {
|
export const gridOptions: VxeGridProps<RowType> = {
|
||||||
checkboxConfig: {
|
checkboxConfig: {
|
||||||
highlight: true,
|
highlight: true,
|
||||||
@@ -27,47 +28,35 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
{ type: 'checkbox', width: 60 },
|
{ type: 'checkbox', width: 60 },
|
||||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
{
|
||||||
{ field: 'drug_name', align: 'left', title: '服务包名称' },
|
field: 'drug_name',
|
||||||
{ field: 'pinyin_simple', title: '拼音' },
|
align: 'left',
|
||||||
|
title: '商品信息',
|
||||||
|
minWidth: 260,
|
||||||
|
slots: { default: 'product_info' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'zone_name',
|
field: 'zone_name',
|
||||||
title: '分区',
|
title: '分类仓储',
|
||||||
width: 100,
|
minWidth: 200,
|
||||||
slots: { default: 'zone' },
|
slots: { default: 'category_warehouse' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'category_name',
|
field: 'central_price',
|
||||||
title: '分类',
|
title: '总仓售价',
|
||||||
width: 120,
|
width: 110,
|
||||||
slots: { default: 'category' },
|
slots: { default: 'central_price' },
|
||||||
},
|
},
|
||||||
{ field: 'supplier.name', title: '供应商' },
|
{ field: 'supplier.name', title: '供应商', width: 130 },
|
||||||
{
|
{ field: 'function', title: '主要功能', width: 200 },
|
||||||
field: 'image',
|
{ field: 'specification', title: '规格', width: 120 },
|
||||||
align: 'left',
|
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||||
title: '产品图片',
|
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
|
||||||
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' } },
|
|
||||||
],
|
],
|
||||||
keepSource: true,
|
keepSource: true,
|
||||||
pagerConfig: {},
|
pagerConfig: {},
|
||||||
proxyConfig: {
|
proxyConfig: {
|
||||||
ajax: {
|
ajax: {
|
||||||
// 请求后端接口方法
|
|
||||||
query: async ({ page }, formValues) => {
|
query: async ({ page }, formValues) => {
|
||||||
return await getServicePackList({
|
return await getServicePackList({
|
||||||
page: page.currentPage,
|
page: page.currentPage,
|
||||||
@@ -80,19 +69,16 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
height: 'auto',
|
height: 'auto',
|
||||||
border: false,
|
border: false,
|
||||||
toolbarConfig: {
|
toolbarConfig: {
|
||||||
// 是否显示搜索表单控制按钮
|
|
||||||
// @ts-ignore 正式环境时有完整的类型声明
|
// @ts-ignore 正式环境时有完整的类型声明
|
||||||
search: true,
|
search: true,
|
||||||
refresh: true, // 刷新
|
refresh: true,
|
||||||
print: false, // 打印
|
print: false,
|
||||||
export: false, // 导出
|
export: false,
|
||||||
// custom: true, // 自定义列
|
zoom: true,
|
||||||
zoom: true, // 最大化最小化
|
|
||||||
slots: {
|
slots: {
|
||||||
buttons: 'toolbar-buttons',
|
buttons: 'toolbar-buttons',
|
||||||
},
|
},
|
||||||
custom: {
|
custom: {
|
||||||
// 自定义列-图标
|
|
||||||
icon: 'vxe-icon-menu',
|
icon: 'vxe-icon-menu',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,10 +5,17 @@ import { ref } from 'vue';
|
|||||||
|
|
||||||
import { Page, useVbenModal } from '@vben/common-ui';
|
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 { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import { TableAction } from '#/components/table-action';
|
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 { deleteServicePack } from './api';
|
||||||
import FormModalDemo from './components/modal.vue';
|
import FormModalDemo from './components/modal.vue';
|
||||||
@@ -16,6 +23,9 @@ import ZoneCategoryModal from './components/ZoneCategoryModal.vue';
|
|||||||
import { formOptions } from './config/search';
|
import { formOptions } from './config/search';
|
||||||
import { gridOptions } from './config/table';
|
import { gridOptions } from './config/table';
|
||||||
|
|
||||||
|
/** 产品服务包产品类型 */
|
||||||
|
const PRODUCT_TYPE = 5;
|
||||||
|
|
||||||
const hasTopTableDropDownActions = ref(false);
|
const hasTopTableDropDownActions = ref(false);
|
||||||
|
|
||||||
const gridEvents: VxeGridListeners<any> = {
|
const gridEvents: VxeGridListeners<any> = {
|
||||||
@@ -45,6 +55,79 @@ const [ZoneCategoryModalComp, zoneCategoryModalApi] = useVbenModal({
|
|||||||
connectedComponent: ZoneCategoryModal,
|
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) => {
|
const openZoneCategoryModal = (row: any) => {
|
||||||
zoneCategoryModalApi.setData({
|
zoneCategoryModalApi.setData({
|
||||||
row,
|
row,
|
||||||
@@ -81,6 +164,9 @@ const deleteApi = (row: any) => {
|
|||||||
<Page auto-content-height title="产品服务包管理">
|
<Page auto-content-height title="产品服务包管理">
|
||||||
<FormModal />
|
<FormModal />
|
||||||
<ZoneCategoryModalComp />
|
<ZoneCategoryModalComp />
|
||||||
|
<BindListModalComp />
|
||||||
|
<CentralStockInModalComp />
|
||||||
|
<WarehousePriceModalComp />
|
||||||
<Grid>
|
<Grid>
|
||||||
<template #toolbar-buttons>
|
<template #toolbar-buttons>
|
||||||
<TableAction
|
<TableAction
|
||||||
@@ -114,23 +200,24 @@ const deleteApi = (row: any) => {
|
|||||||
</template>
|
</template>
|
||||||
</TableAction>
|
</TableAction>
|
||||||
</template>
|
</template>
|
||||||
<template #zone="{ row }">
|
<template #product_info="{ row }">
|
||||||
<a class="text-primary cursor-pointer hover:underline" @click="openZoneCategoryModal(row)">
|
<ProductInfoCell :row="row" />
|
||||||
{{ row.zone_name || '未设置' }}
|
|
||||||
</a>
|
|
||||||
</template>
|
</template>
|
||||||
<template #category="{ row }">
|
<template #category_warehouse="{ row }">
|
||||||
<a class="text-primary cursor-pointer hover:underline" @click="openZoneCategoryModal(row)">
|
<CategoryWarehouseCell
|
||||||
{{ row.category_name || '未设置' }}
|
:row="row"
|
||||||
</a>
|
@category="openZoneCategoryModal"
|
||||||
|
@bind="openBindList"
|
||||||
|
@bind-create="openBindCreate"
|
||||||
|
@stock-in="openStockIn"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #image="{ row }">
|
<template #central_price="{ row }">
|
||||||
<Image :src="row.image" height="30" width="30" />
|
<CentralPriceCell
|
||||||
</template>
|
:row="row"
|
||||||
<template #instruction="{ row }">
|
@adjust="openCentralPriceAdjust"
|
||||||
<div class="div" style="width: 120px; height: 120px; overflow: hidden">
|
@stock-in="openStockIn"
|
||||||
<Image :src="row.instruction" height="30" width="30" />
|
/>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #status="{ row }">
|
<template #status="{ row }">
|
||||||
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||||
@@ -147,6 +234,13 @@ const deleteApi = (row: any) => {
|
|||||||
// auth: ['service-pack', 'sys:role:detail'],
|
// auth: ['service-pack', 'sys:role:detail'],
|
||||||
onClick: showModal.bind(null, row, true),
|
onClick: showModal.bind(null, row, true),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '绑定配送仓',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'mdi:warehouse',
|
||||||
|
size: 'small',
|
||||||
|
onClick: openBindList.bind(null, row),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '删除',
|
label: '删除',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
|
|||||||
@@ -27,60 +27,53 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
{ type: 'checkbox', width: 60 },
|
{ type: 'checkbox', width: 60 },
|
||||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
{
|
||||||
{ field: 'drug_name', align: 'left', title: '药品名称', width: 120 },
|
field: 'drug_name',
|
||||||
{ field: 'pinyin_simple', title: '拼音', width: 120 },
|
align: 'left',
|
||||||
|
title: '商品信息',
|
||||||
|
minWidth: 260,
|
||||||
|
slots: { default: 'product_info' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'zone_name',
|
field: 'zone_name',
|
||||||
title: '分区',
|
title: '分类仓储',
|
||||||
width: 100,
|
minWidth: 200,
|
||||||
slots: { default: 'zone' },
|
slots: { default: 'category_warehouse' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'category_name',
|
field: 'central_price',
|
||||||
title: '分类',
|
title: '总仓售价',
|
||||||
width: 120,
|
width: 110,
|
||||||
slots: { default: 'category' },
|
slots: { default: 'central_price' },
|
||||||
},
|
},
|
||||||
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' },width: 130 },
|
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
|
||||||
{
|
{ field: 'function', title: '主要功能', width: 200 },
|
||||||
field: 'image',
|
{ field: 'specification', title: '规格', width: 120 },
|
||||||
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: 'is_otc',
|
field: 'is_otc',
|
||||||
title: '处方药',
|
title: '处方药',
|
||||||
width: 100,
|
width: 100,
|
||||||
slots: { default: 'is_otc' },
|
slots: { default: 'is_otc' },
|
||||||
},
|
},
|
||||||
{ field: 'status', title: '状态', slots: { default: 'status' } },
|
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||||
{ field: 'created_at', title: '发布时间', width: 240 },
|
|
||||||
{
|
{
|
||||||
field: 'min_adjust_price',
|
field: 'min_adjust_price',
|
||||||
title: '最低调整金额',
|
title: '最低调整金额',
|
||||||
width: 130,
|
width: 120,
|
||||||
slots: { default: 'min_adjust_price' },
|
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,
|
keepSource: true,
|
||||||
pagerConfig: {},
|
pagerConfig: {},
|
||||||
proxyConfig: {
|
proxyConfig: {
|
||||||
ajax: {
|
ajax: {
|
||||||
// 请求后端接口方法
|
|
||||||
query: async ({ page }, formValues) => {
|
query: async ({ page }, formValues) => {
|
||||||
return await getWesternMedicineList({
|
return await getWesternMedicineList({
|
||||||
page: page.currentPage,
|
page: page.currentPage,
|
||||||
@@ -90,25 +83,19 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// exportConfig: {
|
|
||||||
// api: passApplicationApi,
|
|
||||||
// },
|
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
border: false,
|
border: false,
|
||||||
toolbarConfig: {
|
toolbarConfig: {
|
||||||
// 是否显示搜索表单控制按钮
|
// @ts-ignore
|
||||||
// @ts-ignore 正式环境时有完整的类型声明
|
|
||||||
search: true,
|
search: true,
|
||||||
refresh: true, // 刷新
|
refresh: true,
|
||||||
print: false, // 打印
|
print: false,
|
||||||
export: false, // 导出
|
export: false,
|
||||||
// custom: true, // 自定义列
|
zoom: true,
|
||||||
zoom: true, // 最大化最小化
|
|
||||||
slots: {
|
slots: {
|
||||||
buttons: 'toolbar-buttons',
|
buttons: 'toolbar-buttons',
|
||||||
},
|
},
|
||||||
custom: {
|
custom: {
|
||||||
// 自定义列-图标
|
|
||||||
icon: 'vxe-icon-menu',
|
icon: 'vxe-icon-menu',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import { ref } from 'vue';
|
|||||||
|
|
||||||
import { Page, useVbenModal } from '@vben/common-ui';
|
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 { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import { TableAction } from '#/components/table-action';
|
import { TableAction } from '#/components/table-action';
|
||||||
import { downloadByData } from '#/util/tool';
|
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 { deleteWesternMedicine, exportWesternMedicineApi } from './api';
|
||||||
import FormModalDemo from './components/modal.vue';
|
import FormModalDemo from './components/modal.vue';
|
||||||
@@ -20,6 +27,9 @@ import ErpQtyFactorModal from './components/ErpQtyFactorModal.vue';
|
|||||||
import { formOptions } from './config/search';
|
import { formOptions } from './config/search';
|
||||||
import { gridOptions } from './config/table';
|
import { gridOptions } from './config/table';
|
||||||
|
|
||||||
|
/** 西药(含中成药列表)产品类型 */
|
||||||
|
const PRODUCT_TYPE = 2;
|
||||||
|
|
||||||
const hasTopTableDropDownActions = ref(false);
|
const hasTopTableDropDownActions = ref(false);
|
||||||
|
|
||||||
const gridEvents: VxeGridListeners<any> = {
|
const gridEvents: VxeGridListeners<any> = {
|
||||||
@@ -61,6 +71,78 @@ const [ErpQtyFactorModalComp, erpQtyFactorModalApi] = useVbenModal({
|
|||||||
connectedComponent: ErpQtyFactorModal,
|
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) => {
|
const openCategoryModal = (row: any) => {
|
||||||
categoryModalApi.setData({
|
categoryModalApi.setData({
|
||||||
row,
|
row,
|
||||||
@@ -138,6 +220,9 @@ const openExcelUploadModal = () => {
|
|||||||
<CategoryModalComp />
|
<CategoryModalComp />
|
||||||
<MinPriceModalComp />
|
<MinPriceModalComp />
|
||||||
<ErpQtyFactorModalComp />
|
<ErpQtyFactorModalComp />
|
||||||
|
<BindListModalComp />
|
||||||
|
<CentralStockInModalComp />
|
||||||
|
<WarehousePriceModalComp />
|
||||||
<Grid>
|
<Grid>
|
||||||
<template #toolbar-buttons>
|
<template #toolbar-buttons>
|
||||||
<TableAction
|
<TableAction
|
||||||
@@ -186,27 +271,28 @@ const openExcelUploadModal = () => {
|
|||||||
</template>
|
</template>
|
||||||
</TableAction>
|
</TableAction>
|
||||||
</template>
|
</template>
|
||||||
<template #image="{ row }">
|
<template #product_info="{ row }">
|
||||||
<Image :src="row.image" height="30" width="30" />
|
<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>
|
||||||
<template #supplier="{ row }">
|
<template #supplier="{ row }">
|
||||||
{{ row.supplier?.name || row.source }}
|
{{ row.supplier?.name || row.source }}
|
||||||
</template>
|
</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 }">
|
<template #is_otc="{ row }">
|
||||||
<Tag :color="row.is_otc === 0 ? 'red' : 'green'">
|
<Tag :color="row.is_otc === 0 ? 'red' : 'green'">
|
||||||
{{ row.is_otc === 0 ? '处方药' : '非处方药' }}
|
{{ row.is_otc === 0 ? '处方药' : '非处方药' }}
|
||||||
@@ -246,6 +332,13 @@ const openExcelUploadModal = () => {
|
|||||||
// auth: ['western-medicine', 'sys:role:detail'],
|
// auth: ['western-medicine', 'sys:role:detail'],
|
||||||
onClick: showModal.bind(null, row, true),
|
onClick: showModal.bind(null, row, true),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '绑定配送仓',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'mdi:warehouse',
|
||||||
|
size: 'small',
|
||||||
|
onClick: openBindList.bind(null, row),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '设置底价',
|
label: '设置底价',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ import { requestClient } from '#/api/request';
|
|||||||
|
|
||||||
const prefix = 'doctor-reception/';
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 医生应用特色方到开方区
|
* 医生应用特色方到开方区
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
{ field: 'id', align: 'left', title: 'ID', width: 50 },
|
{ field: 'id', align: 'left', title: 'ID', width: 50 },
|
||||||
{ field: 'user_info', title: '申请诊所/供应商/平台', slots: { default: 'user-info'} },
|
{ field: 'user_info', title: '申请诊所/供应商/平台/配送仓', slots: { default: 'user-info'} },
|
||||||
{ field: 'order_no', title: '订单号' },
|
{ field: 'order_no', title: '订单号' },
|
||||||
{ field: 'apply_cash', title: '申请金额' },
|
{ field: 'apply_cash', title: '申请金额' },
|
||||||
{ field: 'true_cash', title: '打款金额' },
|
{ field: 'true_cash', title: '打款金额' },
|
||||||
|
|||||||
@@ -57,7 +57,13 @@ const infoModal = (id, modalType = 0) => {
|
|||||||
<template #user-info="{ row }">
|
<template #user-info="{ row }">
|
||||||
<div>
|
<div>
|
||||||
<Tag color="green">{{ row?.user_type_txt || '平台' }}</Tag>
|
<Tag color="green">{{ row?.user_type_txt || '平台' }}</Tag>
|
||||||
<Tag class="mt-3" color="#455cda">{{ row.store?.name || row.supplier?.name || '萧康云医' }}</Tag>
|
<Tag class="mt-3" color="#455cda">{{
|
||||||
|
row.store?.name ||
|
||||||
|
row.supplier?.name ||
|
||||||
|
row.delivery_warehouse?.name ||
|
||||||
|
row.deliveryWarehouse?.name ||
|
||||||
|
'萧康云医'
|
||||||
|
}}</Tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #check-id="{ row }">
|
<template #check-id="{ row }">
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ export const accountChangeSearchOptions: VbenFormProps = {
|
|||||||
fieldName: 'source_type',
|
fieldName: 'source_type',
|
||||||
label: '来源类型',
|
label: '来源类型',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenSelect',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '变更字段',
|
||||||
|
allowClear: true,
|
||||||
|
options: [
|
||||||
|
{ label: '可提现金额', value: 'able_cash' },
|
||||||
|
{ label: '可提现余额', value: 'balance' },
|
||||||
|
{ label: '冻结金额(用户未确认收货)', value: 'frozen' },
|
||||||
|
{ label: '审核中金额', value: 'frozen_cash' },
|
||||||
|
{ label: '审核中金额(余额账户)', value: 'withdrawn_frozen' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
defaultValue: '',
|
||||||
|
fieldName: 'field_name',
|
||||||
|
label: '变更字段',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export const accountChangeGridOptions: VxeGridProps = {
|
|||||||
slots: { default: 'change_amount' },
|
slots: { default: 'change_amount' },
|
||||||
},
|
},
|
||||||
{ field: 'order_no', title: '订单号', minWidth: 160 },
|
{ field: 'order_no', title: '订单号', minWidth: 160 },
|
||||||
{ field: 'source_table', title: '来源表', width: 140 },
|
// 后端 formatListItem 返回 source_table_txt(中文),缺省时回退英文表名
|
||||||
|
{ field: 'source_table_txt', title: '来源表', width: 140 },
|
||||||
{ field: 'remark', title: '备注', minWidth: 180 },
|
{ field: 'remark', title: '备注', minWidth: 180 },
|
||||||
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 100 },
|
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 100 },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const modalCardFormProps: VbenFormProps = {
|
|||||||
{ label: '诊所', value: 1 },
|
{ label: '诊所', value: 1 },
|
||||||
{ label: '平台', value: 2 },
|
{ label: '平台', value: 2 },
|
||||||
{ label: '供应商', value: 3 },
|
{ label: '供应商', value: 3 },
|
||||||
|
{ label: '配送仓库', value: 4 },
|
||||||
],
|
],
|
||||||
disabled: true,
|
disabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -104,60 +104,67 @@ function updateShowStatus() {
|
|||||||
>
|
>
|
||||||
<OrderDetailModal />
|
<OrderDetailModal />
|
||||||
<Statistics v-show="isShow" :grid-api="GridApi" />
|
<Statistics v-show="isShow" :grid-api="GridApi" />
|
||||||
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
|
|
||||||
<template #trends>
|
<div style="min-height: 500px">
|
||||||
<div style="min-height: 500px">
|
|
||||||
<Grid>
|
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
|
||||||
<template #toolbar-buttons></template>
|
<template #trends>
|
||||||
<template #toolbar-tools></template>
|
<div style="min-height: 500px">
|
||||||
<template #user_id="{ row }">
|
<Grid>
|
||||||
<span v-if="row.user_type === 1">{{ row.store.name }}</span>
|
<template #toolbar-buttons></template>
|
||||||
<span v-else-if="row.user_type === 2">萧康平台</span>
|
<template #toolbar-tools></template>
|
||||||
<span v-else-if="row.user_type === 3">{{
|
<template #user_id="{ row }">
|
||||||
row.supplier.name
|
<span v-if="row.user_type === 1">{{ row.store?.name }}</span>
|
||||||
}}</span>
|
<span v-else-if="row.user_type === 2">萧康平台</span>
|
||||||
</template>
|
<span v-else-if="row.user_type === 3">{{
|
||||||
<template #check_status="{ row }">
|
row.supplier?.name
|
||||||
<Tag v-if="row.check_status === 1" color="blue">待审核</Tag>
|
}}</span>
|
||||||
<Tag v-else-if="row.check_status === 2" color="green">
|
<span v-else-if="row.user_type === 4">{{
|
||||||
审核成功
|
row.delivery_warehouse?.name || row.deliveryWarehouse?.name
|
||||||
</Tag>
|
}}</span>
|
||||||
<Tag v-else-if="row.check_status === 3" color="red">拒绝</Tag>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
<template #check_result="{ row }">
|
<template #check_status="{ row }">
|
||||||
<Tag v-if="row.check_status === 2" color="green">
|
<Tag v-if="row.check_status === 1" color="blue">待审核</Tag>
|
||||||
{{ row.check_result }}
|
<Tag v-else-if="row.check_status === 2" color="green">
|
||||||
</Tag>
|
审核成功
|
||||||
<Tag v-else-if="row.check_status === 3" color="red">
|
</Tag>
|
||||||
{{ row.check_result }}
|
<Tag v-else-if="row.check_status === 3" color="red">拒绝</Tag>
|
||||||
</Tag>
|
</template>
|
||||||
</template>
|
<template #check_result="{ row }">
|
||||||
<template #dakuan_status="{ row }">
|
<Tag v-if="row.check_status === 2" color="green">
|
||||||
<span v-if="row.dakuan_status === -1">打款失败</span>
|
{{ row.check_result }}
|
||||||
<span v-else-if="row.dakuan_status === 0">处理中</span>
|
</Tag>
|
||||||
<span v-else-if="row.dakuan_status === 1">打款成功</span>
|
<Tag v-else-if="row.check_status === 3" color="red">
|
||||||
</template>
|
{{ row.check_result }}
|
||||||
<template #check_id="{ row }">
|
</Tag>
|
||||||
|
</template>
|
||||||
|
<template #dakuan_status="{ row }">
|
||||||
|
<span v-if="row.dakuan_status === -1">打款失败</span>
|
||||||
|
<span v-else-if="row.dakuan_status === 0">处理中</span>
|
||||||
|
<span v-else-if="row.dakuan_status === 1">打款成功</span>
|
||||||
|
</template>
|
||||||
|
<template #check_id="{ row }">
|
||||||
<span>{{
|
<span>{{
|
||||||
row.check_admin?.username ||
|
row.check_admin?.username ||
|
||||||
row.check_admin?.nick_name ||
|
row.check_admin?.nick_name ||
|
||||||
'暂无'
|
'暂无'
|
||||||
}}</span>
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
<TableAction :actions="[]" :drop-down-actions="[]" />
|
<TableAction :actions="[]" :drop-down-actions="[]" />
|
||||||
</template>
|
</template>
|
||||||
</Grid>
|
</Grid>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #visits>
|
<template #visits>
|
||||||
<div style="min-height: 500px">
|
<div style="min-height: 500px">
|
||||||
<Grid3>
|
<Grid3>
|
||||||
<template #toolbar-buttons></template>
|
<template #toolbar-buttons></template>
|
||||||
<template #toolbar-tools></template>
|
<template #toolbar-tools></template>
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
<TableAction
|
<TableAction
|
||||||
:actions="[
|
:actions="[
|
||||||
{
|
{
|
||||||
label: '查看明细',
|
label: '查看明细',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
@@ -171,37 +178,41 @@ function updateShowStatus() {
|
|||||||
onClick: openOrderDetail.bind(null, row),
|
onClick: openOrderDetail.bind(null, row),
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
:drop-down-actions="[]"
|
:drop-down-actions="[]"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Grid3>
|
</Grid3>
|
||||||
<SettlementDetailDrawer ref="settlementDetailDrawerRef" />
|
<SettlementDetailDrawer ref="settlementDetailDrawerRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #visitsItems>
|
<template #visitsItems>
|
||||||
<div style="min-height: 500px">
|
<div style="min-height: 500px">
|
||||||
<Grid2>
|
<Grid2>
|
||||||
<template #toolbar-buttons></template>
|
<template #toolbar-buttons></template>
|
||||||
<template #toolbar-tools></template>
|
<template #toolbar-tools></template>
|
||||||
<template #user_id="{ row }">
|
<template #user_id="{ row }">
|
||||||
<span v-if="row.user_type === 1">{{ row.store.name }}</span>
|
<span v-if="row.user_type === 1">{{ row.store?.name }}</span>
|
||||||
<span v-else-if="row.user_type === 2">萧康平台</span>
|
<span v-else-if="row.user_type === 2">萧康平台</span>
|
||||||
<span v-else-if="row.user_type === 3">{{
|
<span v-else-if="row.user_type === 3">{{
|
||||||
row.supplier.name
|
row.supplier?.name
|
||||||
}}</span>
|
}}</span>
|
||||||
</template>
|
<span v-else-if="row.user_type === 4">{{
|
||||||
<template #action="{ row }">
|
row.delivery_warehouse?.name || row.deliveryWarehouse?.name
|
||||||
<TableAction :actions="[]" :drop-down-actions="[]" />
|
}}</span>
|
||||||
</template>
|
<span v-else>-</span>
|
||||||
</Grid2>
|
</template>
|
||||||
</div>
|
<template #action="{ row }">
|
||||||
</template>
|
<TableAction :actions="[]" :drop-down-actions="[]" />
|
||||||
<template #accountChange>
|
</template>
|
||||||
<div style="min-height: 500px">
|
</Grid2>
|
||||||
<Grid4>
|
</div>
|
||||||
<template #toolbar-buttons></template>
|
</template>
|
||||||
<template #toolbar-tools></template>
|
<template #accountChange>
|
||||||
<template #change_amount="{ row }">
|
<div style="min-height: 500px">
|
||||||
|
<Grid4>
|
||||||
|
<template #toolbar-buttons></template>
|
||||||
|
<template #toolbar-tools></template>
|
||||||
|
<template #change_amount="{ row }">
|
||||||
<span
|
<span
|
||||||
v-if="Number(row.change_amount) > 0"
|
v-if="Number(row.change_amount) > 0"
|
||||||
style="
|
style="
|
||||||
@@ -215,24 +226,24 @@ function updateShowStatus() {
|
|||||||
+{{ Number(row.change_amount).toFixed(2) }}
|
+{{ Number(row.change_amount).toFixed(2) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
v-else-if="Number(row.change_amount) < 0"
|
v-else-if="Number(row.change_amount) < 0"
|
||||||
style="
|
style="
|
||||||
color: #ff4d4f;
|
color: #ff4d4f;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<ArrowDownOutlined />
|
<ArrowDownOutlined />
|
||||||
{{ Number(row.change_amount).toFixed(2) }}
|
{{ Number(row.change_amount).toFixed(2) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span v-else style="color: #999">0</span>
|
<span v-else style="color: #999">0</span>
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
<TableAction
|
<TableAction
|
||||||
:actions="[
|
:actions="[
|
||||||
{
|
{
|
||||||
label: '订单详情',
|
label: '订单详情',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
@@ -241,13 +252,14 @@ function updateShowStatus() {
|
|||||||
onClick: openOrderDetail.bind(null, row),
|
onClick: openOrderDetail.bind(null, row),
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
:drop-down-actions="[]"
|
:drop-down-actions="[]"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Grid4>
|
</Grid4>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</AnalysisChartsTabs>
|
</AnalysisChartsTabs>
|
||||||
|
</div>
|
||||||
<FloatButtonGroup :style="{ right: '24px', bottom: '84px' }" shape="circle">
|
<FloatButtonGroup :style="{ right: '24px', bottom: '84px' }" shape="circle">
|
||||||
<Popover placement="left" title="显示余额卡片">
|
<Popover placement="left" title="显示余额卡片">
|
||||||
<template #content>
|
<template #content>
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { createAdminGridOptions } from './table-config';
|
|||||||
import { getRoleMeta } from './role-meta';
|
import { getRoleMeta } from './role-meta';
|
||||||
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
|
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
|
||||||
import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue';
|
import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue';
|
||||||
import OaAccountModal from '#/views/system/admin/_shared/components/oa-account-modal.vue';
|
|
||||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||||
|
|
||||||
function formatAdminRegion(row: {
|
function formatAdminRegion(row: {
|
||||||
@@ -122,20 +121,6 @@ const openDoctorCard = (row: { doctor_id?: number }) => {
|
|||||||
DoctorCardModalApi.open();
|
DoctorCardModalApi.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 员工 OA 账号弹窗:每个员工可配置各平台的 @ 标识 */
|
|
||||||
const [OaAccountModals, OaAccountModalApi] = useVbenModal({
|
|
||||||
connectedComponent: OaAccountModal,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 打开员工 OA 账号弹窗 */
|
|
||||||
const openOaAccount = (row: { id: number; nick_name?: string; name?: string }) => {
|
|
||||||
OaAccountModalApi.setData({
|
|
||||||
id: row.id,
|
|
||||||
name: row.nick_name || row.name,
|
|
||||||
});
|
|
||||||
OaAccountModalApi.open();
|
|
||||||
};
|
|
||||||
|
|
||||||
const gridEvents: VxeGridListeners<any> = {
|
const gridEvents: VxeGridListeners<any> = {
|
||||||
checkboxChange() {
|
checkboxChange() {
|
||||||
const records = gridApi.grid.getCheckboxRecords();
|
const records = gridApi.grid.getCheckboxRecords();
|
||||||
@@ -225,7 +210,6 @@ const copyToClipboard = async (text: string) => {
|
|||||||
<Form />
|
<Form />
|
||||||
</Modal>
|
</Modal>
|
||||||
<DoctorCardModals />
|
<DoctorCardModals />
|
||||||
<OaAccountModals />
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<template #toolbar-buttons>
|
<template #toolbar-buttons>
|
||||||
<TableAction
|
<TableAction
|
||||||
@@ -384,13 +368,6 @@ const copyToClipboard = async (text: string) => {
|
|||||||
confirm: resetPasswordApi.bind(null, row.id),
|
confirm: resetPasswordApi.bind(null, row.id),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'OA 账号',
|
|
||||||
type: 'link',
|
|
||||||
icon: 'ant-design:notification-outlined',
|
|
||||||
size: 'small',
|
|
||||||
onClick: openOaAccount.bind(null, row),
|
|
||||||
},
|
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const UNUSED_ID_FIELDS = [
|
|||||||
'doctor_id',
|
'doctor_id',
|
||||||
'pharmacist_id',
|
'pharmacist_id',
|
||||||
'supplier_id',
|
'supplier_id',
|
||||||
|
'delivery_warehouse_id',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function isEmptyId(value: unknown): boolean {
|
function isEmptyId(value: unknown): boolean {
|
||||||
@@ -53,15 +54,24 @@ export function normalizeAdminPayload(
|
|||||||
toZeroOrOmit(payload, 'store_id', true);
|
toZeroOrOmit(payload, 'store_id', true);
|
||||||
toZeroOrOmit(payload, 'doctor_id', true);
|
toZeroOrOmit(payload, 'doctor_id', true);
|
||||||
toZeroOrOmit(payload, 'pharmacist_id', true);
|
toZeroOrOmit(payload, 'pharmacist_id', true);
|
||||||
|
toZeroOrOmit(payload, 'delivery_warehouse_id', true);
|
||||||
|
break;
|
||||||
|
case 'deliveryWarehouse':
|
||||||
|
toZeroOrOmit(payload, 'store_id', true);
|
||||||
|
toZeroOrOmit(payload, 'doctor_id', true);
|
||||||
|
toZeroOrOmit(payload, 'pharmacist_id', true);
|
||||||
|
toZeroOrOmit(payload, 'supplier_id', true);
|
||||||
break;
|
break;
|
||||||
case 'clinic':
|
case 'clinic':
|
||||||
toZeroOrOmit(payload, 'doctor_id', true);
|
toZeroOrOmit(payload, 'doctor_id', true);
|
||||||
toZeroOrOmit(payload, 'pharmacist_id', true);
|
toZeroOrOmit(payload, 'pharmacist_id', true);
|
||||||
toZeroOrOmit(payload, 'supplier_id', true);
|
toZeroOrOmit(payload, 'supplier_id', true);
|
||||||
|
toZeroOrOmit(payload, 'delivery_warehouse_id', true);
|
||||||
break;
|
break;
|
||||||
case 'doctor':
|
case 'doctor':
|
||||||
toZeroOrOmit(payload, 'pharmacist_id', true);
|
toZeroOrOmit(payload, 'pharmacist_id', true);
|
||||||
toZeroOrOmit(payload, 'supplier_id', true);
|
toZeroOrOmit(payload, 'supplier_id', true);
|
||||||
|
toZeroOrOmit(payload, 'delivery_warehouse_id', true);
|
||||||
if (isEmptyId(payload.doctor_id)) {
|
if (isEmptyId(payload.doctor_id)) {
|
||||||
payload.doctor_id = 0;
|
payload.doctor_id = 0;
|
||||||
}
|
}
|
||||||
@@ -69,6 +79,7 @@ export function normalizeAdminPayload(
|
|||||||
case 'pharmacist':
|
case 'pharmacist':
|
||||||
toZeroOrOmit(payload, 'doctor_id', true);
|
toZeroOrOmit(payload, 'doctor_id', true);
|
||||||
toZeroOrOmit(payload, 'supplier_id', true);
|
toZeroOrOmit(payload, 'supplier_id', true);
|
||||||
|
toZeroOrOmit(payload, 'delivery_warehouse_id', true);
|
||||||
if (isEmptyId(payload.pharmacist_id)) {
|
if (isEmptyId(payload.pharmacist_id)) {
|
||||||
payload.pharmacist_id = 0;
|
payload.pharmacist_id = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
|
||||||
|
|
||||||
import { Button, Empty, message, Spin, Tabs } from 'ant-design-vue';
|
|
||||||
|
|
||||||
import { getOaPlatformList } from '#/views/system/oa-platform/api';
|
|
||||||
import {
|
|
||||||
getAdminOaAccounts,
|
|
||||||
saveAdminOaAccounts,
|
|
||||||
} from '#/views/system/admin/_shared/oa-account/api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 员工 OA 账号管理弹窗
|
|
||||||
*
|
|
||||||
* 用途:在员工管理页的「更多操作」中点击「OA 账号」打开,
|
|
||||||
* 为该员工配置每个平台的 @ 标识(企业微信 userid、钉钉 userid、飞书 open_id 等)。
|
|
||||||
*
|
|
||||||
* 设计要点:
|
|
||||||
* - 每个启用平台一个 Tab,Tab 内可以添加多条账号(一个员工在一个平台可有多个账号)
|
|
||||||
* - 账号标识和昵称两个输入框:account_id 用于 @,account_name 仅用于展示
|
|
||||||
* - 保存时全量覆盖该员工的所有账号(先软删旧的,再插入新的)
|
|
||||||
*/
|
|
||||||
const loading = ref(false);
|
|
||||||
const saving = ref(false);
|
|
||||||
const platforms = ref<any[]>([]);
|
|
||||||
const activeTab = ref('');
|
|
||||||
const adminId = ref(0);
|
|
||||||
const adminName = ref('');
|
|
||||||
|
|
||||||
/** 按平台分组的账号列表:[platform_code => [{account_id, account_name, key}]] */
|
|
||||||
const accountsByPlatform = ref<Record<string, Array<{ account_id: string; account_name: string; key: number }>>>({});
|
|
||||||
|
|
||||||
let keyCounter = 0;
|
|
||||||
function genKey() {
|
|
||||||
keyCounter += 1;
|
|
||||||
return keyCounter;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
|
||||||
fullscreenButton: false,
|
|
||||||
draggable: true,
|
|
||||||
onCancel() {
|
|
||||||
modalApi.close();
|
|
||||||
},
|
|
||||||
onConfirm: async () => {
|
|
||||||
if (!adminId.value) {
|
|
||||||
message.error('员工 ID 不能为空');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
// 提交时去除 key 字段(后端不需要),过滤空 account_id
|
|
||||||
const payload: Record<string, any[]> = {};
|
|
||||||
for (const [platformCode, accounts] of Object.entries(accountsByPlatform.value)) {
|
|
||||||
const filtered = accounts
|
|
||||||
.filter((a) => a.account_id.trim() !== '')
|
|
||||||
.map((a) => ({
|
|
||||||
account_id: a.account_id.trim(),
|
|
||||||
account_name: a.account_name,
|
|
||||||
}));
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
payload[platformCode] = filtered;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await saveAdminOaAccounts(adminId.value, payload);
|
|
||||||
message.success('保存成功');
|
|
||||||
modalApi.close();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || '保存失败');
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onOpenChange(isOpen: boolean) {
|
|
||||||
if (isOpen) {
|
|
||||||
const data = modalApi.getData<{ id: number; name?: string }>();
|
|
||||||
adminId.value = data?.id || 0;
|
|
||||||
adminName.value = data?.name || '';
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载平台列表 + 该员工的已有账号
|
|
||||||
*/
|
|
||||||
async function loadData() {
|
|
||||||
if (!adminId.value) return;
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const [platformList, accountList] = await Promise.all([
|
|
||||||
getOaPlatformList(),
|
|
||||||
getAdminOaAccounts(adminId.value),
|
|
||||||
]);
|
|
||||||
const platformRows: any[] = Array.isArray(platformList)
|
|
||||||
? platformList
|
|
||||||
: platformList?.data || [];
|
|
||||||
platforms.value = platformRows.sort(
|
|
||||||
(a: any, b: any) => (b.sort || 0) - (a.sort || 0),
|
|
||||||
);
|
|
||||||
activeTab.value = platforms.value[0]?.platform_code || '';
|
|
||||||
|
|
||||||
// 把后端返回的按平台分组的账号填充到表单
|
|
||||||
const grouped: typeof accountsByPlatform.value = {};
|
|
||||||
for (const platform of platforms.value) {
|
|
||||||
const code = platform.platform_code;
|
|
||||||
const accounts: any[] = (accountList?.[code] || accountList?.data?.[code]) || [];
|
|
||||||
grouped[code] = accounts.map((a) => ({
|
|
||||||
account_id: String(a.account_id || ''),
|
|
||||||
account_name: String(a.account_name || ''),
|
|
||||||
key: genKey(),
|
|
||||||
}));
|
|
||||||
// 至少留一条空行,便于新增
|
|
||||||
if (grouped[code].length === 0) {
|
|
||||||
grouped[code] = [{ account_id: '', account_name: '', key: genKey() }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
accountsByPlatform.value = grouped;
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || '加载数据失败');
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加一行账号输入框
|
|
||||||
*/
|
|
||||||
function addAccount(platformCode: string) {
|
|
||||||
if (!accountsByPlatform.value[platformCode]) {
|
|
||||||
accountsByPlatform.value[platformCode] = [];
|
|
||||||
}
|
|
||||||
accountsByPlatform.value[platformCode].push({
|
|
||||||
account_id: '',
|
|
||||||
account_name: '',
|
|
||||||
key: genKey(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除一行账号
|
|
||||||
*/
|
|
||||||
function removeAccount(platformCode: string, key: number) {
|
|
||||||
accountsByPlatform.value[platformCode] = (
|
|
||||||
accountsByPlatform.value[platformCode] || []
|
|
||||||
).filter((a) => a.key !== key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 平台编码 → 显示名
|
|
||||||
*/
|
|
||||||
function platformName(code: string): string {
|
|
||||||
return platforms.value.find((p) => p.platform_code === code)?.name || code;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 平台编码 → 该平台 @ 标识的提示文案
|
|
||||||
*/
|
|
||||||
function platformTip(code: string): string {
|
|
||||||
if (code === 'work_wechat') return '企业微信 userid(在通讯录中查看,组织内唯一)';
|
|
||||||
if (code === 'work_wechat_app')
|
|
||||||
return '企业微信 userid(应用 API @ 群成员可选;员工 @ 主路径仍用手机号)';
|
|
||||||
if (code === 'dingtalk') return '钉钉 userid(管理后台 → 通讯录 → 成员详情)';
|
|
||||||
if (code === 'feishu') return '飞书 open_id(ou_ 开头)';
|
|
||||||
return '该平台的账号标识';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Modal
|
|
||||||
:title="`员工 OA 账号 - ${adminName || `#${adminId}`}`"
|
|
||||||
class="w-[50%]"
|
|
||||||
>
|
|
||||||
<Spin :spinning="loading || saving">
|
|
||||||
<div v-if="platforms.length === 0 && !loading" class="py-6">
|
|
||||||
<Empty description="暂无启用的 OA 平台" />
|
|
||||||
</div>
|
|
||||||
<Tabs v-else v-model:active-key="activeTab">
|
|
||||||
<Tabs.TabPane
|
|
||||||
v-for="platform in platforms"
|
|
||||||
:key="platform.platform_code"
|
|
||||||
:tab="platform.name"
|
|
||||||
>
|
|
||||||
<div class="oa-account-tab">
|
|
||||||
<div class="mb-3 text-sm text-gray-500">
|
|
||||||
{{ platformTip(platform.platform_code) }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="account in accountsByPlatform[platform.platform_code] || []"
|
|
||||||
:key="account.key"
|
|
||||||
class="mb-2 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="account.account_id"
|
|
||||||
class="ant-input ant-input-sm flex-1"
|
|
||||||
placeholder="账号标识(用于 @)"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="account.account_name"
|
|
||||||
class="ant-input ant-input-sm flex-1"
|
|
||||||
placeholder="账号昵称(仅用于展示,可选)"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
@click="removeAccount(platform.platform_code, account.key)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
size="small"
|
|
||||||
block
|
|
||||||
@click="addAccount(platform.platform_code)"
|
|
||||||
>
|
|
||||||
+ 添加{{ platformName(platform.platform_code) }}账号
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Tabs.TabPane>
|
|
||||||
</Tabs>
|
|
||||||
</Spin>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.oa-account-tab {
|
|
||||||
padding: 8px;
|
|
||||||
max-height: 360px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -3,6 +3,7 @@ import type { VbenFormProps } from '#/adapter/form';
|
|||||||
import { useUserStore } from '@vben/stores';
|
import { useUserStore } from '@vben/stores';
|
||||||
|
|
||||||
import { z } from '#/adapter/form';
|
import { z } from '#/adapter/form';
|
||||||
|
import { getDeliveryWarehouseOption } from '#/views/system/delivery-warehouse/api';
|
||||||
import { getSupplierOption } from '#/views/system/supplier/api';
|
import { getSupplierOption } from '#/views/system/supplier/api';
|
||||||
|
|
||||||
import type { AdminFormType } from './role-meta';
|
import type { AdminFormType } from './role-meta';
|
||||||
@@ -177,6 +178,29 @@ export function createAdminModalFormProps(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formType === 'deliveryWarehouse') {
|
||||||
|
schema.push({
|
||||||
|
component: 'ApiSelect',
|
||||||
|
componentProps: {
|
||||||
|
allowClear: true,
|
||||||
|
filterOption: true,
|
||||||
|
showSearch: true,
|
||||||
|
afterFetch: (data: { id: number; name: string }[]) => {
|
||||||
|
return data.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
api: getDeliveryWarehouseOption,
|
||||||
|
placeholder: '请选择',
|
||||||
|
},
|
||||||
|
fieldName: 'delivery_warehouse_id',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
label: '所属配送仓库',
|
||||||
|
rules: 'required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (formType === 'clinic' || formType === 'doctor' || formType === 'pharmacist') {
|
if (formType === 'clinic' || formType === 'doctor' || formType === 'pharmacist') {
|
||||||
schema.push({
|
schema.push({
|
||||||
component: 'StorePicker',
|
component: 'StorePicker',
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { requestClient } from '#/api/request';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 员工 OA 账号管理 API
|
|
||||||
* 路由前缀:admin-oa-account/
|
|
||||||
*/
|
|
||||||
const prefix = 'admin-oa-account/';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取指定员工的所有 OA 账号(按平台分组)
|
|
||||||
* 返回结构:['work_wechat' => [{id, account_id, account_name}, ...], ...]
|
|
||||||
*/
|
|
||||||
export async function getAdminOaAccounts(adminId: number) {
|
|
||||||
return requestClient.get<any>(`${prefix}list`, { params: { admin_id: adminId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存员工的 OA 账号配置(全量同步)
|
|
||||||
* accounts 结构:{ platform_code: [{account_id, account_name}, ...] }
|
|
||||||
*/
|
|
||||||
export async function saveAdminOaAccounts(adminId: number, accounts: Record<string, any[]>) {
|
|
||||||
return requestClient.post<any>(`${prefix}save`, { admin_id: adminId, accounts });
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ export type AdminFormType =
|
|||||||
| 'basic'
|
| 'basic'
|
||||||
| 'address'
|
| 'address'
|
||||||
| 'supplier'
|
| 'supplier'
|
||||||
|
| 'deliveryWarehouse'
|
||||||
| 'clinic'
|
| 'clinic'
|
||||||
| 'doctor'
|
| 'doctor'
|
||||||
| 'pharmacist';
|
| 'pharmacist';
|
||||||
@@ -28,6 +29,8 @@ export const ADMIN_ROLE_LIST: AdminRoleMeta[] = [
|
|||||||
{ id: 10, title: '医生', slug: 'doctor', formType: 'doctor' },
|
{ id: 10, title: '医生', slug: 'doctor', formType: 'doctor' },
|
||||||
{ id: 11, title: '药师', slug: 'pharmacist', formType: 'pharmacist' },
|
{ id: 11, title: '药师', slug: 'pharmacist', formType: 'pharmacist' },
|
||||||
{ id: 14, title: '诊所推广员', slug: 'clinic-salesperson', formType: 'clinic' },
|
{ id: 14, title: '诊所推广员', slug: 'clinic-salesperson', formType: 'clinic' },
|
||||||
|
{ id: 15, title: '订单管理员', slug: 'order', formType: 'basic' },
|
||||||
|
{ id: 16, title: '配送仓库管理员', slug: 'delivery-warehouse', formType: 'deliveryWarehouse' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ROLE_CITY_MANAGER = 4;
|
export const ROLE_CITY_MANAGER = 4;
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ function buildColumns(formType: AdminFormType, roleId: number) {
|
|||||||
cols.push({ field: 'supplier.name', title: '所属供应商' });
|
cols.push({ field: 'supplier.name', title: '所属供应商' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formType === 'deliveryWarehouse') {
|
||||||
|
cols.push({ field: 'delivery_warehouse.name', title: '所属配送仓库' });
|
||||||
|
}
|
||||||
|
|
||||||
if (formType === 'clinic' || formType === 'doctor' || formType === 'pharmacist') {
|
if (formType === 'clinic' || formType === 'doctor' || formType === 'pharmacist') {
|
||||||
cols.push({
|
cols.push({
|
||||||
field: 'store_id',
|
field: 'store_id',
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { VbenFormProps } from '#/adapter/form';
|
|||||||
|
|
||||||
import { z } from '#/adapter/form';
|
import { z } from '#/adapter/form';
|
||||||
import { getRoleOption } from '#/views/system/role/api';
|
import { getRoleOption } from '#/views/system/role/api';
|
||||||
|
import { getDeliveryWarehouseOption } from '#/views/system/delivery-warehouse/api';
|
||||||
import { getSupplierOption } from '#/views/system/supplier/api';
|
import { getSupplierOption } from '#/views/system/supplier/api';
|
||||||
|
|
||||||
const defaultPassword = 'Xk123456@';
|
const defaultPassword = 'Xk123456@';
|
||||||
|
|
||||||
const supplierId = 7;
|
const supplierId = 7;
|
||||||
|
const deliveryWarehouseAdminId = 16;
|
||||||
const pharmacistId = 3;
|
const pharmacistId = 3;
|
||||||
const cityId = 4;
|
const cityId = 4;
|
||||||
const districtId = 5;
|
const districtId = 5;
|
||||||
@@ -123,6 +125,32 @@ export const modalFormProps: VbenFormProps = {
|
|||||||
label: '所属供应商',
|
label: '所属供应商',
|
||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: 'ApiSelect',
|
||||||
|
componentProps: {
|
||||||
|
allowClear: true,
|
||||||
|
filterOption: true,
|
||||||
|
showSearch: true,
|
||||||
|
afterFetch: (data: { id: number; name: string }[]) => {
|
||||||
|
return data.map((item: any) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
api: getDeliveryWarehouseOption,
|
||||||
|
placeholder: '请选择',
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
show: (values) => {
|
||||||
|
return values.role_id === deliveryWarehouseAdminId;
|
||||||
|
},
|
||||||
|
triggerFields: ['role_id'],
|
||||||
|
},
|
||||||
|
fieldName: 'delivery_warehouse_id',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
label: '所属配送仓库',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'password',
|
fieldName: 'password',
|
||||||
label: '密码',
|
label: '密码',
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 配送仓管理员账号列表(角色 16)
|
||||||
|
*/
|
||||||
|
import AdminPage from '../_shared/admin-page.vue';
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<AdminPage :role-id="16" />
|
||||||
|
</template>
|
||||||
10
apps/web-antd/src/views/system/admin/order/index.vue
Normal file
10
apps/web-antd/src/views/system/admin/order/index.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 订单管理员账号列表(角色 15)
|
||||||
|
* 本阶段仅账号入口,业务菜单权限后续再挂
|
||||||
|
*/
|
||||||
|
import AdminPage from '../_shared/admin-page.vue';
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<AdminPage :role-id="15" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
const prefix = 'delivery-warehouse-bank-card-report/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取银行卡报备详情
|
||||||
|
*/
|
||||||
|
export async function getDeliveryWarehouseBankCardDetail(params?: {
|
||||||
|
warehouse_id?: number;
|
||||||
|
}) {
|
||||||
|
return requestClient.get<any>(`${prefix}detail`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交银行卡报备
|
||||||
|
*/
|
||||||
|
export async function reportDeliveryWarehouseBankCard(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}report`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步报备状态
|
||||||
|
*/
|
||||||
|
export async function syncDeliveryWarehouseBankCardStatus(data?: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}sync-status`, data || {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
export const reportFormProps: VbenFormProps = {
|
||||||
|
wrapperClass: 'grid-cols-12',
|
||||||
|
commonConfig: {
|
||||||
|
formItemClass: 'col-span-12',
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
fieldName: 'id',
|
||||||
|
label: 'ID',
|
||||||
|
dependencies: {
|
||||||
|
show: false,
|
||||||
|
triggerFields: ['id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
fieldName: 'warehouse_id',
|
||||||
|
label: '仓库ID',
|
||||||
|
dependencies: {
|
||||||
|
show: false,
|
||||||
|
triggerFields: ['warehouse_id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入开户人姓名',
|
||||||
|
},
|
||||||
|
fieldName: 'bank_user_name',
|
||||||
|
label: '开户人姓名',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入银行卡号',
|
||||||
|
},
|
||||||
|
fieldName: 'bank_card',
|
||||||
|
label: '银行卡号',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入开户行',
|
||||||
|
},
|
||||||
|
fieldName: 'bank_name',
|
||||||
|
label: '开户行',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'RadioGroup',
|
||||||
|
componentProps: {
|
||||||
|
options: [
|
||||||
|
{ label: '对公', value: 1 },
|
||||||
|
{ label: '对私', value: 2 },
|
||||||
|
{ label: '存折', value: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fieldName: 'bank_account_type',
|
||||||
|
label: '账户类型',
|
||||||
|
defaultValue: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入联行号',
|
||||||
|
},
|
||||||
|
fieldName: 'bank_no',
|
||||||
|
label: '联行号',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入证件号码',
|
||||||
|
},
|
||||||
|
fieldName: 'cert_no',
|
||||||
|
label: '证件号码',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'UploadOssFile',
|
||||||
|
componentProps: {
|
||||||
|
maxCount: 1,
|
||||||
|
},
|
||||||
|
fieldName: 'attachment',
|
||||||
|
label: '合同附件',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showDefaultActions: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { Page } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Button, Card, Descriptions, message, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDeliveryWarehouseBankCardDetail,
|
||||||
|
reportDeliveryWarehouseBankCard,
|
||||||
|
syncDeliveryWarehouseBankCardStatus,
|
||||||
|
} from './api';
|
||||||
|
import { reportFormProps } from './config/form';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const syncing = ref(false);
|
||||||
|
const detail = ref<Record<string, any>>({});
|
||||||
|
|
||||||
|
const reportStatusMap: Record<number, { text: string; color: string }> = {
|
||||||
|
1: { text: '待审核', color: 'orange' },
|
||||||
|
2: { text: '有效', color: 'green' },
|
||||||
|
3: { text: '审核不通过', color: 'red' },
|
||||||
|
4: { text: '无效', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const bankAccountTypeMap: Record<number, string> = {
|
||||||
|
1: '对公',
|
||||||
|
2: '对私',
|
||||||
|
5: '存折',
|
||||||
|
};
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm(reportFormProps);
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getDeliveryWarehouseBankCardDetail();
|
||||||
|
detail.value = res || {};
|
||||||
|
if (res) {
|
||||||
|
formApi.setValues({
|
||||||
|
id: res.id,
|
||||||
|
warehouse_id: res.warehouse_id,
|
||||||
|
bank_user_name: res.bank_user_name,
|
||||||
|
bank_card: res.bank_card,
|
||||||
|
bank_name: res.bank_name,
|
||||||
|
bank_account_type: res.bank_account_type,
|
||||||
|
bank_no: res.bank_no,
|
||||||
|
cert_no: res.cert_no,
|
||||||
|
attachment: res.attachment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const result = await formApi.validate();
|
||||||
|
if (!result.valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const values = await formApi.getValues();
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await reportDeliveryWarehouseBankCard(values);
|
||||||
|
message.success('报备提交成功');
|
||||||
|
await loadDetail();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncStatus() {
|
||||||
|
syncing.value = true;
|
||||||
|
try {
|
||||||
|
await syncDeliveryWarehouseBankCardStatus(
|
||||||
|
detail.value.warehouse_id
|
||||||
|
? { warehouse_id: detail.value.warehouse_id }
|
||||||
|
: {},
|
||||||
|
);
|
||||||
|
message.success('状态已同步');
|
||||||
|
await loadDetail();
|
||||||
|
} finally {
|
||||||
|
syncing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDetail();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height title="银行卡报备">
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Card :loading="loading" title="当前报备状态">
|
||||||
|
<Descriptions :column="1" bordered size="small">
|
||||||
|
<Descriptions.Item label="报备状态">
|
||||||
|
<Tag
|
||||||
|
v-if="detail.report_status"
|
||||||
|
:color="reportStatusMap[detail.report_status]?.color"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
detail.report_status_text ||
|
||||||
|
reportStatusMap[detail.report_status]?.text ||
|
||||||
|
'-'
|
||||||
|
}}
|
||||||
|
</Tag>
|
||||||
|
<span v-else>未报备</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="审核意见">
|
||||||
|
{{ detail.audit_remarks || detail.report_status_msg || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户人">
|
||||||
|
{{ detail.bank_user_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="银行卡号">
|
||||||
|
{{ detail.bank_card || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户行">
|
||||||
|
{{ detail.bank_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账户类型">
|
||||||
|
{{ bankAccountTypeMap[detail.bank_account_type] || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<div class="mt-4">
|
||||||
|
<Button :loading="syncing" type="primary" @click="handleSyncStatus">
|
||||||
|
查询状态
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card :loading="loading" title="提交报备">
|
||||||
|
<Form />
|
||||||
|
<div class="mt-4">
|
||||||
|
<Button :loading="loading" type="primary" @click="handleSubmit">
|
||||||
|
提交报备
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
const prefix = 'delivery-warehouse-drug/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询仓药绑定列表
|
||||||
|
*/
|
||||||
|
export async function getDeliveryWarehouseDrugList(data: any) {
|
||||||
|
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取仓药绑定详情
|
||||||
|
*/
|
||||||
|
export async function getDeliveryWarehouseDrugInfo(id: number) {
|
||||||
|
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增仓药绑定
|
||||||
|
*/
|
||||||
|
export async function createDeliveryWarehouseDrug(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}create`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑仓药绑定
|
||||||
|
*/
|
||||||
|
export async function updateDeliveryWarehouseDrug(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}update`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除仓药绑定
|
||||||
|
*/
|
||||||
|
export async function deleteDeliveryWarehouseDrug(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}delete`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 已禁止直改库存
|
||||||
|
*/
|
||||||
|
export async function updateDeliveryWarehouseDrugStock(data: {
|
||||||
|
id: number;
|
||||||
|
stock: number;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}update-stock`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动入库 */
|
||||||
|
export async function stockInDeliveryWarehouseDrug(data: {
|
||||||
|
id: number;
|
||||||
|
qty: number;
|
||||||
|
remark?: string;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}stock-in`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动出库 */
|
||||||
|
export async function stockOutDeliveryWarehouseDrug(data: {
|
||||||
|
id: number;
|
||||||
|
qty: number;
|
||||||
|
remark?: string;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}stock-out`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 出入库流水 */
|
||||||
|
export async function getDeliveryWarehouseStockLogList(data: Record<string, any>) {
|
||||||
|
return requestClient.get<any>(`${prefix}stock-log-list`, { params: data });
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 某药品的配送仓绑定列表弹窗(卡片展示)
|
||||||
|
* 展示报价/推广费/平台费/库存;新增/编辑打开仓药绑定表单(药品预填)
|
||||||
|
* 打开时尽量补齐总仓售价,供费用封顶与平台费默认 5%
|
||||||
|
*/
|
||||||
|
import { computed, nextTick, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Button, Empty, message, Pagination, Spin, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getWarehouseDrugManagementList } from '#/views/business/warehouse-drug-management/admin/api';
|
||||||
|
|
||||||
|
import { deleteDeliveryWarehouseDrug, getDeliveryWarehouseDrugList } from '../api';
|
||||||
|
import { resolveCentralPrice } from '../utils/resolve-central-price';
|
||||||
|
import FormModal from './modal.vue';
|
||||||
|
|
||||||
|
const drugId = ref(0);
|
||||||
|
const drugName = ref('');
|
||||||
|
const drugMeta = ref<Record<string, any>>({});
|
||||||
|
/** 总仓售价,用于绑仓表单默认平台费与封顶 */
|
||||||
|
const centralPrice = ref<number | null>(null);
|
||||||
|
const productGridApi = ref();
|
||||||
|
const loading = ref(false);
|
||||||
|
const items = ref<Record<string, any>[]>([]);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(10);
|
||||||
|
const total = ref(0);
|
||||||
|
/** 打开后是否立刻弹出新增表单 */
|
||||||
|
const pendingAutoCreate = ref(false);
|
||||||
|
|
||||||
|
const [FormModalComp, formModalApi] = useVbenModal({
|
||||||
|
connectedComponent: FormModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const priceText = computed(() => {
|
||||||
|
if (centralPrice.value != null && centralPrice.value > 0) {
|
||||||
|
return `¥${Number(centralPrice.value).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
return '无总仓售价';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载绑定卡片数据
|
||||||
|
*/
|
||||||
|
async function loadList() {
|
||||||
|
if (!drugId.value) {
|
||||||
|
items.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getDeliveryWarehouseDrugList({
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
drug_id: drugId.value,
|
||||||
|
});
|
||||||
|
items.value = res?.items || [];
|
||||||
|
total.value = Number(res?.total || 0);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表未带售价时,按 drug_id 查总仓补齐
|
||||||
|
*/
|
||||||
|
async function ensureCentralPrice() {
|
||||||
|
if (centralPrice.value != null && centralPrice.value > 0) return;
|
||||||
|
if (!drugId.value) return;
|
||||||
|
try {
|
||||||
|
const res = await getWarehouseDrugManagementList({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
drug_id: drugId.value,
|
||||||
|
});
|
||||||
|
const row = res?.items?.[0];
|
||||||
|
const price = Number(row?.price ?? 0);
|
||||||
|
if (price > 0) {
|
||||||
|
centralPrice.value = price;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 补齐失败不阻断绑仓,表单内仍可手填三费
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开新增:药品锁定预填 + 带上 number 类型总仓售价
|
||||||
|
*/
|
||||||
|
async function openCreate() {
|
||||||
|
await ensureCentralPrice();
|
||||||
|
const price =
|
||||||
|
centralPrice.value != null && centralPrice.value > 0
|
||||||
|
? Number(centralPrice.value)
|
||||||
|
: null;
|
||||||
|
formModalApi.setData({
|
||||||
|
update: false,
|
||||||
|
// 新增成功后刷新业务列表与本卡片列表
|
||||||
|
gridApi: {
|
||||||
|
query: () => {
|
||||||
|
loadList();
|
||||||
|
productGridApi.value?.query?.();
|
||||||
|
},
|
||||||
|
reload: () => {
|
||||||
|
loadList();
|
||||||
|
productGridApi.value?.query?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lockDrug: true,
|
||||||
|
central_price: price,
|
||||||
|
presetDrug: {
|
||||||
|
id: drugId.value,
|
||||||
|
drug_name: drugName.value,
|
||||||
|
specification: drugMeta.value.specification || '',
|
||||||
|
image: drugMeta.value.image || '',
|
||||||
|
central_price: price,
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
drug_id: drugId.value,
|
||||||
|
...(price != null ? { sale_price: price } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
formModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEdit(row: any) {
|
||||||
|
await ensureCentralPrice();
|
||||||
|
const price =
|
||||||
|
centralPrice.value != null && centralPrice.value > 0
|
||||||
|
? Number(centralPrice.value)
|
||||||
|
: resolveCentralPrice(row);
|
||||||
|
formModalApi.setData({
|
||||||
|
update: true,
|
||||||
|
gridApi: {
|
||||||
|
query: () => {
|
||||||
|
loadList();
|
||||||
|
productGridApi.value?.query?.();
|
||||||
|
},
|
||||||
|
reload: () => {
|
||||||
|
loadList();
|
||||||
|
productGridApi.value?.query?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
central_price: price,
|
||||||
|
values: {
|
||||||
|
...row,
|
||||||
|
...(price != null ? { sale_price: price } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
formModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: number) {
|
||||||
|
deleteDeliveryWarehouseDrug({ ids: [id] }).then(() => {
|
||||||
|
message.success('删除成功');
|
||||||
|
loadList();
|
||||||
|
productGridApi.value?.query?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(p: number) {
|
||||||
|
page.value = p;
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
showConfirmButton: false,
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
productGridApi.value?.query?.();
|
||||||
|
pendingAutoCreate.value = false;
|
||||||
|
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}`;
|
||||||
|
drugMeta.value = {
|
||||||
|
specification: data.specification || data.values?.specification || '',
|
||||||
|
image: data.image || data.values?.image || '',
|
||||||
|
};
|
||||||
|
// 与表单同源解析:兼容嵌套 central_warehouse_drug
|
||||||
|
centralPrice.value =
|
||||||
|
resolveCentralPrice(data) ??
|
||||||
|
resolveCentralPrice(data.values) ??
|
||||||
|
resolveCentralPrice(data.presetDrug) ??
|
||||||
|
null;
|
||||||
|
productGridApi.value = data.gridApi;
|
||||||
|
pendingAutoCreate.value = !!data.autoCreate;
|
||||||
|
page.value = 1;
|
||||||
|
await ensureCentralPrice();
|
||||||
|
await loadList();
|
||||||
|
if (pendingAutoCreate.value) {
|
||||||
|
pendingAutoCreate.value = false;
|
||||||
|
await nextTick();
|
||||||
|
await openCreate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Modal :title="`配送仓绑定 - ${drugName}`" class="w-[720px]">
|
||||||
|
<FormModalComp />
|
||||||
|
<div class="bind-list-header">
|
||||||
|
<div class="bind-list-header__meta">
|
||||||
|
<span>药品售价:</span>
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
centralPrice && centralPrice > 0
|
||||||
|
? 'bind-list-header__price'
|
||||||
|
: 'bind-list-header__price--empty'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ priceText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" @click="openCreate">新增绑定</Button>
|
||||||
|
</div>
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<div v-if="items.length" class="bind-card-list">
|
||||||
|
<div v-for="row in items" :key="row.id" class="bind-card">
|
||||||
|
<div class="bind-card__head">
|
||||||
|
<div class="bind-card__title">
|
||||||
|
{{ row.warehouse?.name || `仓库#${row.warehouse_id}` }}
|
||||||
|
</div>
|
||||||
|
<Tag v-if="row.status === 2" color="green">上架</Tag>
|
||||||
|
<Tag v-else color="default">下架</Tag>
|
||||||
|
</div>
|
||||||
|
<div class="bind-card__body">
|
||||||
|
<div class="bind-card__item">
|
||||||
|
<span class="bind-card__label">报价</span>
|
||||||
|
<span>¥{{ Number(row.quote || 0).toFixed(4) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bind-card__item">
|
||||||
|
<span class="bind-card__label">推广费</span>
|
||||||
|
<span>¥{{ Number(row.promo_fee || 0).toFixed(4) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bind-card__item">
|
||||||
|
<span class="bind-card__label">平台费</span>
|
||||||
|
<span>¥{{ Number(row.platform_fee || 0).toFixed(4) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bind-card__item">
|
||||||
|
<span class="bind-card__label">库存</span>
|
||||||
|
<span>{{ row.stock ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bind-card__actions">
|
||||||
|
<Button type="link" size="small" @click="openEdit(row)">编辑</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
@click="handleDelete(row.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Empty v-else description="暂无绑定" />
|
||||||
|
</Spin>
|
||||||
|
<div v-if="total > pageSize" class="bind-list-pager">
|
||||||
|
<Pagination
|
||||||
|
:current="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
size="small"
|
||||||
|
@change="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.bind-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.bind-list-header__meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.bind-list-header__price {
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.bind-list-header__price--empty {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.bind-card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.bind-card {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.bind-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.bind-card__title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.bind-card__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.bind-card__item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.bind-card__label {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.bind-card__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.bind-list-pager {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 仓药绑定弹窗
|
||||||
|
* 新增时用 DrugSearchSelect(is-card) 选药;编辑/预填锁定时不可换药
|
||||||
|
* 有总仓售价时:展示、新增默认平台费=售价5%、三费超售价实时提醒并拦截提交
|
||||||
|
* 注意:必须 await setValues,否则异步覆盖会把 sale_price 冲掉
|
||||||
|
*/
|
||||||
|
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import { DrugSearchSelect } from '#/components/drug-search-select';
|
||||||
|
import {
|
||||||
|
createDeliveryWarehouseDrug,
|
||||||
|
updateDeliveryWarehouseDrug,
|
||||||
|
} from '#/views/system/delivery-warehouse-drug/api';
|
||||||
|
import {
|
||||||
|
calcFeeSum,
|
||||||
|
isFeeOverSalePrice,
|
||||||
|
modalFormProps,
|
||||||
|
registerFeeOverTipHandler,
|
||||||
|
syncFeeSumDisplay,
|
||||||
|
} from '#/views/system/delivery-warehouse-drug/config/form';
|
||||||
|
import { resolveCentralPrice } from '#/views/system/delivery-warehouse-drug/utils/resolve-central-price';
|
||||||
|
|
||||||
|
/** 非中药品类:西药/保健/中成药/服务包/非药品/医疗器械 */
|
||||||
|
const NON_CHINESE_DRUG_TYPES = [2, 3, 4, 5, 6, 7];
|
||||||
|
|
||||||
|
/** 只读卡片占位图 */
|
||||||
|
const DRUG_PLACEHOLDER_IMAGE =
|
||||||
|
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
|
||||||
|
|
||||||
|
const isUpdate = ref(false);
|
||||||
|
/** 从药品列表打开时锁定药品 */
|
||||||
|
const lockDrug = ref(false);
|
||||||
|
const gridApi = ref();
|
||||||
|
/** 三费超售价时的醒目红色提示(空串表示无提示) */
|
||||||
|
const feeOverTip = ref('');
|
||||||
|
const editDrugCard = ref<{
|
||||||
|
name: string;
|
||||||
|
id: number | null;
|
||||||
|
spec: string;
|
||||||
|
image: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||||
|
|
||||||
|
// 表单 dependencies 触发 sync 时同步到弹窗红色文案
|
||||||
|
onMounted(() => {
|
||||||
|
registerFeeOverTipHandler((tip) => {
|
||||||
|
feeOverTip.value = tip;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
registerFeeOverTipHandler(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入售价;新增时默认平台费 = 售价 5%
|
||||||
|
*/
|
||||||
|
async function applyCentralPrice(
|
||||||
|
price: number | string | null | undefined,
|
||||||
|
isCreate: boolean,
|
||||||
|
) {
|
||||||
|
const n = Number(price);
|
||||||
|
if (!(n > 0)) {
|
||||||
|
await formApi.setFieldValue('sale_price', undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sale = Number(n.toFixed(4));
|
||||||
|
await formApi.setFieldValue('sale_price', sale);
|
||||||
|
if (isCreate) {
|
||||||
|
await formApi.setFieldValue('platform_fee', Number((sale * 0.05).toFixed(4)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从弹窗入参解析售价:优先 data.central_price,再嵌套/values/preset
|
||||||
|
*/
|
||||||
|
function pickSalePrice(data: Record<string, any>) {
|
||||||
|
const fromData = resolveCentralPrice(data);
|
||||||
|
if (fromData != null) return fromData;
|
||||||
|
const fromValues = resolveCentralPrice(data.values);
|
||||||
|
if (fromValues != null) return fromValues;
|
||||||
|
return resolveCentralPrice(data.presetDrug);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
onConfirm: async () => {
|
||||||
|
const values = await formApi.getValues();
|
||||||
|
if (!isUpdate.value && !values.drug_id) {
|
||||||
|
message.error('请选择药品');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 提交前实时拦截:三费合计不得超过药品售价
|
||||||
|
if (isFeeOverSalePrice(values)) {
|
||||||
|
message.warning(
|
||||||
|
`三费合计已超过药品售价 ¥${Number(values.sale_price).toFixed(4)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formApi.validate().then(async (e: any) => {
|
||||||
|
if (e.valid) {
|
||||||
|
const payload = { ...(await formApi.getValues()) };
|
||||||
|
// sale_price 只作封顶校验,不用费用合计覆盖
|
||||||
|
if (payload.sale_price === undefined || payload.sale_price === '') {
|
||||||
|
delete payload.sale_price;
|
||||||
|
}
|
||||||
|
delete payload._fee_sum;
|
||||||
|
if (Number(calcFeeSum(payload)) <= 0) {
|
||||||
|
message.warning('报价/推广费/平台费合计必须大于0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.setState({ loading: true, confirmLoading: true });
|
||||||
|
const submitApi = isUpdate.value
|
||||||
|
? updateDeliveryWarehouseDrug
|
||||||
|
: createDeliveryWarehouseDrug;
|
||||||
|
submitApi(payload)
|
||||||
|
.then(() => {
|
||||||
|
message.success('保存成功');
|
||||||
|
gridApi.value?.query?.() ?? gridApi.value?.reload?.();
|
||||||
|
modalApi.close();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
modalApi.setState({ loading: false, confirmLoading: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await formApi.validateAndSubmitForm();
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||||
|
if (!isOpen) {
|
||||||
|
feeOverTip.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = modalApi.getData<Record<string, any>>() || {};
|
||||||
|
const { values, update, presetDrug } = data;
|
||||||
|
isUpdate.value = !!update;
|
||||||
|
lockDrug.value = !!data.lockDrug && !update;
|
||||||
|
editDrugCard.value = null;
|
||||||
|
feeOverTip.value = '';
|
||||||
|
const salePrice = pickSalePrice(data);
|
||||||
|
// 先重置,再 await 写入,避免异步 setValues 冲掉 sale_price
|
||||||
|
await formApi.resetForm();
|
||||||
|
const nextValues = {
|
||||||
|
...(values || {}),
|
||||||
|
...(salePrice != null ? { sale_price: salePrice } : {}),
|
||||||
|
};
|
||||||
|
if (Object.keys(nextValues).length) {
|
||||||
|
await formApi.setValues(nextValues);
|
||||||
|
}
|
||||||
|
if (update && values) {
|
||||||
|
editDrugCard.value = {
|
||||||
|
id: values.drug_id ? Number(values.drug_id) : null,
|
||||||
|
name:
|
||||||
|
values.drug?.drug_name ||
|
||||||
|
values.drug_name ||
|
||||||
|
(values.drug_id ? `药品#${values.drug_id}` : ''),
|
||||||
|
spec: values.drug?.specification || values.specification || '',
|
||||||
|
image: values.drug?.image || values.image || DRUG_PLACEHOLDER_IMAGE,
|
||||||
|
};
|
||||||
|
// 编辑:只写售价,不覆盖已有三费
|
||||||
|
await applyCentralPrice(salePrice, false);
|
||||||
|
} else if (lockDrug.value && presetDrug) {
|
||||||
|
const id = Number(presetDrug.id || values?.drug_id || 0);
|
||||||
|
await formApi.setFieldValue('drug_id', id);
|
||||||
|
editDrugCard.value = {
|
||||||
|
id,
|
||||||
|
name: presetDrug.drug_name || `药品#${id}`,
|
||||||
|
spec: presetDrug.specification || '',
|
||||||
|
image: presetDrug.image || DRUG_PLACEHOLDER_IMAGE,
|
||||||
|
};
|
||||||
|
await applyCentralPrice(salePrice, true);
|
||||||
|
} else if (!update) {
|
||||||
|
await applyCentralPrice(salePrice, true);
|
||||||
|
}
|
||||||
|
// 再确认一次:部分表单实现会二次回填空值
|
||||||
|
await nextTick();
|
||||||
|
const after = await formApi.getValues();
|
||||||
|
const afterSale = Number(after?.sale_price);
|
||||||
|
if (salePrice != null && (!(afterSale > 0) || afterSale !== salePrice)) {
|
||||||
|
await applyCentralPrice(salePrice, !update);
|
||||||
|
}
|
||||||
|
// 打开后同步一次上限与提示(售价写入后 dependencies 可能未立刻跑完)
|
||||||
|
const latest = await formApi.getValues();
|
||||||
|
feeOverTip.value = syncFeeSumDisplay(latest, formApi);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSelectDrug(item: any) {
|
||||||
|
if (isUpdate.value || lockDrug.value) return;
|
||||||
|
if (!item) {
|
||||||
|
await formApi.setFieldValue('drug_id', undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const drugId = Number(item?.drug?.id || item?.drug_id || item?.id || 0);
|
||||||
|
if (!drugId) {
|
||||||
|
message.error('药品数据异常');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await formApi.setFieldValue('drug_id', drugId);
|
||||||
|
// 搜药选中后若带总仓价则预填平台费 5%
|
||||||
|
const price = resolveCentralPrice(item) ?? resolveCentralPrice(item?.drug);
|
||||||
|
if (price != null) {
|
||||||
|
await applyCentralPrice(price, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:title="`${isUpdate === true ? '编辑' : '新增'}仓药绑定`"
|
||||||
|
class="w-[40%]"
|
||||||
|
>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="mb-1 text-sm text-gray-600">
|
||||||
|
药品
|
||||||
|
<span class="text-red-500">*</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="(isUpdate || lockDrug) && editDrugCard"
|
||||||
|
class="warehouse-drug-readonly-card"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="editDrugCard.image || DRUG_PLACEHOLDER_IMAGE"
|
||||||
|
alt=""
|
||||||
|
class="warehouse-drug-readonly-card__img"
|
||||||
|
/>
|
||||||
|
<div class="warehouse-drug-readonly-card__info">
|
||||||
|
<div class="warehouse-drug-readonly-card__name">
|
||||||
|
{{ editDrugCard.name || `药品ID:${editDrugCard.id}` }}
|
||||||
|
</div>
|
||||||
|
<div class="warehouse-drug-readonly-card__meta">
|
||||||
|
<span v-if="editDrugCard.id">ID:{{ editDrugCard.id }}</span>
|
||||||
|
<span v-if="editDrugCard.spec">规格:{{ editDrugCard.spec }}</span>
|
||||||
|
<span class="warehouse-drug-readonly-card__hint">
|
||||||
|
{{ isUpdate ? '编辑时不可更换药品' : '已自动选中当前药品' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DrugSearchSelect
|
||||||
|
v-else
|
||||||
|
:types="NON_CHINESE_DRUG_TYPES"
|
||||||
|
:is-card="true"
|
||||||
|
placeholder="聚焦或搜索药品(不含中药)"
|
||||||
|
@select="onSelectDrug"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 超售价醒目提示:不能只靠 InputNumber max -->
|
||||||
|
<div v-if="feeOverTip" class="fee-over-tip">{{ feeOverTip }}</div>
|
||||||
|
<Form />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.fee-over-tip {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
/* 只用框架主题变量,避免硬编码 / :global(.dark) 污染全局配色 */
|
||||||
|
.warehouse-drug-readonly-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: hsl(var(--muted) / 0.3);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.warehouse-drug-readonly-card__img {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.warehouse-drug-readonly-card__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.warehouse-drug-readonly-card__name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.warehouse-drug-readonly-card__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.warehouse-drug-readonly-card__hint {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
import { getDeliveryWarehouseOption } from '#/views/system/delivery-warehouse/api';
|
||||||
|
|
||||||
|
/** 计算三费合计,用于表单联动展示 */
|
||||||
|
export function calcFeeSum(values: Record<string, any>) {
|
||||||
|
const quote = Number(values.quote) || 0;
|
||||||
|
const promoFee = Number(values.promo_fee) || 0;
|
||||||
|
const platformFee = Number(values.platform_fee) || 0;
|
||||||
|
return (quote + promoFee + platformFee).toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三费是否超过药品售价 */
|
||||||
|
export function isFeeOverSalePrice(values: Record<string, any>) {
|
||||||
|
const sale = Number(values.sale_price);
|
||||||
|
if (!(sale > 0)) return false;
|
||||||
|
return Number(calcFeeSum(values)) > sale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 超售价文案回调(由 modal 注册,用于醒目红色提示) */
|
||||||
|
type FeeOverTipHandler = (tip: string) => void;
|
||||||
|
let feeOverTipHandler: FeeOverTipHandler | null = null;
|
||||||
|
|
||||||
|
/** 注册/注销弹窗内的超售价文本提示处理器 */
|
||||||
|
export function registerFeeOverTipHandler(handler: FeeOverTipHandler | null) {
|
||||||
|
feeOverTipHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 费用合计联动:实时重算、三费 max、超售价 help + 文本提示
|
||||||
|
* 为什么放在 trigger:输入任三费即触发,不必等提交
|
||||||
|
* @returns 超售价提示文案(无则空串)
|
||||||
|
*/
|
||||||
|
export function syncFeeSumDisplay(
|
||||||
|
values: Record<string, any>,
|
||||||
|
formApi: any,
|
||||||
|
): string {
|
||||||
|
const sum = calcFeeSum(values);
|
||||||
|
formApi.setFieldValue('_fee_sum', sum);
|
||||||
|
const sale = Number(values.sale_price) || 0;
|
||||||
|
const over = sale > 0 && Number(sum) > sale;
|
||||||
|
const tip = over
|
||||||
|
? `三费合计已超过药品售价 ¥${sale.toFixed(4)},请下调报价/推广费/平台费`
|
||||||
|
: '';
|
||||||
|
const feeHelp = over
|
||||||
|
? tip
|
||||||
|
: sale > 0
|
||||||
|
? `报价+推广费+平台费之和不得超过药品售价 ¥${sale.toFixed(4)}`
|
||||||
|
: '报价+推广费+平台费之和,保存时需大于0';
|
||||||
|
// 单项输入上限:有售价时不得超过售价;同时刷新合计 help
|
||||||
|
const feeInputProps = {
|
||||||
|
min: 0,
|
||||||
|
precision: 4,
|
||||||
|
class: 'w-full',
|
||||||
|
...(sale > 0 ? { max: sale } : {}),
|
||||||
|
};
|
||||||
|
formApi.updateSchema([
|
||||||
|
{
|
||||||
|
fieldName: 'quote',
|
||||||
|
componentProps: {
|
||||||
|
...feeInputProps,
|
||||||
|
placeholder: '请输入报价',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'promo_fee',
|
||||||
|
componentProps: {
|
||||||
|
...feeInputProps,
|
||||||
|
placeholder: '请输入推广费',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'platform_fee',
|
||||||
|
componentProps: {
|
||||||
|
...feeInputProps,
|
||||||
|
placeholder: '请输入平台费',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: '_fee_sum',
|
||||||
|
help: feeHelp,
|
||||||
|
componentProps: {
|
||||||
|
disabled: true,
|
||||||
|
placeholder: '报价+推广费+平台费',
|
||||||
|
style: over ? { color: 'hsl(var(--destructive))' } : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
feeOverTipHandler?.(tip);
|
||||||
|
return tip;
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
dependencies: {
|
||||||
|
show: false,
|
||||||
|
triggerFields: ['id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'ApiSelect',
|
||||||
|
componentProps: {
|
||||||
|
allowClear: true,
|
||||||
|
showSearch: true,
|
||||||
|
filterOption: (input: string, option: any) =>
|
||||||
|
option?.label.toLowerCase().indexOf(input.toLowerCase()) >= 0,
|
||||||
|
afterFetch: (data: { id: number; name: string }[]) =>
|
||||||
|
data.map((item: any) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
api: getDeliveryWarehouseOption,
|
||||||
|
placeholder: '请选择配送仓库',
|
||||||
|
},
|
||||||
|
fieldName: 'warehouse_id',
|
||||||
|
label: '配送仓库',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 由弹窗顶部 DrugSearchSelect 写入,表单内隐藏避免手填
|
||||||
|
component: 'InputNumber',
|
||||||
|
fieldName: 'drug_id',
|
||||||
|
label: '药品ID',
|
||||||
|
rules: 'required',
|
||||||
|
dependencies: {
|
||||||
|
show: false,
|
||||||
|
triggerFields: ['drug_id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 总仓售价只读展示,同时作为提交时的 sale_price 封顶基准
|
||||||
|
component: 'InputNumber',
|
||||||
|
componentProps: {
|
||||||
|
disabled: true,
|
||||||
|
precision: 4,
|
||||||
|
class: 'w-full',
|
||||||
|
placeholder: '无总仓售价',
|
||||||
|
},
|
||||||
|
fieldName: 'sale_price',
|
||||||
|
label: '药品售价',
|
||||||
|
help: '来自总仓库建议售价;三费合计不得超过该值',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'InputNumber',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入报价',
|
||||||
|
min: 0,
|
||||||
|
precision: 4,
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
fieldName: 'quote',
|
||||||
|
label: '报价',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'InputNumber',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入推广费',
|
||||||
|
min: 0,
|
||||||
|
precision: 4,
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
fieldName: 'promo_fee',
|
||||||
|
label: '推广费',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'InputNumber',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入平台费',
|
||||||
|
min: 0,
|
||||||
|
precision: 4,
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
fieldName: 'platform_fee',
|
||||||
|
label: '平台费',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
disabled: true,
|
||||||
|
placeholder: '报价+推广费+平台费',
|
||||||
|
},
|
||||||
|
fieldName: '_fee_sum',
|
||||||
|
label: '费用合计',
|
||||||
|
help: '报价+推广费+平台费之和,保存时需大于0',
|
||||||
|
dependencies: {
|
||||||
|
trigger(values, formApi) {
|
||||||
|
syncFeeSumDisplay(values, formApi);
|
||||||
|
},
|
||||||
|
triggerFields: ['quote', 'promo_fee', 'platform_fee', 'sale_price'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 绑定创建时库存固定为 0,后续在「库存管理」入库/出库,禁止表单直改数量
|
||||||
|
{
|
||||||
|
component: 'RadioGroup',
|
||||||
|
componentProps: {
|
||||||
|
options: [
|
||||||
|
{ label: '下架', value: 1 },
|
||||||
|
{ label: '上架', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '状态',
|
||||||
|
defaultValue: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showDefaultActions: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
import { getDeliveryWarehouseOption } from '#/views/system/delivery-warehouse/api';
|
||||||
|
|
||||||
|
export const formOptions: VbenFormProps = {
|
||||||
|
collapsed: false,
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'ApiSelect',
|
||||||
|
componentProps: {
|
||||||
|
allowClear: true,
|
||||||
|
showSearch: true,
|
||||||
|
filterOption: (input: string, option: any) =>
|
||||||
|
option?.label.toLowerCase().indexOf(input.toLowerCase()) >= 0,
|
||||||
|
afterFetch: (data: { id: number; name: string }[]) =>
|
||||||
|
data.map((item: any) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
api: getDeliveryWarehouseOption,
|
||||||
|
placeholder: '请选择配送仓库',
|
||||||
|
},
|
||||||
|
fieldName: 'warehouse_id',
|
||||||
|
label: '配送仓库',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '输入药品ID',
|
||||||
|
},
|
||||||
|
fieldName: 'drug_id',
|
||||||
|
label: '药品ID',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCollapseButton: true,
|
||||||
|
submitButtonOptions: {
|
||||||
|
content: '查询',
|
||||||
|
},
|
||||||
|
submitOnChange: true,
|
||||||
|
submitOnEnter: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { getDeliveryWarehouseDrugList } from '#/views/system/delivery-warehouse-drug/api';
|
||||||
|
|
||||||
|
interface RowType {
|
||||||
|
id: string;
|
||||||
|
warehouse_id: number;
|
||||||
|
drug_id: number;
|
||||||
|
quote: string;
|
||||||
|
promo_fee: string;
|
||||||
|
platform_fee: string;
|
||||||
|
stock: number;
|
||||||
|
frozen_number: number;
|
||||||
|
status: number;
|
||||||
|
created_at: string;
|
||||||
|
drug?: { drug_name: string };
|
||||||
|
warehouse?: { name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gridOptions: VxeGridProps<RowType> = {
|
||||||
|
checkboxConfig: {
|
||||||
|
highlight: true,
|
||||||
|
labelField: '',
|
||||||
|
},
|
||||||
|
columnConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ type: 'checkbox', width: 60 },
|
||||||
|
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||||
|
{
|
||||||
|
field: 'warehouse_id',
|
||||||
|
title: '配送仓库',
|
||||||
|
slots: { default: 'warehouse_id' },
|
||||||
|
},
|
||||||
|
{ field: 'drug_id', title: '药品ID', width: 100 },
|
||||||
|
{
|
||||||
|
field: 'drug_name',
|
||||||
|
title: '药品名称',
|
||||||
|
slots: { default: 'drug_name' },
|
||||||
|
},
|
||||||
|
{ field: 'quote', title: '报价' },
|
||||||
|
{ field: 'promo_fee', title: '推广费' },
|
||||||
|
{ field: 'platform_fee', title: '平台费' },
|
||||||
|
{ field: 'stock', title: '库存' },
|
||||||
|
{ field: 'frozen_number', title: '冻结数量' },
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '状态',
|
||||||
|
slots: { default: 'status' },
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{ field: 'created_at', title: '创建时间' },
|
||||||
|
{ type: 'html', title: '操作', slots: { default: 'action' } },
|
||||||
|
],
|
||||||
|
keepSource: true,
|
||||||
|
pagerConfig: {},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
return await getDeliveryWarehouseDrugList({
|
||||||
|
page: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
height: 'auto',
|
||||||
|
border: false,
|
||||||
|
toolbarConfig: {
|
||||||
|
// @ts-ignore
|
||||||
|
search: true,
|
||||||
|
refresh: true,
|
||||||
|
print: false,
|
||||||
|
export: false,
|
||||||
|
zoom: true,
|
||||||
|
slots: {
|
||||||
|
buttons: 'toolbar-buttons',
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
icon: 'vxe-icon-menu',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showOverflow: false,
|
||||||
|
};
|
||||||
135
apps/web-antd/src/views/system/delivery-warehouse-drug/index.vue
Normal file
135
apps/web-antd/src/views/system/delivery-warehouse-drug/index.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<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, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { TableAction } from '#/components/table-action';
|
||||||
|
|
||||||
|
import { deleteDeliveryWarehouseDrug } from './api';
|
||||||
|
import FormModalDemo from './components/modal.vue';
|
||||||
|
import { formOptions } 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,
|
||||||
|
gridOptions,
|
||||||
|
gridEvents,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [FormModal, formModalApi] = useVbenModal({
|
||||||
|
connectedComponent: FormModalDemo,
|
||||||
|
});
|
||||||
|
|
||||||
|
const showModal = (data = {}, isUpdate = false) => {
|
||||||
|
formModalApi.setData({
|
||||||
|
values: data,
|
||||||
|
update: isUpdate,
|
||||||
|
gridApi,
|
||||||
|
});
|
||||||
|
formModalApi.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteApi = (row: any) => {
|
||||||
|
let ids = [];
|
||||||
|
if (row) {
|
||||||
|
ids.push(row);
|
||||||
|
} else {
|
||||||
|
ids = gridApi.grid.getCheckboxRecords().map((item) => item.id);
|
||||||
|
}
|
||||||
|
deleteDeliveryWarehouseDrug({ ids }).then(() => {
|
||||||
|
message.success('删除成功!');
|
||||||
|
gridApi.query();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height title="仓药绑定管理">
|
||||||
|
<FormModal />
|
||||||
|
<Grid>
|
||||||
|
<template #toolbar-buttons>
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '新增',
|
||||||
|
type: 'primary',
|
||||||
|
icon: 'ant-design:plus-outlined',
|
||||||
|
onClick: showModal.bind(null),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:drop-down-actions="[
|
||||||
|
{
|
||||||
|
label: '删除',
|
||||||
|
icon: 'ant-design:delete-outlined',
|
||||||
|
ifShow: hasTopTableDropDownActions,
|
||||||
|
popConfirm: {
|
||||||
|
title: '确定删除吗?',
|
||||||
|
confirm: deleteApi.bind(null, false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<template #more>
|
||||||
|
<Button style="margin-left: 16px">
|
||||||
|
批量操作
|
||||||
|
<Icon icon="ant-design:down-outlined" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</TableAction>
|
||||||
|
</template>
|
||||||
|
<template #warehouse_id="{ row }">
|
||||||
|
{{ row.warehouse?.name || row.warehouse_id }}
|
||||||
|
</template>
|
||||||
|
<template #drug_name="{ row }">
|
||||||
|
{{ row.drug?.drug_name || '-' }}
|
||||||
|
</template>
|
||||||
|
<template #status="{ row }">
|
||||||
|
<Tag v-if="row.status === 2" color="green">上架</Tag>
|
||||||
|
<Tag v-else color="default">下架</Tag>
|
||||||
|
</template>
|
||||||
|
<template #toolbar-tools></template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '编辑',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'uil:edit',
|
||||||
|
size: 'small',
|
||||||
|
onClick: showModal.bind(null, row, true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '删除',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'ant-design:delete-outlined',
|
||||||
|
size: 'small',
|
||||||
|
popConfirm: {
|
||||||
|
title: '确定删除吗?',
|
||||||
|
confirm: deleteApi.bind(null, row.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:drop-down-actions="[]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 从列表行 / 弹窗入参中解析总仓售价
|
||||||
|
* 兼容 snake / camel 及嵌套 central_warehouse_drug
|
||||||
|
*/
|
||||||
|
export function resolveCentralPrice(
|
||||||
|
source: Record<string, any> | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (!source) return null;
|
||||||
|
const nested =
|
||||||
|
source.central_warehouse_drug ?? source.centralWarehouseDrug ?? null;
|
||||||
|
const candidates = [
|
||||||
|
source.central_price,
|
||||||
|
source.centralPrice,
|
||||||
|
nested?.price,
|
||||||
|
source.sale_price,
|
||||||
|
source.price,
|
||||||
|
];
|
||||||
|
for (const c of candidates) {
|
||||||
|
const n = Number(c);
|
||||||
|
if (n > 0) return Number(n.toFixed(4));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
const prefix = 'delivery-warehouse-order/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配送仓库订单列表
|
||||||
|
*/
|
||||||
|
export async function getDeliveryWarehouseOrderList(data: any) {
|
||||||
|
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配送仓库订单详情(本仓药品 + 收货/物流基础字段)
|
||||||
|
*/
|
||||||
|
export async function getDeliveryWarehouseOrderDetail(id: number | string) {
|
||||||
|
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按订单查物流轨迹(复用平台 express-detail 接口)
|
||||||
|
*/
|
||||||
|
export async function expressDetailByOrderId(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`express-detail/detail-by-order`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配送仓库订单发货
|
||||||
|
*/
|
||||||
|
export async function sendDeliveryWarehouseOrder(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}ware-send`, data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, nextTick, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Button, Descriptions, Image, Tag, Timeline } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
expressDetailByOrderId,
|
||||||
|
getDeliveryWarehouseOrderDetail,
|
||||||
|
} from '#/views/system/delivery-warehouse-order/api';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'DeliveryWarehouseOrderDetailModal',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = ref<Record<string, any>>({});
|
||||||
|
const expressDetail = ref<Record<string, any>>({});
|
||||||
|
const scrollToLogistics = ref(false);
|
||||||
|
const logisticsAnchorRef = ref<HTMLElement | null>(null);
|
||||||
|
/** 待发货时由列表传入,点击后关详情并打开发货弹窗 */
|
||||||
|
const onShipFn = ref<null | (() => void)>(null);
|
||||||
|
|
||||||
|
const canShip = computed(
|
||||||
|
() => Number(data.value.status) === 1 && typeof onShipFn.value === 'function',
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusMap: Record<number, { text: string; color: string }> = {
|
||||||
|
0: { text: '待支付', color: 'default' },
|
||||||
|
1: { text: '待发货', color: 'orange' },
|
||||||
|
2: { text: '待收货', color: 'blue' },
|
||||||
|
3: { text: '待评价', color: 'cyan' },
|
||||||
|
4: { text: '已退款', color: 'red' },
|
||||||
|
5: { text: '退款中', color: 'volcano' },
|
||||||
|
6: { text: '已收货', color: 'green' },
|
||||||
|
7: { text: '确认收货', color: 'green' },
|
||||||
|
8: { text: '拒绝退款', color: 'gold' },
|
||||||
|
9: { text: '已取消', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const deliveryMethodMap: Record<number, string> = {
|
||||||
|
0: '快递邮寄',
|
||||||
|
1: '到店自取',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取订单详情;已发货时继续拉物流轨迹
|
||||||
|
*/
|
||||||
|
async function loadDetail(id: number | string) {
|
||||||
|
data.value = (await getDeliveryWarehouseOrderDetail(id)) || {};
|
||||||
|
expressDetail.value = {};
|
||||||
|
const sent =
|
||||||
|
Number(data.value.is_send) === 1 ||
|
||||||
|
Number(data.value.status) >= 2 ||
|
||||||
|
!!data.value.express_no_id;
|
||||||
|
if (sent && data.value.id) {
|
||||||
|
try {
|
||||||
|
expressDetail.value = await expressDetailByOrderId({
|
||||||
|
order_id: data.value.id,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
expressDetail.value = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scrollToLogistics.value) {
|
||||||
|
await nextTick();
|
||||||
|
logisticsAnchorRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColor(status: any) {
|
||||||
|
switch (status) {
|
||||||
|
case '揽收': {
|
||||||
|
return 'orange';
|
||||||
|
}
|
||||||
|
case '派件': {
|
||||||
|
return 'blue';
|
||||||
|
}
|
||||||
|
case '签收': {
|
||||||
|
return 'green';
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expressNoRow = () =>
|
||||||
|
data.value.express_no || data.value.expressNo || {};
|
||||||
|
|
||||||
|
function handleShip() {
|
||||||
|
const fn = onShipFn.value;
|
||||||
|
if (typeof fn === 'function') {
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
showConfirmButton: false,
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
data.value = {};
|
||||||
|
expressDetail.value = {};
|
||||||
|
scrollToLogistics.value = false;
|
||||||
|
onShipFn.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = modalApi.getData<Record<string, any>>() || {};
|
||||||
|
scrollToLogistics.value = !!payload.scrollToLogistics;
|
||||||
|
onShipFn.value =
|
||||||
|
typeof payload.onShip === 'function' ? payload.onShip : null;
|
||||||
|
const id = payload.id || payload.values?.id;
|
||||||
|
if (id) {
|
||||||
|
loadDetail(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal title="订单详情" class="w-[720px]">
|
||||||
|
<div class="wh-order-detail space-y-5">
|
||||||
|
<div v-if="canShip" class="flex justify-end">
|
||||||
|
<Button type="primary" @click="handleShip">发货</Button>
|
||||||
|
</div>
|
||||||
|
<section>
|
||||||
|
<h3 class="section-title">收货人信息</h3>
|
||||||
|
<Descriptions
|
||||||
|
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="收货人">
|
||||||
|
{{ data.express_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="联系电话">
|
||||||
|
{{ data.express_mobile || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="收货地区" :span="2">
|
||||||
|
{{ data.express_region || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="详细地址" :span="2">
|
||||||
|
{{ data.express_address || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 class="section-title">订单信息</h3>
|
||||||
|
<Descriptions
|
||||||
|
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="订单号">
|
||||||
|
{{ data.order_no || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单状态">
|
||||||
|
<Tag :color="statusMap[data.status]?.color || 'default'">
|
||||||
|
{{ statusMap[data.status]?.text || data.status || '-' }}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="邮寄方式">
|
||||||
|
{{
|
||||||
|
deliveryMethodMap[data.delivery_method] ??
|
||||||
|
data.delivery_method ??
|
||||||
|
'-'
|
||||||
|
}}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="下单时间">
|
||||||
|
{{ data.created_at || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="发货时间">
|
||||||
|
{{ data.send_time || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 class="section-title">药品信息(本仓)</h3>
|
||||||
|
<div
|
||||||
|
v-if="Array.isArray(data.products) && data.products.length"
|
||||||
|
class="product-list"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="item in data.products"
|
||||||
|
:key="item.id"
|
||||||
|
class="product-row"
|
||||||
|
>
|
||||||
|
<div class="product-cover">
|
||||||
|
<Image
|
||||||
|
:src="item.drug?.image || item.drug_image || '/img/empty.png'"
|
||||||
|
alt=""
|
||||||
|
height="64"
|
||||||
|
width="64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="product-meta">
|
||||||
|
<div class="product-name">{{ item.drug_name || '-' }}</div>
|
||||||
|
<div class="product-sub">
|
||||||
|
规格:{{ item.drug?.specification || '-' }}
|
||||||
|
</div>
|
||||||
|
<div class="product-sub">
|
||||||
|
数量:{{ item.number ?? '-' }}
|
||||||
|
<span v-if="item.dosage != null && item.dosage !== ''">
|
||||||
|
|剂量:{{ item.dosage }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-tip">暂无本仓药品</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section ref="logisticsAnchorRef">
|
||||||
|
<h3 class="section-title">物流信息</h3>
|
||||||
|
<template
|
||||||
|
v-if="
|
||||||
|
Number(data.is_send) === 1 ||
|
||||||
|
Number(data.status) >= 2 ||
|
||||||
|
expressNoRow().express_no ||
|
||||||
|
expressDetail.express_no
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Descriptions
|
||||||
|
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="物流公司">
|
||||||
|
{{
|
||||||
|
expressDetail.express_company_name ||
|
||||||
|
expressNoRow().express_company_name ||
|
||||||
|
'-'
|
||||||
|
}}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="运单号">
|
||||||
|
{{
|
||||||
|
expressDetail.express_no || expressNoRow().express_no || '-'
|
||||||
|
}}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最新状态">
|
||||||
|
{{ expressDetail.state_txt || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<div v-if="Array.isArray(expressDetail.detail) && expressDetail.detail.length" class="mt-4">
|
||||||
|
<h4 class="mb-2 text-sm font-medium">物流追踪</h4>
|
||||||
|
<Timeline>
|
||||||
|
<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>
|
||||||
|
<div v-else class="empty-tip mt-3">暂无物流轨迹</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty-tip">暂无物流</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section-title {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.product-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.product-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
}
|
||||||
|
.product-cover {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.product-meta {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.product-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.product-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<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 { sendDeliveryWarehouseOrder } from '#/views/system/delivery-warehouse-order/api';
|
||||||
|
import { modalFormProps } from '#/views/system/delivery-warehouse-order/config/form';
|
||||||
|
|
||||||
|
const gridApi = ref();
|
||||||
|
const orderNo = 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 });
|
||||||
|
sendDeliveryWarehouseOrder(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 } = modalApi.getData<Record<string, any>>();
|
||||||
|
if (values) {
|
||||||
|
orderNo.value = values.order_no;
|
||||||
|
formApi.setValues(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Modal :title="`订单:【 ${orderNo} 】发货`" class="w-[30%]">
|
||||||
|
<Form />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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: 'order_id',
|
||||||
|
label: '订单ID',
|
||||||
|
dependencies: {
|
||||||
|
show: false,
|
||||||
|
triggerFields: ['order_id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入快递单号',
|
||||||
|
},
|
||||||
|
fieldName: 'express_no',
|
||||||
|
label: '快递单号',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'ExpressCompanySelect',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择快递公司',
|
||||||
|
},
|
||||||
|
fieldName: 'express_company_code',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
label: '快递公司',
|
||||||
|
rules: 'required',
|
||||||
|
// 单号变化时传入 trackingNo,组件按前缀自动匹配快递公司
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: ['express_no'],
|
||||||
|
componentProps(values) {
|
||||||
|
return {
|
||||||
|
placeholder: '请选择快递公司',
|
||||||
|
trackingNo: values.express_no,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Textarea',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入发货备注',
|
||||||
|
},
|
||||||
|
fieldName: 'introduce',
|
||||||
|
label: '发货备注',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showDefaultActions: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
export const formOptions: VbenFormProps = {
|
||||||
|
collapsed: false,
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '输入订单号',
|
||||||
|
},
|
||||||
|
defaultValue: '',
|
||||||
|
fieldName: 'order_no',
|
||||||
|
label: '订单号',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCollapseButton: false,
|
||||||
|
submitButtonOptions: {
|
||||||
|
content: '查询',
|
||||||
|
},
|
||||||
|
submitOnChange: true,
|
||||||
|
submitOnEnter: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { getDeliveryWarehouseOrderList } from '#/views/system/delivery-warehouse-order/api';
|
||||||
|
|
||||||
|
interface RowType {
|
||||||
|
id: number;
|
||||||
|
order_no: string;
|
||||||
|
status: number;
|
||||||
|
express_name: string;
|
||||||
|
express_mobile: string;
|
||||||
|
express_region: string;
|
||||||
|
express_address: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gridOptions: VxeGridProps<RowType> = {
|
||||||
|
columnConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ field: 'order_no', align: 'left', title: '订单号', minWidth: 160 },
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '状态',
|
||||||
|
slots: { default: 'status' },
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{ field: 'express_name', title: '收货人' },
|
||||||
|
{ field: 'express_mobile', title: '联系电话' },
|
||||||
|
{ field: 'express_region', title: '收货地区' },
|
||||||
|
{ field: 'express_address', title: '收货地址', minWidth: 200 },
|
||||||
|
{
|
||||||
|
field: 'logistics_summary',
|
||||||
|
title: '物流',
|
||||||
|
minWidth: 160,
|
||||||
|
slots: { default: 'logistics' },
|
||||||
|
},
|
||||||
|
{ 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 getDeliveryWarehouseOrderList({
|
||||||
|
page: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
height: 'auto',
|
||||||
|
border: false,
|
||||||
|
toolbarConfig: {
|
||||||
|
// @ts-ignore
|
||||||
|
search: true,
|
||||||
|
refresh: true,
|
||||||
|
print: false,
|
||||||
|
export: false,
|
||||||
|
zoom: true,
|
||||||
|
slots: {
|
||||||
|
buttons: 'toolbar-buttons',
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
icon: 'vxe-icon-menu',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showOverflow: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import { Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Empty,
|
||||||
|
Image,
|
||||||
|
Input,
|
||||||
|
Pagination,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getDeliveryWarehouseOrderList } from '#/views/system/delivery-warehouse-order/api';
|
||||||
|
|
||||||
|
import DetailModalDemo from './components/detail-modal.vue';
|
||||||
|
import FormModalDemo from './components/modal.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'DeliveryWarehouseOrderIndex' });
|
||||||
|
|
||||||
|
/** Tab:待发货1 / 待收货2 / 确认收货7(仅7) */
|
||||||
|
type StatusTab = '1' | '2' | '7';
|
||||||
|
|
||||||
|
const statusTab = ref<StatusTab>('1');
|
||||||
|
const orderNo = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const list = ref<Record<string, any>[]>([]);
|
||||||
|
const pager = reactive({
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 12,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusMeta: Record<number, { text: string; color: string }> = {
|
||||||
|
0: { text: '待支付', color: 'default' },
|
||||||
|
1: { text: '待发货', color: 'orange' },
|
||||||
|
2: { text: '待收货', color: 'blue' },
|
||||||
|
3: { text: '待评价', color: 'cyan' },
|
||||||
|
4: { text: '已退款', color: 'red' },
|
||||||
|
5: { text: '退款中', color: 'volcano' },
|
||||||
|
6: { text: '已收货', color: 'green' },
|
||||||
|
7: { text: '确认收货', color: 'green' },
|
||||||
|
8: { text: '拒绝退款', color: 'gold' },
|
||||||
|
9: { text: '已取消', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabLabel = computed(() => {
|
||||||
|
if (statusTab.value === '1') return '待发货';
|
||||||
|
if (statusTab.value === '2') return '待收货';
|
||||||
|
return '确认收货';
|
||||||
|
});
|
||||||
|
|
||||||
|
const [FormModal, formModalApi] = useVbenModal({
|
||||||
|
connectedComponent: FormModalDemo,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [DetailModal, detailModalApi] = useVbenModal({
|
||||||
|
connectedComponent: DetailModalDemo,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载当前 Tab 订单卡片列表
|
||||||
|
*/
|
||||||
|
async function loadList() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getDeliveryWarehouseOrderList({
|
||||||
|
page: pager.currentPage,
|
||||||
|
pageSize: pager.pageSize,
|
||||||
|
status: Number(statusTab.value),
|
||||||
|
order_no: orderNo.value?.trim() || undefined,
|
||||||
|
});
|
||||||
|
list.value = res?.items ?? res?.list ?? [];
|
||||||
|
pager.total = Number(res?.total ?? res?.pager?.total ?? 0);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabChange(key: string | number) {
|
||||||
|
statusTab.value = String(key) as StatusTab;
|
||||||
|
pager.currentPage = 1;
|
||||||
|
void loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
pager.currentPage = 1;
|
||||||
|
void loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表刷新回调(发货成功后用) */
|
||||||
|
const gridApiLike = {
|
||||||
|
reload: () => loadList(),
|
||||||
|
query: () => loadList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 打开发货弹窗 */
|
||||||
|
function wareSend(row: Record<string, any>) {
|
||||||
|
formModalApi.setData({
|
||||||
|
values: {
|
||||||
|
order_id: row?.id,
|
||||||
|
order_no: row?.order_no,
|
||||||
|
},
|
||||||
|
gridApi: gridApiLike,
|
||||||
|
});
|
||||||
|
formModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开详情;scrollToLogistics=true 时详情加载后滚到物流区块
|
||||||
|
* 待发货时传入 onShip,详情内可直接打开发货弹窗
|
||||||
|
*/
|
||||||
|
function openDetail(row: Record<string, any>, scrollToLogistics = false) {
|
||||||
|
detailModalApi.setData({
|
||||||
|
id: row?.id,
|
||||||
|
scrollToLogistics,
|
||||||
|
onShip:
|
||||||
|
Number(row?.status) === 1
|
||||||
|
? () => {
|
||||||
|
detailModalApi.close();
|
||||||
|
wareSend(row);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
detailModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本仓商品行(图/名/规格/数量) */
|
||||||
|
function productRows(row: Record<string, any>) {
|
||||||
|
return Array.isArray(row.products) ? row.products : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数量展示:有剂量时显示 number×dosage */
|
||||||
|
function qtyText(p: Record<string, any>) {
|
||||||
|
const n = p.number ?? '-';
|
||||||
|
if (p.dosage != null && p.dosage !== '' && Number(p.dosage) !== 1) {
|
||||||
|
return `${n}×${p.dosage}`;
|
||||||
|
}
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressText(row: Record<string, any>) {
|
||||||
|
const region = row.express_region || '';
|
||||||
|
const addr = row.express_address || '';
|
||||||
|
return `${region}${addr}`.trim() || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height title="我的订单">
|
||||||
|
<FormModal />
|
||||||
|
<DetailModal />
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Tabs
|
||||||
|
:active-key="statusTab"
|
||||||
|
type="card"
|
||||||
|
@change="handleTabChange"
|
||||||
|
>
|
||||||
|
<Tabs.TabPane key="1" tab="待发货" />
|
||||||
|
<Tabs.TabPane key="2" tab="待收货" />
|
||||||
|
<Tabs.TabPane key="7" tab="确认收货" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Input
|
||||||
|
v-model:value="orderNo"
|
||||||
|
allow-clear
|
||||||
|
class="w-64"
|
||||||
|
placeholder="输入订单号"
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
<Button type="primary" @click="handleSearch">查询</Button>
|
||||||
|
<span class="text-sm text-[hsl(var(--muted-foreground))]">
|
||||||
|
当前:{{ tabLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<div
|
||||||
|
v-if="list.length"
|
||||||
|
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
v-for="row in list"
|
||||||
|
:key="row.id"
|
||||||
|
class="overflow-hidden"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-medium">{{ row.order_no || '—' }}</span>
|
||||||
|
<Tag :color="statusMeta[row.status]?.color || 'default'">
|
||||||
|
{{ statusMeta[row.status]?.text || row.status }}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #extra>
|
||||||
|
<span class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||||
|
{{ row.created_at || '' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-[hsl(var(--muted-foreground))]">收件人:</span>
|
||||||
|
{{ row.express_name || '—' }}
|
||||||
|
<span class="ml-2">{{ row.express_mobile || '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-[hsl(var(--muted-foreground))]">地址:</span>
|
||||||
|
{{ addressText(row) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-[hsl(var(--muted-foreground))]">
|
||||||
|
本仓商品:
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="productRows(row).length"
|
||||||
|
class="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="p in productRows(row)"
|
||||||
|
:key="p.id"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
:src="p.drug?.image || p.drug_image || ''"
|
||||||
|
:width="40"
|
||||||
|
:height="40"
|
||||||
|
class="shrink-0 rounded object-cover"
|
||||||
|
fallback="/img/empty.png"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1 text-xs">
|
||||||
|
<div class="truncate font-medium">
|
||||||
|
{{ p.drug_name || p.drug?.drug_name || '-' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[hsl(var(--muted-foreground))]">
|
||||||
|
规格:{{ p.drug?.specification || '-' }}
|
||||||
|
· 数量:{{ qtyText(p) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-[hsl(var(--muted-foreground))]">物流:</span>
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
row.logistics_summary === '未发货'
|
||||||
|
? 'text-[hsl(var(--muted-foreground))]'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ row.logistics_summary || '未发货' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<Space>
|
||||||
|
<Button size="small" type="link" @click="openDetail(row, false)">
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="
|
||||||
|
Number(row.is_send) === 1 ||
|
||||||
|
Number(row.status) >= 2 ||
|
||||||
|
!!row.waybill_no
|
||||||
|
"
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
@click="openDetail(row, true)"
|
||||||
|
>
|
||||||
|
物流
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="Number(row.status) === 1"
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
@click="wareSend(row)"
|
||||||
|
>
|
||||||
|
发货
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<Empty v-else class="py-16" :description="`${tabLabel}暂无订单`" />
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
<div v-if="pager.total > 0" class="flex justify-end">
|
||||||
|
<Pagination
|
||||||
|
v-model:current="pager.currentPage"
|
||||||
|
v-model:page-size="pager.pageSize"
|
||||||
|
:total="pager.total"
|
||||||
|
show-size-changer
|
||||||
|
:page-size-options="['12', '24', '48']"
|
||||||
|
@change="loadList"
|
||||||
|
@show-size-change="loadList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
getDeliveryWarehouseDrugList,
|
||||||
|
getDeliveryWarehouseStockLogList,
|
||||||
|
stockInDeliveryWarehouseDrug,
|
||||||
|
stockOutDeliveryWarehouseDrug,
|
||||||
|
} from '#/views/system/delivery-warehouse-drug/api';
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Form, Input, InputNumber, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
stockInDeliveryWarehouseDrug,
|
||||||
|
stockOutDeliveryWarehouseDrug,
|
||||||
|
} from '#/views/system/delivery-warehouse-stock/api';
|
||||||
|
|
||||||
|
defineOptions({ name: 'DeliveryWarehouseStockIoModal' });
|
||||||
|
|
||||||
|
const mode = ref<'in' | 'out'>('in');
|
||||||
|
const row = ref<Record<string, any>>({});
|
||||||
|
const qty = ref<number>(1);
|
||||||
|
const remark = ref('');
|
||||||
|
const reloadFn = ref<null | (() => void)>(null);
|
||||||
|
|
||||||
|
const title = computed(() =>
|
||||||
|
mode.value === 'in' ? '手动入库' : '手动出库',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
onConfirm: async () => {
|
||||||
|
const id = Number(row.value?.id);
|
||||||
|
const n = Number(qty.value);
|
||||||
|
if (!id || n <= 0) {
|
||||||
|
message.error('请填写正确的数量');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.setState({ confirmLoading: true });
|
||||||
|
try {
|
||||||
|
if (mode.value === 'in') {
|
||||||
|
await stockInDeliveryWarehouseDrug({
|
||||||
|
id,
|
||||||
|
qty: n,
|
||||||
|
remark: remark.value?.trim() || undefined,
|
||||||
|
});
|
||||||
|
message.success('入库成功');
|
||||||
|
} else {
|
||||||
|
await stockOutDeliveryWarehouseDrug({
|
||||||
|
id,
|
||||||
|
qty: n,
|
||||||
|
remark: remark.value?.trim() || undefined,
|
||||||
|
});
|
||||||
|
message.success('出库成功');
|
||||||
|
}
|
||||||
|
reloadFn.value?.();
|
||||||
|
modalApi.close();
|
||||||
|
} finally {
|
||||||
|
modalApi.setState({ confirmLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpenChange(isOpen) {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = modalApi.getData<{
|
||||||
|
mode: 'in' | 'out';
|
||||||
|
row: Record<string, any>;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}>();
|
||||||
|
mode.value = data?.mode === 'out' ? 'out' : 'in';
|
||||||
|
row.value = data?.row || {};
|
||||||
|
qty.value = 1;
|
||||||
|
remark.value = '';
|
||||||
|
reloadFn.value = data?.onSuccess || null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :title="title" class="w-[480px]">
|
||||||
|
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
|
||||||
|
{{ row.drug?.drug_name || '-' }} /
|
||||||
|
可用 {{ row.available_stock ?? Math.max((row.stock || 0) - (row.frozen_number || 0), 0) }}
|
||||||
|
/ 冻结 {{ row.frozen_number ?? 0 }}
|
||||||
|
/ 库存 {{ row.stock ?? 0 }}
|
||||||
|
</div>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item :label="mode === 'in' ? '入库数量' : '出库数量'" required>
|
||||||
|
<InputNumber v-model:value="qty" :min="1" class="w-full" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="备注">
|
||||||
|
<Input.TextArea
|
||||||
|
v-model:value="remark"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="可选,便于追溯"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenDrawer } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Empty, Spin, Table, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getDeliveryWarehouseStockLogList } from '#/views/system/delivery-warehouse-stock/api';
|
||||||
|
|
||||||
|
defineOptions({ name: 'DeliveryWarehouseStockLogDrawer' });
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const rows = ref<Record<string, any>[]>([]);
|
||||||
|
const drugName = ref('');
|
||||||
|
const pager = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
const filter = reactive({
|
||||||
|
warehouse_drug_id: 0 as number,
|
||||||
|
drug_id: 0 as number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||||
|
{ title: '类型', dataIndex: 'biz_type_txt', key: 'biz_type_txt', width: 120 },
|
||||||
|
{ title: '数量', dataIndex: 'qty', key: 'qty', width: 80 },
|
||||||
|
{ title: '库存前→后', key: 'stock_chg', width: 120 },
|
||||||
|
{ title: '冻结前→后', key: 'frozen_chg', width: 120 },
|
||||||
|
{ title: '订单ID', dataIndex: 'order_id', key: 'order_id', width: 90 },
|
||||||
|
{ title: '备注', dataIndex: 'remark', key: 'remark' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
if (!filter.warehouse_drug_id && !filter.drug_id) {
|
||||||
|
rows.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getDeliveryWarehouseStockLogList({
|
||||||
|
page: pager.current,
|
||||||
|
pageSize: pager.pageSize,
|
||||||
|
warehouse_drug_id: filter.warehouse_drug_id || undefined,
|
||||||
|
drug_id: filter.drug_id || undefined,
|
||||||
|
});
|
||||||
|
rows.value = res?.items ?? res?.list ?? [];
|
||||||
|
pager.total = Number(res?.total ?? 0);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Drawer, drawerApi] = useVbenDrawer({
|
||||||
|
class: 'w-[720px]',
|
||||||
|
showConfirmButton: false,
|
||||||
|
onOpenChange(isOpen) {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = drawerApi.getData<{ row: Record<string, any> }>();
|
||||||
|
const row = data?.row || {};
|
||||||
|
drugName.value = row.drug?.drug_name || '';
|
||||||
|
filter.warehouse_drug_id = Number(row.id || 0);
|
||||||
|
filter.drug_id = Number(row.drug_id || 0);
|
||||||
|
pager.current = 1;
|
||||||
|
void loadLogs();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Drawer :title="`出入库记录${drugName ? ` - ${drugName}` : ''}`">
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<Table
|
||||||
|
v-if="rows.length"
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="rows"
|
||||||
|
:pagination="{
|
||||||
|
current: pager.current,
|
||||||
|
pageSize: pager.pageSize,
|
||||||
|
total: pager.total,
|
||||||
|
onChange: (p: number) => {
|
||||||
|
pager.current = p;
|
||||||
|
void loadLogs();
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'biz_type_txt'">
|
||||||
|
<Tag>{{ record.biz_type_txt || record.biz_type }}</Tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'stock_chg'">
|
||||||
|
{{ record.stock_before }} → {{ record.stock_after }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'frozen_chg'">
|
||||||
|
{{ record.frozen_before }} → {{ record.frozen_after }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'order_id'">
|
||||||
|
{{ record.order_id > 0 ? record.order_id : '-' }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
<Empty v-else class="py-10" description="暂无出入库记录" />
|
||||||
|
</Spin>
|
||||||
|
</Drawer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
export const formOptions: VbenFormProps = {
|
||||||
|
collapsed: false,
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '输入药品名称',
|
||||||
|
},
|
||||||
|
fieldName: 'drug_name',
|
||||||
|
label: '药品名称',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCollapseButton: false,
|
||||||
|
submitButtonOptions: {
|
||||||
|
content: '查询',
|
||||||
|
},
|
||||||
|
submitOnChange: true,
|
||||||
|
submitOnEnter: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { getDeliveryWarehouseDrugList } from '#/views/system/delivery-warehouse-stock/api';
|
||||||
|
|
||||||
|
interface RowType {
|
||||||
|
id: number;
|
||||||
|
drug_name: string;
|
||||||
|
quote: string;
|
||||||
|
promo_fee: string;
|
||||||
|
platform_fee: string;
|
||||||
|
stock: number;
|
||||||
|
frozen_number: number;
|
||||||
|
drug?: { drug_name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gridOptions: VxeGridProps<RowType> = {
|
||||||
|
columnConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
field: 'drug_name',
|
||||||
|
title: '药品名称',
|
||||||
|
slots: { default: 'drug_name' },
|
||||||
|
minWidth: 160,
|
||||||
|
},
|
||||||
|
{ field: 'quote', title: '报价' },
|
||||||
|
{ field: 'promo_fee', title: '推广费' },
|
||||||
|
{ field: 'platform_fee', title: '平台费' },
|
||||||
|
{ field: 'stock', title: '库存' },
|
||||||
|
{ field: 'frozen_number', title: '冻结数量' },
|
||||||
|
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 100 },
|
||||||
|
],
|
||||||
|
keepSource: true,
|
||||||
|
pagerConfig: {},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
return await getDeliveryWarehouseDrugList({
|
||||||
|
page: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
height: 'auto',
|
||||||
|
border: false,
|
||||||
|
toolbarConfig: {
|
||||||
|
// @ts-ignore
|
||||||
|
search: true,
|
||||||
|
refresh: true,
|
||||||
|
print: false,
|
||||||
|
export: false,
|
||||||
|
zoom: true,
|
||||||
|
slots: {
|
||||||
|
buttons: 'toolbar-buttons',
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
icon: 'vxe-icon-menu',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showOverflow: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Empty,
|
||||||
|
Input,
|
||||||
|
Pagination,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Tag,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getDeliveryWarehouseDrugList } from '#/views/system/delivery-warehouse-stock/api';
|
||||||
|
|
||||||
|
import StockIoModal from './components/stock-io-modal.vue';
|
||||||
|
import StockLogDrawer from './components/stock-log-drawer.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'DeliveryWarehouseStockIndex' });
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const keyword = ref('');
|
||||||
|
const list = ref<Record<string, any>[]>([]);
|
||||||
|
const pager = reactive({
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 12,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [IoModal, ioModalApi] = useVbenModal({
|
||||||
|
connectedComponent: StockIoModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [LogDrawer, logDrawerApi] = useVbenDrawer({
|
||||||
|
connectedComponent: StockLogDrawer,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载库存卡片(绑药列表)
|
||||||
|
*/
|
||||||
|
async function loadList() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getDeliveryWarehouseDrugList({
|
||||||
|
page: pager.currentPage,
|
||||||
|
pageSize: pager.pageSize,
|
||||||
|
// 后端无药名 like 时仅本地过滤;有则透传
|
||||||
|
drug_name: keyword.value?.trim() || undefined,
|
||||||
|
});
|
||||||
|
let items = res?.items ?? res?.list ?? [];
|
||||||
|
const kw = keyword.value?.trim();
|
||||||
|
if (kw) {
|
||||||
|
items = items.filter((row: any) => {
|
||||||
|
const name = String(row.drug?.drug_name || '');
|
||||||
|
const no = String(row.drug?.drug_number || '');
|
||||||
|
return name.includes(kw) || no.includes(kw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
list.value = items;
|
||||||
|
pager.total = Number(res?.total ?? 0);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openIo(row: Record<string, any>, mode: 'in' | 'out') {
|
||||||
|
ioModalApi.setData({
|
||||||
|
mode,
|
||||||
|
row,
|
||||||
|
onSuccess: () => loadList(),
|
||||||
|
});
|
||||||
|
ioModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLogs(row: Record<string, any>) {
|
||||||
|
logDrawerApi.setData({ row });
|
||||||
|
logDrawerApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function available(row: Record<string, any>) {
|
||||||
|
if (row.available_stock !== undefined && row.available_stock !== null) {
|
||||||
|
return Number(row.available_stock);
|
||||||
|
}
|
||||||
|
return Math.max(Number(row.stock || 0) - Number(row.frozen_number || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height title="库存管理">
|
||||||
|
<IoModal />
|
||||||
|
<LogDrawer />
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Input
|
||||||
|
v-model:value="keyword"
|
||||||
|
allow-clear
|
||||||
|
class="w-64"
|
||||||
|
placeholder="药品名称/编号"
|
||||||
|
@press-enter="() => { pager.currentPage = 1; void loadList(); }"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
pager.currentPage = 1;
|
||||||
|
void loadList();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<span class="text-sm text-[hsl(var(--muted-foreground))]">
|
||||||
|
库存变动请用入库/出库,禁止直接改数量
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<div
|
||||||
|
v-if="list.length"
|
||||||
|
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
v-for="row in list"
|
||||||
|
:key="row.id"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span>{{ row.drug?.drug_name || '-' }}</span>
|
||||||
|
<span class="text-xs font-normal text-[hsl(var(--muted-foreground))]">
|
||||||
|
规格:{{ row.drug?.specification?.trim() || '-' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-normal text-[hsl(var(--muted-foreground))]">
|
||||||
|
{{ row.drug?.drug_number || '-' }}
|
||||||
|
<template v-if="row.warehouse?.name">
|
||||||
|
· {{ row.warehouse.name }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #extra>
|
||||||
|
<Tag :color="Number(row.status) === 2 ? 'green' : 'default'">
|
||||||
|
{{ Number(row.status) === 2 ? '上架' : '下架' }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-2 text-center text-sm">
|
||||||
|
<div class="rounded bg-[hsl(var(--muted)/0.35)] p-2">
|
||||||
|
<div class="text-lg font-semibold text-emerald-600">
|
||||||
|
{{ available(row) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||||
|
可用
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded bg-[hsl(var(--muted)/0.35)] p-2">
|
||||||
|
<div class="text-lg font-semibold text-orange-500">
|
||||||
|
{{ row.frozen_number ?? 0 }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||||
|
冻结
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded bg-[hsl(var(--muted)/0.35)] p-2">
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ row.stock ?? 0 }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||||
|
总库存
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<Space wrap>
|
||||||
|
<Button size="small" type="link" @click="openIo(row, 'in')">
|
||||||
|
入库
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="link" @click="openIo(row, 'out')">
|
||||||
|
出库
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="link" @click="openLogs(row)">
|
||||||
|
出入库记录
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<Empty v-else class="py-16" description="暂无库存药品" />
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
<div v-if="pager.total > 0" class="flex justify-end">
|
||||||
|
<Pagination
|
||||||
|
v-model:current="pager.currentPage"
|
||||||
|
v-model:page-size="pager.pageSize"
|
||||||
|
:total="pager.total"
|
||||||
|
show-size-changer
|
||||||
|
:page-size-options="['12', '24', '48']"
|
||||||
|
@change="loadList"
|
||||||
|
@show-size-change="loadList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
const prefix = 'withdrawal-application/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提现申请列表
|
||||||
|
*/
|
||||||
|
export async function getWithdrawalApplicationList(data: any = {}) {
|
||||||
|
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 申请提现(后端方法名为 withdrawal)
|
||||||
|
*/
|
||||||
|
export async function applyWithdrawal(data: { amount: number }) {
|
||||||
|
return requestClient.post<any>(`${prefix}withdrawal`, data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
export const applyFormProps: VbenFormProps = {
|
||||||
|
wrapperClass: 'grid-cols-12',
|
||||||
|
commonConfig: {
|
||||||
|
formItemClass: 'col-span-12',
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'InputNumber',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入提现金额',
|
||||||
|
min: 1,
|
||||||
|
precision: 2,
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
fieldName: 'amount',
|
||||||
|
label: '提现金额',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showDefaultActions: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { getWithdrawalApplicationList } from '#/views/system/delivery-warehouse-withdrawal/api';
|
||||||
|
|
||||||
|
interface RowType {
|
||||||
|
id: number;
|
||||||
|
apply_cash: number;
|
||||||
|
true_cash: number;
|
||||||
|
charge_cash: number;
|
||||||
|
apply_time: string;
|
||||||
|
check_status: number;
|
||||||
|
check_result: string;
|
||||||
|
dakuan_status: number;
|
||||||
|
dakuan_time: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gridOptions: VxeGridProps<RowType> = {
|
||||||
|
columnConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||||
|
{ field: 'apply_cash', title: '申请金额' },
|
||||||
|
{ field: 'true_cash', title: '实际到账' },
|
||||||
|
{ field: 'charge_cash', title: '手续费' },
|
||||||
|
{ field: 'apply_time', title: '申请时间' },
|
||||||
|
{
|
||||||
|
field: 'check_status',
|
||||||
|
title: '审核状态',
|
||||||
|
slots: { default: 'check_status' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'check_result',
|
||||||
|
title: '审核结果',
|
||||||
|
slots: { default: 'check_result' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'dakuan_status',
|
||||||
|
title: '打款状态',
|
||||||
|
slots: { default: 'dakuan_status' },
|
||||||
|
},
|
||||||
|
{ field: 'dakuan_time', title: '打款时间' },
|
||||||
|
],
|
||||||
|
keepSource: true,
|
||||||
|
pagerConfig: {},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }) => {
|
||||||
|
return await getWithdrawalApplicationList({
|
||||||
|
page: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
height: 'auto',
|
||||||
|
border: false,
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
print: false,
|
||||||
|
export: false,
|
||||||
|
zoom: true,
|
||||||
|
custom: {
|
||||||
|
icon: 'vxe-icon-menu',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showOverflow: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 已废弃:仓侧提现与财务中心共用 views/finance/withdrawal/index
|
||||||
|
* 菜单 225 已指向共用页;本文件仅作兼容重定向,避免旧路由 404
|
||||||
|
*/
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { Page } from '@vben/common-ui';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 优先跳到仓菜单配置的提现路径;找不到则落到财务提现
|
||||||
|
const routes = router.getRoutes();
|
||||||
|
const hit = routes.find(
|
||||||
|
(r) =>
|
||||||
|
typeof r.path === 'string' &&
|
||||||
|
r.path.includes('delivery-warehouse') &&
|
||||||
|
r.path.includes('withdrawal') &&
|
||||||
|
!r.path.includes('delivery-warehouse-withdrawal'),
|
||||||
|
);
|
||||||
|
router.replace(hit?.path || '/finance/withdrawal');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page title="提现申请">
|
||||||
|
<div class="text-[hsl(var(--muted-foreground))]">正在跳转至提现管理…</div>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
const prefix = 'delivery-warehouse/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询配送仓库列表
|
||||||
|
*/
|
||||||
|
export async function getDeliveryWarehouseList(data: any) {
|
||||||
|
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配送仓库下拉选项
|
||||||
|
*/
|
||||||
|
export async function getDeliveryWarehouseOption(data: any = {}) {
|
||||||
|
return requestClient.get<any>(`${prefix}option`, { params: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取配送仓库详情
|
||||||
|
*/
|
||||||
|
export async function getDeliveryWarehouseInfo(id: number) {
|
||||||
|
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增配送仓库
|
||||||
|
*/
|
||||||
|
export async function createDeliveryWarehouse(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}create`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑配送仓库
|
||||||
|
*/
|
||||||
|
export async function updateDeliveryWarehouse(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}update`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除配送仓库
|
||||||
|
*/
|
||||||
|
export async function deleteDeliveryWarehouse(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}delete`, data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 平台仓库列表:银行卡报备弹窗
|
||||||
|
* 复用仓侧报备表单与 API,提交时带当前行 warehouse_id
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import {
|
||||||
|
getDeliveryWarehouseBankCardDetail,
|
||||||
|
reportDeliveryWarehouseBankCard,
|
||||||
|
} from '#/views/system/delivery-warehouse-bank-card/api';
|
||||||
|
import { reportFormProps } from '#/views/system/delivery-warehouse-bank-card/config/form';
|
||||||
|
|
||||||
|
const warehouseId = ref(0);
|
||||||
|
const warehouseName = ref('');
|
||||||
|
const gridApi = ref();
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm(reportFormProps);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
await reportDeliveryWarehouseBankCard({
|
||||||
|
...values,
|
||||||
|
warehouse_id: warehouseId.value,
|
||||||
|
});
|
||||||
|
message.success('银行卡报备已提交');
|
||||||
|
gridApi.value?.query?.() ?? gridApi.value?.reload?.();
|
||||||
|
modalApi.close();
|
||||||
|
} finally {
|
||||||
|
modalApi.setState({ loading: false, confirmLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = modalApi.getData<Record<string, any>>() || {};
|
||||||
|
warehouseId.value = Number(data.warehouse_id || data.values?.id || 0);
|
||||||
|
warehouseName.value = data.warehouse_name || data.values?.name || '';
|
||||||
|
gridApi.value = data.gridApi;
|
||||||
|
formApi.resetForm();
|
||||||
|
formApi.setValues({ warehouse_id: warehouseId.value });
|
||||||
|
// 有历史草稿或主体镜像时预填,方便驳回后重提
|
||||||
|
try {
|
||||||
|
const detail = await getDeliveryWarehouseBankCardDetail({
|
||||||
|
warehouse_id: warehouseId.value,
|
||||||
|
});
|
||||||
|
if (detail) {
|
||||||
|
formApi.setValues({
|
||||||
|
warehouse_id: warehouseId.value,
|
||||||
|
bank_user_name: detail.bank_user_name,
|
||||||
|
bank_card: detail.bank_card,
|
||||||
|
bank_name: detail.bank_name,
|
||||||
|
bank_account_type: detail.bank_account_type || 2,
|
||||||
|
bank_no: detail.bank_no,
|
||||||
|
cert_no: detail.cert_no,
|
||||||
|
attachment: detail.attachment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 未报备时 detail 可能为空,忽略即可
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:title="`银行卡报备${warehouseName ? ` - ${warehouseName}` : ''}`"
|
||||||
|
class="w-[520px]"
|
||||||
|
>
|
||||||
|
<Form />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 平台仓库列表:银行卡报备状态查询弹窗
|
||||||
|
* 展示最新报备信息,支持同步易票联状态
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Button, Descriptions, message, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDeliveryWarehouseBankCardDetail,
|
||||||
|
syncDeliveryWarehouseBankCardStatus,
|
||||||
|
} from '#/views/system/delivery-warehouse-bank-card/api';
|
||||||
|
|
||||||
|
const warehouseId = ref(0);
|
||||||
|
const warehouseName = ref('');
|
||||||
|
const detail = ref<Record<string, any>>({});
|
||||||
|
const loading = ref(false);
|
||||||
|
const syncing = ref(false);
|
||||||
|
const gridApi = ref();
|
||||||
|
|
||||||
|
const reportStatusMap: Record<number, { text: string; color: string }> = {
|
||||||
|
1: { text: '待审核', color: 'orange' },
|
||||||
|
2: { text: '有效', color: 'green' },
|
||||||
|
3: { text: '审核不通过', color: 'red' },
|
||||||
|
4: { text: '无效', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const bankAccountTypeMap: Record<number, string> = {
|
||||||
|
1: '对公',
|
||||||
|
2: '对私',
|
||||||
|
5: '存折',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!warehouseId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
detail.value =
|
||||||
|
(await getDeliveryWarehouseBankCardDetail({
|
||||||
|
warehouse_id: warehouseId.value,
|
||||||
|
})) || {};
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncStatus() {
|
||||||
|
syncing.value = true;
|
||||||
|
try {
|
||||||
|
await syncDeliveryWarehouseBankCardStatus({
|
||||||
|
warehouse_id: warehouseId.value,
|
||||||
|
});
|
||||||
|
message.success('状态已同步');
|
||||||
|
await loadDetail();
|
||||||
|
gridApi.value?.query?.() ?? gridApi.value?.reload?.();
|
||||||
|
} finally {
|
||||||
|
syncing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
showConfirmButton: false,
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = modalApi.getData<Record<string, any>>() || {};
|
||||||
|
warehouseId.value = Number(data.warehouse_id || data.values?.id || 0);
|
||||||
|
warehouseName.value = data.warehouse_name || data.values?.name || '';
|
||||||
|
gridApi.value = data.gridApi;
|
||||||
|
loadDetail();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:title="`报备状态${warehouseName ? ` - ${warehouseName}` : ''}`"
|
||||||
|
class="w-[480px]"
|
||||||
|
>
|
||||||
|
<Descriptions :column="1" bordered size="small" :loading="loading">
|
||||||
|
<Descriptions.Item label="报备状态">
|
||||||
|
<Tag
|
||||||
|
v-if="detail.report_status"
|
||||||
|
:color="reportStatusMap[detail.report_status]?.color"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
detail.report_status_text ||
|
||||||
|
reportStatusMap[detail.report_status]?.text ||
|
||||||
|
'-'
|
||||||
|
}}
|
||||||
|
</Tag>
|
||||||
|
<span v-else>未报备</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="审核意见">
|
||||||
|
{{ detail.audit_remarks || detail.report_status_msg || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户人">
|
||||||
|
{{ detail.bank_user_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="银行卡号">
|
||||||
|
{{ detail.bank_card_masked || detail.bank_card || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户行">
|
||||||
|
{{ detail.bank_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账户类型">
|
||||||
|
{{ bankAccountTypeMap[detail.bank_account_type] || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<div class="mt-4">
|
||||||
|
<Button :loading="syncing" type="primary" @click="handleSyncStatus">
|
||||||
|
查询/同步状态
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<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 {
|
||||||
|
createDeliveryWarehouse,
|
||||||
|
updateDeliveryWarehouse,
|
||||||
|
} from '#/views/system/delivery-warehouse/api';
|
||||||
|
import { modalFormProps } from '#/views/system/delivery-warehouse/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
|
||||||
|
? updateDeliveryWarehouse
|
||||||
|
: createDeliveryWarehouse;
|
||||||
|
submitApi(values)
|
||||||
|
.then(() => {
|
||||||
|
message.success('保存成功');
|
||||||
|
// 优先 query,兼容部分页面挂的 reload
|
||||||
|
gridApi.value?.query?.() ?? 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) {
|
||||||
|
isUpdate.value = update;
|
||||||
|
formApi.setValues(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:title="`${isUpdate === true ? '编辑' : '新增'}配送仓库`"
|
||||||
|
class="w-[40%]"
|
||||||
|
>
|
||||||
|
<Form />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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',
|
||||||
|
dependencies: {
|
||||||
|
show: false,
|
||||||
|
triggerFields: ['id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入配送仓库名称',
|
||||||
|
},
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '仓库名称',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Textarea',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入仓库介绍(选填)',
|
||||||
|
},
|
||||||
|
fieldName: 'introduce',
|
||||||
|
label: '仓库介绍',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'RadioGroup',
|
||||||
|
componentProps: {
|
||||||
|
options: [
|
||||||
|
{ label: '禁用', value: 0 },
|
||||||
|
{ label: '启用', value: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '状态',
|
||||||
|
defaultValue: 1,
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showDefaultActions: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
export const formOptions: VbenFormProps = {
|
||||||
|
collapsed: false,
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '输入名称',
|
||||||
|
},
|
||||||
|
defaultValue: '',
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '仓库名称',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCollapseButton: true,
|
||||||
|
submitButtonOptions: {
|
||||||
|
content: '查询',
|
||||||
|
},
|
||||||
|
submitOnChange: true,
|
||||||
|
submitOnEnter: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { getDeliveryWarehouseList } from '#/views/system/delivery-warehouse/api';
|
||||||
|
|
||||||
|
interface RowType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
introduce: string;
|
||||||
|
status: number;
|
||||||
|
bank_card_report_status?: number;
|
||||||
|
bank_card_report_status_text?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gridOptions: VxeGridProps<RowType> = {
|
||||||
|
checkboxConfig: {
|
||||||
|
highlight: true,
|
||||||
|
labelField: '',
|
||||||
|
},
|
||||||
|
columnConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ type: 'checkbox', width: 60 },
|
||||||
|
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||||
|
{ field: 'name', align: 'left', title: '仓库名称' },
|
||||||
|
{ field: 'introduce', title: '仓库介绍' },
|
||||||
|
{
|
||||||
|
field: 'bank_card_report_status',
|
||||||
|
title: '银行卡报备',
|
||||||
|
slots: { default: 'bank_card_report_status' },
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '状态',
|
||||||
|
slots: { default: 'status' },
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{ field: 'created_at', title: '创建时间' },
|
||||||
|
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 260 },
|
||||||
|
],
|
||||||
|
keepSource: true,
|
||||||
|
pagerConfig: {},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
return await getDeliveryWarehouseList({
|
||||||
|
page: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
height: 'auto',
|
||||||
|
border: false,
|
||||||
|
toolbarConfig: {
|
||||||
|
// @ts-ignore
|
||||||
|
search: true,
|
||||||
|
refresh: true,
|
||||||
|
print: false,
|
||||||
|
export: false,
|
||||||
|
zoom: true,
|
||||||
|
slots: {
|
||||||
|
buttons: 'toolbar-buttons',
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
icon: 'vxe-icon-menu',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showOverflow: false,
|
||||||
|
};
|
||||||
234
apps/web-antd/src/views/system/delivery-warehouse/index.vue
Normal file
234
apps/web-antd/src/views/system/delivery-warehouse/index.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<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, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { TableAction } from '#/components/table-action';
|
||||||
|
|
||||||
|
import { deleteDeliveryWarehouse } from './api';
|
||||||
|
import BankCardReportModal from './components/bank-card-report-modal.vue';
|
||||||
|
import BankCardStatusModal from './components/bank-card-status-modal.vue';
|
||||||
|
import FormModalDemo from './components/modal.vue';
|
||||||
|
import { formOptions } from './config/search';
|
||||||
|
import { gridOptions } from './config/table';
|
||||||
|
|
||||||
|
const hasTopTableDropDownActions = ref(false);
|
||||||
|
|
||||||
|
const reportStatusColorMap: Record<number, string> = {
|
||||||
|
0: 'default',
|
||||||
|
1: 'orange',
|
||||||
|
2: 'green',
|
||||||
|
3: 'red',
|
||||||
|
4: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
gridOptions,
|
||||||
|
gridEvents,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [FormModal, formModalApi] = useVbenModal({
|
||||||
|
connectedComponent: FormModalDemo,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [BankCardReportModalComponent, bankCardReportModalApi] = useVbenModal({
|
||||||
|
connectedComponent: BankCardReportModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [BankCardStatusModalComponent, bankCardStatusModalApi] = useVbenModal({
|
||||||
|
connectedComponent: BankCardStatusModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const showModal = (data = {}, isUpdate = false) => {
|
||||||
|
formModalApi.setData({
|
||||||
|
values: data,
|
||||||
|
update: isUpdate,
|
||||||
|
gridApi,
|
||||||
|
});
|
||||||
|
formModalApi.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未报备/驳回:打开报备弹窗;待审核/有效:打开状态弹窗
|
||||||
|
*/
|
||||||
|
function handleBankCardReportClick(row: any) {
|
||||||
|
const status = Number(row.bank_card_report_status ?? 0);
|
||||||
|
if (status === 1 || status === 2) {
|
||||||
|
bankCardStatusModalApi.setData({
|
||||||
|
warehouse_id: row.id,
|
||||||
|
warehouse_name: row.name,
|
||||||
|
values: row,
|
||||||
|
gridApi,
|
||||||
|
});
|
||||||
|
bankCardStatusModalApi.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bankCardReportModalApi.setData({
|
||||||
|
warehouse_id: row.id,
|
||||||
|
warehouse_name: row.name,
|
||||||
|
values: row,
|
||||||
|
gridApi,
|
||||||
|
});
|
||||||
|
bankCardReportModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBankCardStatus(row: any) {
|
||||||
|
bankCardStatusModalApi.setData({
|
||||||
|
warehouse_id: row.id,
|
||||||
|
warehouse_name: row.name,
|
||||||
|
values: row,
|
||||||
|
gridApi,
|
||||||
|
});
|
||||||
|
bankCardStatusModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteApi = (row: any) => {
|
||||||
|
let ids = [];
|
||||||
|
if (row) {
|
||||||
|
ids.push(row);
|
||||||
|
} else {
|
||||||
|
ids = gridApi.grid.getCheckboxRecords().map((item) => item.id);
|
||||||
|
}
|
||||||
|
deleteDeliveryWarehouse({ ids }).then(() => {
|
||||||
|
message.success('删除成功!');
|
||||||
|
gridApi.query();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height title="配送仓库管理">
|
||||||
|
<FormModal />
|
||||||
|
<BankCardReportModalComponent />
|
||||||
|
<BankCardStatusModalComponent />
|
||||||
|
<Grid>
|
||||||
|
<template #toolbar-buttons>
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '新增',
|
||||||
|
type: 'primary',
|
||||||
|
icon: 'ant-design:plus-outlined',
|
||||||
|
onClick: showModal.bind(null),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:drop-down-actions="[
|
||||||
|
{
|
||||||
|
label: '删除',
|
||||||
|
icon: 'ant-design:delete-outlined',
|
||||||
|
ifShow: hasTopTableDropDownActions,
|
||||||
|
popConfirm: {
|
||||||
|
title: '确定删除吗?',
|
||||||
|
confirm: deleteApi.bind(null, false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<template #more>
|
||||||
|
<Button style="margin-left: 16px">
|
||||||
|
批量操作
|
||||||
|
<Icon icon="ant-design:down-outlined" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</TableAction>
|
||||||
|
</template>
|
||||||
|
<template #bank_card_report_status="{ row }">
|
||||||
|
<Tag
|
||||||
|
:color="
|
||||||
|
reportStatusColorMap[Number(row.bank_card_report_status ?? 0)] ||
|
||||||
|
'default'
|
||||||
|
"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="handleBankCardReportClick(row)"
|
||||||
|
>
|
||||||
|
{{ row.bank_card_report_status_text || '未报备' }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
<template #status="{ row }">
|
||||||
|
<Tag v-if="row.status === 1" color="green">启用</Tag>
|
||||||
|
<Tag v-else color="default">禁用</Tag>
|
||||||
|
</template>
|
||||||
|
<template #toolbar-tools></template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '编辑',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'uil:edit',
|
||||||
|
size: 'small',
|
||||||
|
onClick: showModal.bind(null, row, true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label:
|
||||||
|
Number(row.bank_card_report_status ?? 0) === 1 ||
|
||||||
|
Number(row.bank_card_report_status ?? 0) === 2
|
||||||
|
? '报备状态'
|
||||||
|
: '银行卡报备',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'ant-design:credit-card-outlined',
|
||||||
|
size: 'small',
|
||||||
|
onClick: handleBankCardReportClick.bind(null, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '删除',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'ant-design:delete-outlined',
|
||||||
|
size: 'small',
|
||||||
|
popConfirm: {
|
||||||
|
title: '确定删除吗?',
|
||||||
|
confirm: deleteApi.bind(null, row.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:drop-down-actions="
|
||||||
|
Number(row.bank_card_report_status ?? 0) === 1 ||
|
||||||
|
Number(row.bank_card_report_status ?? 0) === 2
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: '重新报备',
|
||||||
|
icon: 'ant-design:reload-outlined',
|
||||||
|
onClick: () => {
|
||||||
|
bankCardReportModalApi.setData({
|
||||||
|
warehouse_id: row.id,
|
||||||
|
warehouse_name: row.name,
|
||||||
|
values: row,
|
||||||
|
gridApi,
|
||||||
|
});
|
||||||
|
bankCardReportModalApi.open();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
label: '查看报备',
|
||||||
|
icon: 'ant-design:eye-outlined',
|
||||||
|
onClick: openBankCardStatus.bind(null, row),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
@@ -30,7 +30,7 @@ export const modalFormProps: VbenFormProps = {
|
|||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: 'Textarea',
|
component: 'VbenTextarea',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '请输入内容',
|
placeholder: '请输入内容',
|
||||||
rows: 4,
|
rows: 4,
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { requestClient } from '#/api/request';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OA 群聊管理 API
|
|
||||||
*
|
|
||||||
* 群聊用于机器人维度的推送分组(N:N 绑定),按平台区分。
|
|
||||||
* 路由前缀:oa-chat/
|
|
||||||
*
|
|
||||||
* 后端约定:
|
|
||||||
* - 标准 CRUD(list/detail/create/update/delete)由 BaseController 通过反射自动注册
|
|
||||||
* - 额外接口 listByPlatform:按平台分组的群聊列表(机器人表单按平台多选时使用)
|
|
||||||
*/
|
|
||||||
const prefix = 'oa-chat/';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分页查询群聊列表
|
|
||||||
*/
|
|
||||||
export async function getOaChatList(data: any) {
|
|
||||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取群聊详情
|
|
||||||
*/
|
|
||||||
export async function getOaChatInfo(id: number) {
|
|
||||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 新增群聊
|
|
||||||
*/
|
|
||||||
export async function createOaChat(data: Record<string, any>) {
|
|
||||||
return requestClient.post<any>(`${prefix}create`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑群聊
|
|
||||||
*/
|
|
||||||
export async function updateOaChat(data: Record<string, any>) {
|
|
||||||
return requestClient.post<any>(`${prefix}update`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除群聊(支持批量)
|
|
||||||
*/
|
|
||||||
export async function deleteOaChat(data: Record<string, any>) {
|
|
||||||
return requestClient.post<any>(`${prefix}delete`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按平台分组的群聊列表(机器人表单选群聊用)
|
|
||||||
*
|
|
||||||
* 返回结构示例:
|
|
||||||
* {
|
|
||||||
* work_wechat: [{ id: 1, name: '财务群' }, ...],
|
|
||||||
* dingtalk: [{ id: 2, name: '运营群' }, ...],
|
|
||||||
* feishu: [{ id: 3, name: '产品群' }, ...],
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export async function getOaChatListByPlatform() {
|
|
||||||
return requestClient.get<any>(`${prefix}list-by-platform`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从企微 92120 同步客户群(按名称匹配本地记录)
|
|
||||||
*/
|
|
||||||
export async function syncOaCustomerGroups(data: { platform_code?: string } = {}) {
|
|
||||||
return requestClient.post<any>(`${prefix}sync-customer-groups`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 调用企微 appchat/create 创建应用群并落库
|
|
||||||
*/
|
|
||||||
export async function createOaAppChat(data: Record<string, any>) {
|
|
||||||
return requestClient.post<any>(`${prefix}create-app-chat`, data);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { requestClient } from '#/api/request';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 企微组织架构 API(OA 群聊页「员工」Tab)
|
|
||||||
* 路由前缀:oa-ww-org/
|
|
||||||
*/
|
|
||||||
const prefix = 'oa-ww-org/';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从企微同步组织架构(部门 + 成员)
|
|
||||||
*/
|
|
||||||
export async function syncOaWwOrg(data: { platform_code?: string } = {}) {
|
|
||||||
return requestClient.post<any>(`${prefix}sync-org`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 本地部门树(antd Tree)
|
|
||||||
*/
|
|
||||||
export async function getOaWwDeptTree(params: { platform_code?: string } = {}) {
|
|
||||||
return requestClient.get<any>(`${prefix}department-tree`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 员工分页列表
|
|
||||||
*/
|
|
||||||
export async function getOaWwUserList(params: {
|
|
||||||
platform_code?: string;
|
|
||||||
dept_id?: number;
|
|
||||||
keyword?: string;
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
}) {
|
|
||||||
return requestClient.get<any>(`${prefix}user-list`, { params });
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
/**
|
|
||||||
* 创建企微应用群聊弹窗
|
|
||||||
* 调 appchat/create,成功后回写本地 xk_oa_chat(chat_kind=应用群,source=接口创建)
|
|
||||||
*/
|
|
||||||
import { ref } from 'vue';
|
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
|
||||||
|
|
||||||
import { useVbenForm } from '#/adapter/form';
|
|
||||||
|
|
||||||
import { createOaAppChat } from '../api';
|
|
||||||
|
|
||||||
const gridApi = ref();
|
|
||||||
|
|
||||||
const [Form, formApi] = useVbenForm({
|
|
||||||
wrapperClass: 'grid-cols-12',
|
|
||||||
commonConfig: {
|
|
||||||
formItemClass: 'col-span-12',
|
|
||||||
componentProps: { class: 'w-full' },
|
|
||||||
},
|
|
||||||
layout: 'horizontal',
|
|
||||||
schema: [
|
|
||||||
{
|
|
||||||
component: 'VbenInput',
|
|
||||||
fieldName: 'name',
|
|
||||||
label: '群名称',
|
|
||||||
rules: 'required',
|
|
||||||
componentProps: { placeholder: '应用群聊名称' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component: 'VbenInput',
|
|
||||||
fieldName: 'owner',
|
|
||||||
label: '群主 userid',
|
|
||||||
rules: 'required',
|
|
||||||
componentProps: { placeholder: '企微成员 userid' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component: 'Select',
|
|
||||||
fieldName: 'userlist',
|
|
||||||
label: '成员 userid',
|
|
||||||
rules: 'selectRequired',
|
|
||||||
help: '至少 2 人(含群主);可输入后回车添加',
|
|
||||||
componentProps: {
|
|
||||||
mode: 'tags',
|
|
||||||
placeholder: '输入 userid 后回车',
|
|
||||||
tokenSeparators: [',', ' '],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component: 'VbenInput',
|
|
||||||
fieldName: 'chatid',
|
|
||||||
label: '指定 chatid',
|
|
||||||
help: '选填;不填则由企微自动生成',
|
|
||||||
componentProps: { placeholder: '选填' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showDefaultActions: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
|
||||||
fullscreenButton: false,
|
|
||||||
draggable: true,
|
|
||||||
onCancel() {
|
|
||||||
modalApi.close();
|
|
||||||
},
|
|
||||||
onConfirm: async () => {
|
|
||||||
const e = await formApi.validate();
|
|
||||||
if (!e.valid) return;
|
|
||||||
const values = await formApi.getValues();
|
|
||||||
const userlist = Array.isArray(values.userlist)
|
|
||||||
? values.userlist
|
|
||||||
: String(values.userlist || '')
|
|
||||||
.split(/[,,\s]+/)
|
|
||||||
.filter(Boolean);
|
|
||||||
modalApi.setState({ loading: true, confirmLoading: true });
|
|
||||||
try {
|
|
||||||
await createOaAppChat({
|
|
||||||
platform_code: 'work_wechat_app',
|
|
||||||
name: values.name,
|
|
||||||
owner: values.owner,
|
|
||||||
userlist,
|
|
||||||
chatid: values.chatid || '',
|
|
||||||
});
|
|
||||||
message.success('应用群创建成功');
|
|
||||||
gridApi.value?.reload?.() ?? gridApi.value?.query?.();
|
|
||||||
modalApi.close();
|
|
||||||
} finally {
|
|
||||||
modalApi.setState({ loading: false, confirmLoading: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onOpenChange(isOpen: boolean) {
|
|
||||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
|
||||||
if (isOpen) {
|
|
||||||
formApi.resetForm();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Modal title="创建应用群聊" class="w-[40%]">
|
|
||||||
<Form />
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
/**
|
|
||||||
* OA 群聊页「员工」面板
|
|
||||||
* 左侧企微部门树自动分类,右侧员工列表;支持同步组织架构与关键词搜索
|
|
||||||
*/
|
|
||||||
import { onMounted, ref } from 'vue';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Empty,
|
|
||||||
InputSearch,
|
|
||||||
message,
|
|
||||||
Pagination,
|
|
||||||
Spin,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
Tree,
|
|
||||||
} from 'ant-design-vue';
|
|
||||||
|
|
||||||
import {
|
|
||||||
getOaWwDeptTree,
|
|
||||||
getOaWwUserList,
|
|
||||||
syncOaWwOrg,
|
|
||||||
} from '../api/org';
|
|
||||||
|
|
||||||
const syncLoading = ref(false);
|
|
||||||
const treeLoading = ref(false);
|
|
||||||
const tableLoading = ref(false);
|
|
||||||
const treeData = ref<any[]>([]);
|
|
||||||
const selectedDeptId = ref<number | null>(null);
|
|
||||||
const keyword = ref('');
|
|
||||||
const users = ref<any[]>([]);
|
|
||||||
const total = ref(0);
|
|
||||||
const page = ref(1);
|
|
||||||
const pageSize = ref(20);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同步企微组织架构
|
|
||||||
*/
|
|
||||||
async function handleSync() {
|
|
||||||
syncLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await syncOaWwOrg({ platform_code: 'work_wechat_app' });
|
|
||||||
const data = res?.data ?? res ?? {};
|
|
||||||
message.success(
|
|
||||||
`同步完成:部门 ${data.dept_count ?? 0},成员 ${data.user_count ?? 0}`,
|
|
||||||
);
|
|
||||||
selectedDeptId.value = null;
|
|
||||||
page.value = 1;
|
|
||||||
await Promise.all([loadTree(), loadUsers()]);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '同步失败,请确认平台凭证与通讯录权限');
|
|
||||||
} finally {
|
|
||||||
syncLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载部门树
|
|
||||||
*/
|
|
||||||
async function loadTree() {
|
|
||||||
treeLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await getOaWwDeptTree({ platform_code: 'work_wechat_app' });
|
|
||||||
const data = res?.data ?? res ?? [];
|
|
||||||
treeData.value = Array.isArray(data) ? data : [];
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
treeData.value = [];
|
|
||||||
} finally {
|
|
||||||
treeLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载员工列表
|
|
||||||
*/
|
|
||||||
async function loadUsers() {
|
|
||||||
tableLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await getOaWwUserList({
|
|
||||||
platform_code: 'work_wechat_app',
|
|
||||||
dept_id: selectedDeptId.value || 0,
|
|
||||||
keyword: keyword.value.trim(),
|
|
||||||
page: page.value,
|
|
||||||
pageSize: pageSize.value,
|
|
||||||
});
|
|
||||||
const data = res?.data ?? res ?? {};
|
|
||||||
users.value = Array.isArray(data.items) ? data.items : [];
|
|
||||||
total.value = Number(data.total || 0);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
users.value = [];
|
|
||||||
total.value = 0;
|
|
||||||
} finally {
|
|
||||||
tableLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 选中部门节点
|
|
||||||
*/
|
|
||||||
function onSelectDept(keys: (string | number)[]) {
|
|
||||||
const key = keys[0];
|
|
||||||
selectedDeptId.value = key !== undefined && key !== null ? Number(key) : null;
|
|
||||||
page.value = 1;
|
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSearch() {
|
|
||||||
page.value = 1;
|
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPageChange(p: number, ps: number) {
|
|
||||||
page.value = p;
|
|
||||||
pageSize.value = ps;
|
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: 'userid', dataIndex: 'userid', key: 'userid', width: 140 },
|
|
||||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
|
|
||||||
{ title: '手机号', dataIndex: 'mobile', key: 'mobile', width: 120 },
|
|
||||||
{
|
|
||||||
title: '主部门',
|
|
||||||
dataIndex: 'main_department_name',
|
|
||||||
key: 'main_department_name',
|
|
||||||
width: 140,
|
|
||||||
},
|
|
||||||
{ title: '职位', dataIndex: 'position', key: 'position', width: 120 },
|
|
||||||
{
|
|
||||||
title: '匹配系统员工',
|
|
||||||
key: 'matched',
|
|
||||||
width: 140,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
key: 'status',
|
|
||||||
width: 90,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function statusLabel(status: number) {
|
|
||||||
const map: Record<number, string> = {
|
|
||||||
1: '已激活',
|
|
||||||
2: '已禁用',
|
|
||||||
4: '未激活',
|
|
||||||
5: '退出企业',
|
|
||||||
};
|
|
||||||
return map[status] || String(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadTree();
|
|
||||||
await loadUsers();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="employee-panel flex flex-col gap-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<Button type="primary" :loading="syncLoading" @click="handleSync">
|
|
||||||
同步组织架构
|
|
||||||
</Button>
|
|
||||||
<InputSearch
|
|
||||||
v-model:value="keyword"
|
|
||||||
allow-clear
|
|
||||||
placeholder="搜索姓名 / userid / 手机号"
|
|
||||||
class="w-[280px]"
|
|
||||||
@search="onSearch"
|
|
||||||
/>
|
|
||||||
<span class="text-xs text-gray-400">
|
|
||||||
需平台凭证且应用已开通通讯录读权限;匹配仅展示,不自动绑定
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<!-- 左侧部门树:固定最大高度,内部滚动,避免撑高整页 -->
|
|
||||||
<aside
|
|
||||||
class="flex w-[260px] shrink-0 flex-col rounded-lg border border-border bg-card p-3"
|
|
||||||
style="max-height: 560px"
|
|
||||||
>
|
|
||||||
<div class="mb-2 text-sm font-medium">组织架构</div>
|
|
||||||
<Spin :spinning="treeLoading">
|
|
||||||
<div class="overflow-auto" style="max-height: 500px">
|
|
||||||
<Empty
|
|
||||||
v-if="!treeLoading && treeData.length === 0"
|
|
||||||
description="暂无部门,请先同步"
|
|
||||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
|
||||||
/>
|
|
||||||
<Tree
|
|
||||||
v-else
|
|
||||||
:tree-data="treeData"
|
|
||||||
:selected-keys="
|
|
||||||
selectedDeptId !== null ? [String(selectedDeptId)] : []
|
|
||||||
"
|
|
||||||
default-expand-all
|
|
||||||
@select="onSelectDept"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Spin>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- 右侧员工表:不设自适应高度,内容自然撑开 + 横向滚动 -->
|
|
||||||
<div class="min-w-0 flex-1 rounded-lg border border-border bg-card p-3">
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
:columns="columns"
|
|
||||||
:data-source="users"
|
|
||||||
:loading="tableLoading"
|
|
||||||
:pagination="false"
|
|
||||||
row-key="id"
|
|
||||||
:scroll="{ x: 900 }"
|
|
||||||
>
|
|
||||||
<template #bodyCell="{ column, record }">
|
|
||||||
<template v-if="column.key === 'matched'">
|
|
||||||
<Tag v-if="record.matched_admin_id" color="success">
|
|
||||||
{{ record.matched_admin_name || `#${record.matched_admin_id}` }}
|
|
||||||
</Tag>
|
|
||||||
<span v-else class="text-gray-400">未匹配</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="column.key === 'status'">
|
|
||||||
<Tag :color="record.status === 1 ? 'success' : 'default'">
|
|
||||||
{{ statusLabel(Number(record.status)) }}
|
|
||||||
</Tag>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</Table>
|
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<Pagination
|
|
||||||
:current="page"
|
|
||||||
:page-size="pageSize"
|
|
||||||
:total="total"
|
|
||||||
show-size-changer
|
|
||||||
:page-size-options="['10', '20', '50', '100']"
|
|
||||||
@change="onPageChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<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 {
|
|
||||||
createOaChat,
|
|
||||||
updateOaChat,
|
|
||||||
} from '#/views/system/oa-chat/api';
|
|
||||||
import { modalFormProps } from '#/views/system/oa-chat/config/form';
|
|
||||||
import { getOaPlatformList } from '#/views/system/oa-platform/api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OA 群聊新增/编辑弹窗
|
|
||||||
*
|
|
||||||
* 关键交互:
|
|
||||||
* - 打开弹窗时动态拉取平台选项注入 platform_code 单选按钮组
|
|
||||||
* - 已禁用平台在 label 上加后缀标记并禁用选择(保持与机器人弹窗一致)
|
|
||||||
* - external_id / sort / status 为选填字段,留空时由后端取默认值
|
|
||||||
*/
|
|
||||||
|
|
||||||
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 ? updateOaChat : createOaChat;
|
|
||||||
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) {
|
|
||||||
isUpdate.value = update;
|
|
||||||
formApi.setValues(values);
|
|
||||||
}
|
|
||||||
// 每次打开都重新拉取平台列表,保证平台启用状态最新
|
|
||||||
loadPlatformOptions();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拉取平台列表并注入到表单的 platform_code 单选按钮组
|
|
||||||
* - 显示全部平台(含已禁用的,便于历史数据回显)
|
|
||||||
* - 已禁用平台在 label 上加后缀标记并禁用选择
|
|
||||||
*/
|
|
||||||
async function loadPlatformOptions() {
|
|
||||||
try {
|
|
||||||
const list = await getOaPlatformList();
|
|
||||||
const rows: any[] = Array.isArray(list) ? list : list?.data || [];
|
|
||||||
const options = rows.map((item) => ({
|
|
||||||
label: item.enabled === 1 ? item.name : `${item.name}(已禁用)`,
|
|
||||||
value: item.platform_code,
|
|
||||||
disabled: item.enabled !== 1,
|
|
||||||
}));
|
|
||||||
formApi.updateSchema([
|
|
||||||
{
|
|
||||||
fieldName: 'platform_code',
|
|
||||||
componentProps: { options },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载平台列表失败', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Modal :title="`${isUpdate === true ? '编辑' : '新增'}群聊`" class="w-[40%]">
|
|
||||||
<Form />
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import type { VbenFormProps } from '#/adapter/form';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 群聊新增/编辑弹窗表单配置
|
|
||||||
*
|
|
||||||
* 关键字段:
|
|
||||||
* - platform_code:所属平台(RadioGroup 单选,选项由弹窗组件 onOpenChange 时动态注入)
|
|
||||||
* - name:群聊名称(用户可读,便于识别)
|
|
||||||
* - external_id:平台原生群聊 ID(选填,预留后期接入获取群成员接口时使用)
|
|
||||||
* - sort:排序值(数字越大越靠前)
|
|
||||||
* - status:启用/禁用
|
|
||||||
*/
|
|
||||||
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',
|
|
||||||
dependencies: {
|
|
||||||
show: false,
|
|
||||||
triggerFields: ['id'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 平台数量有限(企业微信/钉钉/飞书),用单选按钮比下拉更直观
|
|
||||||
component: 'RadioGroup',
|
|
||||||
componentProps: {
|
|
||||||
// 选项由弹窗组件 onOpenChange 时通过 formApi.updateSchema 动态注入
|
|
||||||
options: [],
|
|
||||||
},
|
|
||||||
fieldName: 'platform_code',
|
|
||||||
label: '所属平台',
|
|
||||||
rules: 'selectRequired',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component: 'VbenInput',
|
|
||||||
componentProps: {
|
|
||||||
placeholder: '请输入群聊名称(便于识别,如:财务群-企业微信)',
|
|
||||||
},
|
|
||||||
fieldName: 'name',
|
|
||||||
label: '群聊名称',
|
|
||||||
rules: 'required',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 1=应用群聊(实时 appchat) 2=客户群(群发需确认)
|
|
||||||
component: 'RadioGroup',
|
|
||||||
componentProps: {
|
|
||||||
options: [
|
|
||||||
{ label: '客户群', value: 2 },
|
|
||||||
{ label: '应用群聊', value: 1 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
defaultValue: 2,
|
|
||||||
fieldName: 'chat_kind',
|
|
||||||
label: '群类型',
|
|
||||||
rules: 'selectRequired',
|
|
||||||
help: '客户群走群发任务(需员工确认);应用群聊实时推送',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component: 'VbenInput',
|
|
||||||
componentProps: {
|
|
||||||
placeholder: '企微 chat_id / 客户群 chat_id,同步后会自动回填',
|
|
||||||
},
|
|
||||||
fieldName: 'external_id',
|
|
||||||
label: '平台原生群聊 ID',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component: 'VbenInput',
|
|
||||||
componentProps: {
|
|
||||||
placeholder: '客户群群主 userid,群发时可作 sender 兜底',
|
|
||||||
},
|
|
||||||
fieldName: 'owner_userid',
|
|
||||||
label: '群主 userid',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component: 'InputNumber',
|
|
||||||
componentProps: {
|
|
||||||
placeholder: '数字越大越靠前',
|
|
||||||
min: 0,
|
|
||||||
},
|
|
||||||
defaultValue: 0,
|
|
||||||
fieldName: 'sort',
|
|
||||||
label: '排序',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component: 'RadioGroup',
|
|
||||||
componentProps: {
|
|
||||||
options: [
|
|
||||||
{ label: '启用', value: 1 },
|
|
||||||
{ label: '禁用', value: 0 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
defaultValue: 1,
|
|
||||||
fieldName: 'status',
|
|
||||||
label: '状态',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showDefaultActions: false,
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user