Compare commits
175 Commits
e5e95beea7
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1f223554b | ||
|
|
871982fc07 | ||
|
|
689a0f64a6 | ||
|
|
335b3535d5 | ||
|
|
c4e833a1c4 | ||
|
|
7697af2619 | ||
|
|
396a6e5b8b | ||
|
|
7a504433da | ||
|
|
33dcd3f5d8 | ||
|
|
efd7e8ffc8 | ||
|
|
5b9f3ec3ed | ||
|
|
e434aa9534 | ||
|
|
32eb96b4ad | ||
|
|
9f37802ba7 | ||
|
|
cfc8390573 | ||
|
|
174693f6db | ||
|
|
c1bc234509 | ||
|
|
dc9f9c5c13 | ||
|
|
0331325929 | ||
|
|
9c629ff8ee | ||
|
|
bd5b0b2de1 | ||
|
|
029b45d0bb | ||
|
|
212b16a8ec | ||
|
|
feebf4b14e | ||
|
|
3bc22d02e6 | ||
|
|
371594bfda | ||
|
|
52a1cb1ce5 | ||
|
|
5506c8917f | ||
|
|
05885ed405 | ||
|
|
c8045e1da8 | ||
|
|
14414bf9b0 | ||
|
|
b1df6fb4e8 | ||
|
|
5fcb65aa0d | ||
|
|
bbb133476a | ||
| 777737d063 | |||
|
|
700db76f16 | ||
| 2b13a19731 | |||
| 4224130ad4 | |||
|
|
ee4490a91c | ||
| 2e7bc80a11 | |||
|
|
7656c3ac49 | ||
|
|
c1b71142df | ||
|
|
92149f43a6 | ||
|
|
47a829c1b1 | ||
|
|
5ff6f0d837 | ||
|
|
8eca0bd84d | ||
|
|
316bbabcd1 | ||
|
|
f671ea7d41 | ||
|
|
c6fa14ee90 | ||
|
|
83ce8afefc | ||
|
|
a524982b87 | ||
|
|
464159121c | ||
|
|
3e09ba5225 | ||
|
|
daafdcaed7 | ||
|
|
28ed398581 | ||
|
|
80b612a913 | ||
|
|
e0cb5c660c | ||
|
|
026d0e0a48 | ||
|
|
a05917ac18 | ||
|
|
d93e465d3c | ||
|
|
5620c39e1e | ||
|
|
c02f685d83 | ||
|
|
7907c7cbf2 | ||
|
|
c8b15cd9dd | ||
|
|
92e145f366 | ||
|
|
949167217f | ||
|
|
a38da5ce92 | ||
|
|
69862cd982 | ||
|
|
8cfef12a56 | ||
|
|
e6306eea75 | ||
|
|
824dc2b0e3 | ||
|
|
8eb479696e | ||
|
|
75dd690188 | ||
|
|
073f7f882b | ||
|
|
d9db629c5d | ||
|
|
f36ef45ee2 | ||
|
|
9a1435996e | ||
|
|
4090c2ca3a | ||
|
|
2f37bf2909 | ||
|
|
a74f1cb106 | ||
|
|
b40c7402aa | ||
|
|
ee25ed00fb | ||
|
|
6e5dec9f92 | ||
|
|
d254f99a23 | ||
|
|
75d1b4db42 | ||
|
|
402da17e3d | ||
|
|
68fd7b0045 | ||
|
|
90a9bde586 | ||
|
|
c978b978ec | ||
|
|
0b1b08c7d7 | ||
|
|
56ac91c4af | ||
|
|
cc81e45a5c | ||
|
|
01f0a6b0f9 | ||
|
|
014bf97121 | ||
|
|
6a8cc0458d | ||
|
|
8bcae19369 | ||
|
|
235cccc146 | ||
|
|
8e81929bf1 | ||
|
|
2bbd943b39 | ||
|
|
6e0dc28f43 | ||
|
|
5158499dd1 | ||
|
|
dc9e629b31 | ||
|
|
99ea152081 | ||
|
|
86c5b7431d | ||
|
|
37d42263a8 | ||
|
|
4c03953127 | ||
|
|
5ba5ec65c8 | ||
|
|
e33f2f1edc | ||
|
|
721c5ca610 | ||
|
|
0245f8ff20 | ||
| 59a9dd3d81 | |||
| f16cbf926e | |||
| 187260164b | |||
| 5eec8b370e | |||
| e0acff7faa | |||
| ae364d4319 | |||
| a8f7ffed61 | |||
| 5c1f59e351 | |||
| 7dc90731dc | |||
| d4b9cc32d5 | |||
| f4e51dc773 | |||
| 7f2ed4b30a | |||
| 660717439d | |||
| d81c2da3ba | |||
| 73f858fc07 | |||
| 84cafd66ae | |||
| e9b79b2963 | |||
| f2d7317982 | |||
| 806058eed7 | |||
| 0d701f8158 | |||
| 6a13e3a547 | |||
| 6a13ca7d82 | |||
| 64a3b45b6e | |||
| 84930d8170 | |||
| 5d655892ee | |||
| 881df2f437 | |||
| 8e324accff | |||
| 09a423b8dc | |||
| e637a46889 | |||
| 3e3f0519ad | |||
| f7c8dd71ce | |||
| c2e95ad60b | |||
| 862580c156 | |||
| bf3d02124d | |||
| 70bf4f3019 | |||
| 920b47aace | |||
| 6f8f87fb09 | |||
| c0180d699f | |||
| 55b67a6df4 | |||
| d2990661e2 | |||
| 7fd5c489bc | |||
| 7ef6fa2077 | |||
| c93cbec021 | |||
| 2423326951 | |||
| b17e6c53ad | |||
| 77d608404d | |||
| acc33467da | |||
| 79e5a7c8e4 | |||
| 3c9e64c242 | |||
| 6445162b85 | |||
| a3a09d967e | |||
| 9ab3903952 | |||
| e27d525c43 | |||
| 2db57369f0 | |||
| a26722a655 | |||
| 0de0938956 | |||
| e1f8c76c4d | |||
| d7a446d3f9 | |||
| 20c44595df | |||
| c5d1100088 | |||
| 87aa9f9655 | |||
| a752648272 | |||
| 0c0f22efcb | |||
| f649802c53 | |||
| 11cc8cfa95 |
186
.cursor/rules/Code-Standards.mdc
Normal file
186
.cursor/rules/Code-Standards.mdc
Normal file
@@ -0,0 +1,186 @@
|
||||
---
|
||||
description: 萧康云医管理后台(Vben Admin v5 + Vue 3 + TS + Ant Design Vue)代码规范
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 基础规范(所有项目通用)
|
||||
|
||||
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
|
||||
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于 2 行
|
||||
3. 有封装好的方法、组件需要复用,不要重复造轮子
|
||||
4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(记得使用 v-if 而不是 v-show)
|
||||
5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化
|
||||
|
||||
# 全局架构规范
|
||||
|
||||
## 目录结构规范
|
||||
|
||||
- 业务页面统一放在 `apps/web-antd/src/views/<端>/<模块>/`(端:system / business / doctor 等)
|
||||
- 标准 CRUD 模块目录结构:
|
||||
|
||||
```
|
||||
views/<端>/<模块>/
|
||||
├── index.vue # 列表页(Page + Grid + Modal)
|
||||
├── api/index.ts # 本模块 API(CRUD)
|
||||
├── config/
|
||||
│ ├── table.ts # vxe-grid 列定义 + proxyConfig
|
||||
│ ├── search.ts # 顶部搜索表单 schema
|
||||
│ └── form.ts # 新增/编辑弹窗 form schema
|
||||
├── components/
|
||||
│ └── modal.vue # 新增/编辑弹窗
|
||||
└── utils/ # 模块工具(可选)
|
||||
```
|
||||
|
||||
- 全局复用组件放 `apps/web-antd/src/components/`
|
||||
- 表单内可用组件放 `apps/web-antd/src/components/form/components/`,文件名 kebab-case,自动注册为 schema 的 `component` 名(PascalCase 引用)
|
||||
|
||||
## 强制复用封装(禁止重复造轮子)
|
||||
|
||||
- 表格统一用 `useVbenVxeGrid`(来自 `#/adapter/vxe-table`),**不要直接 new VxeGrid**
|
||||
- 表单统一用 `useVbenForm`(来自 `#/adapter/form`),**不要直接写 `<Form>` + 手动校验**
|
||||
- 弹窗统一用 `useVbenModal`(来自 `@vben/common-ui`),**不要用原生 `<Modal>`**
|
||||
- 页面外壳统一用 `<Page>` 组件(来自 `@vben/common-ui`)
|
||||
- HTTP 请求统一用 `requestClient`(来自 `#/api/request`),**禁止直接 axios / fetch**
|
||||
- 行操作和工具栏按钮统一用 `<TableAction>` 组件(来自 `#/components/table-action`)
|
||||
- 业务选择器(员工、医生、门店、推广员、地区等)必须复用 `#/components/form/components/*-picker.vue`,不要重写
|
||||
|
||||
## API 层规范
|
||||
|
||||
- API 文件统一放在 `<模块>/api/index.ts`
|
||||
- 函数命名约定:`get{Entity}List` / `get{Entity}Option` / `get{Entity}Info` / `create{Entity}` / `update{Entity}` / `delete{Entity}`
|
||||
- URL 使用 kebab-case,统一前缀变量 `const prefix = 'xxx/'`,**URL 末尾不补 `/`**
|
||||
- GET 用 `params`,POST 用 `data`,例如:
|
||||
|
||||
```ts
|
||||
import { requestClient } from '#/api/request';
|
||||
const prefix = 'oa-robot/';
|
||||
export async function getOaRobotList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
```
|
||||
|
||||
## 字段命名对齐后端
|
||||
|
||||
- 数据库字段在前端 schema 的 `field` / `fieldName` 中保持 snake_case(如 `created_at`、`store_id`、`platform_code`),**不要转换为 camelCase**
|
||||
- 时间戳字段(`created_at` 等)后端查询器已格式化为字符串,前端**无需再次格式化**
|
||||
- TS 变量用 camelCase,常量用 UPPER_SNAKE_CASE,类型用 PascalCase
|
||||
|
||||
## 表单校验
|
||||
|
||||
- 必填用字符串规则 `rules: 'required'`(选择项用 `'selectRequired'`),规则定义在 `#/adapter/form.ts` 的 `defineRules`
|
||||
- 校验调用统一用 `formApi.validate().then(e => { if (e.valid) {...} })`
|
||||
- 必填字段在 schema 中加 `rules: 'required'`,禁止散落 `validator` 写法
|
||||
|
||||
## 字典/枚举管理
|
||||
|
||||
- 模块内建 `config/constants.ts`,导出 `{ label, value }[]` 数组,禁止在 schema 中散落字面量
|
||||
- 例如:
|
||||
|
||||
```ts
|
||||
export const OA_MESSAGE_TYPE_OPTIONS = [
|
||||
{ label: '文本', value: 'text' },
|
||||
{ label: 'Markdown', value: 'markdown' },
|
||||
];
|
||||
```
|
||||
|
||||
## 路由规范
|
||||
|
||||
- 业务路由通过后端动态菜单驱动(`xk_menu` 表),**不要在前端 `router/routes/modules/` 静态注册业务路由**
|
||||
- `defineOptions({ name: 'PascalCaseName' })` 必须与 `xk_menu.name` 字段一致
|
||||
|
||||
## Tab 用法
|
||||
|
||||
- 统一用 antd 原生 `Tabs` + `Tabs.TabPane`,**不要找 Vben 自封装的 Tabs**
|
||||
- Tab 选中状态持久化走 `localStorage`(参考 `system-config/index.vue` 的 `TAB_STORAGE_KEY` 模式)
|
||||
|
||||
## 抽屉用法(小程序端,admin 不涉及)
|
||||
|
||||
- 小程序端的抽屉用 `page-container` + `v-if`(不是 `v-show`)防止用户意外退出页面
|
||||
|
||||
## 中文注释
|
||||
|
||||
- 每个 `<script setup>` 顶部说明模块用途
|
||||
- 每个主要函数(特别是有业务逻辑的)必须有中文注释
|
||||
- 复杂的 schema 字段配置要注释意图
|
||||
|
||||
## 空行控制
|
||||
|
||||
- 上一部分代码和下一部分代码中间空行不超过 2 行
|
||||
|
||||
## VIP 功能判断(PC 管理端强制)
|
||||
|
||||
- **判断方法**:统一用 `#/utils/vip` 的 `hasVipPermission` / `useVipPermission` / `VIP_FEATURE` 常量
|
||||
- **显隐组件**:模板包一层 `<VipGate code="medical_record" :vip="receptionVip">`(`#/components/vip/VipGate.vue`)
|
||||
- 接诊/复诊/开方:**必须传履约诊所 vip**(`get-current-store-type` 的 `vip`),禁止只靠登录态判断门店权益
|
||||
- 显式传 `null` 表示「尚无履约诊所权益」,**不会**回落登录态;不传第二参才用 `userInfo.vip`
|
||||
- 禁止在业务页再手写 `permissions.indexOf('medical_record')` 或复制一套 VIP 拉取逻辑
|
||||
|
||||
## 暗色模式适配(PC 管理端强制)
|
||||
|
||||
后台支持亮/暗主题切换,**新增或改样式时必须同时适配暗色**,禁止只按白底写死颜色。
|
||||
|
||||
### 优先用法(推荐)
|
||||
|
||||
- 颜色、边框、背景一律用主题 CSS 变量,例如:
|
||||
- 文字:`hsl(var(--foreground))` / `hsl(var(--muted-foreground))`
|
||||
- 背景:`hsl(var(--background))` / `hsl(var(--card, var(--background)))` / `hsl(var(--muted) / 0.25)`
|
||||
- 边框:`hsl(var(--border))`
|
||||
- 强调/选中:`hsl(var(--primary))`、`hsl(var(--primary) / 0.1)`
|
||||
- 警告:`hsl(var(--warning))`、`hsl(var(--warning) / 0.12)`(参考 `AiDisclaimerBanner.vue`)
|
||||
- **禁止**业务样式写死 `#fff`、`#000`、`#0f172a`、`#f8fafc`、`#64748b` 等仅适合亮色的色值
|
||||
- **禁止**用 `:global(.dark)` 改全局变量以免污染整站配色
|
||||
- 优先「一套变量样式自动跟主题」,避免再写一套 `.dark .xxx { ... }`;仅当变量无法表达(如图片反色、特殊阴影)才用局部 `.dark .xxx`(且必须 `scoped`)
|
||||
|
||||
### 自检清单
|
||||
|
||||
- 弹窗 / 抽屉 / 卡片 / 预览对照区:在暗色下背景、文字、边框对比度可读
|
||||
- 选中态、成功/导入高亮:用 `--primary` 透明度,不要写死浅绿底 `#f0faf8`
|
||||
- 表格空态、分割线、次要文案:用 `--muted` / `--muted-foreground` / `--border`
|
||||
- 改完后切换暗色主题肉眼过一遍,不要只测亮色
|
||||
|
||||
## Vben 组件使用规范
|
||||
|
||||
### VbenModal(来自 @vben/common-ui)
|
||||
|
||||
- 创建弹窗必须用 `useVbenModal`,禁止直接用 antd `Modal` 或原生 `<dialog>`
|
||||
- 抽离弹窗内容到子组件时,外层用 `connectedComponent` 参数连接,内外组件共享 `modalApi`
|
||||
- 底部按钮扩展 slot 优先级:`prepend-footer`(取消按钮左侧)> `center-footer`(取消/确定之间)> `append-footer`(确定右侧)
|
||||
- **严禁覆盖 `#footer` slot**,会丢失默认的取消/确定按钮和 loading 行为
|
||||
- 表单提交 loading 走 `modalApi.setState({ confirmLoading: true })`,禁止自己写 button loading
|
||||
- 提交防抖/防重复用 `modalApi.lock()` / `unlock()`(>5.5.3),禁止手动 disabled
|
||||
- 拖拽开 `draggable: true`,需要拖出可视区时同时开 `overflow: true`
|
||||
- 数据回填走 `modalApi.setData()` + `onOpenChange(isOpen) { const data = modalApi.getData() }`
|
||||
|
||||
### VbenDrawer(来自 @vben/common-ui)
|
||||
|
||||
- 创建抽屉必须用 `useVbenDrawer`,禁止直接用 antd `Drawer`
|
||||
- 底部按钮扩展 slot 与 Modal 一致:`prepend-footer` / `center-footer` / `append-footer`
|
||||
- **同样严禁覆盖 `#footer` slot**
|
||||
- 关闭前校验用 `onBeforeClose`(>5.5.2 支持 Promise),禁止自己拦截
|
||||
- 提交防抖/防重复用 `drawerApi.lock()` / `unlock()`(>5.5.3)
|
||||
- 小程序端的「抽屉」概念与此无关,小程序端按现有规则用 `page-container`
|
||||
|
||||
### VbenVxeTable(来自 #/adapter/vxe-table)
|
||||
|
||||
- 表格必须用 `useVbenVxeGrid`,禁止直接 `new VxeGrid` 或 antd `Table`
|
||||
- 远程数据加载走 `gridOptions.proxyConfig.ajax.query`,**禁止自己 fetch 后 `data = []` 赋值**
|
||||
- query 接口返回结构必须匹配适配器配置:`{ items: [], total: number }`(在 `apps/web-antd/src/adapter/vxe-table.ts` 的 `response` 字段统一配置)
|
||||
- 分页参数从 `{ page }` 解构取:`page.currentPage` / `page.pageSize`,禁止自己读 URL
|
||||
- 刷新表格用 `gridApi.query()`(保留当前页)或 `gridApi.reload()`(回到第一页),禁止整页 `window.location.reload()`
|
||||
- 工具栏按钮插 slot:`toolbar-actions`(标题左侧)、`toolbar-tools`(工具按钮左侧),禁止改 vxe-grid 内部模板
|
||||
- 表格 loading 用 `gridApi.setLoading(bool)`,禁止自己遮罩
|
||||
- 搜索表单由 `formOptions` 配置(底层是 Vben Form),开关走 `gridOptions.toolbarConfig.search = true`
|
||||
- 自定义列渲染走 `slots: { default: 'action' }` + Grid 子组件内 `<template #action="{ row }">`,禁止 column 渲染函数里写 JSX
|
||||
|
||||
### VbenForm(来自 #/adapter/form)
|
||||
|
||||
- 表单必须用 `useVbenForm`,禁止直接 `<form>` 或 antd `Form`
|
||||
- 动态修改 schema 必须用 `formApi.updateSchema([...])`,**vben form 没有 `setComponentProps` 这个方法**(这是已踩过的坑)
|
||||
- 重置表单用 `formApi.resetForm()`,**严禁 `formApi.resetFields()`**(那是 antd Form 的 API,Vben Form 没有,会报 `resetFields is not a function`;已踩过坑)
|
||||
- 取值/赋值用 `formApi.getValues()` / `formApi.setValues(obj)`,禁止自己 `v-model` 收集
|
||||
- 校验用 `formApi.validate().then(e => { if (e.valid) {...} })` 或 `formApi.validateAndSubmitForm()`
|
||||
- 必填规则用字符串 `'required'` / `'selectRequired'`,规则在 `#/adapter/form.ts` 的 `defineRules` 注册
|
||||
- schema 中字段名(`fieldName`)必须与后端字段 snake_case 对齐,禁止前端 camelCase
|
||||
- 密码/敏感字段用 `VbenInputPassword` 组件,禁止用 `VbenInput` 配 `type="password"`
|
||||
- 单选/多选选项来自接口时,必须在弹窗/页面 `onMounted` 或 `onOpenChange` 里拉取后通过 `updateSchema` 注入,禁止写死常量
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -49,3 +49,5 @@ vite.config.ts.*
|
||||
*.sln
|
||||
*.sw?
|
||||
.history
|
||||
/.mimocode/
|
||||
/.cursor/skills/
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
<script setup>
|
||||
import { ref, provide, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useFileUpload } from '../composables/useFileUpload.ts';
|
||||
import { useRecording } from '../composables/useRecording.ts';
|
||||
import { useChatStore } from '../stores/chat.ts';
|
||||
import { useThemeStore } from '../stores/theme.ts';
|
||||
import { useUserStore } from '../stores/user.ts';
|
||||
import { sendMessage } from '../utils/request.ts';
|
||||
import CustomTextarea from './CustomTextarea.vue';
|
||||
import EmojiPicker from './EmojiPicker.vue';
|
||||
import FileUploadPreview from './FileUploadPreview.vue';
|
||||
|
||||
// 定义 props
|
||||
const props = defineProps({
|
||||
currentFriend: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const userStore = useUserStore();
|
||||
const chatStore = useChatStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const messageText = ref('');
|
||||
const messageInputRef = ref(null);
|
||||
const showEmojiPicker = ref(false);
|
||||
|
||||
// 使用文件上传组合式函数
|
||||
const {
|
||||
uploadPreview,
|
||||
imageInput,
|
||||
videoInput,
|
||||
triggerFileInput,
|
||||
handleFileUpload,
|
||||
cancelUpload,
|
||||
} = useFileUpload();
|
||||
|
||||
// 使用录音组合式函数
|
||||
const { isRecording, startRecording, stopRecording } = useRecording();
|
||||
|
||||
// 发送上传的文件
|
||||
const sendUploadedFile = () => {
|
||||
if (!uploadPreview.value) return;
|
||||
|
||||
const messageObj = {
|
||||
type: uploadPreview.value.type,
|
||||
content: uploadPreview.value.url,
|
||||
time: new Date().toLocaleTimeString().slice(0, 5),
|
||||
senderId: `${userStore.currentUser.id}`,
|
||||
receiverId: chatStore.currentFriend.id,
|
||||
read: true,
|
||||
duration: 0,
|
||||
};
|
||||
|
||||
chatStore.addMessage(messageObj, userStore.currentUser.id);
|
||||
|
||||
// 发送到服务器
|
||||
sendMessage({
|
||||
senderId: userStore.currentUser.id,
|
||||
receiverId: chatStore.currentFriend.id,
|
||||
type: uploadPreview.value.type,
|
||||
content: uploadPreview.value.url,
|
||||
}).catch((error) => {
|
||||
console.error('发送文件失败:', error);
|
||||
message.error('文件发送失败');
|
||||
});
|
||||
|
||||
uploadPreview.value = null;
|
||||
};
|
||||
|
||||
// 提供给子组件
|
||||
provide('uploadPreview', uploadPreview);
|
||||
provide('cancelUpload', cancelUpload);
|
||||
provide('sendFile', sendUploadedFile);
|
||||
|
||||
// 发送文本消息
|
||||
const sendTextMessage = async () => {
|
||||
const content = messageText.value.trim();
|
||||
if (!content && !uploadPreview.value) return;
|
||||
|
||||
let messageContent = content;
|
||||
let messageType = 'text';
|
||||
|
||||
// 如果有文件预览,发送文件
|
||||
if (uploadPreview.value) {
|
||||
messageContent = uploadPreview.value.url;
|
||||
messageType = uploadPreview.value.type;
|
||||
uploadPreview.value = null;
|
||||
}
|
||||
|
||||
// 创建消息对象
|
||||
const messageObj = {
|
||||
type: messageType,
|
||||
content: messageContent,
|
||||
time: new Date().toLocaleTimeString().slice(0, 5),
|
||||
senderId: `${userStore.currentUser.id}`,
|
||||
receiverId: chatStore.currentFriend.id,
|
||||
isSent: true,
|
||||
read: true,
|
||||
duration: 0,
|
||||
};
|
||||
|
||||
// return console.log('发送消息:' + userStore.currentUser.id, messageObj);
|
||||
// 添加到本地消息列表
|
||||
chatStore.addMessage(messageObj, userStore.currentUser.id);
|
||||
|
||||
// 发送到服务器
|
||||
try {
|
||||
await sendMessage({
|
||||
senderId: userStore.currentUser.id,
|
||||
receiverId: chatStore.currentFriend.id,
|
||||
roomId: chatStore.currentFriend.room_id,
|
||||
type: messageType,
|
||||
content: messageContent,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
message.error('消息发送失败');
|
||||
}
|
||||
|
||||
// 清空输入框
|
||||
messageText.value = '';
|
||||
|
||||
// 重置焦点
|
||||
nextTick(() => {
|
||||
if (messageInputRef.value) {
|
||||
messageInputRef.value.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeydown = (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendTextMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理粘贴文件
|
||||
const handlePasteFile = (file) => {
|
||||
console.log('检测到粘贴文件:', file);
|
||||
|
||||
// 根据文件类型自动选择处理方式
|
||||
if (file.type.startsWith('image/')) {
|
||||
handleFileFromPaste(file, 'image');
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
handleFileFromPaste(file, 'video');
|
||||
} else {
|
||||
message.warning('不支持的文件类型');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理粘贴的文件
|
||||
const handleFileFromPaste = (file, type) => {
|
||||
// 文件大小检查
|
||||
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
const maxSizeMB = maxSize / 1024 / 1024;
|
||||
message.error(
|
||||
`文件大小超过限制!${type === 'image' ? '图片' : '视频'}最大${maxSizeMB}MB`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', (e) => {
|
||||
uploadPreview.value = {
|
||||
type,
|
||||
url: e.target.result,
|
||||
name: file.nick_name,
|
||||
size: file.size,
|
||||
file,
|
||||
};
|
||||
});
|
||||
|
||||
reader.onerror = (error) => {
|
||||
console.error('文件读取失败:', error);
|
||||
message.error('文件读取失败,请重试');
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// 切换表情选择器
|
||||
const toggleEmojiPicker = () => {
|
||||
showEmojiPicker.value = !showEmojiPicker.value;
|
||||
};
|
||||
|
||||
// 插入表情
|
||||
const insertEmoji = (emoji) => {
|
||||
messageText.value += emoji;
|
||||
showEmojiPicker.value = false;
|
||||
|
||||
nextTick(() => {
|
||||
if (messageInputRef.value) {
|
||||
messageInputRef.value.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 监听录音完成事件
|
||||
const handleAudioRecorded = (event) => {
|
||||
const { url, duration } = event.detail;
|
||||
|
||||
const messageObj = {
|
||||
type: 'audio',
|
||||
content: url,
|
||||
time: new Date().toLocaleTimeString().slice(0, 5),
|
||||
senderId: `${userStore.currentUser.id}`,
|
||||
receiverId: chatStore.currentFriend.id,
|
||||
isSent: true,
|
||||
read: true,
|
||||
duration,
|
||||
};
|
||||
|
||||
chatStore.addMessage(messageObj, userStore.currentUser.id);
|
||||
|
||||
// 发送到服务器
|
||||
sendMessage({
|
||||
senderId: userStore.currentUser.id,
|
||||
receiverId: chatStore.currentFriend.id,
|
||||
roomId: chatStore.currentFriend.room_id,
|
||||
isSent: true,
|
||||
type: 'audio',
|
||||
content: url,
|
||||
duration,
|
||||
}).catch((error) => {
|
||||
console.error('发送音频失败:', error);
|
||||
message.error('音频发送失败');
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('audioRecorded', handleAudioRecorded);
|
||||
|
||||
// 点击外部关闭表情选择器
|
||||
document.addEventListener('click', (event) => {
|
||||
if (
|
||||
!event.target.closest('.emoji-picker') &&
|
||||
!event.target.closest('.function-btn')
|
||||
) {
|
||||
showEmojiPicker.value = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('audioRecorded', handleAudioRecorded);
|
||||
});
|
||||
|
||||
// 定义 emits
|
||||
const emit = defineEmits([
|
||||
'sendMessage',
|
||||
'openPrescription' // 添加开方事件
|
||||
]);
|
||||
|
||||
// 开方功能触发函数
|
||||
const handleOpenPrescription = () => {
|
||||
// 获取当前会话的register_id,这里假设可以通过某种方式获取
|
||||
// 例如从聊天存储或props中获取
|
||||
const registerId = props.currentFriend?.register_id || 0;
|
||||
|
||||
if (registerId) {
|
||||
emit('openPrescription', registerId);
|
||||
} else {
|
||||
console.warn('无法获取当前会话的register_id');
|
||||
// 可以添加一个提示或使用默认值
|
||||
emit('openPrescription', 0);
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-input-container" :class="{ dark: themeStore.isDarkMode }">
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<button
|
||||
class="toolbar-button prescription-button"
|
||||
@click="handleOpenPrescription"
|
||||
title="开方"
|
||||
>
|
||||
<i class="iconfont icon-prescription"></i>
|
||||
开方
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-button"
|
||||
@click="triggerFileInput('image')"
|
||||
title="发送图片"
|
||||
>
|
||||
<i class="fas fa-image"></i>
|
||||
图片
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-button"
|
||||
@click="triggerFileInput('video')"
|
||||
title="发送视频"
|
||||
>
|
||||
<i class="fas fa-video"></i>
|
||||
视频
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: showEmojiPicker }"
|
||||
class="toolbar-button"
|
||||
title="选择表情"
|
||||
@click="toggleEmojiPicker"
|
||||
>
|
||||
<i class="fas fa-smile"></i>
|
||||
表情
|
||||
</button>
|
||||
<button
|
||||
:class="{ recording: isRecording }"
|
||||
class="toolbar-button record-btn"
|
||||
title="按住录音"
|
||||
@mousedown="startRecording"
|
||||
@mouseleave="stopRecording"
|
||||
@mouseup="stopRecording"
|
||||
@touchend="stopRecording"
|
||||
@touchstart="startRecording"
|
||||
>
|
||||
<i class="fas fa-microphone"></i>
|
||||
录音
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传预览 -->
|
||||
<FileUploadPreview v-if="uploadPreview" />
|
||||
<!-- 表情选择器 -->
|
||||
<EmojiPicker v-if="showEmojiPicker" class="mb-10" @select="insertEmoji" />
|
||||
|
||||
<div class="input-container">
|
||||
<!-- 输入框区域 -->
|
||||
<div class="input-section">
|
||||
<CustomTextarea
|
||||
ref="messageInputRef"
|
||||
v-model="messageText"
|
||||
:auto-resize="true"
|
||||
:detect-paste="true"
|
||||
:max-rows="4"
|
||||
class="message-textarea"
|
||||
placeholder="输入消息... (支持拖拽文件)"
|
||||
@keydown="handleKeydown"
|
||||
@paste-file="handlePasteFile"
|
||||
/>
|
||||
|
||||
<button
|
||||
:disabled="!messageText.trim() && !uploadPreview"
|
||||
class="send-btn"
|
||||
title="发送消息"
|
||||
@click="sendTextMessage"
|
||||
>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
<div class="send-ripple"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的文件输入框 -->
|
||||
<input
|
||||
ref="imageInput"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
<input
|
||||
ref="videoInput"
|
||||
accept="video/*"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-input-container {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-input-container.dark {
|
||||
border-top-color: #374151;
|
||||
background: linear-gradient(135deg, #2d2d2d 0%, #1f2937 100%);
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
margin-right: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.prescription-button {
|
||||
color: #455cda;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prescription-button:hover {
|
||||
background-color: #e6e9ff;
|
||||
}
|
||||
|
||||
.function-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.function-btn {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.function-btn:hover {
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.3);
|
||||
}
|
||||
|
||||
.function-btn.active {
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.function-btn.recording {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
|
||||
color: white;
|
||||
animation: pulse-recording 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-recording {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.recording-wave {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
animation: wave-expand 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes wave-expand {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-ripple {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: all 0.6s ease;
|
||||
}
|
||||
|
||||
.function-btn:active .btn-ripple {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.message-input-container.dark .function-btn {
|
||||
background: linear-gradient(135deg, #374151, #4b5563);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.message-input-container.dark .function-btn:hover {
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #4361ee, #3f37c9);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.4);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
background: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.send-ripple {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: all 0.6s ease;
|
||||
}
|
||||
|
||||
.send-btn:active:not(:disabled) .send-ripple {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.function-buttons {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.function-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,793 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted } from 'vue';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Image,
|
||||
ImagePreviewGroup,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
notification,
|
||||
RadioGroup,
|
||||
RadioButton,
|
||||
Row,
|
||||
Select,
|
||||
SelectOption,
|
||||
Tag,
|
||||
Textarea,
|
||||
Timeline,
|
||||
TimelineItem
|
||||
} from 'ant-design-vue';
|
||||
import { DeleteTwoTone, MinusOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
import { getRegisterStatus } from '#/util/tool';
|
||||
import {
|
||||
addWestPrescription,
|
||||
checkChineseMedicineConflictApi,
|
||||
getDrugUseList,
|
||||
getMyStoreListApi,
|
||||
getPatientItem,
|
||||
getProcessRuleList,
|
||||
getProductListDoctorReception,
|
||||
switchStoreApi,
|
||||
} from '#/views/doctor/doctor-reception/api';
|
||||
|
||||
// 定义 props
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
registerId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// 定义 emits
|
||||
const emit = defineEmits(['update:visible', 'prescriptionSent']);
|
||||
|
||||
// 患者数据
|
||||
const patientInfo = ref<null | any>(null);
|
||||
const userPatientHealthInquiry = ref<null | any>(null);
|
||||
const prescriptionList = ref([]);
|
||||
|
||||
// 药品使用相关数据
|
||||
const drugUseNum = ref([]);
|
||||
const drugUseFrequency = ref([]);
|
||||
const drugUseType = ref([]);
|
||||
const drugUnit = ref([]);
|
||||
const drugTime = ref([]);
|
||||
const drugUseWay = ref([]);
|
||||
const myStoreList = ref([]);
|
||||
|
||||
const myStoreId = ref(0);
|
||||
const activeCategory = ref(2);
|
||||
const diagnosis = ref('');
|
||||
const medicalAdvice = ref('');
|
||||
const treatmentPrice = ref(0);
|
||||
const ruleType = ref(1);
|
||||
const drugList = ref([]);
|
||||
|
||||
// 计算商品总价
|
||||
const totalProductCost = computed(() => {
|
||||
if (currentDrugs.value.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
// 如果是中药的时候,计算总价格
|
||||
if (activeCategory.value === 1) {
|
||||
return currentDrugs.value.reduce(
|
||||
(sum, drug) => sum + drug.price * (drug.number || 1) * dosage.value,
|
||||
0,
|
||||
);
|
||||
}
|
||||
return currentDrugs.value.reduce(
|
||||
(sum, drug) => sum + drug.price * (drug.select_number || 1),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
// 计算总价
|
||||
const totalCost = computed(() => {
|
||||
return totalProductCost.value + processingFee.value;
|
||||
});
|
||||
|
||||
const processRulePrice = ref(0);
|
||||
const calcMethod = ref(0);
|
||||
|
||||
// 计算加工费
|
||||
const processingFee = computed(() => {
|
||||
if (ruleType.value === 1) {
|
||||
return 0;
|
||||
}
|
||||
// 已选择药品为空的时候是0
|
||||
if (currentDrugs.value.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
// 获取本次选择的加工规则
|
||||
childProcessRuleList.value.find((item) => {
|
||||
if (item.id === childProcessRuleId.value) {
|
||||
calcMethod.value = item.calc_method;
|
||||
processRulePrice.value = item.price;
|
||||
}
|
||||
});
|
||||
// 模式1:固定价格模式
|
||||
if (calcMethod.value === 1) {
|
||||
return processRulePrice.value;
|
||||
}
|
||||
// 模式2:单价*用量
|
||||
if (calcMethod.value === 2) {
|
||||
return processRulePrice.value * dosage.value;
|
||||
}
|
||||
// 模式3:单价*用量*数量
|
||||
if (calcMethod.value === 3) {
|
||||
const number = currentDrugs.value.reduce(
|
||||
(sum, drug) => sum + drug.number,
|
||||
0,
|
||||
);
|
||||
return processRulePrice.value * dosage.value * number;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const visible = ref<boolean>(false);
|
||||
const previewImage = ref([]);
|
||||
|
||||
// 说明书预览
|
||||
const setVisible = (value, instruction = ''): void => {
|
||||
previewImage.value = [];
|
||||
if (instruction == '') {
|
||||
message.error('该产品没有说明书!');
|
||||
} else {
|
||||
if (typeof instruction === 'string') {
|
||||
// 如果有逗号,则进行分割 没有则追加到previewImage
|
||||
if (instruction.includes(',')) {
|
||||
instruction = instruction.split(',');
|
||||
previewImage.value = instruction;
|
||||
} else {
|
||||
previewImage.value.push(instruction);
|
||||
}
|
||||
}
|
||||
visible.value = value;
|
||||
}
|
||||
};
|
||||
|
||||
// 数量增加
|
||||
const increment = (index: number) => {
|
||||
currentDrugs.value[index].select_number++;
|
||||
saveToLocalStorage();
|
||||
};
|
||||
|
||||
// 数量减少
|
||||
const decrement = (index: number) => {
|
||||
if (currentDrugs.value[index].select_number > 1) {
|
||||
currentDrugs.value[index].select_number--;
|
||||
saveToLocalStorage();
|
||||
}
|
||||
};
|
||||
|
||||
// 保存到本地存储
|
||||
const saveToLocalStorage = () => {
|
||||
localStorage.setItem(
|
||||
`prescriptionData-chat-${props.registerId}`,
|
||||
JSON.stringify(currentDrugs.value),
|
||||
);
|
||||
};
|
||||
|
||||
// 删除药品(本地)
|
||||
const removeDrug = (index: number) => {
|
||||
currentDrugs.value.splice(index, 1);
|
||||
updateLocalStorage();
|
||||
};
|
||||
|
||||
// 修改缓存的处方信息
|
||||
const updateLocalStorage = () => {
|
||||
localStorage.setItem(
|
||||
`prescriptionData-chat-${props.registerId}`,
|
||||
JSON.stringify(currentDrugs.value),
|
||||
);
|
||||
getCurrentDrugs();
|
||||
};
|
||||
|
||||
const currentDrugs = ref([]);
|
||||
const selectProductId = ref(0);
|
||||
|
||||
// 获取缓存的处方信息
|
||||
const getCurrentDrugs = () => {
|
||||
currentDrugs.value = JSON.parse(
|
||||
localStorage.getItem(`prescriptionData-chat-${props.registerId}`) || '[]',
|
||||
);
|
||||
};
|
||||
|
||||
const doctorSecondSign = ref(0);
|
||||
const checkData = ref([]);
|
||||
|
||||
// 检查中药的相冲
|
||||
const checkChineseMedicineConflict = () => {
|
||||
if (activeCategory.value === 2) {
|
||||
sendPrescription();
|
||||
return;
|
||||
}
|
||||
notification.info({
|
||||
message: '正在检查药物相冲',
|
||||
duration: 1,
|
||||
description: '正在检查药物相冲,请稍等...',
|
||||
});
|
||||
const names = currentDrugs.value.map((item) => item.drug_name);
|
||||
checkChineseMedicineConflictApi({names: names}).then((res) => {
|
||||
if (res.is_exist === true) {
|
||||
checkData.value.message = res.message;
|
||||
doctorSecondSignModal.value = true;
|
||||
} else {
|
||||
notification.success({
|
||||
message: '检查成功',
|
||||
duration: 3,
|
||||
description: '暂无相冲药品',
|
||||
});
|
||||
sendPrescription();
|
||||
}
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
// 发送处方
|
||||
const sendPrescription = () => {
|
||||
if (currentDrugs.value.length === 0) {
|
||||
message.error('请选择药品');
|
||||
return;
|
||||
}
|
||||
if (activeCategory.value === 1) {
|
||||
if (ruleType.value === 1) {
|
||||
if (packageMethodId.value == null) {
|
||||
message.error('请选择包法');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (processRuleId.value == null) {
|
||||
message.error('请选择制剂');
|
||||
return;
|
||||
}
|
||||
if (processRuleNoteId.value == null) {
|
||||
message.error('请选择规格');
|
||||
return;
|
||||
}
|
||||
if (childProcessRuleId.value == null) {
|
||||
message.error('请选择备注');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (diagnosis.value === '') {
|
||||
message.error('诊断结果不能为空');
|
||||
return;
|
||||
}
|
||||
if (medicalAdvice.value === '') {
|
||||
message.error('医嘱不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送处方逻辑
|
||||
addWestPrescription({
|
||||
// 当前活动患者的个人信息
|
||||
patient: patientInfo.value,
|
||||
// 当前处方中的药品列表
|
||||
drugs: currentDrugs.value,
|
||||
// 诊断信息
|
||||
diagnosis: diagnosis.value,
|
||||
// 医嘱信息
|
||||
medicalAdvice: medicalAdvice.value,
|
||||
// 总费用
|
||||
total: totalCost.value,
|
||||
// 费用类别
|
||||
category: category.value,
|
||||
// 药品类型
|
||||
drug_type: 2, // 默认为处方类型
|
||||
// 从props获取的挂号ID
|
||||
register_id: props.registerId,
|
||||
// 治疗费用
|
||||
treatment_price: treatmentPrice.value,
|
||||
// 包装方式ID
|
||||
package_method_id: packageMethodId.value,
|
||||
// 加工规则ID
|
||||
process_rule_id: processRuleId.value,
|
||||
// 加工规则备注ID
|
||||
process_rule_note_id: processRuleNoteId.value,
|
||||
// 子加工规则ID
|
||||
child_process_rule_id: childProcessRuleId.value,
|
||||
// 加工规则类型
|
||||
process_rule_type: ruleType.value,
|
||||
// 处方类型
|
||||
prescription_type: activeCategory.value,
|
||||
// 加工费
|
||||
processing_fee: processRulePrice.value,
|
||||
// 用药剂量
|
||||
dosage: dosage.value,
|
||||
// 每日用药次数
|
||||
day_dosage: dayDosage.value,
|
||||
// 是否二次签名
|
||||
doctor_second_sign: doctorSecondSign.value,
|
||||
}).then(() => {
|
||||
message.success('处方已发送');
|
||||
// 清空当前数据
|
||||
currentDrugs.value = [];
|
||||
diagnosis.value = '';
|
||||
medicalAdvice.value = '';
|
||||
packageMethodId.value = 2;
|
||||
processRulePrice.value = 0;
|
||||
dosage.value = 7;
|
||||
dayDosage.value = 2;
|
||||
newDrugInfo.value = {};
|
||||
updateLocalStorage();
|
||||
emit('prescriptionSent');
|
||||
});
|
||||
};
|
||||
|
||||
// 药品分类
|
||||
const categories = [
|
||||
{label: '中药', value: 1},
|
||||
{label: '中成(西)药', value: 2},
|
||||
];
|
||||
|
||||
const category = ref(1);
|
||||
|
||||
// 基础配置
|
||||
const processRuleList = ref([]);
|
||||
// 子配置
|
||||
const childProcessRuleList = ref([]);
|
||||
// 规格
|
||||
const processRuleNoteList = ref([]);
|
||||
const processRuleId = ref();
|
||||
const processRuleNoteId = ref();
|
||||
const childProcessRuleId = ref();
|
||||
|
||||
function getProcessRuleListByDoctor(pid = 0, ruleId = 0) {
|
||||
let data = {};
|
||||
data =
|
||||
ruleId === 0
|
||||
? {
|
||||
pid,
|
||||
}
|
||||
: {
|
||||
rule_id: ruleId,
|
||||
};
|
||||
getProcessRuleList(data).then((value) => {
|
||||
if (ruleId !== 0) {
|
||||
processRuleNoteList.value = value;
|
||||
} else if (pid === 0) {
|
||||
processRuleList.value = value;
|
||||
} else {
|
||||
childProcessRuleList.value = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 选择煎法
|
||||
function selectProcessRule(id) {
|
||||
processRuleId.value = id;
|
||||
getProcessRuleListByDoctor(id);
|
||||
}
|
||||
|
||||
// 选择规格
|
||||
function selectProcessRuleNot(id) {
|
||||
childProcessRuleId.value = id;
|
||||
getProcessRuleListByDoctor(0, id);
|
||||
}
|
||||
|
||||
// 选择备注
|
||||
function selectProcessRuleNotCommit(id) {
|
||||
processRuleNoteId.value = id;
|
||||
}
|
||||
|
||||
const packageMethodId = ref(2);
|
||||
const packageMethod = [
|
||||
{label: '味包', value: 1},
|
||||
{label: '剂包', value: 2},
|
||||
];
|
||||
|
||||
function selectPackageMethod(id) {
|
||||
packageMethodId.value = id;
|
||||
}
|
||||
|
||||
// 剂量
|
||||
const dosage = ref(7);
|
||||
const dayDosage = ref(2);
|
||||
|
||||
function updateChineseNumber() {
|
||||
updateLocalStorage();
|
||||
}
|
||||
|
||||
// 新药品确认弹窗
|
||||
const showNewDrugModal = ref(false);
|
||||
// 二次签名确认弹窗
|
||||
const doctorSecondSignModal = ref(false);
|
||||
|
||||
// 在新的药品数量框失去焦点后弹出新药品弹窗
|
||||
function newDrugBlur() {
|
||||
if (newDrugInfo.value.id > 0 && newDrugInfo.value.number > 0) {
|
||||
showNewDrugModal.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function newDrugModalOk() {
|
||||
addDrugByChinese();
|
||||
showNewDrugModal.value = false;
|
||||
}
|
||||
|
||||
function doctorSecondSignModalOk() {
|
||||
doctorSecondSign.value = 1;
|
||||
doctorSecondSignModal.value = false;
|
||||
sendPrescription();
|
||||
}
|
||||
|
||||
function updateChineseNumberGoNewDrug(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
updateLocalStorage();
|
||||
setTimeout(() => {
|
||||
// 获取新药品卡片中的克数输入框并聚焦
|
||||
const newDrugNumberInput = document.querySelector(
|
||||
'.new-select-drug-name input',
|
||||
);
|
||||
if (newDrugNumberInput) {
|
||||
(newDrugNumberInput as HTMLElement).focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取药品列表
|
||||
const getDrugListByWesternModal = debounce(async (searchText = '') => {
|
||||
if (searchText === '' && activeCategory.value === 1) {
|
||||
drugList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
getCurrentDrugs();
|
||||
|
||||
try {
|
||||
const res = await getProductListDoctorReception({
|
||||
store_id: myStoreId.value,
|
||||
type: activeCategory.value,
|
||||
name: searchText,
|
||||
});
|
||||
|
||||
drugList.value = res.map((item) => {
|
||||
const matched = currentDrugs.value.find((v) => v.index_id === item.id);
|
||||
return {
|
||||
...item,
|
||||
select_number: matched?.select_number || 0,
|
||||
drug: {
|
||||
...item.drug,
|
||||
number: matched?.number || 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取药品列表失败:', error);
|
||||
message.error('获取药品列表失败');
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// 选择药品
|
||||
function selectNewDrugInfo() {
|
||||
// 如果是新药品输入框,调用添加新药品方法
|
||||
const check = currentDrugs.value.find((v) => v.id === newDrugInfo.value.id);
|
||||
if (check) {
|
||||
newDrugInfo.value.id = '';
|
||||
message.error('该药品已经在处方中了!');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = drugList.value.find((v) => v.drug_id === newDrugInfo.value.id);
|
||||
newDrugInfo.value.price = data.price;
|
||||
newDrugInfo.value.name = data.drug.drug_name;
|
||||
setTimeout(() => {
|
||||
// 获取新药品卡片中的克数输入框并聚焦
|
||||
const newDrugNumberInput = document.querySelector(
|
||||
'.new-number-input input',
|
||||
);
|
||||
if (newDrugNumberInput) {
|
||||
(newDrugNumberInput as HTMLElement).focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
const selectChineseIndex = ref(-1);
|
||||
const selectChineseId = ref(0);
|
||||
|
||||
// 选择药品
|
||||
function selectOldDrugInfo(id) {
|
||||
const check = JSON.parse(
|
||||
localStorage.getItem(`prescriptionData-chat-${props.registerId}`) || '[]',
|
||||
).find((v) => v.id === id);
|
||||
if (check) {
|
||||
currentDrugs.value[selectChineseIndex.value].id = selectChineseId.value;
|
||||
drugList.value = [];
|
||||
message.error('该药品已经在处方中了!');
|
||||
return;
|
||||
}
|
||||
const oldDrugInfo = currentDrugs.value[selectChineseIndex.value];
|
||||
// 如果是新药品输入框,调用添加新药品方法
|
||||
const data = drugList.value.find((v) => v.drug_id === id);
|
||||
|
||||
// 创建新的商品对象(避免直接修改原始数据)
|
||||
const newProduct = {
|
||||
// 索引ID(用于在列表中查找)
|
||||
index_id: data.id,
|
||||
// 药品ID
|
||||
id: data.drug.id,
|
||||
// 药品名称
|
||||
drug_name: data.drug.drug_name,
|
||||
// 药品数量
|
||||
number: data.drug.number,
|
||||
// 药品使用数量信息
|
||||
use_num: drugTime.value.find((item) => item.id === data.drug.time_id),
|
||||
// 药品使用类型信息
|
||||
use_type: drugUseType.value.find((item) => item.id === data.drug.type_id),
|
||||
// 药品使用频率信息
|
||||
use_frequency: drugUseFrequency.value.find(
|
||||
(item) => item.id === data.drug.frequency_id,
|
||||
),
|
||||
// 药品单位信息
|
||||
unit: drugUnit.value.find((item) => item.id === data.drug.unit_id),
|
||||
// 药品价格
|
||||
price: data.price,
|
||||
// 药品使用方式ID
|
||||
way_id: data.drug?.way_id,
|
||||
// 药品使用方式信息
|
||||
use_ways: drugUseWay.value.find((item) => item.id === data.drug.way_id),
|
||||
// 药品使用时间ID
|
||||
time_id: data.drug.time_id,
|
||||
// 药品类型ID
|
||||
type_id: data.drug.type_id,
|
||||
// 药品使用频率ID
|
||||
frequency_id: data.drug.frequency_id,
|
||||
// 药品单位ID
|
||||
unit_id: data.drug.unit_id,
|
||||
// 药品图片
|
||||
image: data.drug.image,
|
||||
// 药品说明书
|
||||
instruction: data.drug.instruction,
|
||||
// 药品类型
|
||||
type: data.drug.type,
|
||||
};
|
||||
currentDrugs.value[selectChineseIndex.value] = newProduct;
|
||||
updateLocalStorage();
|
||||
setTimeout(() => {
|
||||
// 获取新药品卡片中的克数输入框并聚焦
|
||||
const newDrugNumberInput = document.querySelector(
|
||||
`.old-number-input-${selectChineseIndex.value} input`,
|
||||
);
|
||||
if (newDrugNumberInput) {
|
||||
(newDrugNumberInput as HTMLElement).focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 写入当前修改的下标
|
||||
function setSelectChineseIndex(index, id) {
|
||||
selectChineseIndex.value = index;
|
||||
selectChineseId.value = id;
|
||||
}
|
||||
|
||||
// 查询中药列表
|
||||
function searchOption(inputValue) {
|
||||
getDrugListByWesternModal(inputValue);
|
||||
}
|
||||
|
||||
// 添加中药
|
||||
function addDrugByChinese() {
|
||||
// 如果是新药品输入框,调用添加新药品方法
|
||||
const check = currentDrugs.value.find(
|
||||
(v) => v.id === newDrugInfo.value.id,
|
||||
);
|
||||
if (check) {
|
||||
message.error('该药品已经在处方中了!');
|
||||
return;
|
||||
}
|
||||
const data = drugList.value.find(
|
||||
(v) => v.drug_id === newDrugInfo.value.id,
|
||||
);
|
||||
if (data === null || data === undefined) {
|
||||
message.error('请选择药品');
|
||||
return;
|
||||
}
|
||||
data.drug.number = newDrugInfo.value.number;
|
||||
data.drug.way_id = newDrugInfo.value.way_id;
|
||||
addProducts(data);
|
||||
newDrugInfo.value = {};
|
||||
}
|
||||
|
||||
// 添加操作
|
||||
function selectDrugByNewDrugInfo(event: KeyboardEvent, isNewDrug: boolean) {
|
||||
// 如果按下的是回车键
|
||||
if (event.key === 'Enter') {
|
||||
// 阻止默认行为(防止表单提交)
|
||||
event.preventDefault();
|
||||
|
||||
if (isNewDrug) {
|
||||
// 如果是新药品输入框,调用添加新药品方法
|
||||
addDrugByChinese();
|
||||
} else {
|
||||
// 如果是已有药品输入框,调用更新数量方法
|
||||
updateChineseNumber();
|
||||
}
|
||||
setTimeout(() => {
|
||||
// 获取新药品卡片中的克数输入框并聚焦
|
||||
const newDrugNumberInput = document.querySelector(
|
||||
'.new-select-drug-name input',
|
||||
);
|
||||
if (newDrugNumberInput) {
|
||||
(newDrugNumberInput as HTMLElement).focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加商品到处方
|
||||
function addProducts(data) {
|
||||
// 查找是否已存在于选择列表中
|
||||
const existItem = currentDrugs.value.find(
|
||||
(item) => item.index_id === data.id,
|
||||
);
|
||||
|
||||
if (existItem) {
|
||||
message.warn('已经存在了');
|
||||
return;
|
||||
}
|
||||
// 创建新的商品对象(避免直接修改原始数据)
|
||||
const newProduct = {
|
||||
// 索引ID(用于在列表中查找)
|
||||
index_id: data.id,
|
||||
// 药品ID
|
||||
id: data.drug.id,
|
||||
// 药品名称
|
||||
drug_name: data.drug.drug_name,
|
||||
// 药品数量
|
||||
number: data.drug.number,
|
||||
// 药品使用数量信息
|
||||
use_num: drugTime.value.find((item) => item.id === data.drug.time_id),
|
||||
// 药品使用类型信息
|
||||
use_type: drugUseType.value.find((item) => item.id === data.drug.type_id),
|
||||
// 药品使用频率信息
|
||||
use_frequency: drugUseFrequency.value.find(
|
||||
(item) => item.id === data.drug.frequency_id,
|
||||
),
|
||||
// 药品单位信息
|
||||
unit: drugUnit.value.find((item) => item.id === data.drug.unit_id),
|
||||
// 药品价格
|
||||
price: data.price,
|
||||
// 药品使用方式ID
|
||||
way_id: data.drug?.way_id,
|
||||
// 药品使用方式信息
|
||||
use_ways: drugUseWay.value.find((item) => item.id === data.drug.way_id),
|
||||
// 药品使用时间ID
|
||||
time_id: data.drug.time_id,
|
||||
// 药品类型ID
|
||||
type_id: data.drug.type_id,
|
||||
// 药品使用频率ID
|
||||
frequency_id: data.drug.frequency_id,
|
||||
// 药品单位ID
|
||||
unit_id: data.drug.unit_id,
|
||||
// 药品图片
|
||||
image: data.drug.image,
|
||||
// 药品说明书
|
||||
instruction: data.drug.instruction,
|
||||
// 药品类型
|
||||
type: data.drug.type,
|
||||
};
|
||||
|
||||
// 首次添加,初始化数量为1
|
||||
newProduct.select_number = 1;
|
||||
|
||||
// 添加到选中列表
|
||||
currentDrugs.value.push(newProduct);
|
||||
|
||||
// 更新本地存储
|
||||
updateLocalStorage();
|
||||
|
||||
// 提示成功
|
||||
message.success(`已将${newProduct.drug_name}添加到清单中!`);
|
||||
}
|
||||
|
||||
const newDrugInfo = ref({});
|
||||
|
||||
function selectProductChange(id) {
|
||||
selectProductId.value = id;
|
||||
}
|
||||
|
||||
// 选择中药用法
|
||||
function selectDrugUseWayChange(id) {
|
||||
currentDrugs.value = currentDrugs.value.map((item) =>
|
||||
// 修改item下面drug的 frequency_id = id
|
||||
item.index_id === selectProductId.value
|
||||
? {
|
||||
...item,
|
||||
way_id: id,
|
||||
use_ways: drugUseWay.value.find((value) => value.id === id),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
updateLocalStorage();
|
||||
}
|
||||
|
||||
// 切换tab
|
||||
function tabChange(id) {
|
||||
localStorage.setItem(`activeCategory-chat-${props.registerId}`, id);
|
||||
currentDrugs.value = [];
|
||||
updateLocalStorage();
|
||||
activeCategory.value = id;
|
||||
}
|
||||
|
||||
// 根据创制string来把字符串的逗号分开变成数组
|
||||
function splitString(str: string) {
|
||||
if (!str) {
|
||||
return [];
|
||||
}
|
||||
return str.split(',');
|
||||
}
|
||||
|
||||
// 获取我的诊所列表
|
||||
function getMyStoreList() {
|
||||
getMyStoreListApi().then((res) => {
|
||||
myStoreList.value = res;
|
||||
if (res.length > 0) {
|
||||
myStoreId.value = res[0].id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取药品使用方式列表
|
||||
function getDrugUseListByWesternModal() {
|
||||
getDrugUseList().then((res) => {
|
||||
// 更新各种药品使用相关数据
|
||||
drugUseNum.value = res.drug_use_num;
|
||||
drugUseFrequency.value = res.drug_use_frequency;
|
||||
drugUseType.value = res.drug_use_type;
|
||||
drugUnit.value = res.drug_unit;
|
||||
drugTime.value = res.drug_time;
|
||||
drugUseWay.value = res.drug_use_way;
|
||||
});
|
||||
}
|
||||
|
||||
// 获取患者信息
|
||||
async function getPatientInfo() {
|
||||
try {
|
||||
const res = await getPatientItem(props.registerId);
|
||||
patientInfo.value = res;
|
||||
userPatientHealthInquiry.value = res.user_patient_health_inquiry;
|
||||
prescriptionList.value = res.prescription;
|
||||
} catch (error) {
|
||||
console.error('获取患者信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 visible prop 变化
|
||||
watch(() => props.visible, (newVal) => {
|
||||
visible.value = newVal;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
getMyStoreList();
|
||||
getDrugUseListByWesternModal();
|
||||
getProcessRuleListByDoctor();
|
||||
getPatientInfo();
|
||||
getCurrentDrugs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="visible"
|
||||
:width="1200"
|
||||
title="开方模块"
|
||||
@cancel="emit('update:visible', false)"
|
||||
@ok="sendPrescription"
|
||||
>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,724 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prescription-module">
|
||||
<div style="width: 0; height: 0; overflow: hidden">
|
||||
<ImagePreviewGroup
|
||||
:preview="{
|
||||
visible,
|
||||
onVisibleChange: setVisible,
|
||||
}"
|
||||
:style="{ display: 'none' }"
|
||||
>
|
||||
<Image
|
||||
v-for="image in previewImage"
|
||||
v-if="visible === true"
|
||||
:src="image"
|
||||
:width="200"
|
||||
/>
|
||||
</ImagePreviewGroup>
|
||||
</div>
|
||||
|
||||
<!-- 患者信息 -->
|
||||
<Descriptions
|
||||
v-if="patientInfo"
|
||||
:column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }"
|
||||
bordered
|
||||
title="患者信息"
|
||||
class="mb-5"
|
||||
>
|
||||
<Descriptions.Item label="患者姓名">
|
||||
{{ patientInfo.user_patient?.name }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="患者年龄">
|
||||
{{ patientInfo.user_patient?.age }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="患者性别">
|
||||
{{
|
||||
patientInfo.user_patient?.sex === 1
|
||||
? '男'
|
||||
: patientInfo.user_patient?.sex === 2
|
||||
? '女'
|
||||
: '未填写'
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Descriptions
|
||||
v-if="userPatientHealthInquiry"
|
||||
:column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }"
|
||||
bordered
|
||||
class="mt-5 mb-5"
|
||||
title="健康信息"
|
||||
>
|
||||
<Descriptions.Item label="肝功能">
|
||||
<span>{{
|
||||
userPatientHealthInquiry.liver_function === 0 ? '正常' : '异常'
|
||||
}}</span>
|
||||
<p
|
||||
v-if="userPatientHealthInquiry.liver_function === 1"
|
||||
class="mt-3"
|
||||
>
|
||||
<Tag
|
||||
v-for="item in splitString(
|
||||
userPatientHealthInquiry.liver_index,
|
||||
)"
|
||||
color="#455cda"
|
||||
>
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</p>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="肾功能">
|
||||
<span>{{
|
||||
userPatientHealthInquiry.renal_function === 0 ? '正常' : '异常'
|
||||
}}</span>
|
||||
<p
|
||||
v-if="userPatientHealthInquiry.renal_function === 1"
|
||||
class="mt-3"
|
||||
>
|
||||
<Tag
|
||||
v-for="item in splitString(
|
||||
userPatientHealthInquiry.renal_index,
|
||||
)"
|
||||
color="#455cda"
|
||||
>
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</p>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="既往史">
|
||||
<span>{{
|
||||
userPatientHealthInquiry.person_status === 0 ? '无' : '有'
|
||||
}}</span>
|
||||
<p v-if="userPatientHealthInquiry.person_status === 1" class="mt-3">
|
||||
<Tag
|
||||
v-for="item in splitString(
|
||||
userPatientHealthInquiry.person_history,
|
||||
)"
|
||||
color="#455cda"
|
||||
>
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</p>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="过敏史">
|
||||
<span>{{
|
||||
userPatientHealthInquiry.allergic_status === 0 ? '无' : '有'
|
||||
}}</span>
|
||||
<p
|
||||
v-if="userPatientHealthInquiry.allergic_status === 1"
|
||||
class="mt-3"
|
||||
>
|
||||
<Tag
|
||||
v-for="item in splitString(
|
||||
userPatientHealthInquiry.allergic_history,
|
||||
)"
|
||||
color="#455cda"
|
||||
>
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</p>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="家庭遗传史">
|
||||
<span>{{
|
||||
userPatientHealthInquiry.family_status === 0 ? '无' : '有'
|
||||
}}</span>
|
||||
<p v-if="userPatientHealthInquiry.family_status === 1" class="mt-3">
|
||||
<Tag
|
||||
v-for="item in splitString(
|
||||
userPatientHealthInquiry.family_history,
|
||||
)"
|
||||
color="#455cda"
|
||||
>
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</p>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Descriptions
|
||||
:column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }"
|
||||
class="mt-5 mb-5"
|
||||
title="处方记录"
|
||||
>
|
||||
<Descriptions.Item>
|
||||
<Timeline>
|
||||
<TimelineItem v-for="item in prescriptionList" :key="item.id">
|
||||
<Tag color="#455cda">{{ item.prescription_no }}</Tag>
|
||||
<span class="time-line-item-created">{{
|
||||
item.created_at
|
||||
}}</span>
|
||||
<Tag class="ml-5" :color="item.category === 1? '' : '#455cda'">
|
||||
{{ item.prescription_type === 1 ? '中药处方' : item.prescription_type === 5 ? '产品服务包' : '西药处方' }} /
|
||||
{{ item.category === 1 ? '自费' : '医保' }}
|
||||
</Tag>
|
||||
|
||||
<Tag v-if="item.status === 0" color="warning">待审核</Tag>
|
||||
<Tag v-else-if="item.status === 1" color="success">已通过</Tag>
|
||||
<Tag v-else-if="item.status === 2" color="error">未通过</Tag>
|
||||
<Tag v-else-if="item.status === 3" color="#455cda">无需审核</Tag>
|
||||
<Tag v-else-if="item.status === 4" color="#455cda">无需审核</Tag>
|
||||
<Tag color="#455cda">处方ID:{{ item.id }}</Tag>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<!-- 药品分类导航 -->
|
||||
<div class="drug-categories">
|
||||
<button
|
||||
v-for="categoryItem in categories"
|
||||
:key="categoryItem.value"
|
||||
:class="{ active: activeCategory === categoryItem.value }"
|
||||
@click="tabChange(categoryItem.value)"
|
||||
>
|
||||
{{ categoryItem.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<RadioGroup
|
||||
v-model:value="category"
|
||||
>
|
||||
<RadioButton :value="1">自费</RadioButton>
|
||||
<RadioButton :value="2">医保</RadioButton>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<!-- 已选药品列表 -->
|
||||
<!-- 中药 -->
|
||||
<div v-if="activeCategory === 1" class="mb-5 mt-3 flex" style="flex-wrap: wrap; width: 100%;">
|
||||
<Row :gutter="[16, 16]">
|
||||
<Col
|
||||
v-for="(drug, index) in currentDrugs"
|
||||
:key="drug.id"
|
||||
:lg="12"
|
||||
:md="24"
|
||||
:sm="24"
|
||||
:xl="12"
|
||||
:xs="24"
|
||||
:xxl="8"
|
||||
>
|
||||
<Card class="mt-0 prescription-card" title="">
|
||||
<div>
|
||||
<span class="card-index">{{ index + 1 }}、</span>
|
||||
<p>
|
||||
<Select
|
||||
v-model:value="drug.id"
|
||||
:filter-option="false"
|
||||
class="old-select-drug-name"
|
||||
style="min-width: 100px"
|
||||
placeholder="药名"
|
||||
show-search
|
||||
@change="selectOldDrugInfo"
|
||||
@dropdown-visible-change="
|
||||
setSelectChineseIndex(index, drug.id)
|
||||
"
|
||||
@search="searchOption"
|
||||
>
|
||||
<SelectOption v-if="drugList.length === 0" :value="drug.id">
|
||||
{{ drug.drug_name }}
|
||||
</SelectOption>
|
||||
<SelectOption
|
||||
v-for="(value, index) in drugList"
|
||||
v-else
|
||||
:key="index"
|
||||
:value="value.drug.id"
|
||||
>
|
||||
{{ value.drug.drug_name }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
,
|
||||
<InputNumber
|
||||
v-model:value="drug.number"
|
||||
:class="`w-1/5 old-number-input-${index}`"
|
||||
style="min-width: 100px"
|
||||
:controls="false"
|
||||
@blur="updateChineseNumber"
|
||||
@keydown="updateChineseNumberGoNewDrug($event)"
|
||||
>
|
||||
<template #addonAfter> g</template>
|
||||
</InputNumber>
|
||||
,
|
||||
<span>
|
||||
<Select
|
||||
:value="drug?.way_id"
|
||||
placeholder="用法"
|
||||
@change="selectDrugUseWayChange"
|
||||
@dropdown-visible-change="
|
||||
selectProductChange(drug.index_id)
|
||||
"
|
||||
>
|
||||
<SelectOption :value="0">煎服</SelectOption>
|
||||
<SelectOption
|
||||
v-for="(value, index) in drugUseWay"
|
||||
:key="index"
|
||||
:value="value.id"
|
||||
>
|
||||
{{ value.name }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</span>
|
||||
<p class="card-price">
|
||||
¥<span>{{ (drug.price * drug.number).toFixed(2) }}</span>
|
||||
</p>
|
||||
<Button
|
||||
class="card-delete"
|
||||
type="link"
|
||||
@click="removeDrug(index)"
|
||||
>
|
||||
<DeleteTwoTone/>
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col
|
||||
:lg="12"
|
||||
:md="24"
|
||||
:sm="24"
|
||||
:xl="12"
|
||||
:xs="24"
|
||||
:xxl="8"
|
||||
>
|
||||
<Card class="prescription-card" title="">
|
||||
<div>
|
||||
<span class="card-index">{{ currentDrugs.length + 1 }}、</span>
|
||||
<div>
|
||||
<Select
|
||||
v-model:value="newDrugInfo.id"
|
||||
:filter-option="false"
|
||||
class="new-select-drug-name w-1/5"
|
||||
style="min-width: 100px"
|
||||
placeholder="药名"
|
||||
show-search
|
||||
@change="selectNewDrugInfo"
|
||||
@search="searchOption"
|
||||
>
|
||||
<SelectOption
|
||||
v-for="(value, index) in drugList"
|
||||
:key="index"
|
||||
:value="value.drug_id"
|
||||
>
|
||||
{{ value.drug.drug_name }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
,
|
||||
<InputNumber
|
||||
v-model:value="newDrugInfo.number"
|
||||
class="new-number-input w-1/5"
|
||||
style="min-width: 100px"
|
||||
@blur="newDrugBlur"
|
||||
@keydown="selectDrugByNewDrugInfo($event, true)"
|
||||
>
|
||||
<template #addonAfter> g</template>
|
||||
</InputNumber>
|
||||
,
|
||||
<span>
|
||||
<Select
|
||||
v-model:value="newDrugInfo.way_id"
|
||||
placeholder="用法"
|
||||
@keydown="selectDrugByNewDrugInfo($event, true)"
|
||||
>
|
||||
<SelectOption :value="0">煎服</SelectOption>
|
||||
<SelectOption
|
||||
v-for="(value, index) in drugUseWay"
|
||||
:key="index"
|
||||
:value="value.id"
|
||||
>
|
||||
{{ value.name }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</span>
|
||||
<p class="card-price">
|
||||
¥<span>{{
|
||||
(
|
||||
(newDrugInfo.price || 0) * (newDrugInfo.number || 1)
|
||||
).toFixed(2)
|
||||
}}元</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<!-- 西(中成)药列表 -->
|
||||
<div v-else class="selected-drugs">
|
||||
<div class="drug-table">
|
||||
<div class="table-header">
|
||||
<span v-if="activeCategory !== 1">商品图片</span>
|
||||
<span>商品名称</span>
|
||||
<span>数量</span>
|
||||
<span>单价</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
<div class="table-body">
|
||||
<div
|
||||
v-for="(drug, index) in currentDrugs"
|
||||
:key="drug.id"
|
||||
class="table-row"
|
||||
>
|
||||
<div
|
||||
v-if="activeCategory !== 1"
|
||||
style="width: 120px; margin: 0 auto"
|
||||
>
|
||||
<Image :src="drug.image"/>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
<span>药品名称:{{ drug.drug_name }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>用法:{{
|
||||
`${drug.use_type?.name},${drug.use_frequency?.name},${drug.use_num?.name},每次${drug.number}${drug.unit?.name}`
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="activeCategory === 1" class="quantity-control">
|
||||
<InputNumber
|
||||
v-model:value="drug.select_number"
|
||||
class="w-full"
|
||||
@blur="updateChineseNumber"
|
||||
>
|
||||
<template #addonAfter> g</template>
|
||||
</InputNumber>
|
||||
</div>
|
||||
<div v-else class="quantity-control">
|
||||
<Button type="primary" @click="decrement(index)">-</Button>
|
||||
<span style="margin: 0 20px">{{ drug.select_number }}</span>
|
||||
<Button type="primary" @click="increment(index)">+</Button>
|
||||
</div>
|
||||
</div>
|
||||
<span>{{ drug.price }}</span>
|
||||
<div>
|
||||
<Button type="link" @click="removeDrug(index)">删除</Button>
|
||||
<Button
|
||||
v-if="drug.instruction !== ''"
|
||||
type="link"
|
||||
@click="setVisible(true, drug.instruction)"
|
||||
>
|
||||
查看说明书
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 诊断和医嘱 -->
|
||||
<div class="diagnosis-area">
|
||||
<div v-if="activeCategory === 1" class="mb-5">
|
||||
<RadioGroup v-model:value="ruleType">
|
||||
<RadioButton :value="1">自制剂</RadioButton>
|
||||
<RadioButton :value="2">委托调剂</RadioButton>
|
||||
</RadioGroup>
|
||||
<div v-if="ruleType === 1">
|
||||
<!-- 选择包法-->
|
||||
<Select
|
||||
:value="packageMethodId"
|
||||
class="mt-3 w-1/5"
|
||||
placeholder="请选择包法"
|
||||
@change="selectPackageMethod"
|
||||
>
|
||||
<SelectOption
|
||||
v-for="(value, index) in packageMethod"
|
||||
:key="index"
|
||||
:value="value.value"
|
||||
>
|
||||
{{ value.label }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-else-if="ruleType === 2">
|
||||
<!-- 选择制剂-->
|
||||
<Select
|
||||
v-model:value="processRuleId"
|
||||
class="mt-3 w-1/5"
|
||||
placeholder="请选择制剂"
|
||||
@change="selectProcessRule"
|
||||
>
|
||||
<SelectOption
|
||||
v-for="(value, index) in processRuleList"
|
||||
:key="index"
|
||||
:value="value.id"
|
||||
>
|
||||
{{ value.name }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
<!-- 选择规格-->
|
||||
<Select
|
||||
v-model:value="childProcessRuleId"
|
||||
class="ml-3 mt-3 w-1/5"
|
||||
placeholder="请选择煎法"
|
||||
@change="selectProcessRuleNot"
|
||||
>
|
||||
<SelectOption
|
||||
v-for="(value, index) in childProcessRuleList"
|
||||
:key="index"
|
||||
:value="value.id"
|
||||
>
|
||||
{{ value.name }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
<!-- 选择备注-->
|
||||
<Select
|
||||
v-model:value="processRuleNoteId"
|
||||
class="ml-3 mt-3 w-1/5"
|
||||
placeholder="请选择备注"
|
||||
@change="selectProcessRuleNotCommit"
|
||||
>
|
||||
<SelectOption
|
||||
v-for="(value, index) in processRuleNoteList"
|
||||
:key="index"
|
||||
:value="value.id"
|
||||
>
|
||||
{{ value.note }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
用量:
|
||||
<InputNumber v-model:value="dosage" class="w-1/5">
|
||||
<template #addonBefore>
|
||||
<MinusOutlined
|
||||
key="prop"
|
||||
@click="dosage = dosage > 1 ? dosage - 1 : 1"
|
||||
/>
|
||||
</template>
|
||||
<template #addonAfter>
|
||||
<PlusOutlined
|
||||
key="add"
|
||||
@click="dosage = dosage < 100 ? dosage + 1 : 100"
|
||||
/>
|
||||
</template>
|
||||
</InputNumber>
|
||||
天 / {{ dosage }} 剂
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
频次:
|
||||
<InputNumber v-model:value="dayDosage" class="w-1/5">
|
||||
<template #addonBefore>
|
||||
<MinusOutlined
|
||||
key="prop"
|
||||
@click="dayDosage = dayDosage > 1 ? dayDosage - 1 : 1"
|
||||
/>
|
||||
</template>
|
||||
<template #addonAfter>
|
||||
<PlusOutlined
|
||||
key="add"
|
||||
@click="dayDosage = dayDosage < 100 ? dayDosage + 1 : 100"
|
||||
/>
|
||||
</template>
|
||||
</InputNumber>
|
||||
次/天
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea v-model:value="diagnosis" placeholder="输入诊断结果..." class="mb-3"/>
|
||||
<Textarea v-model:value="medicalAdvice" placeholder="输入医嘱..." class="mb-3"/>
|
||||
|
||||
诊疗费用:
|
||||
<InputNumber
|
||||
v-model:value="treatmentPrice"
|
||||
placeholder="请输入诊疗价格"
|
||||
class="mb-3"
|
||||
>
|
||||
<template #addonAfter> /元</template>
|
||||
</InputNumber>
|
||||
</div>
|
||||
|
||||
<!-- 费用总计 -->
|
||||
<div class="price-box">
|
||||
<div class="total-cost">加工费:¥{{ processingFee.toFixed(2) }}</div>
|
||||
<div class="total-cost">
|
||||
商品价格:¥{{ totalProductCost.toFixed(2) }}
|
||||
</div>
|
||||
<div class="total-cost">总计:¥{{ totalCost.toFixed(2) }}</div>
|
||||
|
||||
<Button class="mt-5 w-full" style="display: block" type="primary" @click="checkChineseMedicineConflict">
|
||||
发送处方
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal v-model:open="showNewDrugModal" @ok="newDrugModalOk">
|
||||
要把【{{ newDrugInfo.name }}】添加到清单吗?
|
||||
</Modal>
|
||||
<Modal title="二次签名" v-model:open="doctorSecondSignModal" @ok="doctorSecondSignModalOk">
|
||||
<div class="doctor-second-sign-title">有毒:</div>
|
||||
<p v-for="item in checkData.message?.poisonous || []">【{{ item }}】</p>
|
||||
<div class="doctor-second-sign-title">十八反:</div>
|
||||
<p v-for="item in checkData.message?.opposition || []">【{{ item }}】</p>
|
||||
<div class="doctor-second-sign-title">十九畏:</div>
|
||||
<p v-for="item in checkData.message?.conflict || []">【{{ item }}】</p>
|
||||
<p class="mt-5" style="color: red;">如需继续开方需要二次签名,确认要二次签名吗?</p>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doctor-second-sign-title {
|
||||
font-size: 16px;
|
||||
margin: 10px 0;
|
||||
font-weight: bold;
|
||||
border-left: 5px solid rgb(55, 71, 162);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.card-index {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.card-price {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 8px;
|
||||
color: red;
|
||||
}
|
||||
|
||||
.card-delete {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.prescription-card {
|
||||
position: relative;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.drug-categories {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.drug-categories button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dark .drug-categories button {
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.drug-categories button.active {
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
animation: pulse 0.5s /*infinite*/;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
background: #455cda;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.selected-drugs {
|
||||
margin: 1rem 0;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dark .selected-drugs {
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.table-body {
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 0.5fr 1fr 1fr 1fr 1fr;
|
||||
padding: 12px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 0.5fr 1fr 1fr 1fr 1fr;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
text-align: center;
|
||||
height: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dark .table-row {
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.diagnosis-area textarea {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dark .diagnosis-area textarea {
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.price-box {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.total-cost {
|
||||
text-align: right;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
color: #f56c6c;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.time-line-item-created {
|
||||
color: #909399;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.drug-categories {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-header,
|
||||
.table-row {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.table-header span:nth-child(4),
|
||||
.table-header span:nth-child(5),
|
||||
.table-row > span:nth-child(4),
|
||||
.table-row > div:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,3 +1,4 @@
|
||||
# 开发环境配置`n
|
||||
# 应用标题
|
||||
VITE_APP_TITLE=萧康云医
|
||||
|
||||
@@ -6,3 +7,6 @@ VITE_APP_NAMESPACE=xk-admin-
|
||||
|
||||
# 应用版本
|
||||
XK_APP_VERSION=1.0.0
|
||||
|
||||
# WebSocket 连接地址
|
||||
VITE_WS_URL=ws://localhost:12080/ws
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# 生产环境配置
|
||||
VITE_BASE=/
|
||||
|
||||
# 接口地址
|
||||
VITE_GLOB_API_URL=/api/admin
|
||||
|
||||
# 是否开启压缩,可以设置为 none, brotli, gzip
|
||||
VITE_COMPRESS=none
|
||||
VITE_COMPRESS=gzip
|
||||
|
||||
# 是否开启 PWA
|
||||
VITE_PWA=false
|
||||
@@ -17,3 +18,7 @@ VITE_INJECT_APP_LOADING=true
|
||||
|
||||
# 打包后是否生成dist.zip
|
||||
VITE_ARCHIVER=true
|
||||
|
||||
# WebSocket 连接地址
|
||||
VITE_WS_URL=wss://api.ws.g.xiaokang88.com/ws
|
||||
# VITE_WS_URL=wss://xk.ws.nailaoyun.cn/ws
|
||||
|
||||
@@ -44,9 +44,15 @@
|
||||
"@vueuse/core": "catalog:",
|
||||
"ant-design-vue": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"exceljs": "^4.4.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pinia": "catalog:",
|
||||
"sortablejs": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
"vue-router": "catalog:",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sortablejs": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/web-antd/public/audio/sys.mp3
Normal file
BIN
apps/web-antd/public/audio/sys.mp3
Normal file
Binary file not shown.
BIN
apps/web-antd/public/img/traditional-chinese-medicine.jpg
Normal file
BIN
apps/web-antd/public/img/traditional-chinese-medicine.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -13,7 +13,8 @@ import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
AutoComplete,
|
||||
Button, Cascader,
|
||||
Button,
|
||||
Cascader,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
DatePicker,
|
||||
@@ -60,6 +61,7 @@ export type ComponentType =
|
||||
| 'DatePicker'
|
||||
| 'DefaultButton'
|
||||
| 'Divider'
|
||||
| 'GalleryPickLink' // OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型)
|
||||
| 'IconPicker'
|
||||
| 'Input'
|
||||
| 'InputNumber'
|
||||
@@ -77,6 +79,10 @@ export type ComponentType =
|
||||
| 'TimePicker'
|
||||
| 'TreeSelect'
|
||||
| 'Upload'
|
||||
| 'UploadImage'
|
||||
| 'UploadImageSortable'
|
||||
| 'UploadOssFile'
|
||||
| 'UploadDraggerPaste'
|
||||
| BaseFormComponentType;
|
||||
|
||||
async function initComponentAdapter() {
|
||||
|
||||
@@ -19,6 +19,8 @@ setupVbenForm<ComponentType>({
|
||||
Radio: 'checked',
|
||||
Switch: 'checked',
|
||||
Upload: 'fileList',
|
||||
UploadImage: 'modelValue',
|
||||
UploadOssFile: 'modelValue',
|
||||
},
|
||||
},
|
||||
defineRules: {
|
||||
|
||||
@@ -3,39 +3,124 @@ import { baseRequestClient, requestClient } from '#/api/request';
|
||||
export namespace AuthApi {
|
||||
/** 登录接口参数 */
|
||||
export interface LoginParams {
|
||||
account: string;
|
||||
password?: string;
|
||||
username?: string;
|
||||
code?: string;
|
||||
admin_id?: number;
|
||||
/** @deprecated 兼容旧客户端 */
|
||||
login_method?: 'phone' | 'login_account' | 'job_number';
|
||||
username?: string;
|
||||
phone?: string;
|
||||
login_account?: string;
|
||||
job_number?: string;
|
||||
}
|
||||
|
||||
/** 登录接口返回值 */
|
||||
export interface AccountOption {
|
||||
account_type?: string;
|
||||
id: number;
|
||||
nick_name: string;
|
||||
role_name: string;
|
||||
store_name: string;
|
||||
login_account?: string;
|
||||
is_current?: boolean;
|
||||
password_matched?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
token?: string;
|
||||
need_select?: boolean;
|
||||
accounts?: AccountOption[];
|
||||
default_account_id?: number;
|
||||
default_account_type?: string;
|
||||
}
|
||||
|
||||
export interface RefreshTokenResult {
|
||||
data: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
/** 发送验证码返回结果 */
|
||||
export interface SendCodeResult {
|
||||
code?: string;
|
||||
need_select?: boolean;
|
||||
accounts?: AccountOption[];
|
||||
default_account_id?: number;
|
||||
default_account_type?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
export async function loginApi(data: AuthApi.LoginParams) {
|
||||
return requestClient.post<AuthApi.LoginResult>('login', {
|
||||
phone: data.username,
|
||||
// PC 管理端固定 web,供单机登录按端区分会话
|
||||
const clientType = 'web';
|
||||
if (data.account) {
|
||||
return requestClient.post<AuthApi.LoginResult>('login', {
|
||||
account: data.account,
|
||||
password: data.password,
|
||||
code: data.code,
|
||||
admin_id: data.admin_id,
|
||||
client_type: clientType,
|
||||
});
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
login_method: data.login_method || 'phone',
|
||||
password: data.password,
|
||||
code: data.code,
|
||||
});
|
||||
client_type: clientType,
|
||||
};
|
||||
|
||||
if (data.login_method === 'phone') {
|
||||
params.phone = data.phone || data.username;
|
||||
} else if (data.login_method === 'login_account') {
|
||||
params.login_account = data.login_account;
|
||||
} else if (data.login_method === 'job_number') {
|
||||
params.job_number = data.job_number;
|
||||
}
|
||||
|
||||
return requestClient.post<AuthApi.LoginResult>('login', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
export async function sendVerificationCode(username: string) {
|
||||
return requestClient.post('send-verification-code', {
|
||||
username,
|
||||
});
|
||||
export async function sendVerificationCode(params: {
|
||||
account?: string;
|
||||
password?: string;
|
||||
admin_id?: number;
|
||||
login_method?: 'phone' | 'login_account' | 'job_number';
|
||||
username?: string;
|
||||
phone?: string;
|
||||
login_account?: string;
|
||||
job_number?: string;
|
||||
}) {
|
||||
if (params.account) {
|
||||
return requestClient.post<AuthApi.SendCodeResult>('send-verification-code', {
|
||||
account: params.account,
|
||||
password: params.password,
|
||||
admin_id: params.admin_id,
|
||||
});
|
||||
}
|
||||
|
||||
const requestParams: Record<string, unknown> = {
|
||||
login_method: params.login_method || 'phone',
|
||||
};
|
||||
|
||||
if (params.login_method === 'phone') {
|
||||
requestParams.phone = params.phone || params.username;
|
||||
} else if (params.login_method === 'login_account') {
|
||||
requestParams.login_account = params.login_account;
|
||||
} else if (params.login_method === 'job_number') {
|
||||
requestParams.job_number = params.job_number;
|
||||
}
|
||||
|
||||
return requestClient.post<AuthApi.SendCodeResult>(
|
||||
'send-verification-code',
|
||||
requestParams,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,3 +147,19 @@ export async function logoutApi() {
|
||||
export async function getAccessCodesApi() {
|
||||
return requestClient.get<string[]>('auth/codes');
|
||||
}
|
||||
|
||||
/**
|
||||
* 同手机号可切换的管理员账号
|
||||
*/
|
||||
export async function getSiblingAccountsApi() {
|
||||
return requestClient.get<AuthApi.AccountOption[]>('auth/sibling-accounts');
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换管理员账号
|
||||
*/
|
||||
export async function switchAccountApi(data: {
|
||||
admin_id: number;
|
||||
}) {
|
||||
return requestClient.post<AuthApi.LoginResult>('auth/switch-account', data);
|
||||
}
|
||||
|
||||
90
apps/web-antd/src/api/core/file-gallery.ts
Normal file
90
apps/web-antd/src/api/core/file-gallery.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'file-gallery/';
|
||||
|
||||
export interface FileGalleryItem {
|
||||
id: number;
|
||||
url: string;
|
||||
file_name?: string;
|
||||
file_size: number;
|
||||
file_size_text: string;
|
||||
type?: number;
|
||||
type_text?: string;
|
||||
type_icon?: string;
|
||||
group_ids?: number[];
|
||||
created_at?: string;
|
||||
original_name?: string;
|
||||
object_key?: string;
|
||||
source?: number;
|
||||
}
|
||||
|
||||
export interface FileTypeItem {
|
||||
id: number;
|
||||
name: string;
|
||||
icon?: string;
|
||||
value: number;
|
||||
suffix: string;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
export interface FileGalleryDetail extends FileGalleryItem {
|
||||
source_text?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export async function getFileGalleryList(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
pid?: number;
|
||||
type?: number;
|
||||
group_id?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return requestClient.get<{
|
||||
items: FileGalleryItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}>(`${prefix}list`, { params });
|
||||
}
|
||||
|
||||
export async function getFileGalleryDetail(id: number) {
|
||||
return requestClient.get<FileGalleryDetail>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
export async function deleteFileGalleryItem(id: number) {
|
||||
return requestClient.post<any>(`${prefix}delete`, { id });
|
||||
}
|
||||
|
||||
export async function renameFileGalleryItem(id: number, file_name: string) {
|
||||
return requestClient.post<any>(`${prefix}rename`, { id, file_name });
|
||||
}
|
||||
|
||||
export async function moveFileGalleryType(id: number, type: number) {
|
||||
return requestClient.post<any>(`${prefix}move-type`, { id, type });
|
||||
}
|
||||
|
||||
export async function batchArchiveFiles() {
|
||||
return requestClient.post<{
|
||||
reclassified: number;
|
||||
file_name_filled: number;
|
||||
}>(`${prefix}batch-archive`);
|
||||
}
|
||||
|
||||
export async function getFileTypes() {
|
||||
return requestClient.get<FileTypeItem[]>(`${prefix}file-types`);
|
||||
}
|
||||
|
||||
export async function syncFileGalleryFromOss() {
|
||||
return requestClient.post<{ synced: number; skipped: number; scanned: number }>(`${prefix}sync-oss`);
|
||||
}
|
||||
|
||||
/** 文件加入自定义分组 */
|
||||
export async function addFileToGroup(file_id: number, group_id: number) {
|
||||
return requestClient.post<any>(`${prefix}add-to-group`, { file_id, group_id });
|
||||
}
|
||||
|
||||
/** 文件移出自定义分组 */
|
||||
export async function removeFileFromGroup(file_id: number, group_id: number) {
|
||||
return requestClient.post<any>(`${prefix}remove-from-group`, { file_id, group_id });
|
||||
}
|
||||
51
apps/web-antd/src/api/core/file-group.ts
Normal file
51
apps/web-antd/src/api/core/file-group.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'file-group/';
|
||||
|
||||
export interface FileGroupRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
sort: number;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface FileGroupOptionItem {
|
||||
id: number;
|
||||
name: string;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
/** 分组选项缓存结构 */
|
||||
export interface FileGroupCachePayload {
|
||||
hash: string;
|
||||
items: FileGroupOptionItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端分组列表
|
||||
*/
|
||||
export async function getFileGroupList() {
|
||||
return requestClient.get<FileGroupRecord[]>(`${prefix}list`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 带 hash 的分组选项:无变化时 result 为 0
|
||||
*/
|
||||
export async function getFileGroupOptions(hash?: string) {
|
||||
return requestClient.get<FileGroupOptionItem[] | 0>(`${prefix}options`, {
|
||||
params: hash ? { hash } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createFileGroup(data: Partial<FileGroupRecord>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateFileGroup(data: Partial<FileGroupRecord> & { id: number }) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteFileGroup(id: number) {
|
||||
return requestClient.post<any>(`${prefix}delete`, { id });
|
||||
}
|
||||
35
apps/web-antd/src/api/core/file-type.ts
Normal file
35
apps/web-antd/src/api/core/file-type.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'file-type/';
|
||||
|
||||
export interface FileTypeRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
value: number;
|
||||
suffix: string;
|
||||
sort: number;
|
||||
status: number;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export async function getFileTypeList() {
|
||||
return requestClient.get<FileTypeRecord[]>(`${prefix}list`);
|
||||
}
|
||||
|
||||
export async function getFileTypeDetail(id: number) {
|
||||
return requestClient.get<FileTypeRecord>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
export async function createFileType(data: Partial<FileTypeRecord>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateFileType(data: Partial<FileTypeRecord> & { id: number }) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteFileType(id: number) {
|
||||
return requestClient.post<any>(`${prefix}delete`, { id });
|
||||
}
|
||||
@@ -2,15 +2,80 @@ import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 文件上传
|
||||
* @param data
|
||||
* 通过后端服务器上传文件到OSS
|
||||
*
|
||||
* @param data 上传数据,包含file字段(文件对象)
|
||||
* @returns Promise 返回上传结果,包含url字段(文件URL)
|
||||
*/
|
||||
export async function uploadFile(data: any) {
|
||||
return requestClient.upload('upload/image', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传 - 聊天信息
|
||||
* 用于聊天场景的文件上传
|
||||
*
|
||||
* @param data 上传数据,包含file字段(文件对象)
|
||||
* @returns Promise 返回上传结果,包含url字段(文件URL)
|
||||
*/
|
||||
export async function uploadChatFile(data: any) {
|
||||
return requestClient.upload('upload/chat-file', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取OSS上传签名信息
|
||||
*
|
||||
* 该接口用于获取OSS直传所需的签名信息,包括:
|
||||
* - accessKeyId: 阿里云访问密钥ID
|
||||
* - policy: Base64编码的上传策略
|
||||
* - signature: 签名值
|
||||
* - host: OSS服务端点URL
|
||||
* - bucket: 存储桶名称(从后端环境变量获取,返回给前端)
|
||||
* - key: 文件路径前缀
|
||||
* - expire: 签名过期时间戳
|
||||
*
|
||||
* 使用场景:
|
||||
* - 前端需要直接上传文件到OSS时,先调用此接口获取签名
|
||||
* - 签名具有时效性,需要在有效期内使用
|
||||
* - 每次上传前建议重新获取签名,确保签名未过期
|
||||
*
|
||||
* 错误处理:
|
||||
* - 如果后端返回错误,会通过requestClient的统一错误处理机制处理
|
||||
* - 调用方需要捕获异常并进行相应处理
|
||||
*
|
||||
* @returns Promise<OssSignature> 返回OSS签名信息对象
|
||||
* @throws 如果API调用失败,会抛出错误
|
||||
*
|
||||
* @example
|
||||
* try {
|
||||
* const signature = await getOssSignature();
|
||||
* console.log('获取签名成功', signature);
|
||||
* } catch (error) {
|
||||
* console.error('获取签名失败', error);
|
||||
* }
|
||||
*/
|
||||
export async function getOssSignature() {
|
||||
return requestClient.get('/upload/oss-signature');
|
||||
}
|
||||
|
||||
/**
|
||||
* OSS 直传成功后登记 xk_file
|
||||
*/
|
||||
export async function registerOssFile(data: {
|
||||
url: string;
|
||||
type?: number;
|
||||
source?: number;
|
||||
file_size?: number;
|
||||
file_name?: string;
|
||||
}) {
|
||||
return requestClient.post('/upload/register-oss-file', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过文件id集合获取文件信息
|
||||
* @param data
|
||||
*
|
||||
* @param data 请求数据,包含fileInfoIds字段(文件ID数组或逗号分隔的字符串)
|
||||
* @returns Promise 返回文件信息数组
|
||||
*/
|
||||
export async function getFileInfoByIds(data: any) {
|
||||
return requestClient.post('/sys/fileInfo/getFileInfoByIds', data);
|
||||
|
||||
13
apps/web-antd/src/api/order/priceAdjust.ts
Normal file
13
apps/web-antd/src/api/order/priceAdjust.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const orderPrefix = 'order/';
|
||||
|
||||
export async function adjustOrderPercent(data: Record<string, any>) {
|
||||
return requestClient.post(`${orderPrefix}adjust-order-percent`, data);
|
||||
}
|
||||
|
||||
export async function getOrderPriceAdjustConfig(storeId?: number) {
|
||||
return requestClient.get(`${orderPrefix}price-adjust-config`, {
|
||||
params: storeId ? { store_id: storeId } : {},
|
||||
});
|
||||
}
|
||||
@@ -12,31 +12,23 @@ import {
|
||||
} from '@vben/request';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
// eslint-disable-next-line n/no-extraneous-import
|
||||
import { Base64 } from 'js-base64';
|
||||
|
||||
import {
|
||||
ENCRYPT_FIELDS,
|
||||
isSensitiveField,
|
||||
maskSensitiveField,
|
||||
TM_TEXT_SUFFIX,
|
||||
} from '#/constants/sensitive-fields';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import { refreshTokenApi } from './core';
|
||||
import {downloadByData} from "#/util/tool";
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
|
||||
const arr = new Set([
|
||||
'accept_tel',
|
||||
'doctor',
|
||||
'express_mobile',
|
||||
'id_card',
|
||||
'idcard',
|
||||
'mobile',
|
||||
'password',
|
||||
'patient_mobile',
|
||||
'phone',
|
||||
]);
|
||||
|
||||
// 敏感数据
|
||||
const sensitiveData = new Set(['id_card', 'idcard']);
|
||||
|
||||
function createRequestClient(baseURL: string) {
|
||||
const client = new RequestClient({
|
||||
baseURL,
|
||||
@@ -45,6 +37,8 @@ function createRequestClient(baseURL: string) {
|
||||
/**
|
||||
* 重新认证逻辑
|
||||
*/
|
||||
/** 防重复:并发 401 只弹一次重新登录提示 */
|
||||
let authModalShowing = false;
|
||||
async function doReAuthenticate() {
|
||||
const accessStore = useAccessStore();
|
||||
const authStore = useAuthStore();
|
||||
@@ -55,6 +49,21 @@ function createRequestClient(baseURL: string) {
|
||||
) {
|
||||
accessStore.setLoginExpired(true);
|
||||
} else {
|
||||
// 被踢/登录失效时用模态框提示,确认后再退出
|
||||
if (!authModalShowing) {
|
||||
authModalShowing = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
Modal.warning({
|
||||
title: '登录失效',
|
||||
content: '账号需重新登录',
|
||||
okText: '重新登录',
|
||||
centered: true,
|
||||
onOk: () => resolve(),
|
||||
onCancel: () => resolve(),
|
||||
});
|
||||
});
|
||||
authModalShowing = false;
|
||||
}
|
||||
await authStore.logout(true);
|
||||
}
|
||||
}
|
||||
@@ -131,6 +140,9 @@ function createRequestClient(baseURL: string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.data instanceof Blob) {
|
||||
return response;
|
||||
}
|
||||
|
||||
throw Object.assign({}, response, { response });
|
||||
},
|
||||
@@ -173,60 +185,34 @@ export const baseRequestClient = new RequestClient({ baseURL: apiURL });
|
||||
*/
|
||||
function getRes(obj: any, isDecode = true) {
|
||||
try {
|
||||
if (obj == null || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
for (const key in obj) {
|
||||
// 跳过脱敏展示字段,避免重复处理
|
||||
if (key.endsWith(TM_TEXT_SUFFIX)) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(obj[key])) {
|
||||
obj[key] = getRes(obj[key]);
|
||||
} else if (typeof obj[key] === 'object') {
|
||||
obj[key] = getRes(obj[key]);
|
||||
} else {
|
||||
// 判断obj[key]是否在arr中
|
||||
if (arr.has(key)) {
|
||||
let aseFile = '';
|
||||
aseFile = isDecode
|
||||
? customBase64Decode(obj[key], key)
|
||||
: customBase64Encode(obj[key]);
|
||||
const isGarbled =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\u0000-\u0008\v\f\u000E-\u001F\u007F-\u00FF\u{E000}-\u{F8FF}]/u.test(
|
||||
aseFile,
|
||||
);
|
||||
if (isGarbled) {
|
||||
aseFile = obj[key];
|
||||
}
|
||||
if (isDecode && sensitiveData.has(key)) {
|
||||
// 数据脱敏,自动匹配姓名、手机号、身份证
|
||||
switch (key) {
|
||||
// case 'accept_name':
|
||||
// case 'express_name':
|
||||
// case 'express_region':
|
||||
// case 'patient': {
|
||||
// // 保留前两个字符,其余用星号代替
|
||||
// aseFile = aseFile.slice(0, 1).padEnd(aseFile.length, '*');
|
||||
// break;
|
||||
// }
|
||||
// case 'express_mobile':
|
||||
// case 'mobile': {
|
||||
// // 保留前三位和后四位,中间用星号代替
|
||||
// aseFile =
|
||||
// aseFile.slice(0, 3).padEnd(aseFile.length - 4, '*') +
|
||||
// aseFile.slice(-4);
|
||||
// break;
|
||||
// }
|
||||
case 'id_card':
|
||||
case 'idcard': {
|
||||
// 保留前六位和后四位,中间用星号代替
|
||||
aseFile =
|
||||
aseFile.slice(0, 6).padEnd(aseFile.length - 4, '*') +
|
||||
aseFile.slice(-4);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// 默认情况下,全部用星号代替
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
obj[key] = aseFile;
|
||||
obj[key] = getRes(obj[key], isDecode);
|
||||
} else if (obj[key] !== null && typeof obj[key] === 'object') {
|
||||
obj[key] = getRes(obj[key], isDecode);
|
||||
} else if (ENCRYPT_FIELDS.has(key)) {
|
||||
let plainValue = isDecode
|
||||
? customBase64Decode(obj[key], key)
|
||||
: customBase64Encode(obj[key]);
|
||||
const isGarbled =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\u0000-\u0008\v\f\u000E-\u001F\u007F-\u00FF\u{E000}-\u{F8FF}]/u.test(
|
||||
plainValue,
|
||||
);
|
||||
if (isGarbled) {
|
||||
plainValue = obj[key];
|
||||
}
|
||||
obj[key] = plainValue;
|
||||
// 解密后写入脱敏展示字段,明文保留在原字段
|
||||
if (isDecode && isSensitiveField(key)) {
|
||||
obj[key + TM_TEXT_SUFFIX] = maskSensitiveField(key, plainValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
81
apps/web-antd/src/api/spreadsheet-formula.ts
Normal file
81
apps/web-antd/src/api/spreadsheet-formula.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'spreadsheet-formula/';
|
||||
|
||||
export interface SpreadsheetFormulaRefSlot {
|
||||
slot: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SpreadsheetFormulaItem {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
expression_template: string;
|
||||
ref_slots: SpreadsheetFormulaRefSlot[];
|
||||
sort: number;
|
||||
status: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export async function listSpreadsheetFormula() {
|
||||
return requestClient.get<SpreadsheetFormulaItem[]>(`${prefix}list`);
|
||||
}
|
||||
|
||||
export async function createSpreadsheetFormula(data: {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
expression_template: string;
|
||||
ref_slots: SpreadsheetFormulaRefSlot[];
|
||||
sort?: number;
|
||||
status?: boolean;
|
||||
}) {
|
||||
return requestClient.post<SpreadsheetFormulaItem>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateSpreadsheetFormula(data: {
|
||||
id: number;
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
expression_template?: string;
|
||||
ref_slots?: SpreadsheetFormulaRefSlot[];
|
||||
sort?: number;
|
||||
status?: boolean;
|
||||
}) {
|
||||
return requestClient.post<SpreadsheetFormulaItem>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteSpreadsheetFormula(id: number) {
|
||||
return requestClient.post<boolean>(`${prefix}delete`, { id });
|
||||
}
|
||||
|
||||
/** Replace REF0/REF1 with Excel-style column letters + row 1 */
|
||||
export function buildFormulaFromTemplate(
|
||||
template: string,
|
||||
refColumns: number[],
|
||||
): string {
|
||||
let result = template;
|
||||
refColumns.forEach((colIndex, i) => {
|
||||
const letters = colIndexToLetters(colIndex);
|
||||
const re = new RegExp(`REF${i}`, 'g');
|
||||
result = result.replace(re, `${letters}1`);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function colIndexToLetters(index: number): string {
|
||||
let n = index + 1;
|
||||
let result = '';
|
||||
while (n > 0) {
|
||||
n -= 1;
|
||||
result = String.fromCharCode(65 + (n % 26)) + result;
|
||||
n = Math.floor(n / 26);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export { colIndexToLetters };
|
||||
38
apps/web-antd/src/api/spreadsheet-history.ts
Normal file
38
apps/web-antd/src/api/spreadsheet-history.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
import type { SpreadsheetHistoryEntry } from '#/components/canvas-spreadsheet/types';
|
||||
|
||||
const prefix = 'spreadsheet-history/';
|
||||
|
||||
export async function saveSpreadsheetHistoryBatch(
|
||||
entries: SpreadsheetHistoryEntry[],
|
||||
) {
|
||||
return requestClient.post<{ saved_ids: string[] }>(`${prefix}save-batch`, {
|
||||
entries: entries.map((e) => ({
|
||||
client_entry_id: e.id,
|
||||
table_name: e.tableName,
|
||||
sheet_key: e.sheetKey,
|
||||
row_key: String(e.rowKey),
|
||||
field_id: e.fieldId,
|
||||
action: e.action,
|
||||
before_snapshot: e.beforeSnapshot,
|
||||
after_snapshot: e.afterSnapshot,
|
||||
created_at: e.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSpreadsheetHistory(params: {
|
||||
table_name: string;
|
||||
sheet_key?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
return requestClient.get<{ items: any[]; total: number }>(`${prefix}list`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function rollbackSpreadsheetHistory(id: number) {
|
||||
return requestClient.post<{ success: boolean }>(`${prefix}rollback`, { id });
|
||||
}
|
||||
55
apps/web-antd/src/api/spreadsheet-table-config.ts
Normal file
55
apps/web-antd/src/api/spreadsheet-table-config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
import type { SpreadsheetConfigJson } from '#/views/business/warehouse-drug-management/admin/config/spreadsheetDefaults';
|
||||
|
||||
const prefix = 'spreadsheet-table-config/';
|
||||
|
||||
export interface SpreadsheetTableConfigItem {
|
||||
id: number;
|
||||
admin_id: number;
|
||||
table_name: string;
|
||||
sheet_key: string;
|
||||
name: string;
|
||||
is_default: number;
|
||||
config: SpreadsheetConfigJson;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export async function listSpreadsheetTableConfig(params: {
|
||||
table_name: string;
|
||||
sheet_key?: string;
|
||||
}) {
|
||||
return requestClient.get<SpreadsheetTableConfigItem[]>(`${prefix}list`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSpreadsheetTableConfigDetail(id: number) {
|
||||
return requestClient.get<SpreadsheetTableConfigItem>(`${prefix}detail`, {
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSpreadsheetTableConfig(data: {
|
||||
table_name: string;
|
||||
sheet_key?: string;
|
||||
name: string;
|
||||
is_default?: boolean;
|
||||
config: SpreadsheetConfigJson;
|
||||
}) {
|
||||
return requestClient.post<SpreadsheetTableConfigItem>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateSpreadsheetTableConfig(data: {
|
||||
id: number;
|
||||
name?: string;
|
||||
is_default?: boolean;
|
||||
config?: SpreadsheetConfigJson;
|
||||
}) {
|
||||
return requestClient.post<SpreadsheetTableConfigItem>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteSpreadsheetTableConfig(id: number) {
|
||||
return requestClient.post<boolean>(`${prefix}delete`, { id });
|
||||
}
|
||||
@@ -1,40 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useAntdDesignTokens } from '@vben/hooks';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { useUserStore as useVbenUserStore } from '@vben/stores';
|
||||
|
||||
import { App, ConfigProvider, theme } from 'ant-design-vue';
|
||||
|
||||
import { antdLocale } from '#/locales';
|
||||
import { GlobalContextMenu } from '#/components/context-menu';
|
||||
import { useWebSocket } from '#/views/business/chat/composables/useWebSocket';
|
||||
import { useChatStore } from '#/views/business/chat/stores/chat';
|
||||
import { useUserStore } from '#/views/business/chat/stores/user';
|
||||
|
||||
defineOptions({ name: 'App' });
|
||||
// 定义颜色代码
|
||||
const redBoldYellowBg = '\u001B[1;31;43m'; // 1=加粗 31=红色文字 43=黄色背景
|
||||
const reset = '\u001B[0m';
|
||||
|
||||
console.log(
|
||||
`${redBoldYellowBg}......................阿弥陀佛...................... \n` +
|
||||
` _oo0oo_ \n` +
|
||||
` o8888888o \n` +
|
||||
` 88" . "88 \n` +
|
||||
` (| -_- |) \n` +
|
||||
` 0\\ = /0 \n` +
|
||||
` ___/‘---’\\___ \n` +
|
||||
` .' \\| |/ '. \n` +
|
||||
` / \\\\||| : |||// \\ \n` +
|
||||
` / _||||| -卍-|||||_ \\ \n` +
|
||||
` | | \\\\\\ - /// | | \n` +
|
||||
` | \\_| ''\\---/'' |_/ | \n` +
|
||||
` \\ .-\\__ '-' ___/-. / \n` +
|
||||
` ___'. .' /--.--\\ '. .'___ \n` +
|
||||
` ."" ‘< ‘.___\\_<|>_/___.’>’ "". \n` +
|
||||
` | | : ‘- \\‘.;‘\\ _ /’;.’/ - ’ : | | \n` +
|
||||
` \\ \\ ‘_. \\_ __\\ /__ _/ .-’ / / \n` +
|
||||
` =====‘-.____‘.___ \\_____/___.-’___.-’===== \n` +
|
||||
` ‘=---=’ \n` +
|
||||
` \n` +
|
||||
`....................佛祖保佑 ,永无BUG...................${reset}`,
|
||||
const { connectWebSocket } = useWebSocket();
|
||||
const userStore = useUserStore();
|
||||
const chatStore = useChatStore();
|
||||
const vbenUserStore = useVbenUserStore();
|
||||
|
||||
// 用户信息加载状态跟踪
|
||||
const userInfoLoaded = ref(false);
|
||||
|
||||
// 监听userInfo加载完成
|
||||
watch(
|
||||
() => vbenUserStore.userInfo,
|
||||
(newUserInfo) => {
|
||||
if (newUserInfo) {
|
||||
console.log('用户信息已加载:', newUserInfo)
|
||||
userStore.currentUser = newUserInfo;
|
||||
userStore.login(newUserInfo);
|
||||
userInfoLoaded.value = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
@@ -45,7 +45,6 @@ const tokenTheme = computed(() => {
|
||||
? [theme.darkAlgorithm]
|
||||
: [theme.defaultAlgorithm];
|
||||
|
||||
// antd 紧凑模式算法
|
||||
if (preferences.app.compact) {
|
||||
algorithm.push(theme.compactAlgorithm);
|
||||
}
|
||||
@@ -55,12 +54,34 @@ const tokenTheme = computed(() => {
|
||||
token: tokens,
|
||||
};
|
||||
});
|
||||
|
||||
const initWebsocket = () => {
|
||||
// 确保用户信息已加载
|
||||
if (!userStore.currentUser) return;
|
||||
|
||||
// 初始化数据库
|
||||
chatStore.initDB();
|
||||
|
||||
// 加载好友列表(使用用户ID)
|
||||
chatStore.loadFriends(userStore.presetUsers, userStore.currentUser?.doctor_id);
|
||||
|
||||
// 创建WebSocket连接
|
||||
connectWebSocket();
|
||||
};
|
||||
|
||||
// 当userInfo加载完成后再执行初始化
|
||||
watch(userInfoLoaded, (loaded) => {
|
||||
if (loaded) {
|
||||
initWebsocket();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
|
||||
<App>
|
||||
<RouterView />
|
||||
<GlobalContextMenu />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,950 @@
|
||||
import type { SpreadsheetTheme } from './theme';
|
||||
import { getSpreadsheetTheme, resolveHeaderColor } from './theme';
|
||||
import type {
|
||||
CellRange,
|
||||
CellType,
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetHeaderConfig,
|
||||
SpreadsheetTextAlign,
|
||||
} from '../types';
|
||||
import {
|
||||
COLUMN_LETTER_HEIGHT,
|
||||
DEFAULT_COL_WIDTH,
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
HEADER_HEIGHT,
|
||||
HEADER_TITLE_HEIGHT,
|
||||
ROW_NUMBER_WIDTH,
|
||||
SCROLL_BUFFER_ROWS,
|
||||
} from '../types';
|
||||
import { getRowTop } from '../composables/useAutoMeasure';
|
||||
import { formatCellWithSuffix } from '../composables/cellDisplay';
|
||||
import { resolveColumnTextAlign } from '../composables/columnTextAlign';
|
||||
import { colIndexToLetters } from '../formula/evaluator';
|
||||
import { iterateRanges, normalizeRange, rangesContainCell } from '../commands/FillRangeCommand';
|
||||
import {
|
||||
computeFixedWidth,
|
||||
getDataColX,
|
||||
getFixedColIndices,
|
||||
isFixedCol,
|
||||
} from './columnLayout';
|
||||
|
||||
export {
|
||||
computeFixedWidth,
|
||||
getDataColX,
|
||||
getFixedColIndices,
|
||||
} from './columnLayout';
|
||||
|
||||
export interface RenderOptions {
|
||||
headers: SpreadsheetHeaderConfig[];
|
||||
columns: SpreadsheetColumnConfig[];
|
||||
rows: Record<string, unknown>[];
|
||||
colWidths: number[];
|
||||
rowHeight: number;
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
displayRowCount: number;
|
||||
colCount: number;
|
||||
displayColCount: number;
|
||||
defaultColWidth: number;
|
||||
defaultTextAlign: SpreadsheetTextAlign;
|
||||
isDark: boolean;
|
||||
fixedCols: number[];
|
||||
fixedWidth: number;
|
||||
selectionRanges: CellRange[];
|
||||
activeCell: { row: number; col: number } | null;
|
||||
dirtyKeys: Set<string>;
|
||||
filterValues: Record<number, string>;
|
||||
filteredRowIndices: number[];
|
||||
showFillHandle: boolean;
|
||||
theme: SpreadsheetTheme;
|
||||
findMatches?: Array<{ row: number; col: number }>;
|
||||
findActiveIndex?: number;
|
||||
resolveDisplayValue?: (
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
) => unknown;
|
||||
resolveCellStyle?: (
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
) => { bgColor?: string; textColor?: string } | undefined;
|
||||
referenceCell?: { row: number; col: number } | null;
|
||||
}
|
||||
|
||||
function colByIndex(columns: SpreadsheetColumnConfig[], col: number) {
|
||||
return columns.find((c) => c.col === col);
|
||||
}
|
||||
|
||||
function isInSelection(
|
||||
row: number,
|
||||
col: number,
|
||||
ranges: CellRange[],
|
||||
): boolean {
|
||||
return rangesContainCell(ranges, row, col);
|
||||
}
|
||||
|
||||
function isFindMatch(
|
||||
row: number,
|
||||
col: number,
|
||||
matches?: Array<{ row: number; col: number }>,
|
||||
): boolean {
|
||||
if (!matches?.length) return false;
|
||||
return matches.some((m) => m.row === row && m.col === col);
|
||||
}
|
||||
|
||||
function isActiveFindMatch(
|
||||
row: number,
|
||||
col: number,
|
||||
matches?: Array<{ row: number; col: number }>,
|
||||
activeIndex?: number,
|
||||
): boolean {
|
||||
if (activeIndex === undefined || activeIndex < 0 || !matches?.length) {
|
||||
return false;
|
||||
}
|
||||
const m = matches[activeIndex];
|
||||
return !!m && m.row === row && m.col === col;
|
||||
}
|
||||
|
||||
function isRowInSelection(
|
||||
row: number,
|
||||
ranges: CellRange[],
|
||||
maxCol: number,
|
||||
): boolean {
|
||||
for (const range of ranges) {
|
||||
const r = normalizeRange(range);
|
||||
if (r.startCol === 0 && r.endCol === maxCol && row >= r.startRow && row <= r.endRow) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isColInSelection(
|
||||
col: number,
|
||||
ranges: CellRange[],
|
||||
maxRow: number,
|
||||
): boolean {
|
||||
for (const range of ranges) {
|
||||
const r = normalizeRange(range);
|
||||
if (r.startRow === 0 && r.endRow === maxRow && col >= r.startCol && col <= r.endCol) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAllSelected(
|
||||
ranges: CellRange[],
|
||||
maxRow: number,
|
||||
maxCol: number,
|
||||
): boolean {
|
||||
if (ranges.length !== 1) return false;
|
||||
const r = normalizeRange(ranges[0]!);
|
||||
return r.startRow === 0 && r.endRow === maxRow && r.startCol === 0 && r.endCol === maxCol;
|
||||
}
|
||||
|
||||
function formatCellDisplay(value: unknown, cellType: CellType): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (cellType === 'number') {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? String(n) : String(value);
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function drawAlignedText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
align: SpreadsheetTextAlign,
|
||||
) {
|
||||
ctx.textBaseline = 'middle';
|
||||
if (align === 'center') {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(text, x + w / 2, y);
|
||||
} else if (align === 'right') {
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(text, x + w - 8, y);
|
||||
} else {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(text, x + 8, y, w - 16);
|
||||
}
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
function drawCellContent(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
value: unknown,
|
||||
cellType: CellType,
|
||||
colCfg: SpreadsheetColumnConfig | undefined,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
theme: SpreadsheetTheme,
|
||||
textColor: string | undefined,
|
||||
defaultTextAlign: SpreadsheetTextAlign,
|
||||
) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, w, h);
|
||||
ctx.clip();
|
||||
|
||||
const align = resolveColumnTextAlign(colCfg, defaultTextAlign);
|
||||
|
||||
if (cellType === 'image' && value) {
|
||||
ctx.fillStyle = theme.imagePlaceholder;
|
||||
ctx.fillRect(x + 4, y + 4, h - 8, h - 8);
|
||||
ctx.fillStyle = theme.textMuted;
|
||||
ctx.font = '11px sans-serif';
|
||||
drawAlignedText(ctx, 'IMG', x, y + h / 2, w, align);
|
||||
} else {
|
||||
ctx.fillStyle = textColor ?? theme.text;
|
||||
ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
const text = colCfg
|
||||
? formatCellWithSuffix(value, colCfg)
|
||||
: formatCellDisplay(value, cellType);
|
||||
drawAlignedText(ctx, text, x, y + h / 2, w, align);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawRowNumber(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
y: number,
|
||||
rowHeight: number,
|
||||
label: string,
|
||||
theme: SpreadsheetTheme,
|
||||
selected = false,
|
||||
) {
|
||||
ctx.fillStyle = selected ? theme.selectionBg : theme.rowNumBg;
|
||||
ctx.fillRect(0, y, ROW_NUMBER_WIDTH, rowHeight);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(0, y, ROW_NUMBER_WIDTH, rowHeight);
|
||||
ctx.fillStyle = theme.textMuted;
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, ROW_NUMBER_WIDTH / 2 - 4, y + rowHeight / 2);
|
||||
}
|
||||
|
||||
function drawHeaderCorner(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
theme: SpreadsheetTheme,
|
||||
allSelected = false,
|
||||
) {
|
||||
ctx.fillStyle = allSelected ? theme.selectionBg : theme.rowNumBg;
|
||||
ctx.fillRect(0, 0, ROW_NUMBER_WIDTH, HEADER_HEIGHT);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(0, 0, ROW_NUMBER_WIDTH, HEADER_HEIGHT);
|
||||
ctx.fillStyle = theme.textMuted;
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
'#',
|
||||
ROW_NUMBER_WIDTH / 2 - 4,
|
||||
COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT / 2,
|
||||
);
|
||||
}
|
||||
|
||||
function drawDataCell(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: {
|
||||
x: number;
|
||||
y: number;
|
||||
cw: number;
|
||||
rowHeight: number;
|
||||
bg: string;
|
||||
theme: SpreadsheetTheme;
|
||||
value?: unknown;
|
||||
cellType?: CellType;
|
||||
colCfg?: SpreadsheetColumnConfig;
|
||||
textColor?: string;
|
||||
empty?: boolean;
|
||||
defaultTextAlign: SpreadsheetTextAlign;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
cw,
|
||||
rowHeight,
|
||||
bg,
|
||||
theme,
|
||||
value,
|
||||
cellType,
|
||||
colCfg,
|
||||
textColor,
|
||||
empty,
|
||||
defaultTextAlign,
|
||||
} = options;
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(x, y, cw, rowHeight);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(x, y, cw, rowHeight);
|
||||
if (!empty && cellType && value !== undefined) {
|
||||
drawCellContent(
|
||||
ctx,
|
||||
value,
|
||||
cellType,
|
||||
colCfg,
|
||||
x,
|
||||
y,
|
||||
cw,
|
||||
rowHeight,
|
||||
theme,
|
||||
textColor,
|
||||
defaultTextAlign,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCellBg(
|
||||
options: RenderOptions,
|
||||
vi: number,
|
||||
col: number,
|
||||
colCfg: SpreadsheetColumnConfig | undefined,
|
||||
row: Record<string, unknown> | undefined,
|
||||
isEmpty: boolean,
|
||||
dataRow: number | undefined,
|
||||
): string {
|
||||
const { theme } = options;
|
||||
const selected = isInSelection(vi, col, options.selectionRanges);
|
||||
const findActive = isActiveFindMatch(
|
||||
vi,
|
||||
col,
|
||||
options.findMatches,
|
||||
options.findActiveIndex,
|
||||
);
|
||||
const findMatch = isFindMatch(vi, col, options.findMatches);
|
||||
|
||||
if (isEmpty || !colCfg) {
|
||||
if (findActive) return theme.findActiveMatchBg;
|
||||
if (findMatch) return theme.findMatchBg;
|
||||
return selected ? theme.selectionBg : theme.cellBg;
|
||||
}
|
||||
const dirtyKey =
|
||||
dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : '';
|
||||
const cellStyle = options.resolveCellStyle?.(vi, colCfg, row);
|
||||
if (options.dirtyKeys.has(dirtyKey)) return theme.dirtyBg;
|
||||
if (findActive) return theme.findActiveMatchBg;
|
||||
if (findMatch) return theme.findMatchBg;
|
||||
if (cellStyle?.bgColor) return cellStyle.bgColor;
|
||||
if (selected) return theme.selectionBg;
|
||||
return theme.cellBg;
|
||||
}
|
||||
|
||||
function drawHeaderCell(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: RenderOptions,
|
||||
col: number,
|
||||
cw: number,
|
||||
x: number,
|
||||
title: string,
|
||||
headerColor?: string,
|
||||
) {
|
||||
const { theme } = options;
|
||||
const letterY = 0;
|
||||
const titleY = COLUMN_LETTER_HEIGHT;
|
||||
const maxRow = Math.max(0, options.filteredRowIndices.length - 1);
|
||||
const colSelected = isColInSelection(col, options.selectionRanges, maxRow);
|
||||
|
||||
ctx.fillStyle = colSelected ? theme.selectionBg : theme.headerBg;
|
||||
ctx.fillRect(x, letterY, cw, COLUMN_LETTER_HEIGHT);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(x, letterY, cw, COLUMN_LETTER_HEIGHT);
|
||||
ctx.fillStyle = theme.textMuted;
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(colIndexToLetters(col), x + cw / 2, letterY + COLUMN_LETTER_HEIGHT / 2);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
const bg = resolveHeaderColor(
|
||||
headerColor,
|
||||
options.isDark,
|
||||
theme.headerBg,
|
||||
);
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(x, titleY, cw, HEADER_TITLE_HEIGHT);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(x, titleY, cw, HEADER_TITLE_HEIGHT);
|
||||
ctx.fillStyle = theme.headerText;
|
||||
ctx.font = '600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
const colCfg = colByIndex(options.columns, col);
|
||||
const align = resolveColumnTextAlign(colCfg, options.defaultTextAlign);
|
||||
drawAlignedText(
|
||||
ctx,
|
||||
title,
|
||||
x,
|
||||
titleY + HEADER_TITLE_HEIGHT / 2,
|
||||
cw,
|
||||
align,
|
||||
);
|
||||
}
|
||||
|
||||
function drawHeaderTitleOnly(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
cw: number,
|
||||
title: string,
|
||||
headerColor: string | undefined,
|
||||
col: number,
|
||||
columns: SpreadsheetColumnConfig[],
|
||||
defaultTextAlign: SpreadsheetTextAlign,
|
||||
isDark: boolean,
|
||||
theme: SpreadsheetTheme,
|
||||
) {
|
||||
const bg = resolveHeaderColor(headerColor, isDark, theme.headerBg);
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(x, y, cw, HEADER_TITLE_HEIGHT);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(x, y, cw, HEADER_TITLE_HEIGHT);
|
||||
ctx.fillStyle = theme.headerText;
|
||||
ctx.font = '600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
const colCfg = colByIndex(columns, col);
|
||||
const align = resolveColumnTextAlign(colCfg, defaultTextAlign);
|
||||
drawAlignedText(ctx, title, x, y + HEADER_TITLE_HEIGHT / 2, cw, align);
|
||||
}
|
||||
|
||||
function resolveExportCellBg(
|
||||
theme: SpreadsheetTheme,
|
||||
viewRow: number,
|
||||
col: number,
|
||||
colCfg: SpreadsheetColumnConfig | undefined,
|
||||
row: Record<string, unknown> | undefined,
|
||||
dataRow: number | undefined,
|
||||
dirtyKeys: Set<string>,
|
||||
resolveCellStyle?: RenderOptions['resolveCellStyle'],
|
||||
): string {
|
||||
if (!colCfg) return theme.cellBg;
|
||||
const dirtyKey =
|
||||
dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : '';
|
||||
const cellStyle = resolveCellStyle?.(viewRow, colCfg, row);
|
||||
if (dirtyKeys.has(dirtyKey)) return theme.dirtyBg;
|
||||
if (cellStyle?.bgColor) return cellStyle.bgColor;
|
||||
return theme.cellBg;
|
||||
}
|
||||
|
||||
export interface SelectionImageParams {
|
||||
selectionRanges: CellRange[];
|
||||
headers: SpreadsheetHeaderConfig[];
|
||||
columns: SpreadsheetColumnConfig[];
|
||||
colWidths: number[];
|
||||
rowHeight: number;
|
||||
defaultColWidth: number;
|
||||
defaultTextAlign: SpreadsheetTextAlign;
|
||||
isDark: boolean;
|
||||
filteredRowIndices: number[];
|
||||
rows: Record<string, unknown>[];
|
||||
dirtyKeys: Set<string>;
|
||||
resolveDisplayValue?: RenderOptions['resolveDisplayValue'];
|
||||
resolveCellStyle?: RenderOptions['resolveCellStyle'];
|
||||
}
|
||||
|
||||
export async function renderSelectionImage(
|
||||
params: SelectionImageParams,
|
||||
): Promise<Blob> {
|
||||
const selRanges = params.selectionRanges.map((r) => normalizeRange(r));
|
||||
if (!selRanges.length) throw new Error('No selection');
|
||||
|
||||
const rowSet = new Set<number>();
|
||||
const colSet = new Set<number>();
|
||||
iterateRanges(selRanges, (viewRow, col) => {
|
||||
rowSet.add(viewRow);
|
||||
colSet.add(col);
|
||||
});
|
||||
|
||||
const uniqueRows = [...rowSet].sort((a, b) => a - b);
|
||||
const uniqueCols = [...colSet].sort((a, b) => a - b);
|
||||
if (!uniqueRows.length || !uniqueCols.length) {
|
||||
throw new Error('No selection');
|
||||
}
|
||||
|
||||
const theme = getSpreadsheetTheme(params.isDark);
|
||||
|
||||
let width = 0;
|
||||
for (const col of uniqueCols) {
|
||||
width += params.colWidths[col] ?? params.defaultColWidth;
|
||||
}
|
||||
const height =
|
||||
HEADER_TITLE_HEIGHT + uniqueRows.length * params.rowHeight;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.ceil(width * dpr);
|
||||
canvas.height = Math.ceil(height * dpr);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas not supported');
|
||||
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.fillStyle = theme.cellBg;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
let headerX = 0;
|
||||
for (const col of uniqueCols) {
|
||||
const cw = params.colWidths[col] ?? params.defaultColWidth;
|
||||
const header = params.headers.find((h) => h.col === col);
|
||||
const colCfg = colByIndex(params.columns, col);
|
||||
const title = header?.title ?? colCfg?.fieldName ?? '';
|
||||
drawHeaderTitleOnly(
|
||||
ctx,
|
||||
headerX,
|
||||
0,
|
||||
cw,
|
||||
title,
|
||||
header?.headerColor,
|
||||
col,
|
||||
params.columns,
|
||||
params.defaultTextAlign,
|
||||
params.isDark,
|
||||
theme,
|
||||
);
|
||||
headerX += cw;
|
||||
}
|
||||
|
||||
uniqueRows.forEach((viewRow, rowIndex) => {
|
||||
const dataRow = params.filteredRowIndices[viewRow];
|
||||
const row =
|
||||
dataRow !== undefined ? params.rows[dataRow] : undefined;
|
||||
const y = HEADER_TITLE_HEIGHT + rowIndex * params.rowHeight;
|
||||
let cellX = 0;
|
||||
for (const col of uniqueCols) {
|
||||
const cw = params.colWidths[col] ?? params.defaultColWidth;
|
||||
const colCfg = colByIndex(params.columns, col);
|
||||
const inSel = rangesContainCell(selRanges, viewRow, col);
|
||||
const isEmpty =
|
||||
!inSel || dataRow === undefined || !row || !colCfg;
|
||||
const bg = inSel
|
||||
? resolveExportCellBg(
|
||||
theme,
|
||||
viewRow,
|
||||
col,
|
||||
colCfg,
|
||||
row,
|
||||
dataRow,
|
||||
params.dirtyKeys,
|
||||
params.resolveCellStyle,
|
||||
)
|
||||
: theme.cellBg;
|
||||
const cellStyle =
|
||||
inSel && !isEmpty && colCfg
|
||||
? params.resolveCellStyle?.(viewRow, colCfg, row)
|
||||
: undefined;
|
||||
const displayCellType = colCfg?.cellType === 'formula'
|
||||
? 'number'
|
||||
: colCfg?.cellType;
|
||||
drawDataCell(ctx, {
|
||||
x: cellX,
|
||||
y,
|
||||
cw,
|
||||
rowHeight: params.rowHeight,
|
||||
bg,
|
||||
theme,
|
||||
empty: isEmpty,
|
||||
value:
|
||||
inSel && !isEmpty && colCfg
|
||||
? params.resolveDisplayValue?.(viewRow, colCfg, row)
|
||||
: undefined,
|
||||
cellType: displayCellType,
|
||||
colCfg: inSel ? colCfg : undefined,
|
||||
textColor: cellStyle?.textColor,
|
||||
defaultTextAlign: params.defaultTextAlign,
|
||||
});
|
||||
cellX += cw;
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob);
|
||||
else reject(new Error('Failed to create image'));
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
function drawHeaderFillerCell(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: RenderOptions,
|
||||
col: number,
|
||||
cw: number,
|
||||
x: number,
|
||||
) {
|
||||
const { theme } = options;
|
||||
const maxRow = Math.max(0, options.filteredRowIndices.length - 1);
|
||||
const colSelected = isColInSelection(col, options.selectionRanges, maxRow);
|
||||
const letterBg = colSelected ? theme.selectionBg : theme.headerBg;
|
||||
ctx.fillStyle = letterBg;
|
||||
ctx.fillRect(x, 0, cw, COLUMN_LETTER_HEIGHT);
|
||||
ctx.strokeStyle = theme.border;
|
||||
ctx.strokeRect(x, 0, cw, COLUMN_LETTER_HEIGHT);
|
||||
ctx.fillStyle = theme.textMuted;
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(colIndexToLetters(col), x + cw / 2, COLUMN_LETTER_HEIGHT / 2);
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillRect(x, COLUMN_LETTER_HEIGHT, cw, HEADER_TITLE_HEIGHT);
|
||||
ctx.strokeRect(x, COLUMN_LETTER_HEIGHT, cw, HEADER_TITLE_HEIGHT);
|
||||
}
|
||||
|
||||
function drawScrollableHeaders(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: RenderOptions,
|
||||
w: number,
|
||||
) {
|
||||
const { fixedCols, fixedWidth } = options;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(fixedWidth, 0, w - fixedWidth, HEADER_HEIGHT);
|
||||
ctx.clip();
|
||||
|
||||
for (const header of options.headers) {
|
||||
if (isFixedCol(header.col, fixedCols)) continue;
|
||||
const x = getDataColX(
|
||||
header.col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const cw = options.colWidths[header.col] ?? options.defaultColWidth;
|
||||
if (x + cw <= fixedWidth || x > w) continue;
|
||||
drawHeaderCell(ctx, options, header.col, cw, x, header.title, header.headerColor);
|
||||
}
|
||||
|
||||
for (let col = options.colCount; col < options.displayColCount; col += 1) {
|
||||
if (isFixedCol(col, fixedCols)) continue;
|
||||
const x = getDataColX(
|
||||
col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const cw = options.defaultColWidth;
|
||||
if (x + cw <= fixedWidth || x > w) continue;
|
||||
drawHeaderFillerCell(ctx, options, col, cw, x);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawFrozenHeaders(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: RenderOptions,
|
||||
w: number,
|
||||
) {
|
||||
const { fixedCols, fixedWidth, theme } = options;
|
||||
const maxRow = Math.max(0, options.filteredRowIndices.length - 1);
|
||||
const maxCol = options.colCount - 1;
|
||||
drawHeaderCorner(
|
||||
ctx,
|
||||
theme,
|
||||
isAllSelected(options.selectionRanges, maxRow, maxCol),
|
||||
);
|
||||
|
||||
for (const header of options.headers) {
|
||||
if (!isFixedCol(header.col, fixedCols)) continue;
|
||||
const x = getDataColX(
|
||||
header.col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const cw = options.colWidths[header.col] ?? options.defaultColWidth;
|
||||
if (x + cw <= ROW_NUMBER_WIDTH) continue;
|
||||
drawHeaderCell(ctx, options, header.col, cw, x, header.title, header.headerColor);
|
||||
}
|
||||
|
||||
if (fixedCols.length > 0 && fixedWidth < w) {
|
||||
ctx.fillStyle = theme.fixedShadow;
|
||||
ctx.fillRect(fixedWidth, 0, 1, HEADER_HEIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHeader(canvas: HTMLCanvasElement, options: RenderOptions) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth;
|
||||
const h = HEADER_HEIGHT;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
ctx.fillStyle = options.theme.cellBg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
drawScrollableHeaders(ctx, options, w);
|
||||
drawFrozenHeaders(ctx, options, w);
|
||||
}
|
||||
|
||||
function drawScrollableBodyRow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: RenderOptions,
|
||||
vi: number,
|
||||
y: number,
|
||||
dataRow: number | undefined,
|
||||
row: Record<string, unknown> | undefined,
|
||||
w: number,
|
||||
) {
|
||||
const { theme, rowHeight, fixedCols, fixedWidth } = options;
|
||||
const isEmpty = dataRow === undefined || !row;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(fixedWidth, y, w - fixedWidth, rowHeight);
|
||||
ctx.clip();
|
||||
|
||||
for (const colCfg of options.columns) {
|
||||
const col = colCfg.col;
|
||||
if (isFixedCol(col, fixedCols)) continue;
|
||||
const x = getDataColX(
|
||||
col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const cw = options.colWidths[col] ?? options.defaultColWidth;
|
||||
if (x + cw <= fixedWidth || x > options.viewportWidth) continue;
|
||||
|
||||
const bg = resolveCellBg(options, vi, col, colCfg, row, isEmpty, dataRow);
|
||||
const cellStyle = !isEmpty
|
||||
? options.resolveCellStyle?.(vi, colCfg, row)
|
||||
: undefined;
|
||||
const displayCellType =
|
||||
colCfg.cellType === 'formula' ? 'number' : colCfg.cellType;
|
||||
|
||||
drawDataCell(ctx, {
|
||||
x,
|
||||
y,
|
||||
cw,
|
||||
rowHeight,
|
||||
bg,
|
||||
theme,
|
||||
empty: isEmpty,
|
||||
value: options.resolveDisplayValue
|
||||
? options.resolveDisplayValue(vi, colCfg, row)
|
||||
: row?.[colCfg.fieldId],
|
||||
cellType: displayCellType,
|
||||
colCfg,
|
||||
textColor: cellStyle?.textColor,
|
||||
defaultTextAlign: options.defaultTextAlign,
|
||||
});
|
||||
}
|
||||
|
||||
for (let col = options.colCount; col < options.displayColCount; col += 1) {
|
||||
if (isFixedCol(col, fixedCols)) continue;
|
||||
const x = getDataColX(
|
||||
col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const cw = options.defaultColWidth;
|
||||
if (x + cw <= fixedWidth || x > options.viewportWidth) continue;
|
||||
const bg = resolveCellBg(options, vi, col, undefined, undefined, true, dataRow);
|
||||
drawDataCell(ctx, {
|
||||
x,
|
||||
y,
|
||||
cw,
|
||||
rowHeight,
|
||||
bg,
|
||||
theme,
|
||||
empty: true,
|
||||
defaultTextAlign: options.defaultTextAlign,
|
||||
});
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawFrozenBodyRow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
options: RenderOptions,
|
||||
vi: number,
|
||||
y: number,
|
||||
dataRow: number | undefined,
|
||||
row: Record<string, unknown> | undefined,
|
||||
) {
|
||||
const { theme, rowHeight, fixedCols, fixedWidth } = options;
|
||||
const isEmpty = dataRow === undefined || !row;
|
||||
const maxCol = options.colCount - 1;
|
||||
const rowSelected = isRowInSelection(vi, options.selectionRanges, maxCol);
|
||||
|
||||
drawRowNumber(ctx, y, rowHeight, String(vi + 1), theme, rowSelected);
|
||||
|
||||
for (const colCfg of options.columns) {
|
||||
const col = colCfg.col;
|
||||
if (!isFixedCol(col, fixedCols)) continue;
|
||||
const x = getDataColX(
|
||||
col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const cw = options.colWidths[col] ?? options.defaultColWidth;
|
||||
|
||||
const bg = resolveCellBg(options, vi, col, colCfg, row, isEmpty, dataRow);
|
||||
const cellStyle = !isEmpty
|
||||
? options.resolveCellStyle?.(vi, colCfg, row)
|
||||
: undefined;
|
||||
const displayCellType =
|
||||
colCfg.cellType === 'formula' ? 'number' : colCfg.cellType;
|
||||
|
||||
drawDataCell(ctx, {
|
||||
x,
|
||||
y,
|
||||
cw,
|
||||
rowHeight,
|
||||
bg,
|
||||
theme,
|
||||
empty: isEmpty,
|
||||
value: options.resolveDisplayValue
|
||||
? options.resolveDisplayValue(vi, colCfg, row)
|
||||
: row?.[colCfg.fieldId],
|
||||
cellType: displayCellType,
|
||||
colCfg,
|
||||
textColor: cellStyle?.textColor,
|
||||
defaultTextAlign: options.defaultTextAlign,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function renderBody(canvas: HTMLCanvasElement, options: RenderOptions) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const { theme, rowHeight, scrollTop, fixedWidth, fixedCols } = options;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const visibleCount = Math.ceil(h / rowHeight) + SCROLL_BUFFER_ROWS;
|
||||
const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 2);
|
||||
const endRow = Math.min(options.displayRowCount - 1, startRow + visibleCount);
|
||||
|
||||
for (let vi = startRow; vi <= endRow; vi += 1) {
|
||||
const dataRow = options.filteredRowIndices[vi];
|
||||
const row =
|
||||
dataRow !== undefined ? options.rows[dataRow] : undefined;
|
||||
const y = getRowTop(vi, rowHeight) - scrollTop;
|
||||
drawScrollableBodyRow(ctx, options, vi, y, dataRow, row, w);
|
||||
}
|
||||
|
||||
for (let vi = startRow; vi <= endRow; vi += 1) {
|
||||
const dataRow = options.filteredRowIndices[vi];
|
||||
const row =
|
||||
dataRow !== undefined ? options.rows[dataRow] : undefined;
|
||||
const y = getRowTop(vi, rowHeight) - scrollTop;
|
||||
drawFrozenBodyRow(ctx, options, vi, y, dataRow, row);
|
||||
}
|
||||
|
||||
if (fixedCols.length > 0 && fixedWidth < w) {
|
||||
const bodyH = Math.min(h, options.displayRowCount * rowHeight - scrollTop);
|
||||
ctx.fillStyle = theme.fixedShadow;
|
||||
ctx.fillRect(fixedWidth, 0, 1, Math.max(0, bodyH));
|
||||
}
|
||||
|
||||
if (options.activeCell) {
|
||||
const { activeCell } = options;
|
||||
const x = getDataColX(
|
||||
activeCell.col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const y = getRowTop(activeCell.row, rowHeight) - scrollTop;
|
||||
const cw = options.colWidths[activeCell.col] ?? options.defaultColWidth;
|
||||
|
||||
const findActive = isActiveFindMatch(
|
||||
activeCell.row,
|
||||
activeCell.col,
|
||||
options.findMatches,
|
||||
options.findActiveIndex,
|
||||
);
|
||||
|
||||
ctx.strokeStyle = findActive ? theme.findActiveBorder : theme.selectionBorder;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([]);
|
||||
ctx.strokeRect(x + 1, y + 1, cw - 2, rowHeight - 2);
|
||||
|
||||
if (options.showFillHandle) {
|
||||
const fh = 6;
|
||||
ctx.fillStyle = theme.fillHandle;
|
||||
ctx.fillRect(x + cw - fh - 2, y + rowHeight - fh - 2, fh, fh);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.referenceCell) {
|
||||
const { row, col } = options.referenceCell;
|
||||
const x = getDataColX(
|
||||
col,
|
||||
options.colWidths,
|
||||
options.scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const y = getRowTop(row, rowHeight) - scrollTop;
|
||||
const cw = options.colWidths[col] ?? options.defaultColWidth;
|
||||
|
||||
ctx.strokeStyle = theme.selectionBorder;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeRect(x + 1, y + 1, cw - 2, rowHeight - 2);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
export function getVisibleRowIndices(
|
||||
rows: Record<string, unknown>[],
|
||||
columns: SpreadsheetColumnConfig[],
|
||||
filterValues: Record<number, string>,
|
||||
): number[] {
|
||||
const activeFilters = Object.entries(filterValues).filter(([, v]) => v.trim());
|
||||
if (!activeFilters.length) {
|
||||
return rows.map((_, i) => i);
|
||||
}
|
||||
|
||||
return rows
|
||||
.map((row, index) => ({ row, index }))
|
||||
.filter(({ row }) =>
|
||||
activeFilters.every(([colStr, keyword]) => {
|
||||
const col = Number(colStr);
|
||||
const colCfg = colByIndex(columns, col);
|
||||
if (!colCfg) return true;
|
||||
const val = formatCellDisplay(row[colCfg.fieldId], colCfg.cellType);
|
||||
return val.toLowerCase().includes(keyword.trim().toLowerCase());
|
||||
}),
|
||||
)
|
||||
.map(({ index }) => index);
|
||||
}
|
||||
|
||||
export function getContentSize(
|
||||
colWidths: number[],
|
||||
displayRowCount: number,
|
||||
rowHeight: number,
|
||||
displayContentWidth?: number,
|
||||
): { width: number; height: number } {
|
||||
const dataWidth = ROW_NUMBER_WIDTH + colWidths.reduce((s, w) => s + w, 0);
|
||||
const width = displayContentWidth ?? dataWidth;
|
||||
return { width, height: displayRowCount * rowHeight };
|
||||
}
|
||||
|
||||
export { ROW_NUMBER_WIDTH, HEADER_HEIGHT, DEFAULT_ROW_HEIGHT };
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { SpreadsheetHeaderConfig } from '../types';
|
||||
import { DEFAULT_COL_WIDTH, ROW_NUMBER_WIDTH } from '../types';
|
||||
|
||||
export function getFixedColOffset(
|
||||
col: number,
|
||||
colWidths: number[],
|
||||
fixedCols: number[],
|
||||
): number {
|
||||
let w = 0;
|
||||
for (const c of fixedCols) {
|
||||
if (c >= col) break;
|
||||
w += colWidths[c] ?? DEFAULT_COL_WIDTH;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
export function getFixedDataWidth(
|
||||
col: number,
|
||||
colWidths: number[],
|
||||
fixedCols: number[],
|
||||
): number {
|
||||
return getFixedColOffset(col, colWidths, fixedCols);
|
||||
}
|
||||
|
||||
export function isFixedCol(col: number, fixedCols: number[]) {
|
||||
return fixedCols.includes(col);
|
||||
}
|
||||
|
||||
export function getDataColX(
|
||||
col: number,
|
||||
colWidths: number[],
|
||||
scrollLeft: number,
|
||||
fixedCols: number[],
|
||||
fixedWidth: number,
|
||||
): number {
|
||||
if (isFixedCol(col, fixedCols)) {
|
||||
return ROW_NUMBER_WIDTH + getFixedColOffset(col, colWidths, fixedCols);
|
||||
}
|
||||
const scrollColOffset =
|
||||
getColLeft(col, colWidths) - getFixedDataWidth(col, colWidths, fixedCols);
|
||||
return fixedWidth + scrollColOffset - scrollLeft;
|
||||
}
|
||||
|
||||
export function getColLeft(colIndex: number, colWidths: number[]): number {
|
||||
let left = 0;
|
||||
for (let i = 0; i < colIndex; i += 1) {
|
||||
left += colWidths[i] ?? DEFAULT_COL_WIDTH;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
export function computeFixedWidth(
|
||||
colWidths: number[],
|
||||
fixedCols: number[],
|
||||
): number {
|
||||
let w = ROW_NUMBER_WIDTH;
|
||||
for (const col of fixedCols) {
|
||||
w += colWidths[col] ?? DEFAULT_COL_WIDTH;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
/** Keep only contiguous frozen columns starting from col 0 (Excel freeze semantics). */
|
||||
export function normalizeFixedCols(fixedCols: number[]): number[] {
|
||||
if (fixedCols.length === 0) return [];
|
||||
const sorted = [...fixedCols].sort((a, b) => a - b);
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < sorted.length; i += 1) {
|
||||
if (sorted[i] === i) result.push(i);
|
||||
else break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getFixedColIndices(
|
||||
headers: SpreadsheetHeaderConfig[],
|
||||
): number[] {
|
||||
const raw = headers
|
||||
.filter((h) => h.fixed)
|
||||
.map((h) => h.col)
|
||||
.sort((a, b) => a - b);
|
||||
return normalizeFixedCols(raw);
|
||||
}
|
||||
146
apps/web-antd/src/components/canvas-spreadsheet/canvas/theme.ts
Normal file
146
apps/web-antd/src/components/canvas-spreadsheet/canvas/theme.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
export interface SpreadsheetTheme {
|
||||
headerBg: string;
|
||||
headerText: string;
|
||||
cellBg: string;
|
||||
border: string;
|
||||
text: string;
|
||||
textMuted: string;
|
||||
selectionBg: string;
|
||||
selectionBorder: string;
|
||||
dirtyBg: string;
|
||||
rowNumBg: string;
|
||||
fillHandle: string;
|
||||
fixedShadow: string;
|
||||
imagePlaceholder: string;
|
||||
findMatchBg: string;
|
||||
findActiveMatchBg: string;
|
||||
findActiveBorder: string;
|
||||
}
|
||||
|
||||
export function getSpreadsheetTheme(isDark: boolean): SpreadsheetTheme {
|
||||
if (isDark) {
|
||||
return {
|
||||
headerBg: '#1f1f1f',
|
||||
headerText: 'rgba(255, 255, 255, 0.85)',
|
||||
cellBg: '#141414',
|
||||
border: '#424242',
|
||||
text: 'rgba(255, 255, 255, 0.85)',
|
||||
textMuted: 'rgba(255, 255, 255, 0.45)',
|
||||
selectionBg: 'rgba(22, 93, 255, 0.25)',
|
||||
selectionBorder: '#1668dc',
|
||||
dirtyBg: 'rgba(255, 125, 0, 0.2)',
|
||||
rowNumBg: '#1a1a1a',
|
||||
fillHandle: '#1668dc',
|
||||
fixedShadow: 'rgba(0, 0, 0, 0.45)',
|
||||
imagePlaceholder: '#303030',
|
||||
findMatchBg: 'rgba(255, 212, 0, 0.35)',
|
||||
findActiveMatchBg: 'rgba(255, 212, 0, 0.55)',
|
||||
findActiveBorder: '#faad14',
|
||||
};
|
||||
}
|
||||
return {
|
||||
headerBg: '#f5f7fa',
|
||||
headerText: '#1d2129',
|
||||
cellBg: '#ffffff',
|
||||
border: '#e5e6eb',
|
||||
text: '#1d2129',
|
||||
textMuted: '#86909c',
|
||||
selectionBg: 'rgba(22, 93, 255, 0.08)',
|
||||
selectionBorder: '#165dff',
|
||||
dirtyBg: 'rgba(255, 125, 0, 0.12)',
|
||||
rowNumBg: '#fafafa',
|
||||
fillHandle: '#165dff',
|
||||
fixedShadow: 'rgba(0, 0, 0, 0.12)',
|
||||
imagePlaceholder: '#f2f3f5',
|
||||
findMatchBg: '#fff566',
|
||||
findActiveMatchBg: '#ffe58f',
|
||||
findActiveBorder: '#faad14',
|
||||
};
|
||||
}
|
||||
|
||||
export const HEADER_COLOR_PRESETS = [
|
||||
'#f0f5ff',
|
||||
'#fff7e6',
|
||||
'#f6ffed',
|
||||
'#fff1f0',
|
||||
'#f9f0ff',
|
||||
'#e6fffb',
|
||||
];
|
||||
|
||||
function parseHexColor(hex: string): { r: number; g: number; b: number } | null {
|
||||
const normalized = hex.trim().replace('#', '');
|
||||
if (normalized.length === 3) {
|
||||
return {
|
||||
r: Number.parseInt(normalized[0]! + normalized[0], 16),
|
||||
g: Number.parseInt(normalized[1]! + normalized[1], 16),
|
||||
b: Number.parseInt(normalized[2]! + normalized[2], 16),
|
||||
};
|
||||
}
|
||||
if (normalized.length === 6) {
|
||||
return {
|
||||
r: Number.parseInt(normalized.slice(0, 2), 16),
|
||||
g: Number.parseInt(normalized.slice(2, 4), 16),
|
||||
b: Number.parseInt(normalized.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number) {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) {
|
||||
return { h: 0, s: 0, l };
|
||||
}
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h = 0;
|
||||
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
||||
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
||||
else h = ((rn - gn) / d + 4) / 6;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
if (s === 0) {
|
||||
const v = Math.round(l * 255);
|
||||
const hex = v.toString(16).padStart(2, '0');
|
||||
return `#${hex}${hex}${hex}`;
|
||||
}
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
let tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255);
|
||||
const g = Math.round(hue2rgb(p, q, h) * 255);
|
||||
const b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255);
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function resolveHeaderColor(
|
||||
color: string | undefined,
|
||||
isDark: boolean,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (!color) return fallback;
|
||||
if (!isDark) return color;
|
||||
|
||||
const rgb = parseHexColor(color);
|
||||
if (!rgb) return fallback;
|
||||
|
||||
const { h, s, l } = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||
const darkL = Math.min(0.28, Math.max(0.16, l * 0.35 + 0.12));
|
||||
const darkS = Math.min(0.55, s * 0.75 + 0.08);
|
||||
return hslToHex(h, darkS, darkL);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CellRange, SpreadsheetCommand } from '../types';
|
||||
|
||||
export function createFillRangeCommand(
|
||||
persistBatch: (
|
||||
changes: Array<{ row: number; col: number; value: unknown }>,
|
||||
) => void | Promise<void>,
|
||||
before: Array<{ row: number; col: number; value: unknown }>,
|
||||
after: Array<{ row: number; col: number; value: unknown }>,
|
||||
): SpreadsheetCommand {
|
||||
return {
|
||||
label: 'fillRange',
|
||||
undo: () => persistBatch(before),
|
||||
redo: () => persistBatch(after),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRange(range: CellRange): CellRange {
|
||||
return {
|
||||
startRow: Math.min(range.startRow, range.endRow),
|
||||
startCol: Math.min(range.startCol, range.endCol),
|
||||
endRow: Math.max(range.startRow, range.endRow),
|
||||
endCol: Math.max(range.startCol, range.endCol),
|
||||
};
|
||||
}
|
||||
|
||||
export function iterateRange(
|
||||
range: CellRange,
|
||||
fn: (row: number, col: number) => void,
|
||||
) {
|
||||
const r = normalizeRange(range);
|
||||
for (let row = r.startRow; row <= r.endRow; row += 1) {
|
||||
for (let col = r.startCol; col <= r.endCol; col += 1) {
|
||||
fn(row, col);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function iterateRanges(
|
||||
ranges: CellRange[],
|
||||
fn: (row: number, col: number) => void,
|
||||
) {
|
||||
for (const range of ranges) {
|
||||
iterateRange(range, fn);
|
||||
}
|
||||
}
|
||||
|
||||
export function rangesContainCell(
|
||||
ranges: CellRange[],
|
||||
row: number,
|
||||
col: number,
|
||||
): boolean {
|
||||
for (const range of ranges) {
|
||||
const r = normalizeRange(range);
|
||||
if (
|
||||
row >= r.startRow &&
|
||||
row <= r.endRow &&
|
||||
col >= r.startCol &&
|
||||
col <= r.endCol
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SpreadsheetCommand } from '../types';
|
||||
|
||||
export function createPasteRangeCommand(
|
||||
persistBatch: (
|
||||
changes: Array<{ row: number; col: number; value: unknown }>,
|
||||
) => void | Promise<void>,
|
||||
before: Array<{ row: number; col: number; value: unknown }>,
|
||||
after: Array<{ row: number; col: number; value: unknown }>,
|
||||
): SpreadsheetCommand {
|
||||
return {
|
||||
label: 'pasteRange',
|
||||
undo: () => persistBatch(before),
|
||||
redo: () => persistBatch(after),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SpreadsheetCommand } from '../types';
|
||||
|
||||
|
||||
|
||||
export function createSetCellValueCommand(
|
||||
|
||||
undo: () => void | Promise<void>,
|
||||
|
||||
redo: () => void | Promise<void>,
|
||||
|
||||
): SpreadsheetCommand {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
ConditionalFormatRule,
|
||||
SpreadsheetColumnConfig,
|
||||
} from '../types';
|
||||
import { formatCellDisplay } from './useAutoMeasure';
|
||||
|
||||
export function shouldShowSuffix(displayText: string): boolean {
|
||||
if (!displayText) return false;
|
||||
if (displayText === '#ERROR' || displayText === '-') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatCellWithSuffix(
|
||||
value: unknown,
|
||||
colCfg?: SpreadsheetColumnConfig,
|
||||
): string {
|
||||
const cellType =
|
||||
colCfg?.cellType === 'formula' ? 'number' : (colCfg?.cellType ?? 'text');
|
||||
const base = formatCellDisplay(value, cellType);
|
||||
if (!shouldShowSuffix(base) || !colCfg?.suffix) return base;
|
||||
return base + colCfg.suffix;
|
||||
}
|
||||
|
||||
function resolveRuleStyle(
|
||||
rule: ConditionalFormatRule,
|
||||
): { bgColor?: string; textColor?: string } {
|
||||
const mode = rule.colorMode ?? 'background';
|
||||
if (mode === 'text') {
|
||||
return rule.textColor ? { textColor: rule.textColor } : {};
|
||||
}
|
||||
if (mode === 'both') {
|
||||
return {
|
||||
bgColor: rule.bgColor,
|
||||
textColor: rule.textColor,
|
||||
};
|
||||
}
|
||||
return rule.bgColor ? { bgColor: rule.bgColor } : {};
|
||||
}
|
||||
|
||||
export function resolveConditionalStyle(
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
numericValue: number | null,
|
||||
): { bgColor?: string; textColor?: string } | undefined {
|
||||
const rules = colCfg.conditionalFormat;
|
||||
if (rules == null || numericValue == null || Number.isNaN(numericValue)) {
|
||||
return undefined;
|
||||
}
|
||||
if (rules.gt && numericValue > rules.gt.threshold) {
|
||||
return resolveRuleStyle(rules.gt);
|
||||
}
|
||||
if (rules.lt && numericValue < rules.lt.threshold) {
|
||||
return resolveRuleStyle(rules.lt);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetTextAlign,
|
||||
} from '../types';
|
||||
|
||||
export function resolveColumnTextAlign(
|
||||
colCfg: SpreadsheetColumnConfig | undefined,
|
||||
defaultAlign: SpreadsheetTextAlign = 'left',
|
||||
): SpreadsheetTextAlign {
|
||||
const a = colCfg?.textAlign;
|
||||
if (!a || a === 'inherit') return defaultAlign;
|
||||
return a;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { formatCellWithSuffix } from './cellDisplay';
|
||||
import type {
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetColumnFilter,
|
||||
SpreadsheetViewState,
|
||||
} from '../types';
|
||||
|
||||
type ResolveDisplayValue = (
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
) => unknown;
|
||||
|
||||
function formatDisplayText(value: unknown, colCfg: SpreadsheetColumnConfig): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
const cellType = colCfg.cellType === 'formula' ? 'number' : colCfg.cellType;
|
||||
if (colCfg.suffix) {
|
||||
return formatCellWithSuffix(value, colCfg);
|
||||
}
|
||||
if (cellType === 'number') {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? String(n) : String(value);
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function getCellDisplayText(
|
||||
row: Record<string, unknown>,
|
||||
dataRowIndex: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
resolveDisplayValue: ResolveDisplayValue,
|
||||
): string {
|
||||
const raw = resolveDisplayValue(dataRowIndex, colCfg, row);
|
||||
return formatDisplayText(raw, colCfg);
|
||||
}
|
||||
|
||||
export function collectColumnUniqueValues(
|
||||
rows: Record<string, unknown>[],
|
||||
col: number,
|
||||
columns: SpreadsheetColumnConfig[],
|
||||
resolveDisplayValue: ResolveDisplayValue,
|
||||
): string[] {
|
||||
const colCfg = columns.find((c) => c.col === col);
|
||||
if (!colCfg) return [];
|
||||
const set = new Set<string>();
|
||||
rows.forEach((row, dataRowIndex) => {
|
||||
set.add(getCellDisplayText(row, dataRowIndex, colCfg, resolveDisplayValue));
|
||||
});
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function isColumnFilterActive(filter?: SpreadsheetColumnFilter): boolean {
|
||||
return filter?.checkedValues !== undefined;
|
||||
}
|
||||
|
||||
export function isColumnFilterMatching(
|
||||
displayText: string,
|
||||
filter?: SpreadsheetColumnFilter,
|
||||
allValues?: string[],
|
||||
): boolean {
|
||||
if (!filter || filter.checkedValues === undefined) return true;
|
||||
if (filter.checkedValues.length === 0) return false;
|
||||
if (allValues && filter.checkedValues.length >= allValues.length) return true;
|
||||
return filter.checkedValues.includes(displayText);
|
||||
}
|
||||
|
||||
function compareRows(
|
||||
a: number,
|
||||
b: number,
|
||||
sortCol: number,
|
||||
rows: Record<string, unknown>[],
|
||||
columns: SpreadsheetColumnConfig[],
|
||||
resolveDisplayValue: ResolveDisplayValue,
|
||||
): number {
|
||||
const colCfg = columns.find((c) => c.col === sortCol);
|
||||
if (!colCfg) return 0;
|
||||
const rowA = rows[a];
|
||||
const rowB = rows[b];
|
||||
if (!rowA || !rowB) return 0;
|
||||
|
||||
const textA = getCellDisplayText(rowA, a, colCfg, resolveDisplayValue);
|
||||
const textB = getCellDisplayText(rowB, b, colCfg, resolveDisplayValue);
|
||||
|
||||
if (colCfg.cellType === 'number' || colCfg.cellType === 'formula') {
|
||||
const nA = Number(textA);
|
||||
const nB = Number(textB);
|
||||
const aValid = Number.isFinite(nA);
|
||||
const bValid = Number.isFinite(nB);
|
||||
if (!aValid && !bValid) return 0;
|
||||
if (!aValid) return 1;
|
||||
if (!bValid) return -1;
|
||||
return nA - nB;
|
||||
}
|
||||
return textA.localeCompare(textB, 'zh-CN');
|
||||
}
|
||||
|
||||
export function applyViewState(
|
||||
rows: Record<string, unknown>[],
|
||||
columns: SpreadsheetColumnConfig[],
|
||||
viewState: SpreadsheetViewState,
|
||||
resolveDisplayValue: ResolveDisplayValue,
|
||||
): number[] {
|
||||
const activeFilterCols = Object.entries(viewState.filters).filter(([, f]) =>
|
||||
isColumnFilterActive(f),
|
||||
);
|
||||
|
||||
let indices = rows.map((_, i) => i);
|
||||
|
||||
if (activeFilterCols.length > 0) {
|
||||
const uniqueValuesMap = new Map<number, string[]>();
|
||||
for (const [colStr] of activeFilterCols) {
|
||||
const col = Number(colStr);
|
||||
if (!uniqueValuesMap.has(col)) {
|
||||
uniqueValuesMap.set(
|
||||
col,
|
||||
collectColumnUniqueValues(
|
||||
rows,
|
||||
col,
|
||||
columns,
|
||||
resolveDisplayValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
indices = indices.filter((dataRowIndex) => {
|
||||
const row = rows[dataRowIndex];
|
||||
if (!row) return false;
|
||||
return activeFilterCols.every(([colStr, filter]) => {
|
||||
const col = Number(colStr);
|
||||
const colCfg = columns.find((c) => c.col === col);
|
||||
if (!colCfg) return true;
|
||||
const allValues = uniqueValuesMap.get(col);
|
||||
const text = getCellDisplayText(
|
||||
row,
|
||||
dataRowIndex,
|
||||
colCfg,
|
||||
resolveDisplayValue,
|
||||
);
|
||||
return isColumnFilterMatching(text, filter, allValues);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (viewState.sort) {
|
||||
const { col, direction } = viewState.sort;
|
||||
indices = [...indices].sort((a, b) => {
|
||||
const cmp = compareRows(a, b, col, rows, columns, resolveDisplayValue);
|
||||
return direction === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
return indices;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { SpreadsheetColumnConfig } from '../types';
|
||||
import {
|
||||
COLUMN_LETTER_HEIGHT,
|
||||
DEFAULT_COL_WIDTH,
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
MIN_COL_WIDTH,
|
||||
ROW_NUMBER_WIDTH,
|
||||
} from '../types';
|
||||
import { getDataColX } from '../canvas/columnLayout';
|
||||
|
||||
const CELL_PADDING = 16;
|
||||
const SAMPLE_ROWS = 50;
|
||||
|
||||
function measureTextWidth(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
): number {
|
||||
return ctx.measureText(text).width + CELL_PADDING;
|
||||
}
|
||||
|
||||
function formatCellDisplay(value: unknown, cellType: import('../types').CellType): string { if (value === null || value === undefined) return '';
|
||||
if (cellType === 'number') {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? String(n) : String(value);
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function measureColumnWidths(
|
||||
canvas: HTMLCanvasElement,
|
||||
headers: Array<{ title: string; col: number }>,
|
||||
columns: SpreadsheetColumnConfig[],
|
||||
rows: Record<string, unknown>[],
|
||||
defaultWidth: number,
|
||||
resolveDisplayValue?: (
|
||||
dataRowIndex: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row: Record<string, unknown>,
|
||||
) => unknown,
|
||||
formatWithSuffix?: (
|
||||
value: unknown,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
) => string,
|
||||
): number[] { const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return headers.map(() => defaultWidth);
|
||||
|
||||
ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
|
||||
const maxCol = Math.max(...headers.map((h) => h.col), 0);
|
||||
const widths = new Array<number>(maxCol + 1).fill(defaultWidth);
|
||||
|
||||
for (const header of headers) {
|
||||
widths[header.col] = Math.max(
|
||||
widths[header.col] ?? defaultWidth,
|
||||
measureTextWidth(ctx, header.title),
|
||||
MIN_COL_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
const sample = rows.slice(0, SAMPLE_ROWS);
|
||||
for (const colCfg of columns) {
|
||||
let maxW = widths[colCfg.col] ?? defaultWidth;
|
||||
sample.forEach((row, dataRowIdx) => {
|
||||
const raw = resolveDisplayValue
|
||||
? resolveDisplayValue(dataRowIdx, colCfg, row)
|
||||
: row[colCfg.fieldId];
|
||||
const text = formatWithSuffix
|
||||
? formatWithSuffix(raw, colCfg)
|
||||
: formatCellDisplay(raw, colCfg.cellType === 'formula' ? 'number' : colCfg.cellType);
|
||||
maxW = Math.max(maxW, measureTextWidth(ctx, text));
|
||||
});
|
||||
widths[colCfg.col] = Math.max(maxW, MIN_COL_WIDTH);
|
||||
}
|
||||
return widths;
|
||||
}
|
||||
|
||||
export function getRowHeight(
|
||||
cellType: import('../types').CellType, configured: number | 'auto' | undefined,
|
||||
): number {
|
||||
if (typeof configured === 'number') return configured;
|
||||
switch (cellType) {
|
||||
case 'image':
|
||||
case 'video':
|
||||
return 48;
|
||||
default:
|
||||
return DEFAULT_ROW_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTotalWidth(colWidths: number[]): number {
|
||||
return colWidths.reduce((sum, w) => sum + w, 0);
|
||||
}
|
||||
|
||||
export function getRowTop(rowIndex: number, rowHeight: number): number {
|
||||
return rowIndex * rowHeight;
|
||||
}
|
||||
|
||||
export function getColLeft(colIndex: number, colWidths: number[]): number {
|
||||
let left = 0;
|
||||
for (let i = 0; i < colIndex; i += 1) {
|
||||
left += colWidths[i] ?? DEFAULT_COL_WIDTH;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
function getDataColScreenX(
|
||||
col: number,
|
||||
colWidths: number[],
|
||||
scrollLeft: number,
|
||||
fixedCols: number[],
|
||||
fixedWidth: number,
|
||||
): number {
|
||||
return getDataColX(col, colWidths, scrollLeft, fixedCols, fixedWidth);
|
||||
}
|
||||
|
||||
export function hitTestCell(
|
||||
x: number,
|
||||
y: number,
|
||||
scrollTop: number,
|
||||
scrollLeft: number,
|
||||
colWidths: number[],
|
||||
rowHeight: number,
|
||||
rowCount: number,
|
||||
colCount: number,
|
||||
fixedCols: number[] = [],
|
||||
fixedWidth = ROW_NUMBER_WIDTH,
|
||||
): { row: number; col: number } | null {
|
||||
const bodyY = y + scrollTop;
|
||||
if (bodyY < 0) return null;
|
||||
|
||||
const row = Math.floor(bodyY / rowHeight);
|
||||
if (row < 0 || row >= rowCount) return null;
|
||||
|
||||
for (let col = 0; col < colCount; col += 1) {
|
||||
const cellX = getDataColScreenX(
|
||||
col,
|
||||
colWidths,
|
||||
scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const w = colWidths[col] ?? DEFAULT_COL_WIDTH;
|
||||
if (x >= cellX && x < cellX + w) {
|
||||
return { row, col };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hitTestRowNumber(
|
||||
x: number,
|
||||
y: number,
|
||||
scrollTop: number,
|
||||
rowHeight: number,
|
||||
rowCount: number,
|
||||
): number | null {
|
||||
if (x < 0 || x >= ROW_NUMBER_WIDTH) return null;
|
||||
const bodyY = y + scrollTop;
|
||||
if (bodyY < 0) return null;
|
||||
const row = Math.floor(bodyY / rowHeight);
|
||||
if (row < 0 || row >= rowCount) return null;
|
||||
return row;
|
||||
}
|
||||
|
||||
export type HeaderHitArea = 'corner' | 'columnLetter' | 'columnTitle';
|
||||
|
||||
export function hitTestHeaderArea(
|
||||
x: number,
|
||||
y: number,
|
||||
scrollLeft: number,
|
||||
colWidths: number[],
|
||||
colCount: number,
|
||||
fixedCols: number[] = [],
|
||||
fixedWidth = ROW_NUMBER_WIDTH,
|
||||
): { area: HeaderHitArea; col?: number } | null {
|
||||
if (x >= 0 && x < ROW_NUMBER_WIDTH) {
|
||||
return { area: 'corner' };
|
||||
}
|
||||
const col = hitTestHeaderCol(
|
||||
x,
|
||||
scrollLeft,
|
||||
colWidths,
|
||||
colCount,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
if (col === null) return null;
|
||||
if (y < COLUMN_LETTER_HEIGHT) {
|
||||
return { area: 'columnLetter', col };
|
||||
}
|
||||
return { area: 'columnTitle', col };
|
||||
}
|
||||
|
||||
export function hitTestHeaderCol(
|
||||
x: number,
|
||||
scrollLeft: number,
|
||||
colWidths: number[],
|
||||
colCount: number,
|
||||
fixedCols: number[] = [],
|
||||
fixedWidth = ROW_NUMBER_WIDTH,
|
||||
): number | null {
|
||||
for (let col = 0; col < colCount; col += 1) {
|
||||
const cellX = getDataColScreenX(
|
||||
col,
|
||||
colWidths,
|
||||
scrollLeft,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
);
|
||||
const w = colWidths[col] ?? DEFAULT_COL_WIDTH;
|
||||
if (x >= cellX && x < cellX + w) return col;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export { getDataColScreenX };
|
||||
|
||||
export { formatCellDisplay, CELL_PADDING };
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import type { ClipboardMatrix } from '../types';
|
||||
|
||||
export function useClipboard() {
|
||||
const internal = ref<ClipboardMatrix | null>(null);
|
||||
|
||||
function setMatrix(matrix: ClipboardMatrix) {
|
||||
internal.value = matrix;
|
||||
}
|
||||
|
||||
function getMatrix() {
|
||||
return internal.value;
|
||||
}
|
||||
|
||||
function hasContent() {
|
||||
return !!internal.value?.rows?.length;
|
||||
}
|
||||
|
||||
function matrixToTsv(matrix: ClipboardMatrix): string {
|
||||
return matrix.rows.map((row) => row.map(String).join('\t')).join('\n');
|
||||
}
|
||||
|
||||
function tsvToMatrix(text: string): unknown[][] {
|
||||
return text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.split('\n')
|
||||
.filter((line, i, arr) => line.length > 0 || i < arr.length - 1)
|
||||
.map((line) => line.split('\t'));
|
||||
}
|
||||
|
||||
async function writeSystemClipboard(matrix: ClipboardMatrix) {
|
||||
const text = matrixToTsv(matrix);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function readSystemClipboard(): Promise<string | null> {
|
||||
try {
|
||||
return await navigator.clipboard.readText();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
internal,
|
||||
setMatrix,
|
||||
getMatrix,
|
||||
hasContent,
|
||||
matrixToTsv,
|
||||
tsvToMatrix,
|
||||
writeSystemClipboard,
|
||||
readSystemClipboard,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import type { SpreadsheetHistoryEntry } from '../types';
|
||||
import {
|
||||
appendLocalHistory,
|
||||
listUnsyncedHistory,
|
||||
markHistorySynced,
|
||||
} from '../history/localHistoryStore';
|
||||
import { saveHistoryBatch } from '../history/remoteHistoryApi';
|
||||
|
||||
let syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function uuid() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
export function useEditHistory(
|
||||
tableName: string,
|
||||
sheetKey?: string,
|
||||
enabled = true,
|
||||
) {
|
||||
const userStore = useUserStore();
|
||||
|
||||
async function recordChange(
|
||||
payload: Omit<
|
||||
SpreadsheetHistoryEntry,
|
||||
'id' | 'tableName' | 'sheetKey' | 'userId' | 'userName' | 'synced' | 'createdAt'
|
||||
>,
|
||||
) {
|
||||
if (!enabled) return;
|
||||
|
||||
const entry: SpreadsheetHistoryEntry = {
|
||||
id: uuid(),
|
||||
tableName,
|
||||
sheetKey,
|
||||
userId: Number(userStore.userInfo?.userId ?? 0),
|
||||
userName: String(
|
||||
userStore.userInfo?.nick_name ??
|
||||
userStore.userInfo?.username ??
|
||||
'未知用户',
|
||||
),
|
||||
createdAt: new Date().toISOString(),
|
||||
synced: false,
|
||||
...payload,
|
||||
};
|
||||
|
||||
await appendLocalHistory(entry);
|
||||
scheduleSync();
|
||||
return entry;
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
if (syncTimer) clearTimeout(syncTimer);
|
||||
syncTimer = setTimeout(async () => {
|
||||
try {
|
||||
const pending = await listUnsyncedHistory();
|
||||
if (!pending.length) return;
|
||||
const res = await saveHistoryBatch(pending);
|
||||
await markHistorySynced(res.saved_ids ?? pending.map((p) => p.id));
|
||||
} catch {
|
||||
// retry on next change
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
return { recordChange, scheduleSync };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useFormulaReferenceMode() {
|
||||
const active = ref(false);
|
||||
const pickedCell = ref<{ row: number; col: number } | null>(null);
|
||||
const caret = ref(0);
|
||||
|
||||
function setCaret(pos: number) {
|
||||
caret.value = Math.max(0, pos);
|
||||
}
|
||||
|
||||
function enterIfNeeded(draft: string, focused: boolean, editable: boolean) {
|
||||
if (!focused || !editable) {
|
||||
active.value = false;
|
||||
pickedCell.value = null;
|
||||
return;
|
||||
}
|
||||
const on = draft.trimStart().startsWith('=');
|
||||
active.value = on;
|
||||
if (!on) pickedCell.value = null;
|
||||
}
|
||||
|
||||
function exit() {
|
||||
active.value = false;
|
||||
pickedCell.value = null;
|
||||
}
|
||||
|
||||
function insertAtCaret(draft: string, text: string): string {
|
||||
const pos = caret.value;
|
||||
const next = draft.slice(0, pos) + text + draft.slice(pos);
|
||||
caret.value = pos + text.length;
|
||||
return next;
|
||||
}
|
||||
|
||||
function pickCell(row: number, col: number) {
|
||||
pickedCell.value = { row, col };
|
||||
}
|
||||
|
||||
return {
|
||||
active,
|
||||
pickedCell,
|
||||
caret,
|
||||
setCaret,
|
||||
enterIfNeeded,
|
||||
exit,
|
||||
insertAtCaret,
|
||||
pickCell,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import type { CellRange } from '../types';
|
||||
import {
|
||||
normalizeRange,
|
||||
rangesContainCell,
|
||||
} from '../commands/FillRangeCommand';
|
||||
|
||||
export interface SelectionModifiers {
|
||||
shift?: boolean;
|
||||
ctrl?: boolean;
|
||||
}
|
||||
|
||||
function fullRowRange(row: number, maxCol: number): CellRange {
|
||||
return normalizeRange({
|
||||
startRow: row,
|
||||
endRow: row,
|
||||
startCol: 0,
|
||||
endCol: maxCol,
|
||||
});
|
||||
}
|
||||
|
||||
function fullColRange(col: number, maxRow: number): CellRange {
|
||||
return normalizeRange({
|
||||
startRow: 0,
|
||||
endRow: maxRow,
|
||||
startCol: col,
|
||||
endCol: col,
|
||||
});
|
||||
}
|
||||
|
||||
function rowRange(r0: number, r1: number, maxCol: number): CellRange {
|
||||
return normalizeRange({
|
||||
startRow: r0,
|
||||
endRow: r1,
|
||||
startCol: 0,
|
||||
endCol: maxCol,
|
||||
});
|
||||
}
|
||||
|
||||
function colRange(c0: number, c1: number, maxRow: number): CellRange {
|
||||
return normalizeRange({
|
||||
startRow: 0,
|
||||
endRow: maxRow,
|
||||
startCol: c0,
|
||||
endCol: c1,
|
||||
});
|
||||
}
|
||||
|
||||
function isFullRowRange(range: CellRange, maxCol: number): boolean {
|
||||
const r = normalizeRange(range);
|
||||
return r.startCol === 0 && r.endCol === maxCol;
|
||||
}
|
||||
|
||||
function isFullColRange(range: CellRange, maxRow: number): boolean {
|
||||
const r = normalizeRange(range);
|
||||
return r.startRow === 0 && r.endRow === maxRow;
|
||||
}
|
||||
|
||||
function rangeContainsRow(range: CellRange, row: number, maxCol: number): boolean {
|
||||
const r = normalizeRange(range);
|
||||
return isFullRowRange(r, maxCol) && row >= r.startRow && row <= r.endRow;
|
||||
}
|
||||
|
||||
function rangeContainsCol(range: CellRange, col: number, maxRow: number): boolean {
|
||||
const r = normalizeRange(range);
|
||||
return isFullColRange(r, maxRow) && col >= r.startCol && col <= r.endCol;
|
||||
}
|
||||
|
||||
export function useSelection() {
|
||||
const ranges = ref<CellRange[]>([]);
|
||||
const anchor = ref<{ row: number; col: number } | null>(null);
|
||||
const activeCell = ref<{ row: number; col: number } | null>(null);
|
||||
const isFillDragging = ref(false);
|
||||
|
||||
/** @deprecated use ranges */
|
||||
const selection = ranges;
|
||||
|
||||
function selectCell(row: number, col: number) {
|
||||
activeCell.value = { row, col };
|
||||
anchor.value = { row, col };
|
||||
ranges.value = [
|
||||
{ startRow: row, startCol: col, endRow: row, endCol: col },
|
||||
];
|
||||
}
|
||||
|
||||
function extendSelection(row: number, col: number) {
|
||||
if (!anchor.value) {
|
||||
selectCell(row, col);
|
||||
return;
|
||||
}
|
||||
ranges.value = [
|
||||
{
|
||||
startRow: anchor.value.row,
|
||||
startCol: anchor.value.col,
|
||||
endRow: row,
|
||||
endCol: col,
|
||||
},
|
||||
];
|
||||
activeCell.value = { row, col };
|
||||
}
|
||||
|
||||
function selectRange(range: CellRange) {
|
||||
const normalized = normalizeRange(range);
|
||||
ranges.value = [normalized];
|
||||
activeCell.value = {
|
||||
row: normalized.startRow,
|
||||
col: normalized.startCol,
|
||||
};
|
||||
anchor.value = { ...activeCell.value };
|
||||
}
|
||||
|
||||
function getSelectionRanges(): CellRange[] {
|
||||
return ranges.value.map((r) => normalizeRange(r));
|
||||
}
|
||||
|
||||
function getNormalizedSelection(): CellRange | null {
|
||||
const primary = getPrimaryRange();
|
||||
return primary;
|
||||
}
|
||||
|
||||
function getPrimaryRange(): CellRange | null {
|
||||
if (!ranges.value.length) return null;
|
||||
if (!activeCell.value) return normalizeRange(ranges.value[0]!);
|
||||
const { row, col } = activeCell.value;
|
||||
for (const range of ranges.value) {
|
||||
const r = normalizeRange(range);
|
||||
if (
|
||||
row >= r.startRow &&
|
||||
row <= r.endRow &&
|
||||
col >= r.startCol &&
|
||||
col <= r.endCol
|
||||
) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
return normalizeRange(ranges.value[ranges.value.length - 1]!);
|
||||
}
|
||||
|
||||
function containsCell(row: number, col: number): boolean {
|
||||
return rangesContainCell(ranges.value, row, col);
|
||||
}
|
||||
|
||||
function selectAll(maxRow: number, maxCol: number) {
|
||||
const range = normalizeRange({
|
||||
startRow: 0,
|
||||
endRow: maxRow,
|
||||
startCol: 0,
|
||||
endCol: maxCol,
|
||||
});
|
||||
ranges.value = [range];
|
||||
activeCell.value = { row: 0, col: 0 };
|
||||
anchor.value = { row: 0, col: 0 };
|
||||
}
|
||||
|
||||
function selectEntireRow(
|
||||
row: number,
|
||||
maxCol: number,
|
||||
opts: SelectionModifiers = {},
|
||||
) {
|
||||
const { shift, ctrl } = opts;
|
||||
if (ctrl) {
|
||||
const next = [...ranges.value];
|
||||
const idx = next.findIndex((r) => rangeContainsRow(r, row, maxCol));
|
||||
if (idx >= 0) {
|
||||
next.splice(idx, 1);
|
||||
} else {
|
||||
next.push(fullRowRange(row, maxCol));
|
||||
}
|
||||
ranges.value = next.length ? next : [];
|
||||
if (ranges.value.length) {
|
||||
activeCell.value = { row, col: 0 };
|
||||
if (!shift) anchor.value = { row, col: 0 };
|
||||
} else {
|
||||
activeCell.value = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (shift && anchor.value) {
|
||||
const range = rowRange(anchor.value.row, row, maxCol);
|
||||
ranges.value = [range];
|
||||
activeCell.value = { row, col: 0 };
|
||||
return;
|
||||
}
|
||||
ranges.value = [fullRowRange(row, maxCol)];
|
||||
activeCell.value = { row, col: 0 };
|
||||
anchor.value = { row, col: 0 };
|
||||
}
|
||||
|
||||
function selectEntireColumn(
|
||||
col: number,
|
||||
maxRow: number,
|
||||
opts: SelectionModifiers = {},
|
||||
) {
|
||||
const { shift, ctrl } = opts;
|
||||
if (ctrl) {
|
||||
const next = [...ranges.value];
|
||||
const idx = next.findIndex((r) => rangeContainsCol(r, col, maxRow));
|
||||
if (idx >= 0) {
|
||||
next.splice(idx, 1);
|
||||
} else {
|
||||
next.push(fullColRange(col, maxRow));
|
||||
}
|
||||
ranges.value = next.length ? next : [];
|
||||
if (ranges.value.length) {
|
||||
activeCell.value = { row: 0, col };
|
||||
if (!shift) anchor.value = { row: 0, col };
|
||||
} else {
|
||||
activeCell.value = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (shift && anchor.value) {
|
||||
const range = colRange(anchor.value.col, col, maxRow);
|
||||
ranges.value = [range];
|
||||
activeCell.value = { row: 0, col };
|
||||
return;
|
||||
}
|
||||
ranges.value = [fullColRange(col, maxRow)];
|
||||
activeCell.value = { row: 0, col };
|
||||
anchor.value = { row: 0, col };
|
||||
}
|
||||
|
||||
function extendRowSelection(row: number, maxCol: number) {
|
||||
if (!anchor.value) {
|
||||
selectEntireRow(row, maxCol);
|
||||
return;
|
||||
}
|
||||
ranges.value = [rowRange(anchor.value.row, row, maxCol)];
|
||||
activeCell.value = { row, col: 0 };
|
||||
}
|
||||
|
||||
function extendColumnSelection(col: number, maxRow: number) {
|
||||
if (!anchor.value) {
|
||||
selectEntireColumn(col, maxRow);
|
||||
return;
|
||||
}
|
||||
ranges.value = [colRange(anchor.value.col, col, maxRow)];
|
||||
activeCell.value = { row: 0, col };
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
ranges.value = [];
|
||||
anchor.value = null;
|
||||
activeCell.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
ranges,
|
||||
selection,
|
||||
anchor,
|
||||
activeCell,
|
||||
isFillDragging,
|
||||
selectCell,
|
||||
extendSelection,
|
||||
selectRange,
|
||||
selectAll,
|
||||
selectEntireRow,
|
||||
selectEntireColumn,
|
||||
extendRowSelection,
|
||||
extendColumnSelection,
|
||||
getSelectionRanges,
|
||||
getNormalizedSelection,
|
||||
getPrimaryRange,
|
||||
containsCell,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { ContextMenuItem } from '#/components/context-menu';
|
||||
import { menuDivider, showContextMenu } from '#/components/context-menu';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import type {
|
||||
CellEditContext,
|
||||
HeaderContextMenuContext,
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetEngineExpose,
|
||||
SpreadsheetHeaderConfig,
|
||||
SpreadsheetTableConfig,
|
||||
} from '../types';
|
||||
|
||||
export interface HeaderLayoutActions {
|
||||
toggleFixed: (col: number) => void;
|
||||
setHeaderColor: (col: number, color?: string) => void;
|
||||
isFixed: (col: number) => boolean;
|
||||
getHeaderColor: (col: number) => string | undefined;
|
||||
}
|
||||
|
||||
export interface SpreadsheetAdminMenuActions {
|
||||
onOpenConfig: () => void;
|
||||
onAddColumn: () => void;
|
||||
onSetColumn: (col: number) => void;
|
||||
}
|
||||
|
||||
export interface SpreadsheetMenuActions {
|
||||
col: number;
|
||||
layoutActions?: HeaderLayoutActions;
|
||||
onOpenHeaderColorPicker: (col: number) => void;
|
||||
admin?: SpreadsheetAdminMenuActions;
|
||||
}
|
||||
|
||||
function resolveCustomItems<T>(
|
||||
config:
|
||||
| ContextMenuItem[]
|
||||
| ((ctx: T) => ContextMenuItem[])
|
||||
| undefined,
|
||||
ctx: T,
|
||||
): ContextMenuItem[] {
|
||||
if (!config) return [];
|
||||
return typeof config === 'function' ? config(ctx) : config;
|
||||
}
|
||||
|
||||
function buildCellBuiltinItems(
|
||||
ctx: CellEditContext,
|
||||
engine: SpreadsheetEngineExpose,
|
||||
onStartEdit: (row: number, col: number) => void,
|
||||
): ContextMenuItem[] {
|
||||
const canEditCell = engine.canEdit(ctx.rowIndex, ctx.colIndex);
|
||||
const sel = ctx.selection;
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
iconName: 'ant-design:edit-outlined',
|
||||
disabled: !canEditCell,
|
||||
handler: () => onStartEdit(ctx.rowIndex, ctx.colIndex),
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
label: '复制',
|
||||
iconName: 'ant-design:copy-outlined',
|
||||
disabled: !sel,
|
||||
handler: () => {
|
||||
if (sel) {
|
||||
engine.copy();
|
||||
message.success('复制成功');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'copy-image',
|
||||
label: '复制为图片',
|
||||
iconName: 'ant-design:picture-outlined',
|
||||
disabled: !sel,
|
||||
handler: () => {
|
||||
if (!sel) return;
|
||||
void engine
|
||||
.copyAsImage()
|
||||
.then(() => {
|
||||
message.success('已复制为图片');
|
||||
})
|
||||
.catch(() => {
|
||||
message.warning('复制图片失败,请检查浏览器剪贴板权限');
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'paste',
|
||||
label: '粘贴',
|
||||
iconName: 'ant-design:snippets-outlined',
|
||||
disabled: !engine.canPaste(),
|
||||
handler: () => {
|
||||
void engine.paste();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'undo',
|
||||
label: '撤回',
|
||||
iconName: 'ant-design:undo-outlined',
|
||||
disabled: !engine.canUndo(),
|
||||
handler: () => {
|
||||
void engine.undo();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildFixedItem(
|
||||
col: number,
|
||||
layoutActions?: HeaderLayoutActions,
|
||||
): ContextMenuItem | null {
|
||||
if (!layoutActions) return null;
|
||||
const fixed = layoutActions.isFixed(col);
|
||||
return {
|
||||
key: 'toggle-fixed',
|
||||
label: fixed ? '取消固定' : '固定此列',
|
||||
iconName: 'ant-design:pushpin-outlined',
|
||||
handler: () => layoutActions.toggleFixed(col),
|
||||
};
|
||||
}
|
||||
|
||||
function buildHeaderColorItem(
|
||||
col: number,
|
||||
onOpen: (col: number) => void,
|
||||
): ContextMenuItem {
|
||||
return {
|
||||
key: 'header-color',
|
||||
label: '表头颜色',
|
||||
iconName: 'ant-design:bg-colors-outlined',
|
||||
handler: () => onOpen(col),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAdminFlatItemsForCol(
|
||||
col: number,
|
||||
admin: SpreadsheetAdminMenuActions,
|
||||
): ContextMenuItem[] {
|
||||
return [
|
||||
{
|
||||
key: 'admin-config',
|
||||
label: 'Excel 配置',
|
||||
iconName: 'mdi:table-cog',
|
||||
handler: () => admin.onOpenConfig(),
|
||||
},
|
||||
{
|
||||
key: 'admin-add-col',
|
||||
label: '新增一列',
|
||||
iconName: 'ant-design:plus-outlined',
|
||||
handler: () => admin.onAddColumn(),
|
||||
},
|
||||
{
|
||||
key: 'admin-set-col',
|
||||
label: '设置列',
|
||||
iconName: 'ant-design:setting-outlined',
|
||||
handler: () => admin.onSetColumn(col),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildColumnSubmenu(menuActions: SpreadsheetMenuActions): ContextMenuItem {
|
||||
const { col, layoutActions, onOpenHeaderColorPicker, admin } = menuActions;
|
||||
const children: ContextMenuItem[] = [];
|
||||
|
||||
if (admin) {
|
||||
children.push({
|
||||
key: 'admin-add-col',
|
||||
label: '新增一列',
|
||||
iconName: 'ant-design:plus-outlined',
|
||||
handler: () => admin.onAddColumn(),
|
||||
});
|
||||
children.push({
|
||||
key: 'admin-set-col',
|
||||
label: '设置列',
|
||||
iconName: 'ant-design:setting-outlined',
|
||||
handler: () => admin.onSetColumn(col),
|
||||
});
|
||||
}
|
||||
|
||||
const fixedItem = buildFixedItem(col, layoutActions);
|
||||
if (fixedItem) children.push(fixedItem);
|
||||
|
||||
children.push(buildHeaderColorItem(col, onOpenHeaderColorPicker));
|
||||
|
||||
return {
|
||||
key: 'column-submenu',
|
||||
label: '列',
|
||||
iconName: 'ant-design:table-outlined',
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function buildHeaderMenuItems(menuActions: SpreadsheetMenuActions): ContextMenuItem[] {
|
||||
const { col, layoutActions, onOpenHeaderColorPicker, admin } = menuActions;
|
||||
const items: ContextMenuItem[] = [];
|
||||
|
||||
const fixedItem = buildFixedItem(col, layoutActions);
|
||||
if (fixedItem) items.push(fixedItem);
|
||||
|
||||
items.push(buildHeaderColorItem(col, onOpenHeaderColorPicker));
|
||||
|
||||
if (admin) {
|
||||
items.push(...buildAdminFlatItemsForCol(col, admin));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildCellAdminItems(menuActions: SpreadsheetMenuActions): ContextMenuItem[] {
|
||||
if (!menuActions.admin) return [];
|
||||
return [
|
||||
{
|
||||
key: 'admin-config',
|
||||
label: 'Excel 配置',
|
||||
iconName: 'mdi:table-cog',
|
||||
handler: () => menuActions.admin!.onOpenConfig(),
|
||||
},
|
||||
buildColumnSubmenu(menuActions),
|
||||
];
|
||||
}
|
||||
|
||||
function mergeMenus(
|
||||
builtin: ContextMenuItem[],
|
||||
custom: ContextMenuItem[],
|
||||
): ContextMenuItem[] {
|
||||
if (!custom.length) return builtin;
|
||||
return [...builtin, menuDivider('__divider__'), ...custom];
|
||||
}
|
||||
|
||||
function appendAdminBlock(
|
||||
items: ContextMenuItem[],
|
||||
adminItems: ContextMenuItem[],
|
||||
): ContextMenuItem[] {
|
||||
if (!adminItems.length) return items;
|
||||
return [...items, menuDivider('__admin_divider__'), ...adminItems];
|
||||
}
|
||||
|
||||
export function openCellContextMenu(
|
||||
e: MouseEvent,
|
||||
ctx: CellEditContext,
|
||||
column: SpreadsheetColumnConfig | undefined,
|
||||
engine: SpreadsheetEngineExpose,
|
||||
onStartEdit: (row: number, col: number) => void,
|
||||
menuActions?: SpreadsheetMenuActions,
|
||||
) {
|
||||
const builtin = buildCellBuiltinItems(ctx, engine, onStartEdit);
|
||||
const custom = resolveCustomItems(column?.contextMenu, ctx);
|
||||
const adminItems = menuActions ? buildCellAdminItems(menuActions) : [];
|
||||
showContextMenu(
|
||||
e,
|
||||
appendAdminBlock(mergeMenus(builtin, custom), adminItems),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
export function openHeaderContextMenu(
|
||||
e: MouseEvent,
|
||||
ctx: HeaderContextMenuContext,
|
||||
header: SpreadsheetHeaderConfig | undefined,
|
||||
tableConfig: SpreadsheetTableConfig,
|
||||
menuActions: SpreadsheetMenuActions,
|
||||
) {
|
||||
const builtin = buildHeaderMenuItems(menuActions);
|
||||
let custom = resolveCustomItems(header?.contextMenu, ctx);
|
||||
if (!custom.length) {
|
||||
custom = resolveCustomItems(tableConfig.headerContextMenu, ctx);
|
||||
}
|
||||
showContextMenu(e, mergeMenus(builtin, custom), ctx);
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
import { computed, ref, shallowRef, watch, type Ref } from 'vue';
|
||||
|
||||
import { createFillRangeCommand, iterateRange, iterateRanges, normalizeRange, rangesContainCell } from '../commands/FillRangeCommand';
|
||||
import { createPasteRangeCommand } from '../commands/PasteRangeCommand';
|
||||
import { createSetCellValueCommand } from '../commands/SetCellValueCommand';
|
||||
import {
|
||||
computeFixedWidth,
|
||||
getContentSize,
|
||||
getFixedColIndices,
|
||||
renderSelectionImage,
|
||||
} from '../canvas/SpreadsheetRenderer';
|
||||
import { applyViewState, isColumnFilterActive } from './filterSort';
|
||||
import {
|
||||
getRowHeight,
|
||||
hitTestCell,
|
||||
hitTestHeaderCol,
|
||||
hitTestRowNumber,
|
||||
hitTestHeaderArea,
|
||||
measureColumnWidths,
|
||||
} from '../composables/useAutoMeasure';
|
||||
import { useClipboard } from '../composables/useClipboard';
|
||||
import { useEditHistory } from '../composables/useEditHistory';
|
||||
import { useSelection } from '../composables/useSelection';
|
||||
import { useUndoRedo } from '../composables/useUndoRedo';
|
||||
import type {
|
||||
CellEditContext,
|
||||
CellRange,
|
||||
SpreadsheetBatchCellChange,
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetEngineExpose,
|
||||
SpreadsheetHeaderConfig,
|
||||
SpreadsheetHistoryEntry,
|
||||
SpreadsheetTableConfig,
|
||||
SpreadsheetViewState,
|
||||
SortDirection,
|
||||
} from '../types';
|
||||
import {
|
||||
createDefaultViewState,
|
||||
DEFAULT_COL_WIDTH,
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
HEADER_HEIGHT,
|
||||
ROW_NUMBER_WIDTH,
|
||||
} from '../types';
|
||||
import {
|
||||
evaluateFormula,
|
||||
formatFormulaResult,
|
||||
type CellValueReader,
|
||||
type FormulaResult,
|
||||
} from '../formula/evaluator';
|
||||
import {
|
||||
formatCellWithSuffix,
|
||||
resolveConditionalStyle,
|
||||
} from '../composables/cellDisplay';
|
||||
|
||||
export function useSpreadsheetEngine(options: {
|
||||
tableConfig: Ref<SpreadsheetTableConfig>;
|
||||
mergedHeaders: Ref<SpreadsheetHeaderConfig[]>;
|
||||
columns: Ref<SpreadsheetColumnConfig[]>;
|
||||
data: Ref<Record<string, unknown>[]>;
|
||||
rowKeyField: Ref<string>;
|
||||
layoutColWidths: Ref<Record<number, number>>;
|
||||
scrollViewportHeight: Ref<number>;
|
||||
isDark: Ref<boolean>;
|
||||
onChange: (data: Record<string, unknown>[]) => void;
|
||||
}) {
|
||||
const sortedColumns = computed(() =>
|
||||
[...options.columns.value].sort((a, b) => a.col - b.col),
|
||||
);
|
||||
|
||||
const colWidths = ref<number[]>([]);
|
||||
const rowHeight = ref(DEFAULT_ROW_HEIGHT);
|
||||
const scrollTop = ref(0);
|
||||
const scrollLeft = ref(0);
|
||||
const viewportWidth = ref(800);
|
||||
const viewportHeight = ref(600);
|
||||
const filterValues = ref<Record<number, string>>({});
|
||||
const viewState = ref<SpreadsheetViewState>(createDefaultViewState());
|
||||
const initialSnapshot = shallowRef<Map<string, unknown>>(new Map());
|
||||
const dirtyKeys = ref<Set<string>>(new Set());
|
||||
const editing = ref<{ row: number; col: number } | null>(null);
|
||||
const measureCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
const selectionApi = useSelection();
|
||||
const undoRedo = useUndoRedo();
|
||||
const clipboard = useClipboard();
|
||||
const history = useEditHistory(
|
||||
options.tableConfig.value.tableName,
|
||||
options.tableConfig.value.sheetKey,
|
||||
options.tableConfig.value.enableHistory !== false,
|
||||
);
|
||||
|
||||
const filteredRowIndices = computed(() =>
|
||||
applyViewState(
|
||||
options.data.value,
|
||||
sortedColumns.value,
|
||||
viewState.value,
|
||||
(dataRowIndex, colCfg, row) =>
|
||||
resolveDisplayValueForDataRow(dataRowIndex, colCfg, row),
|
||||
),
|
||||
);
|
||||
|
||||
function resolveDisplayValueForDataRow(
|
||||
dataRowIndex: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (colCfg.cellType === 'formula' && colCfg.formula) {
|
||||
const result = evaluateFormula(
|
||||
colCfg.formula,
|
||||
dataRowIndex,
|
||||
(viewRow, col) => {
|
||||
const cfg = colConfig(col);
|
||||
if (!cfg || cfg.cellType === 'formula') return 0;
|
||||
const dr = options.data.value[viewRow];
|
||||
return dr ? getCellValue(viewRow, cfg.fieldId) : 0;
|
||||
},
|
||||
);
|
||||
return formatFormulaResult(result);
|
||||
}
|
||||
const rowData = row ?? options.data.value[dataRowIndex];
|
||||
if (!rowData) return undefined;
|
||||
return rowData[colCfg.fieldId];
|
||||
}
|
||||
|
||||
const fixedCols = computed(() =>
|
||||
getFixedColIndices(options.mergedHeaders.value),
|
||||
);
|
||||
|
||||
const fixedWidth = computed(() =>
|
||||
computeFixedWidth(colWidths.value, fixedCols.value),
|
||||
);
|
||||
|
||||
const displayRowCount = computed(() => {
|
||||
const minRows = Math.max(
|
||||
1,
|
||||
Math.ceil(options.scrollViewportHeight.value / rowHeight.value),
|
||||
);
|
||||
return Math.max(filteredRowIndices.value.length, minRows);
|
||||
});
|
||||
|
||||
const colCount = computed(
|
||||
() => Math.max(...sortedColumns.value.map((c) => c.col), 0) + 1,
|
||||
);
|
||||
|
||||
const defaultColWidth = computed(
|
||||
() => options.tableConfig.value.defaultColWidth ?? DEFAULT_COL_WIDTH,
|
||||
);
|
||||
|
||||
const dataContentWidth = computed(
|
||||
() => ROW_NUMBER_WIDTH + colWidths.value.reduce((s, w) => s + w, 0),
|
||||
);
|
||||
|
||||
const displayContentWidth = computed(() =>
|
||||
Math.max(dataContentWidth.value, viewportWidth.value),
|
||||
);
|
||||
|
||||
const fillerColCount = computed(() => {
|
||||
const remaining = displayContentWidth.value - dataContentWidth.value;
|
||||
if (remaining <= 0) return 0;
|
||||
return Math.ceil(remaining / defaultColWidth.value);
|
||||
});
|
||||
|
||||
const displayColCount = computed(
|
||||
() => colCount.value + fillerColCount.value,
|
||||
);
|
||||
|
||||
const paddedColWidths = computed(() => {
|
||||
const defaultW = defaultColWidth.value;
|
||||
const result: number[] = [];
|
||||
for (let c = 0; c < displayColCount.value; c += 1) {
|
||||
result.push(colWidths.value[c] ?? defaultW);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
function colConfig(col: number) {
|
||||
return sortedColumns.value.find((c) => c.col === col);
|
||||
}
|
||||
|
||||
function dataRowFromViewRow(viewRow: number) {
|
||||
return filteredRowIndices.value[viewRow] ?? viewRow;
|
||||
}
|
||||
|
||||
function viewRowFromDataRow(dataRow: number) {
|
||||
return filteredRowIndices.value.indexOf(dataRow);
|
||||
}
|
||||
|
||||
function isEditable(colCfg?: SpreadsheetColumnConfig) {
|
||||
if (!colCfg) return false;
|
||||
if (colCfg.cellType === 'readonly' || colCfg.cellType === 'formula') {
|
||||
return false;
|
||||
}
|
||||
return colCfg.editable !== false;
|
||||
}
|
||||
|
||||
const readCellForFormula: CellValueReader = (viewRow, col) => {
|
||||
const cfg = colConfig(col);
|
||||
if (!cfg || cfg.cellType === 'formula') return 0;
|
||||
const dataRow = dataRowFromViewRow(viewRow);
|
||||
return getCellValue(dataRow, cfg.fieldId);
|
||||
};
|
||||
|
||||
function resolveNumericValue(
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
): number | null {
|
||||
if (colCfg.cellType === 'formula' && colCfg.formula) {
|
||||
const result: FormulaResult = evaluateFormula(
|
||||
colCfg.formula,
|
||||
viewRow,
|
||||
readCellForFormula,
|
||||
);
|
||||
if (result === 'DIV_ZERO' || result == null) return null;
|
||||
return result;
|
||||
}
|
||||
if (colCfg.cellType === 'number' && row) {
|
||||
const n = Number(row[colCfg.fieldId]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveCellStyle(
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
) {
|
||||
return resolveConditionalStyle(
|
||||
colCfg,
|
||||
resolveNumericValue(viewRow, colCfg, row),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDisplayValue(
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (colCfg.cellType === 'formula' && colCfg.formula) {
|
||||
const result = evaluateFormula(
|
||||
colCfg.formula,
|
||||
viewRow,
|
||||
readCellForFormula,
|
||||
);
|
||||
return formatFormulaResult(result);
|
||||
}
|
||||
if (!row) return undefined;
|
||||
return row[colCfg.fieldId];
|
||||
}
|
||||
|
||||
function getCellValue(dataRow: number, fieldId: string) {
|
||||
return options.data.value[dataRow]?.[fieldId];
|
||||
}
|
||||
|
||||
function buildCellContext(viewRow: number, col: number): CellEditContext | null {
|
||||
const colCfg = colConfig(col);
|
||||
if (!colCfg) return null;
|
||||
const dataRow = dataRowFromViewRow(viewRow);
|
||||
const rowData = options.data.value[dataRow];
|
||||
if (!rowData) return null;
|
||||
return {
|
||||
fieldId: colCfg.fieldId,
|
||||
fieldValue: rowData[colCfg.fieldId],
|
||||
rowIndex: viewRow,
|
||||
colIndex: col,
|
||||
rowKey: rowData[options.rowKeyField.value] as string | number,
|
||||
rowData,
|
||||
editParams: colCfg.editParams ?? {},
|
||||
selection: selectionApi.getPrimaryRange() ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotKey(dataRow: number, fieldId: string) {
|
||||
const row = options.data.value[dataRow];
|
||||
const rowKey = row?.[options.rowKeyField.value];
|
||||
return `${rowKey}:${fieldId}`;
|
||||
}
|
||||
|
||||
function markDirty(dataRow: number, fieldId: string, value: unknown) {
|
||||
const key = snapshotKey(dataRow, fieldId);
|
||||
const initial = initialSnapshot.value.get(key);
|
||||
const next = new Set(dirtyKeys.value);
|
||||
if (initial !== undefined && initial !== value) {
|
||||
next.add(`${dataRow}:${fieldId}`);
|
||||
} else {
|
||||
next.delete(`${dataRow}:${fieldId}`);
|
||||
}
|
||||
dirtyKeys.value = next;
|
||||
}
|
||||
|
||||
function applyLocalValue(dataRow: number, fieldId: string, value: unknown) {
|
||||
const rows = options.data.value.map((r, i) =>
|
||||
i === dataRow ? { ...r, [fieldId]: value } : r,
|
||||
);
|
||||
options.onChange(rows);
|
||||
markDirty(dataRow, fieldId, value);
|
||||
}
|
||||
|
||||
async function persistChanges(
|
||||
items: Array<{ viewRow: number; col: number; value: unknown }>,
|
||||
opts: {
|
||||
recordHistory?: boolean;
|
||||
action?: SpreadsheetHistoryEntry['action'];
|
||||
} = {},
|
||||
) {
|
||||
const batchChanges: SpreadsheetBatchCellChange[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const colCfg = colConfig(item.col);
|
||||
if (!colCfg || !isEditable(colCfg)) continue;
|
||||
|
||||
const dataRow = dataRowFromViewRow(item.viewRow);
|
||||
const ctx = buildCellContext(item.viewRow, item.col);
|
||||
if (!ctx) continue;
|
||||
|
||||
const before = getCellValue(dataRow, colCfg.fieldId);
|
||||
applyLocalValue(dataRow, colCfg.fieldId, item.value);
|
||||
batchChanges.push({
|
||||
ctx: { ...ctx, fieldValue: item.value },
|
||||
before,
|
||||
after: item.value,
|
||||
col: item.col,
|
||||
viewRow: item.viewRow,
|
||||
});
|
||||
}
|
||||
|
||||
if (batchChanges.length === 0) return;
|
||||
|
||||
const onBatchEdit = options.tableConfig.value.onBatchEdit;
|
||||
if (onBatchEdit) {
|
||||
await onBatchEdit(batchChanges);
|
||||
} else {
|
||||
for (const ch of batchChanges) {
|
||||
const cfg = colConfig(ch.col);
|
||||
if (cfg?.onEdit) {
|
||||
await cfg.onEdit({ ...ch.ctx, fieldValue: ch.after });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
opts.recordHistory !== false &&
|
||||
options.tableConfig.value.enableHistory !== false
|
||||
) {
|
||||
for (const ch of batchChanges) {
|
||||
await history.recordChange({
|
||||
action: opts.action ?? 'edit',
|
||||
rowKey: ch.ctx.rowKey,
|
||||
fieldId: ch.ctx.fieldId,
|
||||
beforeSnapshot: ch.before,
|
||||
afterSnapshot: ch.after,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toPersistItems(
|
||||
batch: Array<{ row: number; col: number; value: unknown }>,
|
||||
) {
|
||||
return batch.map((ch) => ({
|
||||
viewRow: ch.row,
|
||||
col: ch.col,
|
||||
value: ch.value,
|
||||
}));
|
||||
}
|
||||
|
||||
async function commitCellChange(
|
||||
viewRow: number,
|
||||
col: number,
|
||||
newValue: unknown,
|
||||
recordHistory = true,
|
||||
action: SpreadsheetHistoryEntry['action'] = 'edit',
|
||||
) {
|
||||
const colCfg = colConfig(col);
|
||||
if (!colCfg || !isEditable(colCfg)) return;
|
||||
|
||||
const dataRow = dataRowFromViewRow(viewRow);
|
||||
const before = getCellValue(dataRow, colCfg.fieldId);
|
||||
if (before === newValue) return;
|
||||
|
||||
await persistChanges([{ viewRow, col, value: newValue }], {
|
||||
recordHistory,
|
||||
action,
|
||||
});
|
||||
|
||||
undoRedo.push(
|
||||
createSetCellValueCommand(
|
||||
() =>
|
||||
persistChanges([{ viewRow, col, value: before }], {
|
||||
recordHistory: false,
|
||||
}),
|
||||
() =>
|
||||
persistChanges([{ viewRow, col, value: newValue }], {
|
||||
recordHistory: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function setCellValue(
|
||||
viewRow: number,
|
||||
col: number,
|
||||
value: unknown,
|
||||
recordHistory = true,
|
||||
action: SpreadsheetHistoryEntry['action'] = 'edit',
|
||||
) {
|
||||
await commitCellChange(viewRow, col, value, recordHistory, action);
|
||||
}
|
||||
|
||||
async function fillRange(source: CellRange, target: CellRange) {
|
||||
const src = normalizeRange(source);
|
||||
const tgt = normalizeRange(target);
|
||||
const before: Array<{ row: number; col: number; value: unknown }> = [];
|
||||
const after: Array<{ row: number; col: number; value: unknown }> = [];
|
||||
|
||||
const srcRows = src.endRow - src.startRow + 1;
|
||||
const srcCols = src.endCol - src.startCol + 1;
|
||||
|
||||
iterateRange(tgt, (row, col) => {
|
||||
const colCfg = colConfig(col);
|
||||
if (!colCfg || !isEditable(colCfg)) return;
|
||||
const relRow = (row - tgt.startRow) % srcRows;
|
||||
const relCol = (col - tgt.startCol) % srcCols;
|
||||
const srcRow = src.startRow + relRow;
|
||||
const srcCol = src.startCol + relCol;
|
||||
const srcColCfg = colConfig(srcCol);
|
||||
if (!srcColCfg) return;
|
||||
|
||||
const dataRow = dataRowFromViewRow(row);
|
||||
const beforeVal = getCellValue(dataRow, colCfg.fieldId);
|
||||
|
||||
const afterVal = getCellValue(
|
||||
dataRowFromViewRow(srcRow),
|
||||
srcColCfg.fieldId,
|
||||
);
|
||||
|
||||
before.push({ row, col, value: beforeVal });
|
||||
after.push({ row, col, value: afterVal });
|
||||
});
|
||||
|
||||
await persistChanges(toPersistItems(after), {
|
||||
recordHistory: true,
|
||||
action: 'fill',
|
||||
});
|
||||
|
||||
undoRedo.push(
|
||||
createFillRangeCommand(
|
||||
(batch) => persistChanges(toPersistItems(batch), { recordHistory: false }),
|
||||
before,
|
||||
after,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function copy(selectionOverride?: CellRange | CellRange[]) {
|
||||
const selRanges = Array.isArray(selectionOverride)
|
||||
? selectionOverride.map((r) => normalizeRange(r))
|
||||
: selectionOverride
|
||||
? [normalizeRange(selectionOverride)]
|
||||
: selectionApi.getSelectionRanges();
|
||||
if (!selRanges.length) return;
|
||||
|
||||
const rowSet = new Set<number>();
|
||||
const colSet = new Set<number>();
|
||||
iterateRanges(selRanges, (viewRow, col) => {
|
||||
rowSet.add(viewRow);
|
||||
colSet.add(col);
|
||||
});
|
||||
|
||||
const uniqueRows = [...rowSet].sort((a, b) => a - b);
|
||||
const uniqueCols = [...colSet].sort((a, b) => a - b);
|
||||
if (!uniqueRows.length || !uniqueCols.length) return;
|
||||
|
||||
const rows: unknown[][] = uniqueRows.map((viewRow) =>
|
||||
uniqueCols.map((col) => {
|
||||
if (!rangesContainCell(selRanges, viewRow, col)) return '';
|
||||
const colCfg = colConfig(col);
|
||||
if (!colCfg) return '';
|
||||
const dataRow = dataRowFromViewRow(viewRow);
|
||||
const row = options.data.value[dataRow];
|
||||
const raw = resolveDisplayValue(viewRow, colCfg, row);
|
||||
return formatCellWithSuffix(raw, colCfg);
|
||||
}),
|
||||
);
|
||||
|
||||
const matrix = {
|
||||
rows,
|
||||
startRow: uniqueRows[0]!,
|
||||
startCol: uniqueCols[0]!,
|
||||
};
|
||||
clipboard.setMatrix(matrix);
|
||||
void clipboard.writeSystemClipboard(matrix);
|
||||
}
|
||||
|
||||
async function copyAsImage(selectionOverride?: CellRange | CellRange[]) {
|
||||
const selRanges = Array.isArray(selectionOverride)
|
||||
? selectionOverride.map((r) => normalizeRange(r))
|
||||
: selectionOverride
|
||||
? [normalizeRange(selectionOverride)]
|
||||
: selectionApi.getSelectionRanges();
|
||||
if (!selRanges.length) return;
|
||||
const blob = await renderSelectionImage({
|
||||
selectionRanges: selRanges,
|
||||
headers: options.mergedHeaders.value,
|
||||
columns: sortedColumns.value,
|
||||
colWidths: paddedColWidths.value,
|
||||
rowHeight: rowHeight.value,
|
||||
defaultColWidth: defaultColWidth.value,
|
||||
defaultTextAlign: options.tableConfig.value.defaultTextAlign ?? 'left',
|
||||
isDark: options.isDark.value,
|
||||
filteredRowIndices: filteredRowIndices.value,
|
||||
rows: options.data.value,
|
||||
dirtyKeys: dirtyKeys.value,
|
||||
resolveDisplayValue,
|
||||
resolveCellStyle,
|
||||
});
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function paste() {
|
||||
let matrix = clipboard.getMatrix();
|
||||
if (!matrix) {
|
||||
const text = await clipboard.readSystemClipboard();
|
||||
if (!text) return;
|
||||
matrix = {
|
||||
rows: clipboard.tsvToMatrix(text),
|
||||
startRow: 0,
|
||||
startCol: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const start = selectionApi.activeCell.value ?? {
|
||||
row: 0,
|
||||
col: sortedColumns.value[0]?.col ?? 0,
|
||||
};
|
||||
|
||||
const before: Array<{ row: number; col: number; value: unknown }> = [];
|
||||
const after: Array<{ row: number; col: number; value: unknown }> = [];
|
||||
|
||||
matrix.rows.forEach((rowVals, rOff) => {
|
||||
rowVals.forEach((val, cOff) => {
|
||||
const viewRow = start.row + rOff;
|
||||
const col = start.col + cOff;
|
||||
const colCfg = colConfig(col);
|
||||
if (!colCfg || !isEditable(colCfg)) return;
|
||||
const dataRow = dataRowFromViewRow(viewRow);
|
||||
if (dataRow >= options.data.value.length) return;
|
||||
before.push({
|
||||
row: viewRow,
|
||||
col,
|
||||
value: getCellValue(dataRow, colCfg.fieldId),
|
||||
});
|
||||
after.push({ row: viewRow, col, value: val });
|
||||
});
|
||||
});
|
||||
|
||||
const applyBatch = (
|
||||
changes: Array<{ row: number; col: number; value: unknown }>,
|
||||
) => persistChanges(toPersistItems(changes), { recordHistory: false });
|
||||
|
||||
await persistChanges(toPersistItems(after), {
|
||||
recordHistory: true,
|
||||
action: 'paste',
|
||||
});
|
||||
|
||||
undoRedo.push(createPasteRangeCommand(applyBatch, before, after));
|
||||
}
|
||||
|
||||
function remeasure() {
|
||||
if (!measureCanvas.value) return;
|
||||
const defaultW =
|
||||
options.tableConfig.value.defaultColWidth ?? DEFAULT_COL_WIDTH;
|
||||
const measured = measureColumnWidths(
|
||||
measureCanvas.value,
|
||||
options.mergedHeaders.value,
|
||||
sortedColumns.value,
|
||||
options.data.value,
|
||||
defaultW,
|
||||
(dataRowIdx, colCfg, row) =>
|
||||
resolveDisplayValue(dataRowIdx, colCfg, row),
|
||||
formatCellWithSuffix,
|
||||
);
|
||||
const overrides = options.layoutColWidths.value;
|
||||
colWidths.value = measured.map((w, i) => overrides[i] ?? w);
|
||||
const firstType = sortedColumns.value[0]?.cellType ?? 'text';
|
||||
rowHeight.value = getRowHeight(
|
||||
firstType,
|
||||
options.tableConfig.value.rowHeight,
|
||||
);
|
||||
}
|
||||
|
||||
function initSnapshot() {
|
||||
const map = new Map<string, unknown>();
|
||||
options.data.value.forEach((row, dataRow) => {
|
||||
sortedColumns.value.forEach((col) => {
|
||||
map.set(snapshotKey(dataRow, col.fieldId), row[col.fieldId]);
|
||||
});
|
||||
});
|
||||
initialSnapshot.value = map;
|
||||
dirtyKeys.value = new Set();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => options.data.value,
|
||||
() => {
|
||||
if (initialSnapshot.value.size === 0) initSnapshot();
|
||||
remeasure();
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => options.mergedHeaders.value,
|
||||
() => remeasure(),
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => options.layoutColWidths.value,
|
||||
() => remeasure(),
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const contentSize = computed(() =>
|
||||
getContentSize(
|
||||
colWidths.value,
|
||||
displayRowCount.value,
|
||||
rowHeight.value,
|
||||
displayContentWidth.value,
|
||||
),
|
||||
);
|
||||
|
||||
function setColumnFilter(col: number, checkedValues: string[] | undefined) {
|
||||
const filters = { ...viewState.value.filters };
|
||||
if (checkedValues === undefined) {
|
||||
delete filters[col];
|
||||
} else {
|
||||
filters[col] = { checkedValues };
|
||||
}
|
||||
viewState.value = { ...viewState.value, filters };
|
||||
}
|
||||
|
||||
function clearColumnFilter(col: number) {
|
||||
const next = { ...viewState.value.filters };
|
||||
delete next[col];
|
||||
viewState.value = { ...viewState.value, filters: next };
|
||||
}
|
||||
|
||||
function setSort(col: number, direction: SortDirection) {
|
||||
viewState.value = { ...viewState.value, sort: { col, direction } };
|
||||
}
|
||||
|
||||
function clearSort() {
|
||||
viewState.value = { ...viewState.value, sort: null };
|
||||
}
|
||||
|
||||
const engine: SpreadsheetEngineExpose = {
|
||||
copy,
|
||||
copyAsImage,
|
||||
paste,
|
||||
undo: undoRedo.undo,
|
||||
redo: undoRedo.redo,
|
||||
canUndo: undoRedo.canUndo,
|
||||
canRedo: undoRedo.canRedo,
|
||||
canCopy: () => selectionApi.getSelectionRanges().length > 0,
|
||||
canCopyAsImage: () => selectionApi.getSelectionRanges().length > 0,
|
||||
canPaste: () => clipboard.hasContent() || true,
|
||||
canEdit: (_row, col) => {
|
||||
const cfg = colConfig(col);
|
||||
return !!cfg && isEditable(cfg);
|
||||
},
|
||||
getSelection: selectionApi.getSelectionRanges,
|
||||
getCellEditContext: (row, col) => buildCellContext(row, col),
|
||||
getHeaderContext: (col) => {
|
||||
const header =
|
||||
options.mergedHeaders.value.find((h) => h.col === col) ?? {
|
||||
title: '',
|
||||
col,
|
||||
};
|
||||
return {
|
||||
colIndex: col,
|
||||
fieldId: header.fieldId ?? colConfig(col)?.fieldId,
|
||||
headerTitle: header.title,
|
||||
filterValue: isColumnFilterActive(viewState.value.filters[col])
|
||||
? 'active'
|
||||
: '',
|
||||
};
|
||||
},
|
||||
setCellValue,
|
||||
fillRange,
|
||||
getColumnConfig: colConfig,
|
||||
getFilterValue: (col) =>
|
||||
isColumnFilterActive(viewState.value.filters[col]) ? 'active' : '',
|
||||
};
|
||||
|
||||
return {
|
||||
engine,
|
||||
sortedColumns,
|
||||
colWidths,
|
||||
rowHeight,
|
||||
scrollTop,
|
||||
scrollLeft,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
filterValues,
|
||||
viewState,
|
||||
setColumnFilter,
|
||||
clearColumnFilter,
|
||||
setSort,
|
||||
clearSort,
|
||||
dirtyKeys,
|
||||
editing,
|
||||
measureCanvas,
|
||||
filteredRowIndices,
|
||||
contentSize,
|
||||
displayRowCount,
|
||||
displayColCount,
|
||||
displayContentWidth,
|
||||
defaultColWidth,
|
||||
paddedColWidths,
|
||||
fixedCols,
|
||||
fixedWidth,
|
||||
colCount,
|
||||
selectionApi,
|
||||
undoRedo,
|
||||
dataRowFromViewRow,
|
||||
viewRowFromDataRow,
|
||||
colConfig,
|
||||
isEditable,
|
||||
resolveDisplayValue,
|
||||
resolveCellStyle,
|
||||
buildCellContext,
|
||||
commitCellChange,
|
||||
remeasure,
|
||||
initSnapshot,
|
||||
hitTestCell: (x: number, y: number) =>
|
||||
hitTestCell(
|
||||
x,
|
||||
y,
|
||||
scrollTop.value,
|
||||
scrollLeft.value,
|
||||
paddedColWidths.value,
|
||||
rowHeight.value,
|
||||
displayRowCount.value,
|
||||
colCount.value,
|
||||
fixedCols.value,
|
||||
fixedWidth.value,
|
||||
),
|
||||
hitTestHeaderCol: (x: number) =>
|
||||
hitTestHeaderCol(
|
||||
x,
|
||||
scrollLeft.value,
|
||||
paddedColWidths.value,
|
||||
displayColCount.value,
|
||||
fixedCols.value,
|
||||
fixedWidth.value,
|
||||
),
|
||||
hitTestRowNumber: (x: number, y: number) =>
|
||||
hitTestRowNumber(
|
||||
x,
|
||||
y,
|
||||
scrollTop.value,
|
||||
rowHeight.value,
|
||||
displayRowCount.value,
|
||||
),
|
||||
hitTestHeaderArea: (x: number, y: number) =>
|
||||
hitTestHeaderArea(
|
||||
x,
|
||||
y,
|
||||
scrollLeft.value,
|
||||
paddedColWidths.value,
|
||||
displayColCount.value,
|
||||
fixedCols.value,
|
||||
fixedWidth.value,
|
||||
),
|
||||
HEADER_HEIGHT,
|
||||
ROW_NUMBER_WIDTH,
|
||||
};
|
||||
}
|
||||
|
||||
export type SpreadsheetEngine = ReturnType<typeof useSpreadsheetEngine>;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { computed, ref, watch, type Ref } from 'vue';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
|
||||
import { formatCellWithSuffix } from './cellDisplay';
|
||||
import type { SpreadsheetColumnConfig } from '../types';
|
||||
|
||||
export interface FindMatch {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
export function useSpreadsheetFind(options: {
|
||||
filteredRowIndices: Ref<number[]>;
|
||||
columns: Ref<SpreadsheetColumnConfig[]>;
|
||||
rows: Ref<Record<string, unknown>[]>;
|
||||
resolveDisplayValue: (
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
) => unknown;
|
||||
}) {
|
||||
const open = ref(false);
|
||||
const query = ref('');
|
||||
const matches = ref<FindMatch[]>([]);
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
function computeMatches(keyword: string): FindMatch[] {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
|
||||
const result: FindMatch[] = [];
|
||||
const cols = [...options.columns.value].sort((a, b) => a.col - b.col);
|
||||
const rowCount = options.filteredRowIndices.value.length;
|
||||
|
||||
for (let viewRow = 0; viewRow < rowCount; viewRow += 1) {
|
||||
const dataRow = options.filteredRowIndices.value[viewRow];
|
||||
const row =
|
||||
dataRow !== undefined ? options.rows.value[dataRow] : undefined;
|
||||
if (!row) continue;
|
||||
|
||||
for (const colCfg of cols) {
|
||||
const raw = options.resolveDisplayValue(viewRow, colCfg, row);
|
||||
const text = formatCellWithSuffix(raw, colCfg).toLowerCase();
|
||||
if (text.includes(q)) {
|
||||
result.push({ row: viewRow, col: colCfg.col });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const debouncedSearch = useDebounceFn((keyword: string) => {
|
||||
matches.value = computeMatches(keyword);
|
||||
activeIndex.value = matches.value.length > 0 ? 0 : -1;
|
||||
}, 150);
|
||||
|
||||
watch(query, (v) => {
|
||||
if (!open.value) return;
|
||||
void debouncedSearch(v);
|
||||
});
|
||||
|
||||
const matchCount = computed(() => matches.value.length);
|
||||
|
||||
const activeMatch = computed(() => {
|
||||
if (activeIndex.value < 0 || activeIndex.value >= matches.value.length) {
|
||||
return null;
|
||||
}
|
||||
return matches.value[activeIndex.value] ?? null;
|
||||
});
|
||||
|
||||
function openFind(initialQuery?: string) {
|
||||
open.value = true;
|
||||
if (initialQuery !== undefined) {
|
||||
query.value = initialQuery;
|
||||
}
|
||||
matches.value = computeMatches(query.value);
|
||||
activeIndex.value = matches.value.length > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
function closeFind() {
|
||||
open.value = false;
|
||||
query.value = '';
|
||||
matches.value = [];
|
||||
activeIndex.value = -1;
|
||||
}
|
||||
|
||||
function toggleFind() {
|
||||
if (open.value) closeFind();
|
||||
else openFind();
|
||||
}
|
||||
|
||||
function goNext(): FindMatch | null {
|
||||
if (!matches.value.length) return null;
|
||||
activeIndex.value =
|
||||
activeIndex.value < 0
|
||||
? 0
|
||||
: (activeIndex.value + 1) % matches.value.length;
|
||||
return matches.value[activeIndex.value] ?? null;
|
||||
}
|
||||
|
||||
function goPrev(): FindMatch | null {
|
||||
if (!matches.value.length) return null;
|
||||
activeIndex.value =
|
||||
activeIndex.value < 0
|
||||
? matches.value.length - 1
|
||||
: (activeIndex.value - 1 + matches.value.length) %
|
||||
matches.value.length;
|
||||
return matches.value[activeIndex.value] ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
query,
|
||||
matches,
|
||||
activeIndex,
|
||||
matchCount,
|
||||
activeMatch,
|
||||
openFind,
|
||||
closeFind,
|
||||
toggleFind,
|
||||
goNext,
|
||||
goPrev,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import type { SpreadsheetCommand } from '../types';
|
||||
|
||||
const MAX_STACK = 100;
|
||||
|
||||
export function useUndoRedo() {
|
||||
const undoStack = ref<SpreadsheetCommand[]>([]);
|
||||
const redoStack = ref<SpreadsheetCommand[]>([]);
|
||||
|
||||
function push(cmd: SpreadsheetCommand) {
|
||||
undoStack.value.push(cmd);
|
||||
if (undoStack.value.length > MAX_STACK) {
|
||||
undoStack.value.shift();
|
||||
}
|
||||
redoStack.value = [];
|
||||
}
|
||||
|
||||
async function undo() {
|
||||
const cmd = undoStack.value.pop();
|
||||
if (!cmd) return;
|
||||
await cmd.undo();
|
||||
redoStack.value.push(cmd);
|
||||
}
|
||||
|
||||
async function redo() {
|
||||
const cmd = redoStack.value.pop();
|
||||
if (!cmd) return;
|
||||
await cmd.redo();
|
||||
undoStack.value.push(cmd);
|
||||
}
|
||||
|
||||
function canUndo() {
|
||||
return undoStack.value.length > 0;
|
||||
}
|
||||
|
||||
function canRedo() {
|
||||
return redoStack.value.length > 0;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
undoStack.value = [];
|
||||
redoStack.value = [];
|
||||
}
|
||||
|
||||
return { push, undo, redo, canUndo, canRedo, clear };
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { Input, InputNumber } from 'ant-design-vue';
|
||||
|
||||
import { getDataColScreenX } from '../composables/useAutoMeasure';
|
||||
|
||||
import type { SpreadsheetColumnConfig } from '../types';
|
||||
|
||||
const BLUR_GUARD_MS = 150;
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
viewRow: number;
|
||||
col: number;
|
||||
colWidths: number[];
|
||||
rowHeight: number;
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
fixedCols: number[];
|
||||
fixedWidth: number;
|
||||
isDark?: boolean;
|
||||
column?: SpreadsheetColumnConfig;
|
||||
value: unknown;
|
||||
draftSeed?: string | number | null;
|
||||
selectAllOnFocus?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
commit: [value: unknown];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const inputRef = ref<{ focus?: () => void; select?: () => void } | null>(null);
|
||||
const draft = ref<string | number>('');
|
||||
const ignoreBlurUntil = ref(0);
|
||||
const suppressBlurCommit = ref(false);
|
||||
const isCommitting = ref(false);
|
||||
|
||||
const style = computed(() => {
|
||||
const left = getDataColScreenX(
|
||||
props.col,
|
||||
props.colWidths,
|
||||
props.scrollLeft,
|
||||
props.fixedCols,
|
||||
props.fixedWidth,
|
||||
);
|
||||
const top = props.viewRow * props.rowHeight - props.scrollTop;
|
||||
const width = props.colWidths[props.col] ?? 100;
|
||||
return {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${props.rowHeight}px`,
|
||||
};
|
||||
});
|
||||
|
||||
function getNativeInput(): HTMLInputElement | null {
|
||||
const el = inputRef.value as
|
||||
| { focus?: () => void; select?: () => void; $el?: HTMLElement }
|
||||
| null;
|
||||
if (!el) return null;
|
||||
if (el.$el) {
|
||||
return el.$el.querySelector('input') as HTMLInputElement | null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function focusInput(options?: { selectAll?: boolean }) {
|
||||
const el = inputRef.value as
|
||||
| { focus?: () => void; select?: () => void; $el?: HTMLElement }
|
||||
| null;
|
||||
const native = getNativeInput();
|
||||
|
||||
if (native) {
|
||||
native.focus({ preventScroll: true });
|
||||
if (options?.selectAll) {
|
||||
native.select();
|
||||
} else {
|
||||
const len = String(native.value ?? '').length;
|
||||
native.setSelectionRange(len, len);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!el) return;
|
||||
if (typeof el.focus === 'function') {
|
||||
el.focus();
|
||||
if (options?.selectAll) {
|
||||
el.select?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function blurInput() {
|
||||
getNativeInput()?.blur();
|
||||
}
|
||||
|
||||
function getDraftValue(): unknown {
|
||||
return props.column?.cellType === 'number'
|
||||
? Number(draft.value)
|
||||
: draft.value;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (v, _old, onCleanup) => {
|
||||
let cancelled = false;
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
if (!v) {
|
||||
suppressBlurCommit.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
ignoreBlurUntil.value = Date.now() + BLUR_GUARD_MS;
|
||||
|
||||
if (props.draftSeed != null) {
|
||||
draft.value =
|
||||
props.column?.cellType === 'number'
|
||||
? Number(props.draftSeed)
|
||||
: String(props.draftSeed);
|
||||
} else {
|
||||
draft.value =
|
||||
props.column?.cellType === 'number'
|
||||
? Number(props.value ?? 0)
|
||||
: String(props.value ?? '');
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
if (cancelled || !props.visible) return;
|
||||
focusInput({ selectAll: props.selectAllOnFocus !== false });
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function commitFromEnter() {
|
||||
if (isCommitting.value) return;
|
||||
isCommitting.value = true;
|
||||
suppressBlurCommit.value = true;
|
||||
submit();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commitFromEnter();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressBlurCommit.value = true;
|
||||
blurInput();
|
||||
emit('cancel');
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
emit('commit', getDraftValue());
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (Date.now() < ignoreBlurUntil.value) return;
|
||||
if (suppressBlurCommit.value) return;
|
||||
submit();
|
||||
}
|
||||
|
||||
function stopPointer(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getDraftValue,
|
||||
blurInput,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="cell-editor-overlay"
|
||||
:class="{ 'is-dark': isDark }"
|
||||
:style="style"
|
||||
@mousedown.stop="stopPointer"
|
||||
@click.stop="stopPointer"
|
||||
>
|
||||
<InputNumber
|
||||
v-if="column?.cellType === 'number'"
|
||||
ref="inputRef"
|
||||
v-model:value="draft as number"
|
||||
class="cell-editor-input"
|
||||
size="small"
|
||||
:controls="false"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
ref="inputRef"
|
||||
v-model:value="draft as string"
|
||||
class="cell-editor-input"
|
||||
size="small"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cell-editor-overlay {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #165dff;
|
||||
background: #fff;
|
||||
|
||||
&.is-dark {
|
||||
border-color: #1668dc;
|
||||
background: #141414;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-editor-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
:deep(.ant-input-number),
|
||||
:deep(.ant-input) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
export type CellValueReader = (viewRow: number, col: number) => unknown;
|
||||
|
||||
export type FormulaResult = number | 'DIV_ZERO' | null;
|
||||
|
||||
class DivZeroError extends Error {
|
||||
constructor() {
|
||||
super('Division by zero');
|
||||
this.name = 'DivZeroError';
|
||||
}
|
||||
}
|
||||
|
||||
export function colLettersToIndex(letters: string): number {
|
||||
let n = 0;
|
||||
for (const ch of letters.toUpperCase()) {
|
||||
n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||
}
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
export function colIndexToLetters(index: number): string {
|
||||
let n = index + 1;
|
||||
let result = '';
|
||||
while (n > 0) {
|
||||
n -= 1;
|
||||
result = String.fromCharCode(65 + (n % 26)) + result;
|
||||
n = Math.floor(n / 26);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (value == null || value === '') return 0;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : Number.NaN;
|
||||
}
|
||||
|
||||
function tokenize(expr: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let i = 0;
|
||||
while (i < expr.length) {
|
||||
const ch = expr[i]!;
|
||||
if (ch === ' ' || ch === '\t') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if ('+-*/()'.includes(ch)) {
|
||||
tokens.push(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (/[0-9.]/.test(ch)) {
|
||||
let num = ch;
|
||||
i += 1;
|
||||
while (i < expr.length && /[0-9.]/.test(expr[i]!)) {
|
||||
num += expr[i];
|
||||
i += 1;
|
||||
}
|
||||
tokens.push(num);
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z]/.test(ch)) {
|
||||
let ref = ch;
|
||||
i += 1;
|
||||
while (i < expr.length && /[A-Za-z0-9]/.test(expr[i]!)) {
|
||||
ref += expr[i];
|
||||
i += 1;
|
||||
}
|
||||
tokens.push(ref);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unexpected character: ${ch}`);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function parseFactor(
|
||||
tokens: string[],
|
||||
pos: { i: number },
|
||||
readCell: CellValueReader,
|
||||
viewRow: number,
|
||||
): number {
|
||||
const token = tokens[pos.i];
|
||||
if (token === undefined) throw new Error('Unexpected end');
|
||||
|
||||
if (token === '(') {
|
||||
pos.i += 1;
|
||||
const value = parseExpression(tokens, pos, readCell, viewRow);
|
||||
if (tokens[pos.i] !== ')') throw new Error('Missing )');
|
||||
pos.i += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
if (token === '+') {
|
||||
pos.i += 1;
|
||||
return parseFactor(tokens, pos, readCell, viewRow);
|
||||
}
|
||||
|
||||
if (token === '-') {
|
||||
pos.i += 1;
|
||||
return -parseFactor(tokens, pos, readCell, viewRow);
|
||||
}
|
||||
|
||||
if (/^[0-9]+(\.[0-9]+)?$/.test(token)) {
|
||||
pos.i += 1;
|
||||
return Number(token);
|
||||
}
|
||||
|
||||
const match = /^([A-Za-z]+)(\d+)$/.exec(token);
|
||||
if (match) {
|
||||
pos.i += 1;
|
||||
const col = colLettersToIndex(match[1]!);
|
||||
return toNumber(readCell(viewRow, col));
|
||||
}
|
||||
|
||||
throw new Error(`Invalid token: ${token}`);
|
||||
}
|
||||
|
||||
function parseTerm(
|
||||
tokens: string[],
|
||||
pos: { i: number },
|
||||
readCell: CellValueReader,
|
||||
viewRow: number,
|
||||
): number {
|
||||
let value = parseFactor(tokens, pos, readCell, viewRow);
|
||||
while (pos.i < tokens.length) {
|
||||
const op = tokens[pos.i];
|
||||
if (op === '*' || op === '/') {
|
||||
pos.i += 1;
|
||||
const rhs = parseFactor(tokens, pos, readCell, viewRow);
|
||||
if (op === '/') {
|
||||
if (rhs === 0) throw new DivZeroError();
|
||||
value /= rhs;
|
||||
} else {
|
||||
value *= rhs;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseExpression(
|
||||
tokens: string[],
|
||||
pos: { i: number },
|
||||
readCell: CellValueReader,
|
||||
viewRow: number,
|
||||
): number {
|
||||
let value = parseTerm(tokens, pos, readCell, viewRow);
|
||||
while (pos.i < tokens.length) {
|
||||
const op = tokens[pos.i];
|
||||
if (op === '+' || op === '-') {
|
||||
pos.i += 1;
|
||||
const rhs = parseTerm(tokens, pos, readCell, viewRow);
|
||||
value = op === '+' ? value + rhs : value - rhs;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function evaluateFormula(
|
||||
formula: string,
|
||||
viewRow: number,
|
||||
readCell: CellValueReader,
|
||||
): FormulaResult {
|
||||
if (!formula?.trim()) return null;
|
||||
let expr = formula.trim();
|
||||
if (expr.startsWith('=')) expr = expr.slice(1);
|
||||
if (!expr) return null;
|
||||
|
||||
try {
|
||||
const tokens = tokenize(expr);
|
||||
const pos = { i: 0 };
|
||||
const result = parseExpression(tokens, pos, readCell, viewRow);
|
||||
if (pos.i < tokens.length) throw new Error('Trailing tokens');
|
||||
if (!Number.isFinite(result)) return null;
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err instanceof DivZeroError) return 'DIV_ZERO';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFormulaResult(value: FormulaResult): string {
|
||||
if (value === 'DIV_ZERO') return '-';
|
||||
if (value == null || Number.isNaN(value)) return '#ERROR';
|
||||
if (Number.isInteger(value)) return String(value);
|
||||
return String(Math.round(value * 100) / 100);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
SubMenu,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
|
||||
import type { SortDirection } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
col: number;
|
||||
title: string;
|
||||
left: number;
|
||||
width: number;
|
||||
top: number;
|
||||
active: boolean;
|
||||
sortDirection?: SortDirection | null;
|
||||
uniqueValues: string[];
|
||||
checkedValues?: string[];
|
||||
disabled?: boolean;
|
||||
isDark?: boolean;
|
||||
loadingValues?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [direction: SortDirection];
|
||||
clearSort: [];
|
||||
clearFilter: [];
|
||||
open: [];
|
||||
'update:checkedValues': [values: string[] | undefined];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const search = ref('');
|
||||
|
||||
const effectiveChecked = computed(() => {
|
||||
if (props.checkedValues === undefined) {
|
||||
return new Set(props.uniqueValues);
|
||||
}
|
||||
return new Set(props.checkedValues);
|
||||
});
|
||||
|
||||
const filteredValues = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const list = props.uniqueValues;
|
||||
if (!q) return list.slice(0, 200);
|
||||
return list.filter((v) => v.toLowerCase().includes(q)).slice(0, 200);
|
||||
});
|
||||
|
||||
const allChecked = computed(
|
||||
() =>
|
||||
props.uniqueValues.length > 0 &&
|
||||
props.uniqueValues.every((v) => effectiveChecked.value.has(v)),
|
||||
);
|
||||
|
||||
const indeterminate = computed(() => {
|
||||
if (allChecked.value) return false;
|
||||
return props.uniqueValues.some((v) => effectiveChecked.value.has(v));
|
||||
});
|
||||
|
||||
function toggleValue(value: string, checked: boolean) {
|
||||
const next = new Set(effectiveChecked.value);
|
||||
if (checked) next.add(value);
|
||||
else next.delete(value);
|
||||
emit('update:checkedValues', [...next]);
|
||||
}
|
||||
|
||||
function toggleAll(checked: boolean) {
|
||||
if (checked) {
|
||||
emit('update:checkedValues', undefined);
|
||||
} else {
|
||||
emit('update:checkedValues', []);
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, (v) => {
|
||||
if (v) {
|
||||
emit('open');
|
||||
} else {
|
||||
search.value = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="header-column-filter"
|
||||
:class="{ 'is-dark': isDark }"
|
||||
:style="{ left: `${left}px`, top: `${top}px`, width: `${width}px` }"
|
||||
>
|
||||
<Dropdown
|
||||
v-model:open="open"
|
||||
:trigger="['click']"
|
||||
placement="bottomRight"
|
||||
:disabled="disabled"
|
||||
:overlay-class-name="isDark ? 'sheet-header-filter-dropdown is-dark' : 'sheet-header-filter-dropdown'"
|
||||
>
|
||||
<Button
|
||||
class="filter-btn"
|
||||
:class="{
|
||||
'is-active': active,
|
||||
'is-sorted': !!sortDirection,
|
||||
}"
|
||||
size="small"
|
||||
type="text"
|
||||
@click.stop
|
||||
>
|
||||
<MIcon
|
||||
:icon="
|
||||
sortDirection === 'asc'
|
||||
? 'ant-design:sort-ascending-outlined'
|
||||
: sortDirection === 'desc'
|
||||
? 'ant-design:sort-descending-outlined'
|
||||
: 'ant-design:filter-outlined'
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<template #overlay>
|
||||
<div class="filter-panel" :class="{ 'is-dark': isDark }" @click.stop>
|
||||
<Menu mode="vertical">
|
||||
<SubMenu key="sort" title="排序">
|
||||
<MenuItem @click="emit('sort', 'asc')">升序</MenuItem>
|
||||
<MenuItem @click="emit('sort', 'desc')">降序</MenuItem>
|
||||
<MenuItem @click="emit('clearSort')">清除排序</MenuItem>
|
||||
</SubMenu>
|
||||
<MenuItem @click="emit('clearFilter')">清除筛选</MenuItem>
|
||||
</Menu>
|
||||
<Divider style="margin: 8px 0" />
|
||||
<Input
|
||||
v-model:value="search"
|
||||
allow-clear
|
||||
placeholder="搜索"
|
||||
size="small"
|
||||
/>
|
||||
<div v-if="loadingValues" class="value-loading">加载中...</div>
|
||||
<div v-else class="value-list">
|
||||
<Checkbox
|
||||
:checked="allChecked"
|
||||
:indeterminate="indeterminate"
|
||||
@change="(e) => toggleAll(!!e.target.checked)"
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
v-for="val in filteredValues"
|
||||
:key="val || '__empty__'"
|
||||
:checked="effectiveChecked.has(val)"
|
||||
@change="(e) => toggleValue(val, !!e.target.checked)"
|
||||
>
|
||||
{{ val || '(空白)' }}
|
||||
</Checkbox>
|
||||
<div
|
||||
v-if="uniqueValues.length > 200 && !search.trim()"
|
||||
class="value-hint"
|
||||
>
|
||||
值过多,请搜索缩小范围
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-column-filter {
|
||||
position: absolute;
|
||||
height: 36px;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
pointer-events: auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #86909c;
|
||||
|
||||
&.is-active,
|
||||
&.is-sorted {
|
||||
color: #1668dc;
|
||||
background: rgba(22, 104, 220, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.header-column-filter.is-dark .filter-btn {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&.is-active,
|
||||
&.is-sorted {
|
||||
color: #3c89e8;
|
||||
background: rgba(22, 104, 220, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
width: 240px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
|
||||
&.is-dark {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #424242;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
|
||||
|
||||
:deep(.ant-menu) {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item),
|
||||
:deep(.ant-menu-submenu-title) {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
|
||||
&:hover {
|
||||
background: #303030 !important;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-divider) {
|
||||
border-color: #424242;
|
||||
}
|
||||
|
||||
:deep(.ant-input) {
|
||||
background: #141414;
|
||||
border-color: #424242;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-input-clear-icon) {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
:deep(.ant-checkbox-wrapper) {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.value-hint,
|
||||
.value-loading {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.value-hint,
|
||||
.value-loading {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
padding: 4px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.sheet-header-filter-dropdown.is-dark {
|
||||
.ant-dropdown-menu {
|
||||
background: #1f1f1f;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { getDataColScreenX } from '../composables/useAutoMeasure';
|
||||
import {
|
||||
collectColumnUniqueValues,
|
||||
isColumnFilterActive,
|
||||
} from '../composables/filterSort';
|
||||
import type {
|
||||
SortDirection,
|
||||
SpreadsheetColumnConfig,
|
||||
SpreadsheetHeaderConfig,
|
||||
SpreadsheetViewState,
|
||||
} from '../types';
|
||||
import { COLUMN_LETTER_HEIGHT, HEADER_TITLE_HEIGHT } from '../types';
|
||||
|
||||
import HeaderColumnFilter from './HeaderColumnFilter.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
headers: SpreadsheetHeaderConfig[];
|
||||
columns: SpreadsheetColumnConfig[];
|
||||
rows: Record<string, unknown>[];
|
||||
colWidths: number[];
|
||||
scrollLeft: number;
|
||||
fixedCols: number[];
|
||||
fixedWidth: number;
|
||||
viewportWidth: number;
|
||||
viewState: SpreadsheetViewState;
|
||||
enableFilter?: boolean;
|
||||
isDark?: boolean;
|
||||
resolveDisplayValue: (
|
||||
viewRow: number,
|
||||
colCfg: SpreadsheetColumnConfig,
|
||||
row?: Record<string, unknown>,
|
||||
) => unknown;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [col: number, direction: SortDirection];
|
||||
clearSort: [];
|
||||
clearFilter: [col: number];
|
||||
'update:columnFilter': [col: number, checkedValues: string[] | undefined];
|
||||
}>();
|
||||
|
||||
const uniqueCache = ref<Record<number, string[]>>({});
|
||||
const loadingCols = ref<Set<number>>(new Set());
|
||||
const rowsVersion = ref(0);
|
||||
|
||||
const filterableHeaders = computed(() =>
|
||||
props.headers.filter((h) => h.filterable !== false),
|
||||
);
|
||||
|
||||
function invalidateCache() {
|
||||
uniqueCache.value = {};
|
||||
rowsVersion.value += 1;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.rows.length,
|
||||
invalidateCache,
|
||||
);
|
||||
|
||||
function ensureUniqueValues(col: number) {
|
||||
if (uniqueCache.value[col]?.length !== undefined) return;
|
||||
loadingCols.value = new Set([...loadingCols.value, col]);
|
||||
uniqueCache.value[col] = collectColumnUniqueValues(
|
||||
props.rows,
|
||||
col,
|
||||
props.columns,
|
||||
props.resolveDisplayValue,
|
||||
);
|
||||
const next = new Set(loadingCols.value);
|
||||
next.delete(col);
|
||||
loadingCols.value = next;
|
||||
}
|
||||
|
||||
function onFilterOpen(col: number) {
|
||||
ensureUniqueValues(col);
|
||||
}
|
||||
|
||||
function getUniqueValues(col: number): string[] {
|
||||
return uniqueCache.value[col] ?? [];
|
||||
}
|
||||
|
||||
function isLoadingCol(col: number): boolean {
|
||||
return loadingCols.value.has(col);
|
||||
}
|
||||
|
||||
function headerLeft(col: number): number {
|
||||
return getDataColScreenX(
|
||||
col,
|
||||
props.colWidths,
|
||||
props.scrollLeft,
|
||||
props.fixedCols,
|
||||
props.fixedWidth,
|
||||
);
|
||||
}
|
||||
|
||||
function isFilterActive(col: number): boolean {
|
||||
return isColumnFilterActive(props.viewState.filters[col]);
|
||||
}
|
||||
|
||||
function sortDirection(col: number): SortDirection | null {
|
||||
if (props.viewState.sort?.col === col) {
|
||||
return props.viewState.sort.direction;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="enableFilter !== false"
|
||||
class="header-filter-layer"
|
||||
:style="{ width: `${viewportWidth}px`, height: `${COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT}px` }"
|
||||
>
|
||||
<HeaderColumnFilter
|
||||
v-for="header in filterableHeaders"
|
||||
:key="header.col"
|
||||
:active="isFilterActive(header.col)"
|
||||
:checked-values="viewState.filters[header.col]?.checkedValues"
|
||||
:col="header.col"
|
||||
:is-dark="isDark"
|
||||
:left="headerLeft(header.col)"
|
||||
:loading-values="isLoadingCol(header.col)"
|
||||
:sort-direction="sortDirection(header.col)"
|
||||
:title="header.title"
|
||||
:top="COLUMN_LETTER_HEIGHT"
|
||||
:unique-values="getUniqueValues(header.col)"
|
||||
:width="colWidths[header.col] ?? 100"
|
||||
@clear-filter="emit('clearFilter', header.col)"
|
||||
@clear-sort="emit('clearSort')"
|
||||
@open="onFilterOpen(header.col)"
|
||||
@sort="(dir) => emit('sort', header.col, dir)"
|
||||
@update:checked-values="
|
||||
(v) => emit('update:columnFilter', header.col, v)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-filter-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import ColorPicker from '#/components/form/components/color-picker.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
col: number;
|
||||
color: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:color': [value: string];
|
||||
apply: [col: number, color: string | undefined];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const draftColor = ref(props.color);
|
||||
|
||||
watch(
|
||||
() => props.color,
|
||||
(v) => {
|
||||
draftColor.value = v;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
draftColor.value = props.color;
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
} else {
|
||||
removeListeners();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function removeListeners() {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const target = e.target as Node | null;
|
||||
const panel = panelRef.value;
|
||||
if (panel && target && !panel.contains(target)) {
|
||||
emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
const panelRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function onColorUpdate(value: string) {
|
||||
draftColor.value = value;
|
||||
emit('update:color', value);
|
||||
emit('apply', props.col, value || undefined);
|
||||
}
|
||||
|
||||
onUnmounted(removeListeners);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="panelRef"
|
||||
class="header-color-picker-popover"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<div class="popover-title">表头颜色</div>
|
||||
<ColorPicker
|
||||
:value="draftColor"
|
||||
placeholder="选择颜色"
|
||||
@update:value="onColorUpdate"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-color-picker-popover {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.popover-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.is-dark .header-color-picker-popover {
|
||||
background: #1f1f1f;
|
||||
border-color: #424242;
|
||||
|
||||
.popover-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SpreadsheetLayoutState } from '../types';
|
||||
|
||||
function storageKey(tableName: string, sheetKey?: string) {
|
||||
return `xk_spreadsheet_layout_${tableName}_${sheetKey ?? 'default'}`;
|
||||
}
|
||||
|
||||
export function readSpreadsheetLayout(
|
||||
tableName: string,
|
||||
sheetKey?: string,
|
||||
): SpreadsheetLayoutState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(tableName, sheetKey));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as SpreadsheetLayoutState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSpreadsheetLayout(
|
||||
tableName: string,
|
||||
sheetKey: string | undefined,
|
||||
state: SpreadsheetLayoutState,
|
||||
) {
|
||||
localStorage.setItem(storageKey(tableName, sheetKey), JSON.stringify(state));
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function debouncedWriteSpreadsheetLayout(
|
||||
tableName: string,
|
||||
sheetKey: string | undefined,
|
||||
state: SpreadsheetLayoutState,
|
||||
delayMs = 400,
|
||||
) {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
writeSpreadsheetLayout(tableName, sheetKey, state);
|
||||
saveTimer = null;
|
||||
}, delayMs);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { SpreadsheetHistoryEntry } from '../types';
|
||||
|
||||
const DB_NAME = 'xk_spreadsheet_history';
|
||||
const STORE_NAME = 'entries';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
store.createIndex('tableName', 'tableName', { unique: false });
|
||||
store.createIndex('tableSheet', ['tableName', 'sheetKey'], {
|
||||
unique: false,
|
||||
});
|
||||
store.createIndex('createdAt', 'createdAt', { unique: false });
|
||||
store.createIndex('synced', 'synced', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function appendLocalHistory(entry: SpreadsheetHistoryEntry) {
|
||||
const db = await openDb();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).put(entry);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listLocalHistory(
|
||||
tableName: string,
|
||||
sheetKey?: string,
|
||||
limit = 100,
|
||||
): Promise<SpreadsheetHistoryEntry[]> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const index = store.index('tableName');
|
||||
const req = index.getAll(tableName);
|
||||
req.onsuccess = () => {
|
||||
let items = (req.result as SpreadsheetHistoryEntry[]) ?? [];
|
||||
if (sheetKey) {
|
||||
items = items.filter((i) => i.sheetKey === sheetKey);
|
||||
}
|
||||
items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
resolve(items.slice(0, limit));
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUnsyncedHistory(): Promise<SpreadsheetHistoryEntry[]> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const req = tx.objectStore(STORE_NAME).getAll();
|
||||
req.onsuccess = () => {
|
||||
const items = ((req.result as SpreadsheetHistoryEntry[]) ?? []).filter(
|
||||
(i) => !i.synced,
|
||||
);
|
||||
resolve(items);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function markHistorySynced(ids: string[]) {
|
||||
if (!ids.length) return;
|
||||
const db = await openDb();
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
for (const id of ids) {
|
||||
const getReq = store.get(id);
|
||||
getReq.onsuccess = () => {
|
||||
const entry = getReq.result as SpreadsheetHistoryEntry | undefined;
|
||||
if (entry) {
|
||||
entry.synced = true;
|
||||
store.put(entry);
|
||||
}
|
||||
};
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
import type { SpreadsheetHistoryEntry } from '../types';
|
||||
|
||||
const prefix = 'spreadsheet-history/';
|
||||
|
||||
export interface RemoteHistoryItem {
|
||||
id: number;
|
||||
table_name: string;
|
||||
sheet_key?: string;
|
||||
admin_user_id: number;
|
||||
admin_user_name: string;
|
||||
row_key: string;
|
||||
field_id: string;
|
||||
action: string;
|
||||
before_snapshot: unknown;
|
||||
after_snapshot: unknown;
|
||||
client_entry_id?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function saveHistoryBatch(
|
||||
entries: SpreadsheetHistoryEntry[],
|
||||
) {
|
||||
return requestClient.post<{ saved_ids: string[] }>(`${prefix}save-batch`, {
|
||||
entries: entries.map((e) => ({
|
||||
client_entry_id: e.id,
|
||||
table_name: e.tableName,
|
||||
sheet_key: e.sheetKey,
|
||||
row_key: String(e.rowKey),
|
||||
field_id: e.fieldId,
|
||||
action: e.action,
|
||||
before_snapshot: e.beforeSnapshot,
|
||||
after_snapshot: e.afterSnapshot,
|
||||
created_at: e.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRemoteHistory(params: {
|
||||
table_name: string;
|
||||
sheet_key?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
return requestClient.get<{
|
||||
items: RemoteHistoryItem[];
|
||||
total: number;
|
||||
}>(`${prefix}list`, { params });
|
||||
}
|
||||
|
||||
export async function rollbackRemoteHistory(id: number) {
|
||||
return requestClient.post<{ success: boolean }>(`${prefix}rollback`, { id });
|
||||
}
|
||||
4
apps/web-antd/src/components/canvas-spreadsheet/index.ts
Normal file
4
apps/web-antd/src/components/canvas-spreadsheet/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as CanvasSpreadsheet } from './CanvasSpreadsheet.vue';
|
||||
export * from './types';
|
||||
export { resolveColumnTextAlign } from './composables/columnTextAlign';
|
||||
export { colIndexToLetters, colLettersToIndex } from './formula/evaluator';
|
||||
@@ -0,0 +1,161 @@
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Input } from 'ant-design-vue';
|
||||
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
query: string;
|
||||
matchCount: number;
|
||||
activeIndex: number;
|
||||
isDark?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:query': [value: string];
|
||||
close: [];
|
||||
next: [];
|
||||
prev: [];
|
||||
}>();
|
||||
|
||||
const inputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
void nextTick(() => {
|
||||
const el = inputRef.value?.$el?.querySelector?.('input') as
|
||||
| HTMLInputElement
|
||||
| undefined;
|
||||
el?.focus();
|
||||
el?.select();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function onInput(v: string) {
|
||||
emit('update:query', v);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
emit('close');
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) emit('prev');
|
||||
else emit('next');
|
||||
}
|
||||
}
|
||||
|
||||
const countLabel = () => {
|
||||
if (!props.query.trim()) return '';
|
||||
if (props.matchCount === 0) return '无匹配';
|
||||
const current = props.activeIndex >= 0 ? props.activeIndex + 1 : 0;
|
||||
return `${current} / ${props.matchCount}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="open"
|
||||
class="spreadsheet-find-bar"
|
||||
:class="{ 'is-dark': isDark }"
|
||||
@mousedown.stop
|
||||
>
|
||||
<Input
|
||||
ref="inputRef"
|
||||
class="find-input"
|
||||
:value="query"
|
||||
placeholder="查找"
|
||||
size="small"
|
||||
allow-clear
|
||||
@update:value="onInput"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<span class="find-count">{{ countLabel() }}</span>
|
||||
<Button
|
||||
class="find-nav-btn"
|
||||
size="small"
|
||||
type="text"
|
||||
title="上一个 (Shift+Enter)"
|
||||
:disabled="matchCount === 0"
|
||||
@click="emit('prev')"
|
||||
>
|
||||
<MIcon icon="mdi:chevron-up" />
|
||||
</Button>
|
||||
<Button
|
||||
class="find-nav-btn"
|
||||
size="small"
|
||||
type="text"
|
||||
title="下一个 (Enter)"
|
||||
:disabled="matchCount === 0"
|
||||
@click="emit('next')"
|
||||
>
|
||||
<MIcon icon="mdi:chevron-down" />
|
||||
</Button>
|
||||
<Button
|
||||
class="find-close-btn"
|
||||
size="small"
|
||||
type="text"
|
||||
title="关闭 (Esc)"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<MIcon icon="mdi:close" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.spreadsheet-find-bar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
|
||||
&.is-dark {
|
||||
background: #1f1f1f;
|
||||
border-color: #424242;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.find-input {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.find-count {
|
||||
min-width: 52px;
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
|
||||
.is-dark & {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.find-nav-btn,
|
||||
.find-close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Input, Popover } from 'ant-design-vue';
|
||||
|
||||
import type { SpreadsheetFormulaItem } from '#/api/spreadsheet-formula';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
cellAddress: string;
|
||||
sourceValue: string;
|
||||
editable: boolean;
|
||||
formulas: SpreadsheetFormulaItem[];
|
||||
isDark?: boolean;
|
||||
referenceMode?: boolean;
|
||||
caretPosition?: number;
|
||||
}>(),
|
||||
{
|
||||
isDark: false,
|
||||
referenceMode: false,
|
||||
caretPosition: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
commit: [value: string];
|
||||
'draft-change': [value: string];
|
||||
'caret-change': [position: number];
|
||||
}>();
|
||||
|
||||
const draft = ref('');
|
||||
const fxOpen = ref(false);
|
||||
const focused = ref(false);
|
||||
const inputRef = ref<{ input: HTMLInputElement } | null>(null);
|
||||
|
||||
function syncDraftFromProps() {
|
||||
if (focused.value) return;
|
||||
draft.value = props.sourceValue;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.cellAddress,
|
||||
() => syncDraftFromProps(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.sourceValue,
|
||||
() => syncDraftFromProps(),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.caretPosition,
|
||||
async (pos) => {
|
||||
if (!focused.value || pos == null) return;
|
||||
await nextTick();
|
||||
const el = inputRef.value?.input;
|
||||
if (el) {
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function syncCaret() {
|
||||
const el = inputRef.value?.input;
|
||||
if (!el) return;
|
||||
emit('caret-change', el.selectionStart ?? draft.value.length);
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
focused.value = true;
|
||||
syncCaret();
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
focused.value = false;
|
||||
if (!props.editable || props.referenceMode) return;
|
||||
emit('commit', draft.value);
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
focused.value = false;
|
||||
if (!props.editable) return;
|
||||
emit('commit', draft.value);
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
emit('draft-change', draft.value);
|
||||
syncCaret();
|
||||
}
|
||||
|
||||
function insertFormula(item: SpreadsheetFormulaItem) {
|
||||
draft.value = item.expression_template.replace(/REF(\d+)/g, (_, n) => {
|
||||
return `${String.fromCharCode(65 + Number(n))}1`;
|
||||
});
|
||||
emit('draft-change', draft.value);
|
||||
fxOpen.value = false;
|
||||
}
|
||||
|
||||
function insertAtCaret(text: string) {
|
||||
const pos = props.caretPosition ?? draft.value.length;
|
||||
draft.value = draft.value.slice(0, pos) + text + draft.value.slice(pos);
|
||||
const nextCaret = pos + text.length;
|
||||
emit('draft-change', draft.value);
|
||||
emit('caret-change', nextCaret);
|
||||
void nextTick(() => {
|
||||
const el = inputRef.value?.input;
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.setSelectionRange(nextCaret, nextCaret);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
inputRef.value?.input?.focus();
|
||||
}
|
||||
|
||||
const formulaList = computed(() => props.formulas);
|
||||
|
||||
defineExpose({
|
||||
insertAtCaret,
|
||||
focusInput,
|
||||
getDraft: () => draft.value,
|
||||
setDraft: (v: string) => {
|
||||
draft.value = v;
|
||||
emit('draft-change', v);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="spreadsheet-formula-bar"
|
||||
:class="{ 'is-dark': isDark }"
|
||||
>
|
||||
<div class="cell-address">{{ cellAddress || ' ' }}</div>
|
||||
<Popover
|
||||
v-model:open="fxOpen"
|
||||
placement="bottomLeft"
|
||||
trigger="click"
|
||||
:overlay-class-name="
|
||||
isDark ? 'sheet-fx-popover is-dark' : 'sheet-fx-popover'
|
||||
"
|
||||
>
|
||||
<template #content>
|
||||
<div class="fx-list" :class="{ 'is-dark': isDark }">
|
||||
<div
|
||||
v-for="item in formulaList"
|
||||
:key="item.id"
|
||||
class="fx-item"
|
||||
@click="insertFormula(item)"
|
||||
>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.expression_template }}</span>
|
||||
<small v-if="item.description">{{ item.description }}</small>
|
||||
</div>
|
||||
<div v-if="!formulaList.length" class="fx-empty">暂无公式</div>
|
||||
</div>
|
||||
</template>
|
||||
<Button class="fx-btn" size="small" type="text">fx</Button>
|
||||
</Popover>
|
||||
<Input
|
||||
ref="inputRef"
|
||||
v-model:value="draft"
|
||||
class="formula-input"
|
||||
:readonly="!editable"
|
||||
@blur="onBlur"
|
||||
@click="syncCaret"
|
||||
@focus="onFocus"
|
||||
@input="onInput"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
@keyup="syncCaret"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.spreadsheet-formula-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-top: 1px solid #e5e6eb;
|
||||
background: #fafafa;
|
||||
|
||||
&.is-dark {
|
||||
background: #1f1f1f;
|
||||
border-top-color: #424242;
|
||||
|
||||
.cell-address {
|
||||
background: #141414;
|
||||
border-color: #424242;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.fx-btn {
|
||||
color: #3c89e8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cell-address {
|
||||
min-width: 48px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 8px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.fx-btn {
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
color: #1668dc;
|
||||
}
|
||||
|
||||
.formula-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fx-list {
|
||||
width: 320px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fx-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #4e5969;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 11px;
|
||||
color: #86909c;
|
||||
}
|
||||
}
|
||||
|
||||
.fx-list.is-dark {
|
||||
.fx-item {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
|
||||
&:hover {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
span {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
small {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.fx-empty {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.fx-empty {
|
||||
padding: 12px;
|
||||
color: #86909c;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.sheet-fx-popover.is-dark {
|
||||
.ant-popover-inner {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #424242;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
message,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Table,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
createSpreadsheetFormula,
|
||||
deleteSpreadsheetFormula,
|
||||
listSpreadsheetFormula,
|
||||
type SpreadsheetFormulaItem,
|
||||
updateSpreadsheetFormula,
|
||||
} from '#/api/spreadsheet-formula';
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: [];
|
||||
}>();
|
||||
|
||||
const items = ref<SpreadsheetFormulaItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
const formCode = ref('');
|
||||
const formName = ref('');
|
||||
const formDesc = ref('');
|
||||
const formExpression = ref('');
|
||||
const formRefSlots = ref('[{"slot":"REF0","label":"参数1"}]');
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '全局公式库',
|
||||
class: 'w-[720px]',
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) void loadList();
|
||||
},
|
||||
});
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
items.value = await listSpreadsheetFormula();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null;
|
||||
formCode.value = '';
|
||||
formName.value = '';
|
||||
formDesc.value = '';
|
||||
formExpression.value = '';
|
||||
formRefSlots.value = '[{"slot":"REF0","label":"参数1"}]';
|
||||
}
|
||||
|
||||
function startEdit(item: SpreadsheetFormulaItem) {
|
||||
editingId.value = item.id;
|
||||
formCode.value = item.code;
|
||||
formName.value = item.name;
|
||||
formDesc.value = item.description;
|
||||
formExpression.value = item.expression_template;
|
||||
formRefSlots.value = JSON.stringify(item.ref_slots, null, 2);
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
let refSlots: { slot: string; label: string }[];
|
||||
try {
|
||||
refSlots = JSON.parse(formRefSlots.value);
|
||||
if (!Array.isArray(refSlots)) throw new Error('invalid');
|
||||
} catch {
|
||||
message.error('ref_slots 必须是 JSON 数组');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateSpreadsheetFormula({
|
||||
id: editingId.value,
|
||||
code: formCode.value,
|
||||
name: formName.value,
|
||||
description: formDesc.value,
|
||||
expression_template: formExpression.value,
|
||||
ref_slots: refSlots,
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await createSpreadsheetFormula({
|
||||
code: formCode.value,
|
||||
name: formName.value,
|
||||
description: formDesc.value,
|
||||
expression_template: formExpression.value,
|
||||
ref_slots: refSlots,
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
resetForm();
|
||||
await loadList();
|
||||
emit('updated');
|
||||
} catch (err) {
|
||||
message.error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(id: number) {
|
||||
await deleteSpreadsheetFormula(id);
|
||||
message.success('已删除');
|
||||
await loadList();
|
||||
emit('updated');
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: '编码', dataIndex: 'code', width: 120 },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{ title: '表达式', dataIndex: 'expression_template' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
},
|
||||
]);
|
||||
|
||||
defineExpose({
|
||||
open: () => modalApi.open(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal>
|
||||
<div class="formula-library">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<Space>
|
||||
<Button size="small" type="link" @click="startEdit(record)">
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确认删除?" @confirm="removeItem(record.id)">
|
||||
<Button danger size="small" type="link">删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<div class="formula-form">
|
||||
<Space direction="vertical" style="width: 100%">
|
||||
<Input v-model:value="formCode" placeholder="编码 code" />
|
||||
<Input v-model:value="formName" placeholder="显示名称" />
|
||||
<Input v-model:value="formDesc" placeholder="说明" />
|
||||
<Input
|
||||
v-model:value="formExpression"
|
||||
placeholder="表达式模板,如 =(REF0-REF1)/REF0"
|
||||
/>
|
||||
<Input.TextArea
|
||||
v-model:value="formRefSlots"
|
||||
:rows="4"
|
||||
placeholder='ref_slots JSON,如 [{"slot":"REF0","label":"被减数"}]'
|
||||
/>
|
||||
<Space>
|
||||
<Button type="primary" @click="saveForm">
|
||||
{{ editingId ? '保存' : '新建' }}
|
||||
</Button>
|
||||
<Button @click="resetForm">清空</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.formula-library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.formula-form {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e5e6eb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Empty,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
|
||||
import { listLocalHistory } from '../history/localHistoryStore';
|
||||
import { listRemoteHistory } from '../history/remoteHistoryApi';
|
||||
import type { SpreadsheetHistoryEntry, SpreadsheetTableConfig } from '../types';
|
||||
|
||||
import SpreadsheetFormulaLibraryModal from './SpreadsheetFormulaLibraryModal.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
tableConfig: SpreadsheetTableConfig;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isDark?: boolean;
|
||||
refreshing?: boolean;
|
||||
showConfigButton?: boolean;
|
||||
showFormulaLibrary?: boolean;
|
||||
workbookName?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
undo: [];
|
||||
redo: [];
|
||||
refresh: [];
|
||||
openConfig: [];
|
||||
rollback: [entry: SpreadsheetHistoryEntry];
|
||||
formulaLibraryUpdated: [];
|
||||
}>();
|
||||
|
||||
const historyOpen = ref(false);
|
||||
const historyItems = ref<SpreadsheetHistoryEntry[]>([]);
|
||||
const loadingHistory = ref(false);
|
||||
const formulaLibraryRef = ref<InstanceType<
|
||||
typeof SpreadsheetFormulaLibraryModal
|
||||
> | null>(null);
|
||||
let historyLoadToken = 0;
|
||||
|
||||
function invalidateHistoryLoad() {
|
||||
historyLoadToken += 1;
|
||||
loadingHistory.value = false;
|
||||
}
|
||||
|
||||
async function openHistory() {
|
||||
historyOpen.value = true;
|
||||
const token = ++historyLoadToken;
|
||||
loadingHistory.value = true;
|
||||
try {
|
||||
const local = await listLocalHistory(
|
||||
props.tableConfig.tableName,
|
||||
props.tableConfig.sheetKey,
|
||||
);
|
||||
if (token !== historyLoadToken) return;
|
||||
try {
|
||||
const remote = await listRemoteHistory({
|
||||
table_name: props.tableConfig.tableName,
|
||||
sheet_key: props.tableConfig.sheetKey,
|
||||
pageSize: 50,
|
||||
});
|
||||
if (token !== historyLoadToken) return;
|
||||
const merged = [...local];
|
||||
for (const r of remote.items ?? []) {
|
||||
if (!merged.some((m) => m.id === r.client_entry_id)) {
|
||||
merged.push({
|
||||
id: r.client_entry_id ?? String(r.id),
|
||||
tableName: r.table_name,
|
||||
sheetKey: r.sheet_key,
|
||||
userId: r.admin_user_id,
|
||||
userName: r.admin_user_name,
|
||||
action: r.action as SpreadsheetHistoryEntry['action'],
|
||||
rowKey: r.row_key,
|
||||
fieldId: r.field_id,
|
||||
beforeSnapshot: r.before_snapshot,
|
||||
afterSnapshot: r.after_snapshot,
|
||||
createdAt: r.created_at,
|
||||
synced: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
historyItems.value = merged.slice(0, 100);
|
||||
} catch {
|
||||
if (token !== historyLoadToken) return;
|
||||
historyItems.value = local;
|
||||
}
|
||||
} finally {
|
||||
if (token === historyLoadToken) {
|
||||
loadingHistory.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRollback(entry: SpreadsheetHistoryEntry) {
|
||||
emit('rollback', entry);
|
||||
historyOpen.value = false;
|
||||
}
|
||||
|
||||
function openFormulaLibrary() {
|
||||
formulaLibraryRef.value?.open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="spreadsheet-toolbar" :class="{ 'is-dark': isDark }">
|
||||
<div class="toolbar-row toolbar-main">
|
||||
<Tag v-if="workbookName" class="workbook-tag" color="processing">
|
||||
{{ workbookName }}
|
||||
</Tag>
|
||||
<span v-else class="workbook-tag plain">{{ tableConfig.tableName }}</span>
|
||||
|
||||
<span class="toolbar-divider" />
|
||||
|
||||
<Space :size="4">
|
||||
<Tooltip title="撤回 (Ctrl+Z)">
|
||||
<Button size="small" :disabled="!canUndo" @click="emit('undo')">
|
||||
<MIcon icon="ant-design:undo-outlined" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="重做 (Ctrl+Y)">
|
||||
<Button size="small" :disabled="!canRedo" @click="emit('redo')">
|
||||
<MIcon icon="ant-design:redo-outlined" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="刷新">
|
||||
<Button
|
||||
size="small"
|
||||
:disabled="refreshing"
|
||||
:loading="refreshing"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<MIcon icon="ant-design:reload-outlined" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
v-if="tableConfig.enableHistory !== false"
|
||||
size="small"
|
||||
@click="openHistory"
|
||||
>
|
||||
<MIcon icon="ant-design:history-outlined" />
|
||||
历史
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<span class="toolbar-divider" />
|
||||
|
||||
<Space :size="4">
|
||||
<Button
|
||||
v-if="showConfigButton"
|
||||
size="small"
|
||||
@click="emit('openConfig')"
|
||||
>
|
||||
<MIcon icon="mdi:table-cog" />
|
||||
Excel 配置
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showFormulaLibrary"
|
||||
size="small"
|
||||
@click="openFormulaLibrary"
|
||||
>
|
||||
<MIcon icon="mdi:function-variant" />
|
||||
公式库
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<slot name="formula-bar" />
|
||||
|
||||
<SpreadsheetFormulaLibraryModal
|
||||
ref="formulaLibraryRef"
|
||||
@updated="emit('formulaLibraryUpdated')"
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
v-model:open="historyOpen"
|
||||
:title="`${workbookName || tableConfig.tableName} - 编辑历史`"
|
||||
width="480"
|
||||
@after-open-change="(open: boolean) => { if (!open) invalidateHistoryLoad(); }"
|
||||
>
|
||||
<div v-if="loadingHistory" class="history-loading">加载中...</div>
|
||||
<Empty v-else-if="!historyItems.length" description="暂无历史记录" />
|
||||
<div v-else class="history-list">
|
||||
<div v-for="item in historyItems" :key="item.id" class="history-item">
|
||||
<div class="history-meta">
|
||||
<strong>{{ item.userName }}</strong>
|
||||
<span>{{ item.createdAt }}</span>
|
||||
<Tag size="small">{{ item.action }}</Tag>
|
||||
</div>
|
||||
<div class="history-diff">
|
||||
行 {{ item.rowKey }} · {{ item.fieldId }}
|
||||
<br />
|
||||
{{ item.beforeSnapshot }} → {{ item.afterSnapshot }}
|
||||
</div>
|
||||
<Button size="small" type="link" @click="handleRollback(item)">
|
||||
回滚到此版本
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.spreadsheet-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.toolbar-main {
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
}
|
||||
|
||||
.workbook-tag {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
|
||||
&.plain {
|
||||
font-size: 13px;
|
||||
color: #1d2129;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #e5e6eb;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.history-diff {
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.history-loading {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.spreadsheet-toolbar.is-dark {
|
||||
background: #141414;
|
||||
|
||||
.toolbar-main {
|
||||
border-bottom-color: #424242;
|
||||
}
|
||||
|
||||
.workbook-tag.plain {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
background: #424242;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
border-color: #424242;
|
||||
}
|
||||
|
||||
.history-meta,
|
||||
.history-loading {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
206
apps/web-antd/src/components/canvas-spreadsheet/types.ts
Normal file
206
apps/web-antd/src/components/canvas-spreadsheet/types.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { ContextMenuItem } from '#/components/context-menu';
|
||||
|
||||
export type CellType =
|
||||
| 'text'
|
||||
| 'number'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'file'
|
||||
| 'readonly'
|
||||
| 'formula';
|
||||
|
||||
export interface CellRange {
|
||||
startRow: number;
|
||||
startCol: number;
|
||||
endRow: number;
|
||||
endCol: number;
|
||||
}
|
||||
|
||||
export interface CellEditContext {
|
||||
fieldId: string;
|
||||
fieldValue: unknown;
|
||||
rowIndex: number;
|
||||
colIndex: number;
|
||||
rowKey: string | number;
|
||||
rowData: Record<string, unknown>;
|
||||
editParams: Record<string, unknown>;
|
||||
selection?: CellRange;
|
||||
}
|
||||
|
||||
export interface HeaderContextMenuContext {
|
||||
colIndex: number;
|
||||
fieldId?: string;
|
||||
headerTitle: string;
|
||||
filterValue?: string;
|
||||
}
|
||||
|
||||
export type ConditionalColorMode = 'background' | 'text' | 'both';
|
||||
|
||||
export type SpreadsheetTextAlign = 'left' | 'center' | 'right';
|
||||
|
||||
/** undefined | 'inherit' 均表示跟随全局 defaultTextAlign */
|
||||
export type ColumnTextAlign = SpreadsheetTextAlign | 'inherit';
|
||||
|
||||
export interface ConditionalFormatRule {
|
||||
threshold: number;
|
||||
bgColor?: string;
|
||||
textColor?: string;
|
||||
colorMode?: ConditionalColorMode;
|
||||
}
|
||||
|
||||
export interface ColumnConditionalFormat {
|
||||
gt?: ConditionalFormatRule;
|
||||
lt?: ConditionalFormatRule;
|
||||
}
|
||||
|
||||
export interface SpreadsheetColumnConfig {
|
||||
fieldId: string;
|
||||
fieldName: string;
|
||||
cellType: CellType;
|
||||
col: number;
|
||||
editable?: boolean;
|
||||
formula?: string;
|
||||
suffix?: string;
|
||||
textAlign?: ColumnTextAlign;
|
||||
conditionalFormat?: ColumnConditionalFormat;
|
||||
onEdit?: (ctx: CellEditContext) => void | Promise<void>;
|
||||
onDoubleClick?: (ctx: CellEditContext) => void;
|
||||
editParams?: Record<string, unknown>;
|
||||
contextMenu?:
|
||||
| ContextMenuItem[]
|
||||
| ((ctx: CellEditContext) => ContextMenuItem[]);
|
||||
}
|
||||
|
||||
export interface SpreadsheetHeaderConfig {
|
||||
title: string;
|
||||
col: number;
|
||||
fieldId?: string;
|
||||
filterable?: boolean;
|
||||
width?: number | 'auto';
|
||||
headerColor?: string;
|
||||
fixed?: boolean;
|
||||
contextMenu?:
|
||||
| ContextMenuItem[]
|
||||
| ((ctx: HeaderContextMenuContext) => ContextMenuItem[]);
|
||||
}
|
||||
|
||||
export interface SpreadsheetLayoutState {
|
||||
colWidths?: Record<number, number>;
|
||||
headers?: Array<{
|
||||
col: number;
|
||||
headerColor?: string;
|
||||
fixed?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SpreadsheetBatchCellChange {
|
||||
ctx: CellEditContext;
|
||||
before: unknown;
|
||||
after: unknown;
|
||||
col: number;
|
||||
viewRow: number;
|
||||
}
|
||||
|
||||
export interface SpreadsheetTableConfig {
|
||||
tableName: string;
|
||||
sheetKey?: string;
|
||||
headers: SpreadsheetHeaderConfig[];
|
||||
enableFilter?: boolean;
|
||||
enableHistory?: boolean;
|
||||
rowHeight?: number | 'auto';
|
||||
defaultColWidth?: number;
|
||||
defaultTextAlign?: SpreadsheetTextAlign;
|
||||
onBatchEdit?: (changes: SpreadsheetBatchCellChange[]) => Promise<void>;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
headerContextMenu?:
|
||||
| ContextMenuItem[]
|
||||
| ((ctx: HeaderContextMenuContext) => ContextMenuItem[]);
|
||||
}
|
||||
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface SpreadsheetColumnSort {
|
||||
col: number;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
/** checkedValues === undefined means all selected (no filter) */
|
||||
export interface SpreadsheetColumnFilter {
|
||||
checkedValues?: string[];
|
||||
}
|
||||
|
||||
export interface SpreadsheetViewState {
|
||||
filters: Record<number, SpreadsheetColumnFilter>;
|
||||
sort: SpreadsheetColumnSort | null;
|
||||
}
|
||||
|
||||
export function createDefaultViewState(): SpreadsheetViewState {
|
||||
return { filters: {}, sort: null };
|
||||
}
|
||||
|
||||
export interface SpreadsheetHistoryEntry {
|
||||
id: string;
|
||||
tableName: string;
|
||||
sheetKey?: string;
|
||||
userId: number;
|
||||
userName: string;
|
||||
action: 'edit' | 'fill' | 'paste' | 'rollback';
|
||||
rowKey: string | number;
|
||||
fieldId: string;
|
||||
beforeSnapshot: unknown;
|
||||
afterSnapshot: unknown;
|
||||
createdAt: string;
|
||||
synced: boolean;
|
||||
}
|
||||
|
||||
export interface SpreadsheetCommand {
|
||||
label: string;
|
||||
undo: () => void | Promise<void>;
|
||||
redo: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClipboardMatrix {
|
||||
rows: unknown[][];
|
||||
startRow: number;
|
||||
startCol: number;
|
||||
}
|
||||
|
||||
export interface SpreadsheetEngineExpose {
|
||||
copy: (selectionOverride?: CellRange | CellRange[]) => void;
|
||||
copyAsImage: (selectionOverride?: CellRange | CellRange[]) => Promise<void>;
|
||||
paste: () => Promise<void>;
|
||||
undo: () => Promise<void>;
|
||||
redo: () => Promise<void>;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
canCopy: () => boolean;
|
||||
canCopyAsImage: () => boolean;
|
||||
canPaste: () => boolean;
|
||||
canEdit: (row: number, col: number) => boolean;
|
||||
getSelection: () => CellRange[];
|
||||
getCellEditContext: (row: number, col: number) => CellEditContext | null;
|
||||
getHeaderContext: (col: number) => HeaderContextMenuContext;
|
||||
setCellValue: (
|
||||
row: number,
|
||||
col: number,
|
||||
value: unknown,
|
||||
recordHistory?: boolean,
|
||||
action?: SpreadsheetHistoryEntry['action'],
|
||||
) => Promise<void>;
|
||||
fillRange: (
|
||||
source: CellRange,
|
||||
target: CellRange,
|
||||
) => Promise<void>;
|
||||
getColumnConfig: (col: number) => SpreadsheetColumnConfig | undefined;
|
||||
getFilterValue: (col: number) => string;
|
||||
}
|
||||
|
||||
export const COLUMN_LETTER_HEIGHT = 22;
|
||||
export const HEADER_TITLE_HEIGHT = 36;
|
||||
export const HEADER_HEIGHT = COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT;
|
||||
export const ROW_NUMBER_WIDTH = 48;
|
||||
export const DEFAULT_ROW_HEIGHT = 32;
|
||||
export const DEFAULT_COL_WIDTH = 100;
|
||||
export const MIN_COL_WIDTH = 60;
|
||||
export const SCROLL_BUFFER_ROWS = 8;
|
||||
@@ -0,0 +1,752 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 中药药名气泡选择器
|
||||
* - 对齐诊断/医嘱 EntryKeywordBubble:Input 聚焦弹出 Teleport 浮层
|
||||
* - 远程搜索药品(getProductListDoctorReception),支持药名/别名/首拼/全拼/数字
|
||||
* - 列表展示拼音列;命中别名时展示匹配别名及其拼音
|
||||
* - 选中后回传完整商品行,供接诊/复诊/特色方/常用方复用现有落库逻辑
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { Empty, Input, Spin } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
rankByMatchScore,
|
||||
splitHighlight,
|
||||
type HighlightPart,
|
||||
} from '#/utils/dictSearchRank';
|
||||
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
|
||||
|
||||
export type ChineseDrugBubbleItem = {
|
||||
id: number;
|
||||
drug_id: number;
|
||||
price: number;
|
||||
buy_price?: number;
|
||||
drug: Record<string, any>;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 已选药名(展示回填;打开搜索时仍可编辑关键词) */
|
||||
displayName?: string;
|
||||
/** 药品类型:1 中药,颗粒等由调用方传入 */
|
||||
type?: number;
|
||||
storeId?: number;
|
||||
registerId?: number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/** 卡片内紧凑宽度 */
|
||||
compact?: boolean;
|
||||
/** 额外 class,便于焦点跳转 querySelector */
|
||||
inputClass?: string;
|
||||
}>(),
|
||||
{
|
||||
displayName: '',
|
||||
type: 1,
|
||||
storeId: 0,
|
||||
registerId: 0,
|
||||
placeholder: '药名',
|
||||
disabled: false,
|
||||
compact: false,
|
||||
inputClass: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [item: ChineseDrugBubbleItem];
|
||||
}>();
|
||||
|
||||
const ROW_HEIGHT = 48;
|
||||
const HINT_HEIGHT = 34;
|
||||
const HEAD_HEIGHT = 32;
|
||||
const PANEL_PAD = 8;
|
||||
const PREFERRED_PANEL = 360;
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const panelRef = ref<HTMLElement | null>(null);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
const inputRef = ref<{ focus?: () => void } | null>(null);
|
||||
const keyword = ref('');
|
||||
const loading = ref(false);
|
||||
const open = ref(false);
|
||||
/** 是否处于「正在搜」:true 时不把 displayName 强写回 keyword */
|
||||
const searching = ref(false);
|
||||
const highlightIndex = ref(0);
|
||||
const options = ref<ChineseDrugBubbleItem[]>([]);
|
||||
const scrollTop = ref(0);
|
||||
const listViewportH = ref(200);
|
||||
const panelStyle = ref<Record<string, string>>({});
|
||||
|
||||
const totalHeight = computed(() => options.value.length * ROW_HEIGHT);
|
||||
const startIndex = computed(() =>
|
||||
Math.max(0, Math.floor(scrollTop.value / ROW_HEIGHT) - 2),
|
||||
);
|
||||
const endIndex = computed(() => {
|
||||
const visible = Math.ceil(listViewportH.value / ROW_HEIGHT) + 4;
|
||||
return Math.min(options.value.length - 1, startIndex.value + visible);
|
||||
});
|
||||
const visibleRows = computed(() => {
|
||||
const list = options.value;
|
||||
const start = startIndex.value;
|
||||
const end = endIndex.value;
|
||||
if (list.length === 0 || end < start) return [];
|
||||
return list.slice(start, end + 1).map((item, i) => ({
|
||||
item,
|
||||
index: start + i,
|
||||
}));
|
||||
});
|
||||
const offsetY = computed(() => startIndex.value * ROW_HEIGHT);
|
||||
|
||||
/**
|
||||
* 同步展示名:未打开搜索时用 displayName 回填输入框
|
||||
*/
|
||||
watch(
|
||||
() => props.displayName,
|
||||
(name) => {
|
||||
if (!searching.value && !open.value) {
|
||||
keyword.value = String(name || '');
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function partsOf(text: string): HighlightPart[] {
|
||||
return splitHighlight(text, keyword.value.trim());
|
||||
}
|
||||
|
||||
function drugNameOf(item: ChineseDrugBubbleItem): string {
|
||||
return String(item?.drug?.drug_name || item?.drug_name || '').trim();
|
||||
}
|
||||
|
||||
/** 关键词命中的别名(后端 matched_alias) */
|
||||
function matchedAliasOf(item: ChineseDrugBubbleItem): string {
|
||||
return String(
|
||||
item?.matched_alias || item?.drug?.matched_alias || '',
|
||||
).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼音展示:命中别名时用别名拼音,否则用药名首拼(全拼)
|
||||
*/
|
||||
function pinyinLabelOf(item: ChineseDrugBubbleItem): string {
|
||||
const aliasPy = String(
|
||||
item?.matched_alias_pinyin || item?.drug?.matched_alias_pinyin || '',
|
||||
).trim();
|
||||
if (matchedAliasOf(item) && aliasPy) return aliasPy;
|
||||
const display = String(item?.drug?.pinyin_display || '').trim();
|
||||
if (display) return display;
|
||||
const simple = String(item?.drug?.pinyin_simple || '').trim();
|
||||
const full = String(item?.drug?.pinyin_full || '').trim();
|
||||
if (simple && full && simple.toLowerCase() !== full.toLowerCase()) {
|
||||
return `${simple}(${full})`;
|
||||
}
|
||||
return simple || full || '-';
|
||||
}
|
||||
|
||||
function unitLabelOf(item: ChineseDrugBubbleItem): string {
|
||||
const unit = item?.drug?.unit || item?.unit;
|
||||
if (typeof unit === 'object' && unit?.name) return String(unit.name);
|
||||
return String(item?.drug?.specification || item?.specification || '').trim() || '-';
|
||||
}
|
||||
|
||||
function priceLabelOf(item: ChineseDrugBubbleItem): string {
|
||||
const p = Number(item?.price ?? 0);
|
||||
return Number.isFinite(p) ? `¥${p.toFixed(2)}` : '-';
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板 Teleport + fixed,避免接诊/Modal overflow 裁切
|
||||
*/
|
||||
function updatePanelPlacement() {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const gap = 4;
|
||||
const spaceBelow = vh - rect.bottom - PANEL_PAD;
|
||||
const spaceAbove = rect.top - PANEL_PAD;
|
||||
const placeTop = spaceBelow < 180 && spaceAbove > spaceBelow;
|
||||
const avail = Math.max(140, placeTop ? spaceAbove : spaceBelow);
|
||||
const panelMax = Math.min(PREFERRED_PANEL, avail);
|
||||
listViewportH.value = Math.max(80, panelMax - HINT_HEIGHT - HEAD_HEIGHT);
|
||||
|
||||
// 含拼音列后加宽,避免别名/全拼被裁切
|
||||
const PANEL_MIN_WIDTH = 560;
|
||||
const width = Math.min(
|
||||
Math.max(rect.width, PANEL_MIN_WIDTH),
|
||||
vw - PANEL_PAD * 2,
|
||||
);
|
||||
let left = rect.left;
|
||||
if (left + width > vw - PANEL_PAD) {
|
||||
left = vw - PANEL_PAD - width;
|
||||
}
|
||||
if (left < PANEL_PAD) left = PANEL_PAD;
|
||||
|
||||
const base: Record<string, string> = {
|
||||
position: 'fixed',
|
||||
zIndex: '4000',
|
||||
width: `${width}px`,
|
||||
maxHeight: `${panelMax}px`,
|
||||
left: `${left}px`,
|
||||
right: 'auto',
|
||||
};
|
||||
if (placeTop) {
|
||||
panelStyle.value = {
|
||||
...base,
|
||||
top: 'auto',
|
||||
bottom: `${Math.max(PANEL_PAD, vh - rect.top + gap)}px`,
|
||||
};
|
||||
} else {
|
||||
panelStyle.value = {
|
||||
...base,
|
||||
top: `${rect.bottom + gap}px`,
|
||||
bottom: 'auto',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程搜药;空关键词不请求(与原中药 Select 一致)
|
||||
*/
|
||||
async function fetchDrugs(kw: string) {
|
||||
const name = String(kw || '').trim();
|
||||
if (!name) {
|
||||
options.value = [];
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getProductListDoctorReception({
|
||||
name,
|
||||
type: props.type,
|
||||
store_id: props.storeId || 0,
|
||||
...(props.registerId > 0 ? { register_id: props.registerId } : {}),
|
||||
});
|
||||
const list = Array.isArray(res) ? res : [];
|
||||
const mapped: ChineseDrugBubbleItem[] = list.map((item: any) => {
|
||||
const drug = item.drug || {};
|
||||
return {
|
||||
...item,
|
||||
id: Number(item.id || 0),
|
||||
drug_id: Number(item.drug_id || drug.id || 0),
|
||||
price: Number(item.price ?? 0),
|
||||
buy_price: item.buy_price,
|
||||
drug: {
|
||||
...drug,
|
||||
drug_name: drug.drug_name || item.drug_name || '',
|
||||
},
|
||||
};
|
||||
});
|
||||
options.value = rankByMatchScore(
|
||||
mapped,
|
||||
name,
|
||||
(it) => drugNameOf(it),
|
||||
(it) =>
|
||||
[
|
||||
it.drug?.pinyin_simple,
|
||||
it.drug?.pinyin_full,
|
||||
it.drug?.pinyin_display,
|
||||
it.drug?.drug_alias,
|
||||
it.matched_alias || it.drug?.matched_alias,
|
||||
it.matched_alias_pinyin || it.drug?.matched_alias_pinyin,
|
||||
it.drug?.search_digits,
|
||||
it.drug?.specification,
|
||||
]
|
||||
.map((v) => String(v || ''))
|
||||
.join(' '),
|
||||
);
|
||||
highlightIndex.value = options.value.length > 0 ? 0 : -1;
|
||||
scrollTop.value = 0;
|
||||
if (listRef.value) listRef.value.scrollTop = 0;
|
||||
} catch (e) {
|
||||
console.error('中药药名搜索失败:', e);
|
||||
options.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
nextTick(() => updatePanelPlacement());
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedFetch = useDebounceFn((kw: string) => {
|
||||
void fetchDrugs(kw);
|
||||
}, 280);
|
||||
|
||||
function openPanel() {
|
||||
if (props.disabled) return;
|
||||
open.value = true;
|
||||
searching.value = true;
|
||||
nextTick(() => {
|
||||
updatePanelPlacement();
|
||||
if (keyword.value.trim()) {
|
||||
debouncedFetch(keyword.value);
|
||||
} else {
|
||||
options.value = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closePanel(restoreDisplay = true) {
|
||||
open.value = false;
|
||||
searching.value = false;
|
||||
options.value = [];
|
||||
highlightIndex.value = 0;
|
||||
if (restoreDisplay) {
|
||||
keyword.value = String(props.displayName || '');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (props.disabled) return;
|
||||
// 聚焦时清空为搜索态,便于直接输入新药名
|
||||
if (props.displayName && keyword.value === props.displayName) {
|
||||
keyword.value = '';
|
||||
}
|
||||
openPanel();
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
searching.value = true;
|
||||
if (!open.value) openPanel();
|
||||
else updatePanelPlacement();
|
||||
debouncedFetch(keyword.value);
|
||||
}
|
||||
|
||||
function selectItem(item: ChineseDrugBubbleItem) {
|
||||
const name = drugNameOf(item);
|
||||
keyword.value = name;
|
||||
searching.value = false;
|
||||
open.value = false;
|
||||
options.value = [];
|
||||
emit('select', item);
|
||||
}
|
||||
|
||||
/**
|
||||
* 键盘高亮循环移动(与诊断 EntryKeywordBubble 一致:第一项再上键 → 最后一项)
|
||||
*/
|
||||
function moveHighlight(delta: number) {
|
||||
if (options.value.length === 0) return;
|
||||
const len = options.value.length;
|
||||
let next = highlightIndex.value;
|
||||
if (next < 0) next = 0;
|
||||
else next = (next + delta + len) % len;
|
||||
highlightIndex.value = next;
|
||||
ensureRowVisible(highlightIndex.value);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!open.value) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
openPanel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closePanel(true);
|
||||
return;
|
||||
}
|
||||
if (options.value.length === 0) return;
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
moveHighlight(1);
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
moveHighlight(-1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const hit = options.value[highlightIndex.value];
|
||||
if (hit) selectItem(hit);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureRowVisible(index: number) {
|
||||
const el = listRef.value;
|
||||
if (!el || index < 0) return;
|
||||
const top = index * ROW_HEIGHT;
|
||||
const bottom = top + ROW_HEIGHT;
|
||||
if (top < el.scrollTop) el.scrollTop = top;
|
||||
else if (bottom > el.scrollTop + el.clientHeight) {
|
||||
el.scrollTop = bottom - el.clientHeight;
|
||||
}
|
||||
// 同步虚拟列表切片(循环跳到首/尾时必须刷新可见区)
|
||||
scrollTop.value = el.scrollTop;
|
||||
}
|
||||
|
||||
function onListScroll() {
|
||||
scrollTop.value = listRef.value?.scrollTop || 0;
|
||||
}
|
||||
|
||||
function onDocPointerDown(e: MouseEvent) {
|
||||
if (!open.value) return;
|
||||
const t = e.target as Node;
|
||||
if (containerRef.value?.contains(t)) return;
|
||||
if (panelRef.value?.contains(t)) return;
|
||||
closePanel(true);
|
||||
}
|
||||
|
||||
function onWinChange() {
|
||||
if (open.value) updatePanelPlacement();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onDocPointerDown, true);
|
||||
window.addEventListener('resize', onWinChange);
|
||||
window.addEventListener('scroll', onWinChange, true);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', onDocPointerDown, true);
|
||||
window.removeEventListener('resize', onWinChange);
|
||||
window.removeEventListener('scroll', onWinChange, true);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
focus() {
|
||||
nextTick(() => {
|
||||
const el = inputRef.value as any;
|
||||
el?.focus?.();
|
||||
const native = containerRef.value?.querySelector(
|
||||
'input',
|
||||
) as HTMLInputElement | null;
|
||||
native?.focus();
|
||||
});
|
||||
},
|
||||
close() {
|
||||
closePanel(true);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="chinese-drug-name-bubble"
|
||||
:class="[{ 'chinese-drug-name-bubble--compact': compact }, inputClass]"
|
||||
>
|
||||
<Input
|
||||
ref="inputRef"
|
||||
v-model:value="keyword"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
allow-clear
|
||||
size="small"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<template #prefix>
|
||||
<LoadingOutlined v-if="loading" class="text-muted-foreground" />
|
||||
<SearchOutlined v-else class="text-muted-foreground" />
|
||||
</template>
|
||||
</Input>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="panelRef"
|
||||
class="chinese-drug-name-bubble__panel"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<div class="chinese-drug-name-bubble__hint">
|
||||
<span>↑↓←→ 循环切换 · Enter 选用 · Esc 关闭</span>
|
||||
<span class="chinese-drug-name-bubble__count">
|
||||
<template v-if="keyword.trim()">搜到 {{ options.length }} 条</template>
|
||||
<template v-else>输入药名搜索</template>
|
||||
</span>
|
||||
</div>
|
||||
<Spin :spinning="loading">
|
||||
<Empty
|
||||
v-if="!loading && options.length === 0"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
:description="
|
||||
keyword.trim()
|
||||
? '未找到匹配药材'
|
||||
: '请输入药名/别名/拼音/数字搜索'
|
||||
"
|
||||
class="py-4"
|
||||
/>
|
||||
<template v-else>
|
||||
<div
|
||||
class="chinese-drug-name-bubble__head"
|
||||
:class="{ 'has-score': !!keyword.trim() }"
|
||||
>
|
||||
<span class="col-name">药名</span>
|
||||
<span class="col-py">拼音</span>
|
||||
<span class="col-spec">规格/单位</span>
|
||||
<span class="col-price">价格</span>
|
||||
<span v-if="keyword.trim()" class="col-score">匹配度</span>
|
||||
</div>
|
||||
<div
|
||||
ref="listRef"
|
||||
class="chinese-drug-name-bubble__list"
|
||||
:style="{ height: `${listViewportH}px` }"
|
||||
@scroll="onListScroll"
|
||||
>
|
||||
<div
|
||||
class="chinese-drug-name-bubble__phantom"
|
||||
:style="{ height: `${totalHeight}px` }"
|
||||
/>
|
||||
<div
|
||||
class="chinese-drug-name-bubble__virtual"
|
||||
:style="{ transform: `translateY(${offsetY}px)` }"
|
||||
>
|
||||
<div
|
||||
v-for="{ item, index } in visibleRows"
|
||||
:key="`${item.drug_id}_${index}`"
|
||||
class="chinese-drug-name-bubble__row"
|
||||
:class="{
|
||||
'is-active': index === highlightIndex,
|
||||
'has-score': !!keyword.trim(),
|
||||
}"
|
||||
:style="{ height: `${ROW_HEIGHT}px` }"
|
||||
@mousedown.prevent="selectItem(item)"
|
||||
@mouseenter="highlightIndex = index"
|
||||
>
|
||||
<span
|
||||
class="col-name"
|
||||
:title="
|
||||
matchedAliasOf(item)
|
||||
? `${drugNameOf(item)}(别名:${matchedAliasOf(item)})`
|
||||
: drugNameOf(item)
|
||||
"
|
||||
>
|
||||
<span class="col-name__main">
|
||||
<template
|
||||
v-for="(p, pi) in partsOf(drugNameOf(item))"
|
||||
:key="`n_${pi}`"
|
||||
>
|
||||
<em v-if="p.hit" class="hit">{{ p.text }}</em>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="matchedAliasOf(item)"
|
||||
class="col-name__alias"
|
||||
>
|
||||
别名:
|
||||
<template
|
||||
v-for="(p, pi) in partsOf(matchedAliasOf(item))"
|
||||
:key="`a_${pi}`"
|
||||
>
|
||||
<em v-if="p.hit" class="hit">{{ p.text }}</em>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
<span class="col-py" :title="pinyinLabelOf(item)">
|
||||
<template v-if="pinyinLabelOf(item) === '-'">-</template>
|
||||
<template v-else>
|
||||
<template
|
||||
v-for="(p, pi) in partsOf(pinyinLabelOf(item))"
|
||||
:key="`py_${pi}`"
|
||||
>
|
||||
<em v-if="p.hit" class="hit">{{ p.text }}</em>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</template>
|
||||
</span>
|
||||
<span class="col-spec" :title="unitLabelOf(item)">
|
||||
{{ unitLabelOf(item) }}
|
||||
</span>
|
||||
<span class="col-price">{{ priceLabelOf(item) }}</span>
|
||||
<span v-if="keyword.trim()" class="col-score">
|
||||
{{ Number(item.match_score || 0) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Spin>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chinese-drug-name-bubble {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
box-sizing: border-box;
|
||||
/* 锁死宽度:避免接诊/复诊/特色方/常用方 flex 布局把药名框撑满整行 */
|
||||
width: 100px !important;
|
||||
min-width: 88px !important;
|
||||
max-width: 110px !important;
|
||||
flex: 0 0 100px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chinese-drug-name-bubble--compact {
|
||||
width: 100px !important;
|
||||
min-width: 88px !important;
|
||||
max-width: 110px !important;
|
||||
flex: 0 0 100px !important;
|
||||
}
|
||||
.chinese-drug-name-bubble :deep(.ant-input-affix-wrapper) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
padding-inline: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chinese-drug-name-bubble :deep(.ant-input-prefix) {
|
||||
margin-inline-end: 2px;
|
||||
}
|
||||
.chinese-drug-name-bubble :deep(.ant-input) {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chinese-drug-name-bubble__panel {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--popover, var(--background)));
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.chinese-drug-name-bubble__hint {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
}
|
||||
.chinese-drug-name-bubble__count {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.chinese-drug-name-bubble__list {
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.chinese-drug-name-bubble__phantom {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chinese-drug-name-bubble__virtual {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.chinese-drug-name-bubble__head,
|
||||
.chinese-drug-name-bubble__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 1.1fr) minmax(110px, 1.3fr) 90px 64px;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chinese-drug-name-bubble__head.has-score,
|
||||
.chinese-drug-name-bubble__row.has-score {
|
||||
grid-template-columns: minmax(90px, 1.1fr) minmax(100px, 1.2fr) 80px 56px 48px;
|
||||
}
|
||||
.chinese-drug-name-bubble__head {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--popover, var(--background)));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
.chinese-drug-name-bubble__row {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid hsl(var(--border) / 0.6);
|
||||
}
|
||||
.chinese-drug-name-bubble__row:hover,
|
||||
.chinese-drug-name-bubble__row.is-active {
|
||||
background: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
.col-name,
|
||||
.col-py,
|
||||
.col-spec {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
white-space: normal;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.col-name__main {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-name__alias {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.col-py {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.col-spec {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.col-price {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-score {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: #d4380d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chinese-drug-name-bubble__row.is-active .col-spec,
|
||||
.chinese-drug-name-bubble__row.is-active .col-py,
|
||||
.chinese-drug-name-bubble__row.is-active .col-name__alias {
|
||||
color: inherit;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.chinese-drug-name-bubble__row.is-active .col-score {
|
||||
color: inherit;
|
||||
}
|
||||
.hit {
|
||||
font-style: normal;
|
||||
color: #cf1322;
|
||||
background: rgba(255, 214, 102, 0.55);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 中药药名气泡选择器
|
||||
*/
|
||||
export { default as ChineseDrugNameBubble } from './chinese-drug-name-bubble.vue';
|
||||
export type { ChineseDrugBubbleItem } from './chinese-drug-name-bubble.vue';
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts" setup>
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
import type { ContextMenuItem } from './types';
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
import {
|
||||
contextMenuState,
|
||||
getSubmenuPosition,
|
||||
hideContextMenu,
|
||||
MENU_WIDTH,
|
||||
showSubmenu,
|
||||
submenuState,
|
||||
} from './use-context-menu';
|
||||
|
||||
defineOptions({ name: 'GlobalContextMenu' });
|
||||
|
||||
function onItemEnter(item: ContextMenuItem, e: MouseEvent) {
|
||||
if (item.type === 'divider') {
|
||||
showSubmenu('', [], 0, 0);
|
||||
return;
|
||||
}
|
||||
if (!item.children?.length || item.disabled) {
|
||||
showSubmenu('', [], 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const pos = getSubmenuPosition(rect, item.children.length);
|
||||
showSubmenu(item.key, item.children, pos.left, pos.top);
|
||||
}
|
||||
|
||||
function handleClick(item: ContextMenuItem) {
|
||||
if (item.disabled || item.children?.length) {
|
||||
return;
|
||||
}
|
||||
const payload = contextMenuState.payload;
|
||||
hideContextMenu();
|
||||
void nextTick(() => {
|
||||
item.handler?.(payload);
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmenuClick(item: ContextMenuItem) {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
const payload = contextMenuState.payload;
|
||||
hideContextMenu();
|
||||
void nextTick(() => {
|
||||
item.handler?.(payload);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="contextMenuState.visible"
|
||||
class="global-context-menu"
|
||||
:style="{ left: `${contextMenuState.x}px`, top: `${contextMenuState.y}px`, width: `${MENU_WIDTH}px` }"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<template v-for="item in contextMenuState.items" :key="item.key">
|
||||
<div v-if="item.type === 'divider'" class="context-menu-divider" />
|
||||
<div
|
||||
v-else
|
||||
class="context-menu-item"
|
||||
:class="{ disabled: item.disabled, 'has-children': !!item.children?.length }"
|
||||
@click.stop="handleClick(item)"
|
||||
@mouseenter="onItemEnter(item, $event)"
|
||||
>
|
||||
<span class="item-icon">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<MIcon v-else-if="item.iconName" :icon="item.iconName" size="14" />
|
||||
</span>
|
||||
<span class="item-label">{{ item.label }}</span>
|
||||
<span v-if="item.children?.length" class="item-arrow">›</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="contextMenuState.visible && submenuState.activeKey && submenuState.items.length"
|
||||
class="global-context-menu submenu"
|
||||
:style="{ left: `${submenuState.left}px`, top: `${submenuState.top}px`, width: `${MENU_WIDTH}px` }"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<div
|
||||
v-for="child in submenuState.items"
|
||||
:key="child.key"
|
||||
class="context-menu-item"
|
||||
:class="{ disabled: child.disabled }"
|
||||
@click.stop="handleSubmenuClick(child)"
|
||||
>
|
||||
<span class="item-icon">
|
||||
<component :is="child.icon" v-if="child.icon" />
|
||||
<MIcon v-else-if="child.iconName" :icon="child.iconName" size="14" />
|
||||
</span>
|
||||
<span class="item-label">{{ child.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.global-context-menu {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
padding: 4px 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
color: #1d2129;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: #f2f7ff;
|
||||
color: #165dff;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: #c9cdd4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.has-children {
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-arrow {
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
margin: 4px 8px;
|
||||
background: #e5e6eb;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.global-context-menu {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
color: #e5e7eb;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: #374151;
|
||||
color: #93c5fd;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
background: #374151;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
9
apps/web-antd/src/components/context-menu/index.ts
Normal file
9
apps/web-antd/src/components/context-menu/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ContextMenuItem } from './types';
|
||||
|
||||
export type { ContextMenuItem } from './types';
|
||||
export { showContextMenu, hideContextMenu } from './use-context-menu';
|
||||
export { default as GlobalContextMenu } from './global-context-menu.vue';
|
||||
|
||||
export function menuDivider(key = `divider-${Date.now()}`): ContextMenuItem {
|
||||
return { key, type: 'divider' };
|
||||
}
|
||||
20
apps/web-antd/src/components/context-menu/types.ts
Normal file
20
apps/web-antd/src/components/context-menu/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
export interface ContextMenuItem {
|
||||
key: string;
|
||||
label?: string;
|
||||
type?: 'item' | 'divider';
|
||||
icon?: Component;
|
||||
iconName?: string;
|
||||
disabled?: boolean;
|
||||
handler?: (payload?: any) => void;
|
||||
children?: ContextMenuItem[];
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
payload: any;
|
||||
}
|
||||
116
apps/web-antd/src/components/context-menu/use-context-menu.ts
Normal file
116
apps/web-antd/src/components/context-menu/use-context-menu.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
import type { ContextMenuItem, ContextMenuState } from './types';
|
||||
|
||||
const MENU_WIDTH = 180;
|
||||
const MENU_ITEM_HEIGHT = 36;
|
||||
const MENU_DIVIDER_HEIGHT = 9;
|
||||
const SUBMENU_GAP = 4;
|
||||
|
||||
let listenersBound = false;
|
||||
|
||||
export const contextMenuState = reactive<ContextMenuState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
items: [],
|
||||
payload: null,
|
||||
});
|
||||
|
||||
export const submenuState = reactive({
|
||||
activeKey: null as string | null,
|
||||
items: [] as ContextMenuItem[],
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
|
||||
function bindGlobalListeners() {
|
||||
if (listenersBound) {
|
||||
return;
|
||||
}
|
||||
listenersBound = true;
|
||||
|
||||
const hide = () => hideContextMenu();
|
||||
|
||||
document.addEventListener('click', hide, true);
|
||||
document.addEventListener('scroll', hide, true);
|
||||
window.addEventListener('resize', hide);
|
||||
}
|
||||
|
||||
export function resetSubmenu() {
|
||||
submenuState.activeKey = null;
|
||||
submenuState.items = [];
|
||||
submenuState.left = 0;
|
||||
submenuState.top = 0;
|
||||
}
|
||||
|
||||
export function showSubmenu(key: string, items: ContextMenuItem[], left: number, top: number) {
|
||||
submenuState.activeKey = key;
|
||||
submenuState.items = items;
|
||||
submenuState.left = left;
|
||||
submenuState.top = top;
|
||||
}
|
||||
|
||||
export function showContextMenu(
|
||||
e: MouseEvent,
|
||||
items: ContextMenuItem[],
|
||||
payload?: any,
|
||||
) {
|
||||
e.preventDefault();
|
||||
hideContextMenu();
|
||||
|
||||
const estimatedHeight = estimateMenuHeight(items);
|
||||
let x = e.clientX;
|
||||
let y = e.clientY;
|
||||
|
||||
if (x + MENU_WIDTH > window.innerWidth) {
|
||||
x = Math.max(8, window.innerWidth - MENU_WIDTH - 8);
|
||||
}
|
||||
if (y + estimatedHeight > window.innerHeight) {
|
||||
y = Math.max(8, window.innerHeight - estimatedHeight - 8);
|
||||
}
|
||||
|
||||
contextMenuState.x = x;
|
||||
contextMenuState.y = y;
|
||||
contextMenuState.items = items;
|
||||
contextMenuState.payload = payload ?? null;
|
||||
contextMenuState.visible = true;
|
||||
|
||||
bindGlobalListeners();
|
||||
}
|
||||
|
||||
export function hideContextMenu() {
|
||||
contextMenuState.visible = false;
|
||||
contextMenuState.items = [];
|
||||
contextMenuState.payload = null;
|
||||
resetSubmenu();
|
||||
}
|
||||
|
||||
export function estimateMenuHeight(items: ContextMenuItem[]): number {
|
||||
let height = 8;
|
||||
for (const item of items) {
|
||||
height +=
|
||||
item.type === 'divider' ? MENU_DIVIDER_HEIGHT : MENU_ITEM_HEIGHT;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
export function getSubmenuPosition(
|
||||
parentRect: DOMRect,
|
||||
childCount: number,
|
||||
): { left: number; top: number } {
|
||||
const submenuHeight = childCount * MENU_ITEM_HEIGHT + 8;
|
||||
let left = parentRect.right + SUBMENU_GAP;
|
||||
let top = parentRect.top;
|
||||
|
||||
if (left + MENU_WIDTH > window.innerWidth) {
|
||||
left = parentRect.left - MENU_WIDTH - SUBMENU_GAP;
|
||||
}
|
||||
if (top + submenuHeight > window.innerHeight) {
|
||||
top = Math.max(8, window.innerHeight - submenuHeight - 8);
|
||||
}
|
||||
|
||||
return { left, top };
|
||||
}
|
||||
|
||||
export { MENU_WIDTH, MENU_ITEM_HEIGHT };
|
||||
@@ -0,0 +1,883 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 医生端 PC 传方入口:顶栏按钮 + 子窗口列表 + 状态操作
|
||||
* 权限:有 doctor_id 且绑定 store_id 时由外层 v-if 控制,本组件只负责展示与操作
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
FileTextOutlined,
|
||||
ReloadOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dropdown,
|
||||
Image,
|
||||
Input,
|
||||
Menu,
|
||||
Modal,
|
||||
Select,
|
||||
Spin,
|
||||
Tag,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import {
|
||||
DEFAULT_SUB_WINDOW_Z_INDEX,
|
||||
getSubWindowPopupZIndex,
|
||||
} from '#/components/sub-window/constants';
|
||||
import SubWindow from '#/components/sub-window/SubWindow.vue';
|
||||
import {
|
||||
getTransferPrescriptionListByStoreApi,
|
||||
updateTransferPrescriptionHandleStatusApi,
|
||||
} from '#/views/business/salesperson-transfer-prescription/api';
|
||||
import { invokeDoctorTransferImport } from '#/views/doctor/doctor-reception/composables/useDoctorReceptionTransferImport';
|
||||
|
||||
const REFRESH_STORAGE_KEY = 'pc_doctor_transfer_refresh_sec';
|
||||
const DEFAULT_AVATAR = '/img/user-default-avatar.png';
|
||||
const IMAGE_SCALE_MIN = 0.5;
|
||||
const IMAGE_SCALE_MAX = 3;
|
||||
const IMAGE_SCALE_STEP = 0.25;
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { isDark } = usePreferences();
|
||||
|
||||
/** 与子窗口 z-index 对齐的弹出层层级 */
|
||||
const popupZIndex = computed(() => getSubWindowPopupZIndex(DEFAULT_SUB_WINDOW_Z_INDEX));
|
||||
|
||||
const windowOpen = ref(false);
|
||||
const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
const pollTimer = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const inlineImageUrl = ref('');
|
||||
const imageScale = ref(1);
|
||||
const imagePanX = ref(0);
|
||||
const imagePanY = ref(0);
|
||||
const imageDragging = ref(false);
|
||||
const imageDragStartX = ref(0);
|
||||
const imageDragStartY = ref(0);
|
||||
const imagePanOriginX = ref(0);
|
||||
const imagePanOriginY = ref(0);
|
||||
|
||||
const salespersonKeyword = ref('');
|
||||
const patientName = ref('');
|
||||
const refreshIntervalSec = ref(30);
|
||||
|
||||
const exceptionModalOpen = ref(false);
|
||||
const exceptionMessage = ref('');
|
||||
const pendingStatusItem = ref<any>(null);
|
||||
|
||||
const storeId = computed(() => Number(userStore.userInfo?.store_id || 0));
|
||||
|
||||
const pendingCount = computed(
|
||||
() => list.value.filter((item) => Number(item.status) === 0).length,
|
||||
);
|
||||
|
||||
const refreshOptions = [
|
||||
{ value: 0, label: '不刷新' },
|
||||
{ value: 10, label: '10秒' },
|
||||
{ value: 30, label: '30秒' },
|
||||
{ value: 60, label: '1分钟' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 0, label: '未处理' },
|
||||
{ value: 1, label: '处理中' },
|
||||
{ value: 2, label: '已完成' },
|
||||
{ value: 3, label: '异常' },
|
||||
];
|
||||
|
||||
const imageTransformStyle = computed(() => ({
|
||||
transform: `translate(${imagePanX.value}px, ${imagePanY.value}px) scale(${imageScale.value})`,
|
||||
transformOrigin: 'center top',
|
||||
}));
|
||||
|
||||
/** Dropdown 挂载到 body,避免被 overflow 裁剪 */
|
||||
function getPopupContainer() {
|
||||
return document.body;
|
||||
}
|
||||
|
||||
function statusColor(status: number) {
|
||||
const map: Record<number, string> = {
|
||||
0: 'warning',
|
||||
1: 'processing',
|
||||
2: 'success',
|
||||
3: 'error',
|
||||
};
|
||||
return map[Number(status)] || 'default';
|
||||
}
|
||||
|
||||
/** 重置内嵌看图缩放与平移 */
|
||||
function resetImageView() {
|
||||
imageScale.value = 1;
|
||||
imagePanX.value = 0;
|
||||
imagePanY.value = 0;
|
||||
}
|
||||
|
||||
/** 从 localStorage 读取刷新间隔配置 */
|
||||
function loadRefreshSetting() {
|
||||
const cached = localStorage.getItem(REFRESH_STORAGE_KEY);
|
||||
if (cached !== null) {
|
||||
const val = Number(cached);
|
||||
if ([0, 10, 30, 60].includes(val)) {
|
||||
refreshIntervalSec.value = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveRefreshSetting() {
|
||||
localStorage.setItem(REFRESH_STORAGE_KEY, String(refreshIntervalSec.value));
|
||||
}
|
||||
|
||||
/** 加载当前门店传方列表 */
|
||||
async function loadList(silent = false) {
|
||||
if (!storeId.value) return;
|
||||
if (!silent) loading.value = true;
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
store_id: storeId.value,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
};
|
||||
if (salespersonKeyword.value.trim()) {
|
||||
params.salesperson_keyword = salespersonKeyword.value.trim();
|
||||
}
|
||||
if (patientName.value.trim()) {
|
||||
params.patient_name = patientName.value.trim();
|
||||
}
|
||||
const res = await getTransferPrescriptionListByStoreApi(params);
|
||||
list.value = res?.items ?? [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (!silent) list.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
loadList();
|
||||
}
|
||||
|
||||
function handleManualRefresh() {
|
||||
loadList();
|
||||
}
|
||||
|
||||
/** 按配置间隔启动轮询(0 表示不自动刷新) */
|
||||
function restartPoll() {
|
||||
stopPoll();
|
||||
const sec = refreshIntervalSec.value;
|
||||
if (sec <= 0) return;
|
||||
pollTimer.value = setInterval(() => loadList(true), sec * 1000);
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer.value) {
|
||||
clearInterval(pollTimer.value);
|
||||
pollTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openWindow() {
|
||||
windowOpen.value = true;
|
||||
inlineImageUrl.value = '';
|
||||
resetImageView();
|
||||
loadList();
|
||||
}
|
||||
|
||||
function openImageInWindow(url: string) {
|
||||
inlineImageUrl.value = url;
|
||||
resetImageView();
|
||||
}
|
||||
|
||||
function closeInlineImage() {
|
||||
inlineImageUrl.value = '';
|
||||
resetImageView();
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
imageScale.value = Math.min(
|
||||
IMAGE_SCALE_MAX,
|
||||
Math.round((imageScale.value + IMAGE_SCALE_STEP) * 100) / 100,
|
||||
);
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
imageScale.value = Math.max(
|
||||
IMAGE_SCALE_MIN,
|
||||
Math.round((imageScale.value - IMAGE_SCALE_STEP) * 100) / 100,
|
||||
);
|
||||
}
|
||||
|
||||
function resetImageZoom() {
|
||||
resetImageView();
|
||||
}
|
||||
|
||||
/** 滚轮缩放图片 */
|
||||
function onImageWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
if (e.deltaY < 0) zoomIn();
|
||||
else zoomOut();
|
||||
}
|
||||
|
||||
/** 内嵌看图:按住拖拽平移 */
|
||||
function onImageMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
imageDragging.value = true;
|
||||
imageDragStartX.value = e.clientX;
|
||||
imageDragStartY.value = e.clientY;
|
||||
imagePanOriginX.value = imagePanX.value;
|
||||
imagePanOriginY.value = imagePanY.value;
|
||||
document.addEventListener('mousemove', onImageMouseMove);
|
||||
document.addEventListener('mouseup', onImageMouseUp);
|
||||
}
|
||||
|
||||
function onImageMouseMove(e: MouseEvent) {
|
||||
if (!imageDragging.value) return;
|
||||
imagePanX.value = imagePanOriginX.value + (e.clientX - imageDragStartX.value);
|
||||
imagePanY.value = imagePanOriginY.value + (e.clientY - imageDragStartY.value);
|
||||
}
|
||||
|
||||
function onImageMouseUp() {
|
||||
imageDragging.value = false;
|
||||
document.removeEventListener('mousemove', onImageMouseMove);
|
||||
document.removeEventListener('mouseup', onImageMouseUp);
|
||||
}
|
||||
|
||||
/** 选择处理状态 */
|
||||
async function handleStatusSelect(item: any, status: number) {
|
||||
if (status === 3) {
|
||||
pendingStatusItem.value = item;
|
||||
exceptionMessage.value = item.exception_message || '';
|
||||
exceptionModalOpen.value = true;
|
||||
return;
|
||||
}
|
||||
await submitStatus(item, status, '');
|
||||
}
|
||||
|
||||
async function submitStatus(item: any, status: number, exceptionMsg: string) {
|
||||
if (!storeId.value) return;
|
||||
try {
|
||||
await updateTransferPrescriptionHandleStatusApi({
|
||||
id: item.id,
|
||||
store_id: storeId.value,
|
||||
status,
|
||||
exception_message: status === 3 ? exceptionMsg : '',
|
||||
});
|
||||
message.success('状态已更新');
|
||||
exceptionModalOpen.value = false;
|
||||
pendingStatusItem.value = null;
|
||||
loadList(true);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmException() {
|
||||
if (!exceptionMessage.value.trim()) {
|
||||
message.warning('请填写异常说明');
|
||||
return;
|
||||
}
|
||||
if (pendingStatusItem.value) {
|
||||
await submitStatus(pendingStatusItem.value, 3, exceptionMessage.value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否可导入:手动输入且有药品 */
|
||||
function canImportItem(item: any) {
|
||||
if (Number(item.transfer_mode ?? 1) === 2) return false;
|
||||
return Boolean(item.drug_list?.length || item.drug_text_lines?.length);
|
||||
}
|
||||
|
||||
/** 当前接诊页选中的挂号 ID */
|
||||
function getCurrentRegisterId() {
|
||||
return Number.parseInt(localStorage.getItem('doctorReception-id') || '0', 10);
|
||||
}
|
||||
|
||||
/** 导入传方药品到当前接诊患者 */
|
||||
function handleImportToCurrentPatient(item: any) {
|
||||
const registerId = getCurrentRegisterId();
|
||||
if (!registerId) {
|
||||
message.warning('请先在接诊页选择患者');
|
||||
return;
|
||||
}
|
||||
if (!canImportItem(item)) {
|
||||
message.warning('仅支持手动输入药品的传方');
|
||||
return;
|
||||
}
|
||||
const ok = invokeDoctorTransferImport({ transferId: item.id, registerId });
|
||||
if (!ok) {
|
||||
message.warning('请先打开接诊页面');
|
||||
}
|
||||
}
|
||||
|
||||
watch(refreshIntervalSec, () => {
|
||||
saveRefreshSetting();
|
||||
restartPoll();
|
||||
});
|
||||
|
||||
watch(windowOpen, (v) => {
|
||||
if (!v) {
|
||||
inlineImageUrl.value = '';
|
||||
resetImageView();
|
||||
}
|
||||
});
|
||||
|
||||
watch(storeId, () => {
|
||||
if (windowOpen.value) loadList(true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPoll();
|
||||
document.removeEventListener('mousemove', onImageMouseMove);
|
||||
document.removeEventListener('mouseup', onImageMouseUp);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadRefreshSetting();
|
||||
loadList(true);
|
||||
restartPoll();
|
||||
});
|
||||
|
||||
defineExpose({ loadList });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="doctor-transfer-header-entry">
|
||||
<Tooltip title="门店传方">
|
||||
<Badge :count="pendingCount" :overflow-count="99" size="small">
|
||||
<Button
|
||||
type="text"
|
||||
class="transfer-header-btn"
|
||||
@click="openWindow"
|
||||
>
|
||||
<FileTextOutlined />
|
||||
<span class="transfer-header-btn__text">传方</span>
|
||||
</Button>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SubWindow
|
||||
v-model:open="windowOpen"
|
||||
title="门店传方"
|
||||
storage-key="pc_doctor_transfer_window"
|
||||
:width="440"
|
||||
:height="560"
|
||||
:min-width="360"
|
||||
:min-height="320"
|
||||
:z-index="DEFAULT_SUB_WINDOW_Z_INDEX"
|
||||
>
|
||||
<div class="panel" :class="{ 'is-dark': isDark }">
|
||||
<div v-if="inlineImageUrl" class="inline-image-layer">
|
||||
<div class="inline-image-toolbar">
|
||||
<Button type="link" size="small" @click="closeInlineImage">返回列表</Button>
|
||||
<Button type="link" size="small" @click="zoomIn">
|
||||
<ZoomInOutlined /> 放大
|
||||
</Button>
|
||||
<Button type="link" size="small" @click="zoomOut">
|
||||
<ZoomOutOutlined /> 缩小
|
||||
</Button>
|
||||
<Button type="link" size="small" @click="resetImageZoom">还原</Button>
|
||||
<span class="zoom-percent">{{ Math.round(imageScale * 100) }}%</span>
|
||||
</div>
|
||||
<div
|
||||
class="inline-image-body"
|
||||
:class="{ dragging: imageDragging }"
|
||||
@wheel="onImageWheel"
|
||||
@mousedown="onImageMouseDown"
|
||||
>
|
||||
<img
|
||||
:src="inlineImageUrl"
|
||||
class="inline-image"
|
||||
:style="imageTransformStyle"
|
||||
alt="处方图"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-row">
|
||||
<Input.Search
|
||||
v-model:value="salespersonKeyword"
|
||||
allow-clear
|
||||
placeholder="推广员姓名/手机"
|
||||
size="small"
|
||||
class="toolbar-input"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<Input.Search
|
||||
v-model:value="patientName"
|
||||
allow-clear
|
||||
placeholder="收货人姓名"
|
||||
size="small"
|
||||
class="toolbar-input"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar-row toolbar-row-bottom">
|
||||
<span class="toolbar-label">刷新</span>
|
||||
<Select
|
||||
v-model:value="refreshIntervalSec"
|
||||
size="small"
|
||||
class="toolbar-select"
|
||||
:options="refreshOptions"
|
||||
:get-popup-container="getPopupContainer"
|
||||
:dropdown-style="{ zIndex: popupZIndex }"
|
||||
/>
|
||||
<Button size="small" :loading="loading" @click="handleManualRefresh">
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="panel-scroll">
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="!loading && !list.length" class="empty-tip">暂无传方记录</div>
|
||||
<div v-for="item in list" :key="item.id" class="transfer-card">
|
||||
<div class="card-head">
|
||||
<span class="store-name">{{ item.store_name || '—' }}</span>
|
||||
<Tag :color="statusColor(item.status)">
|
||||
{{ item.status_text || '未处理' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="salesperson-row">
|
||||
<Image
|
||||
:src="item.salesperson_avatar || DEFAULT_AVATAR"
|
||||
:width="32"
|
||||
:height="32"
|
||||
class="salesperson-avatar"
|
||||
:preview="false"
|
||||
/>
|
||||
<div class="salesperson-info">
|
||||
<div class="salesperson-name">{{ item.salesperson_name || '—' }}</div>
|
||||
<div v-if="item.salesperson_phone" class="salesperson-phone">
|
||||
{{ item.salesperson_phone }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="label">收货人</span>
|
||||
<span class="value">
|
||||
{{ item.patient_name }}
|
||||
<SensitiveText :record="item" field="patient_mobile" :show-eye="false" />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.patient_address" class="card-row">
|
||||
<span class="label">地址</span>
|
||||
<span class="value ellipsis">{{ item.patient_address }}</span>
|
||||
</div>
|
||||
<div v-if="item.remark" class="card-row">
|
||||
<span class="label">备注</span>
|
||||
<span class="value">{{ item.remark }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.drug_text_lines?.length"
|
||||
class="drug-block drug-block--importable"
|
||||
@click="canImportItem(item) && handleImportToCurrentPatient(item)"
|
||||
>
|
||||
<span class="label">药方</span>
|
||||
<div class="drug-lines">
|
||||
<div
|
||||
v-for="(line, idx) in item.drug_text_lines"
|
||||
:key="'d-' + idx"
|
||||
class="drug-line"
|
||||
>
|
||||
{{ line }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="canImportItem(item)" class="import-hint">点击导入到当前患者</span>
|
||||
</div>
|
||||
<div v-if="item.prescription_images?.length" class="img-block">
|
||||
<span class="label">处方图</span>
|
||||
<div class="img-row">
|
||||
<Image
|
||||
v-for="(img, idx) in item.prescription_images"
|
||||
:key="'img-' + idx"
|
||||
:src="img"
|
||||
:width="64"
|
||||
:height="64"
|
||||
class="thumb"
|
||||
:preview="{ zIndex: popupZIndex }"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
class="link-btn"
|
||||
@click="openImageInWindow(item.prescription_images[0])"
|
||||
>
|
||||
在窗口查看
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.status === 3 && item.exception_message"
|
||||
class="exception-box"
|
||||
>
|
||||
异常:{{ item.exception_message }}
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<span class="time">{{ item.transfer_time_text }}</span>
|
||||
<div class="card-foot-actions">
|
||||
<Button
|
||||
v-if="canImportItem(item)"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleImportToCurrentPatient(item)"
|
||||
>
|
||||
导入到当前患者
|
||||
</Button>
|
||||
<Dropdown
|
||||
:trigger="['click']"
|
||||
:get-popup-container="getPopupContainer"
|
||||
:overlay-style="{ zIndex: popupZIndex }"
|
||||
>
|
||||
<Button size="small" type="primary" ghost>操作</Button>
|
||||
<template #overlay>
|
||||
<Menu @click="({ key }) => handleStatusSelect(item, Number(key))">
|
||||
<Menu.Item v-for="opt in statusOptions" :key="String(opt.value)">
|
||||
{{ opt.label }}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubWindow>
|
||||
<Modal
|
||||
v-model:open="exceptionModalOpen"
|
||||
title="填写异常说明"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
:z-index="popupZIndex"
|
||||
@ok="confirmException"
|
||||
>
|
||||
<Textarea
|
||||
v-model:value="exceptionMessage"
|
||||
:rows="4"
|
||||
placeholder="请描述异常原因"
|
||||
/>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.panel.is-dark .toolbar {
|
||||
border-bottom-color: #424242;
|
||||
}
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar-row-bottom {
|
||||
margin-top: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.toolbar-input {
|
||||
flex: 1;
|
||||
}
|
||||
.toolbar-select {
|
||||
flex: 1;
|
||||
}
|
||||
.toolbar-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
.panel.is-dark .toolbar-label {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
/* 内部滚动:panel-body 限定高度,panel-scroll 为唯一滚动容器 */
|
||||
.panel-body {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 12px;
|
||||
}
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px 0;
|
||||
}
|
||||
.panel.is-dark .empty-tip {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.transfer-card {
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
.panel.is-dark .transfer-card {
|
||||
background: #262626;
|
||||
border-color: #424242;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.store-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #1d2129;
|
||||
}
|
||||
.panel.is-dark .store-name {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.salesperson-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.salesperson-avatar {
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.salesperson-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.salesperson-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1d2129;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.panel.is-dark .salesperson-name {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.salesperson-phone {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
.panel.is-dark .salesperson-phone {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.card-row {
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.label {
|
||||
color: #86909c;
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.panel.is-dark .label {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.value {
|
||||
color: #4e5969;
|
||||
flex: 1;
|
||||
}
|
||||
.panel.is-dark .value {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.drug-block {
|
||||
margin: 6px 0;
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
}
|
||||
.drug-block--importable {
|
||||
flex-wrap: wrap;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.drug-block--importable:hover {
|
||||
background: rgba(0, 135, 113, 0.08);
|
||||
}
|
||||
.panel.is-dark .drug-block--importable:hover {
|
||||
background: rgba(52, 211, 153, 0.12);
|
||||
}
|
||||
.import-hint {
|
||||
flex-basis: 100%;
|
||||
font-size: 12px;
|
||||
color: #008771;
|
||||
margin-top: 4px;
|
||||
margin-left: 56px;
|
||||
}
|
||||
.panel.is-dark .import-hint {
|
||||
color: #34d399;
|
||||
}
|
||||
.drug-lines {
|
||||
flex: 1;
|
||||
}
|
||||
.drug-line {
|
||||
color: #008771;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.panel.is-dark .drug-line {
|
||||
color: #34d399;
|
||||
}
|
||||
.img-block {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.img-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 6px 0 6px 56px;
|
||||
}
|
||||
.thumb {
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.link-btn {
|
||||
padding-left: 56px;
|
||||
}
|
||||
.exception-box {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.panel.is-dark .exception-box {
|
||||
background: rgba(127, 29, 29, 0.35);
|
||||
color: #fca5a5;
|
||||
}
|
||||
.card-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #e5e7eb;
|
||||
}
|
||||
.card-foot-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.panel.is-dark .card-foot {
|
||||
border-top-color: #424242;
|
||||
}
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.panel.is-dark .time {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.inline-image-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #fff;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.panel.is-dark .inline-image-layer {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
.inline-image-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.panel.is-dark .inline-image-toolbar {
|
||||
border-bottom-color: #424242;
|
||||
}
|
||||
.zoom-percent {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
.panel.is-dark .zoom-percent {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.inline-image-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 12px;
|
||||
cursor: grab;
|
||||
}
|
||||
.inline-image-body.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.inline-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
user-select: none;
|
||||
}
|
||||
.doctor-transfer-header-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.transfer-header-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.transfer-header-btn__text {
|
||||
font-size: 13px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.transfer-header-btn__text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,637 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 药品搜索选择组件(自定义实现)
|
||||
*
|
||||
* @description 自定义药品搜索下拉选择器,不依赖antd的Select组件
|
||||
* - 支持中药/西药类型切换
|
||||
* - 下拉选项展示:药品图片、名称、供应商、规格、价格
|
||||
* - 选择时返回完整药品数据(包含默认用法字段)
|
||||
* - isCard:单选确认场景(如仓绑药)选中后以内嵌卡片展示已选药品
|
||||
* @author 系统
|
||||
* @date 2024
|
||||
*/
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
import { SearchOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||
import { Button, Input, Spin } from 'ant-design-vue';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
|
||||
|
||||
/** 无图时占位(中药/西药等统一兜底,避免仓绑药下拉只剩文字) */
|
||||
const DRUG_PLACEHOLDER_IMAGE =
|
||||
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
|
||||
|
||||
// ==================== Props 定义 ====================
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 药品类型
|
||||
* 1-中药,2-西药
|
||||
*/
|
||||
type?: number;
|
||||
/**
|
||||
* 多药品类型(优先于 type;有值时支持空关键词拉全量)
|
||||
* 例如 [2,4] 表示西药+中成药
|
||||
*/
|
||||
types?: number[];
|
||||
/**
|
||||
* 占位提示文本
|
||||
*/
|
||||
placeholder?: string;
|
||||
/**
|
||||
* 诊所ID(接诊/开方必传;仓药绑定等平台场景可省略,默认 0 走主库 types 检索)
|
||||
*/
|
||||
storeId?: number;
|
||||
/**
|
||||
* 挂号ID(用于取价门店与毛利率权限)
|
||||
*/
|
||||
registerId?: number;
|
||||
/**
|
||||
* 是否禁用
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 是否以卡片展示已选药品(仓绑药等单选确认场景;开方默认 false)
|
||||
*/
|
||||
isCard?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 1,
|
||||
types: () => [],
|
||||
placeholder: '输入药品名称搜索',
|
||||
// 默认 0:平台无门店;诊所场景由调用方传 :store-id
|
||||
storeId: 0,
|
||||
registerId: undefined,
|
||||
disabled: false,
|
||||
isCard: false,
|
||||
});
|
||||
|
||||
// ==================== Emits 定义 ====================
|
||||
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* 选中药品时触发
|
||||
* @param drug 完整的药品数据
|
||||
*/
|
||||
(e: 'select', drug: any): void;
|
||||
}>();
|
||||
|
||||
// ==================== 响应式数据 ====================
|
||||
|
||||
/**
|
||||
* 搜索关键词
|
||||
*/
|
||||
const searchKeyword = ref('');
|
||||
|
||||
/**
|
||||
* 是否正在搜索
|
||||
*/
|
||||
const isSearching = ref(false);
|
||||
|
||||
/**
|
||||
* 搜索结果列表
|
||||
*/
|
||||
const searchResults = ref<any[]>([]);
|
||||
|
||||
/**
|
||||
* 是否显示下拉列表
|
||||
*/
|
||||
const showDropdown = ref(false);
|
||||
|
||||
/**
|
||||
* 组件容器引用
|
||||
*/
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
/**
|
||||
* 当前高亮的选项索引(用于键盘导航)
|
||||
*/
|
||||
const highlightIndex = ref(-1);
|
||||
|
||||
/**
|
||||
* isCard 模式下已选药品(用于输入框下方卡片)
|
||||
*/
|
||||
const selectedDrug = ref<any | null>(null);
|
||||
|
||||
/**
|
||||
* 搜索框引用,便于「重新选择」时聚焦
|
||||
*/
|
||||
const inputRef = ref<{ focus?: () => void } | null>(null);
|
||||
|
||||
/**
|
||||
* 解析下拉展示图:优先真图,无图用统一占位
|
||||
*/
|
||||
function resolveDropdownImage(item: any): string {
|
||||
return item._image || DRUG_PLACEHOLDER_IMAGE;
|
||||
}
|
||||
|
||||
// ==================== 方法定义 ====================
|
||||
|
||||
/**
|
||||
* 搜索药品
|
||||
* @param keyword 搜索关键词
|
||||
* @description 调用后端API搜索药品,支持按名称和拼音搜索;
|
||||
* 传入 types 时允许空关键词拉取该类型全量列表(用于下拉展开)
|
||||
*/
|
||||
const searchDrugs = debounce(async (keyword: string) => {
|
||||
// 单类型模式:空关键词清空;多类型模式:空关键词仍请求后端拉全量
|
||||
if ((!keyword || keyword.length < 1) && props.types.length === 0) {
|
||||
searchResults.value = [];
|
||||
showDropdown.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching.value = true;
|
||||
showDropdown.value = true;
|
||||
|
||||
try {
|
||||
// types 优先:有多类型时只传 types,不传 type,避免后端被单 type 覆盖
|
||||
const typeParams =
|
||||
props.types.length > 0
|
||||
? { types: props.types }
|
||||
: { type: props.type };
|
||||
const res = await getProductListDoctorReception({
|
||||
name: keyword || '',
|
||||
...typeParams,
|
||||
store_id: props.storeId,
|
||||
...(props.registerId ? { register_id: props.registerId } : {}),
|
||||
});
|
||||
|
||||
// 处理返回数据(兼容门店 relation 与平台主库打平结构)
|
||||
if (Array.isArray(res)) {
|
||||
searchResults.value = res.map((item: any) => {
|
||||
const drug = item.drug || {};
|
||||
return {
|
||||
...item,
|
||||
_id: item.id,
|
||||
_drugId: item.drug_id || drug.id || item.id,
|
||||
_drugName: drug.drug_name || item.drug_name || '',
|
||||
_image: drug.image || item.image || '',
|
||||
_specification: drug.specification || item.specification || '',
|
||||
_supplier: drug.supplier?.name || item.supplier?.name || '',
|
||||
_price: Number(item.price ?? 0),
|
||||
// 已绑定配送仓:列表展示「多仓」标识
|
||||
_hasDeliveryWarehouse: Number(item.has_delivery_warehouse ?? 0) === 1,
|
||||
_timeId: drug.time_id || 0,
|
||||
_typeId: drug.type_id || 0,
|
||||
_frequencyId: drug.frequency_id || 0,
|
||||
_unitId: drug.unit_id || 0,
|
||||
_number: drug.number || 1,
|
||||
_useNum: drug.useNum,
|
||||
_useType: drug.useType,
|
||||
_useFrequency: drug.useFrequency,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
searchResults.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索药品失败:', error);
|
||||
searchResults.value = [];
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
/**
|
||||
* 处理输入变化
|
||||
* @param e 输入事件
|
||||
*/
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
searchKeyword.value = target.value;
|
||||
highlightIndex.value = -1;
|
||||
searchDrugs(target.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理输入框获得焦点
|
||||
* @description 多类型模式聚焦即拉取列表并展开;单类型模式仅在已有结果时展示下拉
|
||||
*/
|
||||
function handleFocus() {
|
||||
if (props.types.length > 0) {
|
||||
searchDrugs('');
|
||||
showDropdown.value = true;
|
||||
return;
|
||||
}
|
||||
if (searchResults.value.length > 0) {
|
||||
showDropdown.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理选中药品
|
||||
* @param drug 选中的药品数据
|
||||
* @description isCard 时保留 selectedDrug 用于卡片展示;否则清空选中态
|
||||
*/
|
||||
function handleSelectDrug(drug: any) {
|
||||
emit('select', drug);
|
||||
searchKeyword.value = '';
|
||||
searchResults.value = [];
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
if (props.isCard) {
|
||||
selectedDrug.value = drug;
|
||||
} else {
|
||||
selectedDrug.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空已选卡片并重新搜索(仅 isCard)
|
||||
* 同步 emit null,便于父级清空 drug_id
|
||||
*/
|
||||
function handleReselect() {
|
||||
selectedDrug.value = null;
|
||||
searchKeyword.value = '';
|
||||
searchResults.value = [];
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
emit('select', null);
|
||||
// 下一帧聚焦搜索框,方便重新选药
|
||||
setTimeout(() => {
|
||||
inputRef.value?.focus?.();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理键盘事件
|
||||
* @param e 键盘事件
|
||||
*/
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!showDropdown.value || searchResults.value.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
highlightIndex.value = Math.min(
|
||||
highlightIndex.value + 1,
|
||||
searchResults.value.length - 1,
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
highlightIndex.value = Math.max(highlightIndex.value - 1, 0);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (highlightIndex.value >= 0) {
|
||||
handleSelectDrug(searchResults.value[highlightIndex.value]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理点击外部关闭下拉
|
||||
* @param e 点击事件
|
||||
*/
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
// ==================== 监听 ====================
|
||||
|
||||
/**
|
||||
* 监听类型变化,清空搜索结果
|
||||
*/
|
||||
watch(
|
||||
() => props.type,
|
||||
() => {
|
||||
searchResults.value = [];
|
||||
showDropdown.value = false;
|
||||
searchKeyword.value = '';
|
||||
selectedDrug.value = null;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="drug-search-select">
|
||||
<!-- 搜索输入框 -->
|
||||
<Input
|
||||
ref="inputRef"
|
||||
v-model:value="searchKeyword"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
allow-clear
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<template #prefix>
|
||||
<LoadingOutlined v-if="isSearching" class="text-gray-400" />
|
||||
<SearchOutlined v-else class="text-gray-400" />
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<!-- isCard:已选药品卡片(与下拉项同结构,带边框区分) -->
|
||||
<div
|
||||
v-if="isCard && selectedDrug"
|
||||
class="drug-item drug-item--selected-card"
|
||||
>
|
||||
<div class="drug-item__image">
|
||||
<img
|
||||
:src="resolveDropdownImage(selectedDrug)"
|
||||
alt=""
|
||||
class="drug-item__img"
|
||||
@error="
|
||||
(e) => ((e.target as HTMLImageElement).src = DRUG_PLACEHOLDER_IMAGE)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="drug-item__info">
|
||||
<div class="drug-item__name">{{ selectedDrug._drugName }}</div>
|
||||
<div class="drug-item__meta">
|
||||
<span class="drug-item__id">ID:{{ selectedDrug._drugId }}</span>
|
||||
<span v-if="selectedDrug._specification" class="drug-item__spec">
|
||||
规格:{{ selectedDrug._specification }}
|
||||
</span>
|
||||
<span v-if="selectedDrug._supplier" class="drug-item__supplier">
|
||||
供应商:{{ selectedDrug._supplier }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drug-item__price">
|
||||
<template v-if="Number(selectedDrug._price) > 0">
|
||||
¥{{ Number(selectedDrug._price).toFixed(2) }}
|
||||
</template>
|
||||
<span v-else class="drug-item__price--empty">暂无</span>
|
||||
</div>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
class="drug-item__reselect"
|
||||
@click="handleReselect"
|
||||
>
|
||||
重新选择
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 下拉列表 -->
|
||||
<div v-if="showDropdown" class="drug-dropdown">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="isSearching" class="drug-dropdown__loading">
|
||||
<Spin size="small" />
|
||||
<span>搜索中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 无结果 -->
|
||||
<div
|
||||
v-else-if="searchResults.length === 0"
|
||||
class="drug-dropdown__empty"
|
||||
>
|
||||
暂无匹配药品
|
||||
</div>
|
||||
|
||||
<!-- 结果列表 -->
|
||||
<div v-else class="drug-dropdown__list">
|
||||
<div
|
||||
v-for="(item, index) in searchResults"
|
||||
:key="item._id"
|
||||
class="drug-item"
|
||||
:class="{ 'drug-item--active': index === highlightIndex }"
|
||||
@click="handleSelectDrug(item)"
|
||||
@mouseenter="highlightIndex = index"
|
||||
>
|
||||
<!-- 药品图片 -->
|
||||
<div class="drug-item__image">
|
||||
<img
|
||||
v-if="resolveDropdownImage(item)"
|
||||
:src="resolveDropdownImage(item)"
|
||||
alt=""
|
||||
class="drug-item__img"
|
||||
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
<div v-else class="drug-item__img-placeholder">无图</div>
|
||||
</div>
|
||||
|
||||
<!-- 药品信息:药名 / 多仓 / ID / 规格 / 供应商 -->
|
||||
<div class="drug-item__info">
|
||||
<div class="drug-item__name-row">
|
||||
<div class="drug-item__name">{{ item._drugName }}</div>
|
||||
<span v-if="item._hasDeliveryWarehouse" class="drug-item__multi-wh">多仓</span>
|
||||
</div>
|
||||
<div class="drug-item__meta">
|
||||
<span class="drug-item__id">ID:{{ item._drugId }}</span>
|
||||
<span v-if="item._specification" class="drug-item__spec">
|
||||
规格:{{ item._specification }}
|
||||
</span>
|
||||
<span v-if="item._supplier" class="drug-item__supplier">
|
||||
供应商:{{ item._supplier }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 价格:无价(平台主库常见)展示「暂无」 -->
|
||||
<div class="drug-item__price">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 只用框架主题变量,随亮暗/主题色自动适配,不写 .dark 硬编码覆盖 */
|
||||
.drug-search-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drug-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1050;
|
||||
margin-top: 4px;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
|
||||
&__loading,
|
||||
&__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drug-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover,
|
||||
&--active {
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
&__img-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--accent));
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 配送仓绑定药品标识:小号胶囊,不抢主信息 */
|
||||
&__multi-wh {
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
line-height: 16px;
|
||||
font-size: 11px;
|
||||
color: #2b6de5;
|
||||
background: rgba(43, 109, 229, 0.08);
|
||||
border: 1px solid rgba(43, 109, 229, 0.45);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
&__id {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
&__spec {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
&__supplier {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
&__price {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--destructive));
|
||||
|
||||
&--empty {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
9
apps/web-antd/src/components/drug-search-select/index.ts
Normal file
9
apps/web-antd/src/components/drug-search-select/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 药品搜索选择组件
|
||||
*
|
||||
* @description 封装药品搜索下拉选择器,展示药品详细信息
|
||||
* @author 系统
|
||||
* @date 2024
|
||||
*/
|
||||
export { default as DrugSearchSelect } from './drug-search-select.vue';
|
||||
|
||||
196
apps/web-antd/src/components/file-preview/FilePreviewModal.vue
Normal file
196
apps/web-antd/src/components/file-preview/FilePreviewModal.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 通用文件预览:图片用 Ant Image;PDF fetch→blob→iframe;支持多文件切换
|
||||
* 开票中心与素材库复用,避免两套预览逻辑
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Image, Modal, Spin, message } from 'ant-design-vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 是否显示 */
|
||||
open: boolean;
|
||||
/** 文件 URL 列表 */
|
||||
files?: string[];
|
||||
/** 弹窗标题前缀 */
|
||||
title?: string;
|
||||
}>(),
|
||||
{
|
||||
files: () => [],
|
||||
title: '文件预览',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', v: boolean): void;
|
||||
}>();
|
||||
|
||||
const currentIndex = ref(0);
|
||||
const previewIsImage = ref(false);
|
||||
const previewImageUrl = ref('');
|
||||
const previewPdfUrl = ref('');
|
||||
const previewLoading = ref(false);
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.open,
|
||||
set: (v: boolean) => emit('update:open', v),
|
||||
});
|
||||
|
||||
const currentUrl = computed(() => props.files?.[currentIndex.value] || '');
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
const name = (currentUrl.value || '').split('/').pop() || '';
|
||||
const total = props.files?.length || 0;
|
||||
if (total > 1) {
|
||||
return `${props.title}(${currentIndex.value + 1}/${total})${name ? ' - ' + name : ''}`;
|
||||
}
|
||||
return name ? `${props.title} - ${name}` : props.title;
|
||||
});
|
||||
|
||||
function isImageUrl(url: string) {
|
||||
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
|
||||
}
|
||||
|
||||
function revokePdfBlob() {
|
||||
if (previewPdfUrl.value && previewPdfUrl.value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewPdfUrl.value);
|
||||
}
|
||||
previewPdfUrl.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前文件到预览区
|
||||
*/
|
||||
async function loadCurrent() {
|
||||
const url = currentUrl.value;
|
||||
revokePdfBlob();
|
||||
if (!url) {
|
||||
message.info('暂无文件');
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
if (isImageUrl(url)) {
|
||||
previewIsImage.value = true;
|
||||
previewImageUrl.value = url;
|
||||
previewLoading.value = false;
|
||||
return;
|
||||
}
|
||||
previewIsImage.value = false;
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('文件加载失败');
|
||||
const blob = await resp.blob();
|
||||
const pdfBlob =
|
||||
blob.type && blob.type !== 'application/octet-stream'
|
||||
? blob
|
||||
: new Blob([blob], { type: 'application/pdf' });
|
||||
previewPdfUrl.value = URL.createObjectURL(pdfBlob);
|
||||
} catch {
|
||||
message.error('预览失败,请尝试下载后查看');
|
||||
visible.value = false;
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function prevFile() {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function nextFile() {
|
||||
if (props.files && currentIndex.value < props.files.length - 1) {
|
||||
currentIndex.value += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.open, props.files] as const,
|
||||
([open]) => {
|
||||
if (open) {
|
||||
currentIndex.value = 0;
|
||||
loadCurrent();
|
||||
} else {
|
||||
revokePdfBlob();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(currentIndex, () => {
|
||||
if (props.open) {
|
||||
loadCurrent();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokePdfBlob();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-model:open="visible"
|
||||
:title="displayTitle"
|
||||
:footer="null"
|
||||
width="860px"
|
||||
destroy-on-close
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div class="preview-wrap">
|
||||
<div v-if="(files?.length || 0) > 1" class="nav">
|
||||
<Button size="small" :disabled="currentIndex <= 0" @click="prevFile">上一份</Button>
|
||||
<Button
|
||||
size="small"
|
||||
:disabled="currentIndex >= (files?.length || 0) - 1"
|
||||
@click="nextFile"
|
||||
>
|
||||
下一份
|
||||
</Button>
|
||||
</div>
|
||||
<Spin :spinning="previewLoading">
|
||||
<div v-if="previewIsImage" class="img-box">
|
||||
<Image :src="previewImageUrl" :preview="true" style="max-width: 100%" />
|
||||
</div>
|
||||
<iframe
|
||||
v-else-if="previewPdfUrl"
|
||||
:src="previewPdfUrl"
|
||||
class="pdf-frame"
|
||||
title="pdf-preview"
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preview-wrap {
|
||||
min-height: 240px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.img-box {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--muted) / 0.25);
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
.pdf-frame {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
</style>
|
||||
194
apps/web-antd/src/components/float-fab/FloatFab.vue
Normal file
194
apps/web-antd/src/components/float-fab/FloatFab.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* PC 可拖拽吸附悬浮按钮
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { FileTextOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** localStorage 位置缓存 key */
|
||||
storageKey?: string;
|
||||
/** 显示文案 */
|
||||
label?: string;
|
||||
/** 角标数量 */
|
||||
badge?: number;
|
||||
/** 吸附阈值 px */
|
||||
snapThreshold?: number;
|
||||
zIndex?: number;
|
||||
}>(),
|
||||
{
|
||||
storageKey: 'pc_float_fab_pos',
|
||||
label: '传方',
|
||||
badge: 0,
|
||||
snapThreshold: 40,
|
||||
zIndex: 9999,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
const posX = ref(0);
|
||||
const posY = ref(0);
|
||||
const fabSize = 56;
|
||||
const dragging = ref(false);
|
||||
const moved = ref(false);
|
||||
const startX = ref(0);
|
||||
const startY = ref(0);
|
||||
const originX = ref(0);
|
||||
const originY = ref(0);
|
||||
|
||||
const fabStyle = computed(() => ({
|
||||
left: `${posX.value}px`,
|
||||
top: `${posY.value}px`,
|
||||
zIndex: props.zIndex,
|
||||
}));
|
||||
|
||||
const badgeText = computed(() => {
|
||||
if (props.badge <= 0) return '';
|
||||
return props.badge > 99 ? '99+' : String(props.badge);
|
||||
});
|
||||
|
||||
function initPosition() {
|
||||
const cached = localStorage.getItem(props.storageKey);
|
||||
if (cached) {
|
||||
try {
|
||||
const data = JSON.parse(cached);
|
||||
posX.value = data.posX ?? window.innerWidth - fabSize - 24;
|
||||
posY.value = data.posY ?? window.innerHeight * 0.6;
|
||||
} catch {
|
||||
posX.value = window.innerWidth - fabSize - 24;
|
||||
posY.value = window.innerHeight * 0.6;
|
||||
}
|
||||
} else {
|
||||
posX.value = window.innerWidth - fabSize - 24;
|
||||
posY.value = window.innerHeight * 0.6;
|
||||
}
|
||||
clampPos();
|
||||
}
|
||||
|
||||
function savePosition() {
|
||||
localStorage.setItem(
|
||||
props.storageKey,
|
||||
JSON.stringify({ posX: posX.value, posY: posY.value }),
|
||||
);
|
||||
}
|
||||
|
||||
function clampPos() {
|
||||
posX.value = Math.max(8, Math.min(posX.value, window.innerWidth - fabSize - 8));
|
||||
posY.value = Math.max(60, Math.min(posY.value, window.innerHeight - fabSize - 24));
|
||||
}
|
||||
|
||||
/** 松手吸附到最近左/右边缘 */
|
||||
function snapToEdge() {
|
||||
const centerX = posX.value + fabSize / 2;
|
||||
if (centerX < window.innerWidth / 2) {
|
||||
posX.value = props.snapThreshold;
|
||||
} else {
|
||||
posX.value = window.innerWidth - fabSize - props.snapThreshold;
|
||||
}
|
||||
clampPos();
|
||||
savePosition();
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
dragging.value = true;
|
||||
moved.value = false;
|
||||
startX.value = e.clientX;
|
||||
startY.value = e.clientY;
|
||||
originX.value = posX.value;
|
||||
originY.value = posY.value;
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!dragging.value) return;
|
||||
const dx = e.clientX - startX.value;
|
||||
const dy = e.clientY - startY.value;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved.value = true;
|
||||
posX.value = originX.value + dx;
|
||||
posY.value = originY.value + dy;
|
||||
clampPos();
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
if (dragging.value && moved.value) {
|
||||
snapToEdge();
|
||||
} else if (!moved.value) {
|
||||
emit('click');
|
||||
}
|
||||
dragging.value = false;
|
||||
}
|
||||
|
||||
onMounted(initPosition);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="float-fab" :style="fabStyle" @mousedown="onMouseDown">
|
||||
<div class="fab-inner">
|
||||
<FileTextOutlined class="fab-icon" />
|
||||
<span v-if="label" class="fab-label">{{ label }}</span>
|
||||
</div>
|
||||
<span v-if="badgeText" class="fab-badge">{{ badgeText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.float-fab {
|
||||
position: fixed;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.float-fab:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.fab-inner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6acdbb, #00a88a);
|
||||
box-shadow: 0 4px 16px rgba(0, 168, 138, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.fab-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
.fab-label {
|
||||
font-size: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.fab-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 12px;
|
||||
height: 12px;
|
||||
padding: 0 4px;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
|
||||
|
||||
import { useDebounceFn, useVModel } from '@vueuse/core';
|
||||
|
||||
import { Avatar, Empty, Input, Popover, Spin, Tabs, Tag } from 'ant-design-vue';
|
||||
|
||||
|
||||
|
||||
import { searchApiLogUsers } from '#/views/log/api-log/api';
|
||||
|
||||
|
||||
|
||||
defineOptions({
|
||||
|
||||
name: 'ApiLogUserPicker',
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
export type ApiLogUserFilter = {
|
||||
|
||||
id: number;
|
||||
|
||||
user_type: 'admin' | 'patient' | 'doctor';
|
||||
|
||||
platform_type: 0 | 1 | 2;
|
||||
|
||||
name: string;
|
||||
|
||||
role_label: string;
|
||||
|
||||
avatar?: string;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
type ApiLogUserOption = ApiLogUserFilter;
|
||||
|
||||
|
||||
|
||||
type UserTab = 'admin' | 'patient' | 'doctor';
|
||||
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
value?: ApiLogUserFilter;
|
||||
|
||||
disabled?: boolean;
|
||||
|
||||
onPlatformTypeChange?: (platformType: 0 | 1 | 2) => void;
|
||||
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
const emits = defineEmits<{
|
||||
|
||||
'update:value': [value: ApiLogUserFilter | undefined];
|
||||
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
const mValue = useVModel(props, 'value', emits, { passive: true });
|
||||
|
||||
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const searchKeyword = ref('');
|
||||
|
||||
const activeTab = ref<UserTab>('admin');
|
||||
|
||||
const options = ref<ApiLogUserOption[]>([]);
|
||||
|
||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
|
||||
|
||||
|
||||
const TAB_ITEMS = [
|
||||
|
||||
{ key: 'admin', label: '后台用户' },
|
||||
|
||||
{ key: 'patient', label: '患者端用户' },
|
||||
|
||||
{ key: 'doctor', label: '医生端用户' },
|
||||
|
||||
] as const;
|
||||
|
||||
|
||||
|
||||
const searchPlaceholder = computed(() => {
|
||||
|
||||
if (activeTab.value === 'admin') return '搜索手机号或昵称';
|
||||
|
||||
if (activeTab.value === 'patient') return '搜索用户名字';
|
||||
|
||||
return '搜索名字或手机号';
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
async function fetchOptions() {
|
||||
|
||||
const keyword = searchKeyword.value.trim();
|
||||
|
||||
if (!keyword) {
|
||||
|
||||
options.value = [];
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
|
||||
const result = await searchApiLogUsers({
|
||||
|
||||
user_type: activeTab.value,
|
||||
|
||||
keyword,
|
||||
|
||||
pageSize: 24,
|
||||
|
||||
});
|
||||
|
||||
options.value = result?.items ?? [];
|
||||
|
||||
} finally {
|
||||
|
||||
loading.value = false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const debouncedFetch = useDebounceFn(fetchOptions, 300);
|
||||
|
||||
|
||||
|
||||
watch([searchKeyword, activeTab], () => {
|
||||
|
||||
debouncedFetch();
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
function selectOption(item: ApiLogUserOption) {
|
||||
|
||||
mValue.value = {
|
||||
|
||||
id: item.id,
|
||||
|
||||
user_type: item.user_type,
|
||||
|
||||
platform_type: item.platform_type,
|
||||
|
||||
name: item.name,
|
||||
|
||||
role_label: item.role_label,
|
||||
|
||||
avatar: item.avatar,
|
||||
|
||||
};
|
||||
|
||||
props.onPlatformTypeChange?.(item.platform_type);
|
||||
|
||||
open.value = false;
|
||||
|
||||
searchKeyword.value = '';
|
||||
|
||||
options.value = [];
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function clearSelection(e: Event) {
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
mValue.value = undefined;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function onOpenChange(next: boolean) {
|
||||
|
||||
if (props.disabled) return;
|
||||
|
||||
open.value = next;
|
||||
|
||||
if (next) {
|
||||
|
||||
searchKeyword.value = '';
|
||||
|
||||
options.value = [];
|
||||
|
||||
nextTick(() => searchInputRef.value?.focus?.());
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
watch(
|
||||
|
||||
() => props.disabled,
|
||||
|
||||
(disabled) => {
|
||||
|
||||
if (disabled) open.value = false;
|
||||
|
||||
},
|
||||
|
||||
);
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<Popover
|
||||
|
||||
:open="open"
|
||||
|
||||
trigger="click"
|
||||
|
||||
placement="bottomLeft"
|
||||
|
||||
overlay-class-name="api-log-user-picker-popover"
|
||||
|
||||
@open-change="onOpenChange"
|
||||
|
||||
>
|
||||
|
||||
<template #content>
|
||||
|
||||
<div class="api-log-user-panel">
|
||||
|
||||
<Tabs v-model:active-key="activeTab" size="small" class="api-log-user-tabs">
|
||||
|
||||
<Tabs.TabPane
|
||||
|
||||
v-for="tab in TAB_ITEMS"
|
||||
|
||||
:key="tab.key"
|
||||
|
||||
:tab="tab.label"
|
||||
|
||||
/>
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Input
|
||||
|
||||
ref="searchInputRef"
|
||||
|
||||
v-model:value="searchKeyword"
|
||||
|
||||
allow-clear
|
||||
|
||||
:placeholder="searchPlaceholder"
|
||||
|
||||
class="api-log-user-search"
|
||||
|
||||
/>
|
||||
|
||||
<Spin :spinning="loading">
|
||||
|
||||
<div v-if="options.length" class="api-log-user-grid">
|
||||
|
||||
<button
|
||||
|
||||
v-for="item in options"
|
||||
|
||||
:key="`${item.user_type}-${item.id}`"
|
||||
|
||||
type="button"
|
||||
|
||||
class="api-log-user-card"
|
||||
|
||||
:class="{
|
||||
|
||||
active:
|
||||
|
||||
mValue?.id === item.id && mValue?.user_type === item.user_type,
|
||||
|
||||
}"
|
||||
|
||||
@click="selectOption(item)"
|
||||
|
||||
>
|
||||
|
||||
<Avatar :src="item.avatar" :size="30">
|
||||
|
||||
{{ (item.name || '?').charAt(0) }}
|
||||
|
||||
</Avatar>
|
||||
|
||||
<div class="api-log-user-card-id">#{{ item.id }}</div>
|
||||
|
||||
<div class="api-log-user-card-name" :title="item.name">
|
||||
|
||||
{{ item.name }}
|
||||
|
||||
</div>
|
||||
|
||||
<Tag class="api-log-user-card-role" :bordered="false">
|
||||
|
||||
{{ item.role_label }}
|
||||
|
||||
</Tag>
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<Empty
|
||||
|
||||
v-else
|
||||
|
||||
:description="searchKeyword.trim() ? '无匹配用户' : '请输入关键词搜索'"
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Upload } from 'ant-design-vue';
|
||||
|
||||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { uploadToOss } from '#/utils/oss-upload';
|
||||
import {
|
||||
beforeImageUpload,
|
||||
resolveUploadFile,
|
||||
} from '#/utils/use-image-upload-pending';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
defineOptions({
|
||||
@@ -19,43 +26,77 @@ const mValue = useVModel(props, 'value', emits, {
|
||||
defaultValue: props.value,
|
||||
passive: true,
|
||||
});
|
||||
const customRequest = (e: any) => {
|
||||
uploadFile({
|
||||
file: e.file,
|
||||
}).then((data: any) => {
|
||||
|
||||
const customRequest = async (e: any) => {
|
||||
try {
|
||||
const actualFile = resolveUploadFile(e.file as File);
|
||||
const uploadMethod = preferences.app.uploadMethod || 'direct';
|
||||
|
||||
let data: { url: string };
|
||||
|
||||
if (uploadMethod === 'direct') {
|
||||
data = await uploadToOss({
|
||||
file: actualFile,
|
||||
});
|
||||
} else {
|
||||
data = await uploadFile({
|
||||
file: actualFile,
|
||||
});
|
||||
}
|
||||
|
||||
mValue.value = data.url;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('头像上传失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
function onGallerySelect(urls: string[]) {
|
||||
if (urls[0]) {
|
||||
mValue.value = urls[0];
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
mValue.value = '';
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Upload
|
||||
:custom-request="customRequest"
|
||||
:show-upload-list="false"
|
||||
list-type="picture-card"
|
||||
>
|
||||
<div v-if="mValue" class="m-avatar-wrap">
|
||||
<Icon
|
||||
class="m-avatar-icon-delete"
|
||||
icon="ant-design:delete-outlined"
|
||||
@click="handleRemove"
|
||||
/>
|
||||
<img :src="value" width="100%" />
|
||||
</div>
|
||||
<Icon v-else icon="ant-design:plus-outlined" />
|
||||
</Upload>
|
||||
<div class="avatar-upload-wrap">
|
||||
<Upload
|
||||
:before-upload="beforeImageUpload"
|
||||
:custom-request="customRequest"
|
||||
:show-upload-list="false"
|
||||
list-type="picture-card"
|
||||
>
|
||||
<div v-if="mValue" class="m-avatar-wrap">
|
||||
<Icon
|
||||
class="m-avatar-icon-delete"
|
||||
icon="ant-design:delete-outlined"
|
||||
@click="handleRemove"
|
||||
/>
|
||||
<img :src="mValue" width="100%" />
|
||||
</div>
|
||||
<Icon v-else icon="ant-design:plus-outlined" />
|
||||
</Upload>
|
||||
<GalleryPickLink :accept-types="[0]" @select="onGallerySelect" />
|
||||
</div>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
.avatar-upload-wrap {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.m-avatar-wrap {
|
||||
position: relative;
|
||||
height: 102px;
|
||||
overflow: hidden;
|
||||
|
||||
.m-avatar-icon-delete {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
140
apps/web-antd/src/components/form/components/color-picker.vue
Normal file
140
apps/web-antd/src/components/form/components/color-picker.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '选择颜色',
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['update:value']);
|
||||
const mValue = useVModel(props, 'value', emits, {
|
||||
defaultValue: props.value,
|
||||
passive: true,
|
||||
});
|
||||
|
||||
// 预设颜色列表(来自岗位颜色配置)
|
||||
const presetColors = [
|
||||
{ color: '#3b82f6', name: '蓝色' },
|
||||
{ color: '#8b5cf6', name: '紫色' },
|
||||
{ color: '#10b981', name: '绿色' },
|
||||
{ color: '#f59e0b', name: '橙色' },
|
||||
{ color: '#ec4899', name: '粉色' },
|
||||
{ color: '#06b6d4', name: '青色' },
|
||||
{ color: '#ef4444', name: '红色' },
|
||||
{ color: '#6b7280', name: '灰色' },
|
||||
];
|
||||
|
||||
const handleClear = (e: Event) => {
|
||||
e.preventDefault();
|
||||
mValue.value = '';
|
||||
};
|
||||
|
||||
const selectPresetColor = (color: string) => {
|
||||
mValue.value = color;
|
||||
};
|
||||
|
||||
const isSelected = (color: string) => {
|
||||
return mValue.value?.toLowerCase() === color.toLowerCase();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="color-picker-container rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<!-- 主选择器 -->
|
||||
<div class="color-picker-main flex items-center gap-2.5">
|
||||
<label class="color-picker-label relative inline-block cursor-pointer">
|
||||
<input v-model="mValue" type="color" class="color-input" />
|
||||
<span
|
||||
class="color-preview flex h-10 w-10 items-center justify-center rounded-lg border-2 border-gray-300 shadow-sm transition-all hover:scale-105 hover:border-blue-500 dark:border-gray-500 dark:hover:border-blue-400"
|
||||
:style="{ backgroundColor: mValue || 'var(--color-placeholder-bg)' }"
|
||||
>
|
||||
<span
|
||||
v-if="!mValue"
|
||||
class="text-base font-bold text-gray-400 dark:text-gray-500"
|
||||
>?</span
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
<span
|
||||
class="color-value flex-1 rounded border border-gray-200 bg-white px-2.5 py-1.5 font-mono text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{{ mValue || placeholder }}
|
||||
</span>
|
||||
<a
|
||||
v-if="mValue"
|
||||
class="cursor-pointer rounded px-2 py-1 text-xs text-red-500 transition-all hover:bg-red-50 hover:no-underline dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
@click="handleClear"
|
||||
>
|
||||
清除
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 预设颜色 -->
|
||||
<div class="color-presets mt-3">
|
||||
<span class="presets-label mb-2 block text-xs text-gray-500 dark:text-gray-400">
|
||||
推荐颜色
|
||||
</span>
|
||||
<div class="presets-grid flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="preset in presetColors"
|
||||
:key="preset.color"
|
||||
class="preset-item flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border-2 border-transparent shadow-sm transition-all hover:scale-115 hover:shadow-md"
|
||||
:class="{
|
||||
'ring-2 ring-gray-800 ring-offset-2 dark:ring-gray-200 dark:ring-offset-gray-800':
|
||||
isSelected(preset.color),
|
||||
}"
|
||||
:style="{ backgroundColor: preset.color }"
|
||||
:title="preset.name"
|
||||
@click="selectPresetColor(preset.color)"
|
||||
>
|
||||
<span
|
||||
v-if="isSelected(preset.color)"
|
||||
class="check-icon text-sm font-bold text-white drop-shadow"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.color-picker-container {
|
||||
max-width: 320px;
|
||||
|
||||
--color-placeholder-bg: #e5e7eb;
|
||||
|
||||
.dark & {
|
||||
--color-placeholder-bg: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-label {
|
||||
.color-input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-item {
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
344
apps/web-antd/src/components/form/components/doctor-picker.vue
Normal file
344
apps/web-antd/src/components/form/components/doctor-picker.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Avatar, Empty, Input, Popover, Spin } from 'ant-design-vue';
|
||||
|
||||
import { getDoctorOptionApi } from '#/views/system/store/api';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
defineOptions({
|
||||
name: 'DoctorPicker',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
value?: number;
|
||||
storeId?: number;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:value': [value: number | undefined];
|
||||
}>();
|
||||
|
||||
const mValue = useVModel(props, 'value', emits, { passive: true });
|
||||
|
||||
type DoctorOption = {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
mobile?: string;
|
||||
depart_name?: string;
|
||||
};
|
||||
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const options = ref<DoctorOption[]>([]);
|
||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
|
||||
const isDisabled = computed(() => props.disabled || !props.storeId);
|
||||
|
||||
/** 按关键词过滤医生列表 */
|
||||
function filterDoctorOptions(keyword: string, list: DoctorOption[]) {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter((item) => {
|
||||
const name = (item.name || '').toLowerCase();
|
||||
const mobile = (item.mobile || '').toLowerCase();
|
||||
const depart = (item.depart_name || '').toLowerCase();
|
||||
const idText = String(item.id);
|
||||
return (
|
||||
name.includes(q) ||
|
||||
mobile.includes(q) ||
|
||||
depart.includes(q) ||
|
||||
idText.includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const filteredOptions = computed(() =>
|
||||
filterDoctorOptions(searchKeyword.value, options.value),
|
||||
);
|
||||
|
||||
const selectedOption = computed(() =>
|
||||
options.value.find((item) => item.id === mValue.value),
|
||||
);
|
||||
|
||||
/** 按诊所加载医生选项(id 为 su_id) */
|
||||
async function loadOptions() {
|
||||
if (!props.storeId) {
|
||||
options.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
options.value = (await getDoctorOptionApi(props.storeId)) || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(item: DoctorOption) {
|
||||
mValue.value = item.id;
|
||||
open.value = false;
|
||||
searchKeyword.value = '';
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
mValue.value = undefined;
|
||||
}
|
||||
|
||||
function onOpenChange(next: boolean) {
|
||||
if (isDisabled.value) return;
|
||||
open.value = next;
|
||||
if (next) {
|
||||
searchKeyword.value = '';
|
||||
loadOptions();
|
||||
nextTick(() => searchInputRef.value?.focus?.());
|
||||
}
|
||||
}
|
||||
|
||||
/** 诊所变更时重载列表,并清除不在新诊所下的选中项 */
|
||||
watch(
|
||||
() => props.storeId,
|
||||
async (storeId, prevStoreId) => {
|
||||
if (storeId === prevStoreId) return;
|
||||
await loadOptions();
|
||||
if (!mValue.value) return;
|
||||
const stillValid = options.value.some((item) => item.id === mValue.value);
|
||||
if (!stillValid) {
|
||||
mValue.value = undefined;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
if (disabled) open.value = false;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover
|
||||
:open="open"
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
overlay-class-name="doctor-picker-popover"
|
||||
@open-change="onOpenChange"
|
||||
>
|
||||
<template #content>
|
||||
<div class="doctor-panel">
|
||||
<Input
|
||||
ref="searchInputRef"
|
||||
v-model:value="searchKeyword"
|
||||
allow-clear
|
||||
placeholder="搜索医生姓名、手机号、科室"
|
||||
class="doctor-search"
|
||||
/>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="filteredOptions.length" class="doctor-grid">
|
||||
<button
|
||||
v-for="item in filteredOptions"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="doctor-card"
|
||||
:class="{ active: item.id === mValue }"
|
||||
@click="selectOption(item)"
|
||||
>
|
||||
<Avatar :src="resolveAvatarUrl(item.avatar)" :size="36">
|
||||
{{ (item.name || '?').charAt(0) }}
|
||||
</Avatar>
|
||||
<div class="doctor-card-name" :title="item.name">
|
||||
{{ item.name || '-' }}
|
||||
</div>
|
||||
<div v-if="item.mobile" class="doctor-card-sub">
|
||||
<SensitiveText :record="item" field="mobile" :show-eye="false" />
|
||||
</div>
|
||||
<div v-if="item.depart_name" class="doctor-card-sub">
|
||||
{{ item.depart_name }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<Empty v-else description="该诊所下无匹配医生" class="doctor-empty" />
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
<div class="doctor-trigger" :class="{ disabled: isDisabled }">
|
||||
<div v-if="selectedOption" class="doctor-selected-card">
|
||||
<Avatar :src="resolveAvatarUrl(selectedOption.avatar)" :size="36">
|
||||
{{ (selectedOption.name || '?').charAt(0) }}
|
||||
</Avatar>
|
||||
<div class="doctor-selected-info">
|
||||
<div class="doctor-selected-name">{{ selectedOption.name }}</div>
|
||||
<div class="doctor-selected-sub">
|
||||
<SensitiveText
|
||||
v-if="selectedOption.mobile"
|
||||
:record="selectedOption"
|
||||
field="mobile"
|
||||
:show-eye="false"
|
||||
/>
|
||||
<span v-else>su_id: {{ selectedOption.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="!isDisabled"
|
||||
class="doctor-clear-btn"
|
||||
title="清除"
|
||||
@click.stop="clearSelection"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="mValue" class="doctor-selected-card">
|
||||
<div class="doctor-selected-info">
|
||||
<div class="doctor-selected-name">医生 su_id: {{ mValue }}</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="!isDisabled"
|
||||
class="doctor-clear-btn"
|
||||
title="清除"
|
||||
@click.stop="clearSelection"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="doctor-trigger-placeholder">
|
||||
{{ storeId ? '请选择关联医生(可选)' : '请先选择所属诊所' }}
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './picker-card-theme.scss' as theme;
|
||||
|
||||
.doctor-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.doctor-trigger.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.doctor-trigger:not(.disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.doctor-selected-card {
|
||||
@include theme.picker-selected-card;
|
||||
}
|
||||
|
||||
.doctor-selected-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doctor-selected-name {
|
||||
font-size: 14px;
|
||||
color: #1d2129;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doctor-selected-sub {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.doctor-clear-btn {
|
||||
@include theme.picker-clear-btn;
|
||||
}
|
||||
|
||||
.doctor-trigger-placeholder {
|
||||
@include theme.picker-trigger-placeholder;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.doctor-selected-card {
|
||||
@include theme.picker-selected-card-dark-props;
|
||||
}
|
||||
|
||||
.doctor-selected-name {
|
||||
@include theme.picker-text-primary-dark;
|
||||
}
|
||||
|
||||
.doctor-selected-sub {
|
||||
@include theme.picker-text-secondary-dark;
|
||||
}
|
||||
|
||||
.doctor-clear-btn {
|
||||
@include theme.picker-clear-dark-props;
|
||||
}
|
||||
|
||||
.doctor-trigger-placeholder {
|
||||
@include theme.picker-placeholder-dark-props;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@use './picker-card-theme.scss' as theme;
|
||||
|
||||
.doctor-picker-popover {
|
||||
.ant-popover-inner {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.doctor-panel {
|
||||
width: 540px;
|
||||
max-width: 86vw;
|
||||
}
|
||||
|
||||
.doctor-search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.doctor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.doctor-card {
|
||||
@include theme.picker-card-base;
|
||||
}
|
||||
|
||||
.doctor-card-name {
|
||||
@include theme.picker-card-name;
|
||||
}
|
||||
|
||||
.doctor-card-sub {
|
||||
@include theme.picker-card-sub;
|
||||
}
|
||||
|
||||
.doctor-empty {
|
||||
margin: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .doctor-picker-popover {
|
||||
.doctor-card {
|
||||
@include theme.picker-card-dark-props;
|
||||
}
|
||||
|
||||
.doctor-card-name {
|
||||
@include theme.picker-text-primary-dark;
|
||||
}
|
||||
|
||||
.doctor-card-sub {
|
||||
@include theme.picker-text-secondary-dark;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
283
apps/web-antd/src/components/form/components/editor.vue
Normal file
283
apps/web-antd/src/components/form/components/editor.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { QuillEditor } from '@vueup/vue-quill';
|
||||
// eslint-disable-next-line n/no-extraneous-import
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
// 导入上传相关API和工具函数
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { uploadToOss } from '#/utils/oss-upload';
|
||||
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['update:value']);
|
||||
// 创建对QuillEditor的引用,用于后续获取Quill实例
|
||||
const quillEditorRef = ref(null);
|
||||
const isUpload = ref(false);
|
||||
const mValue = useVModel(props, 'value', emits, {
|
||||
defaultValue: props.value,
|
||||
passive: true,
|
||||
});
|
||||
// 富文本编辑器配置选项
|
||||
const editorOptions = {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
['blockquote', 'code-block'],
|
||||
[{ header: 1 }, { header: 2 }],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ script: 'sub' }, { script: 'super' }],
|
||||
[{ indent: '-1' }, { indent: '+1' }],
|
||||
[{ direction: 'rtl' }],
|
||||
[{ size: ['small', false, 'large', 'huge'] }],
|
||||
[{ header: [1, 2, 3, 4, 5, 6, false] }],
|
||||
[{ color: [] }, { background: [] }],
|
||||
[{ font: [] }],
|
||||
[{ align: [] }],
|
||||
['clean'],
|
||||
['link', 'image', 'video'],
|
||||
],
|
||||
},
|
||||
placeholder: '请输入消息内容',
|
||||
};
|
||||
|
||||
/**
|
||||
* 直接处理编辑器的粘贴事件
|
||||
* 这是一个备用方法,通过Quill的clipboard模块直接处理粘贴事件
|
||||
*/
|
||||
const setupQuillPasteHandler = () => {
|
||||
// 确保编辑器已经挂载
|
||||
if (!quillEditorRef.value) return;
|
||||
|
||||
const quill = quillEditorRef.value.getQuill();
|
||||
if (!quill) return;
|
||||
|
||||
// 获取Quill的clipboard模块
|
||||
const clipboard = quill.getModule('clipboard');
|
||||
|
||||
// 保存原始的粘贴处理函数
|
||||
const originalMatchers = clipboard.matchers;
|
||||
|
||||
// 重写粘贴处理函数
|
||||
clipboard.addMatcher('img', (node: any, delta: any) => {
|
||||
// 这里可以处理HTML中的img标签
|
||||
// 但对于直接粘贴的图片文件,这个方法不会被触发
|
||||
return delta;
|
||||
});
|
||||
|
||||
// 监听编辑器的paste事件
|
||||
quill.root.addEventListener('paste', async (e: ClipboardEvent) => {
|
||||
const imageFile = getImageFromClipboard(e);
|
||||
|
||||
if (imageFile) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isUpload.value === true) {
|
||||
return;
|
||||
}
|
||||
isUpload.value = true;
|
||||
const imageUrl = await uploadImage(imageFile);
|
||||
|
||||
if (imageUrl) {
|
||||
insertImageToEditor(quill, imageUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 从剪贴板数据中提取图片文件
|
||||
* @param {ClipboardEvent} event - 剪贴板事件
|
||||
* @returns {File|null} - 返回图片文件或null
|
||||
*/
|
||||
const getImageFromClipboard = (event: ClipboardEvent): File | null => {
|
||||
const clipboardData = event.clipboardData;
|
||||
if (!clipboardData) return null;
|
||||
|
||||
// 遍历剪贴板中的所有项目
|
||||
const items = clipboardData.items;
|
||||
for (const item of items) {
|
||||
// 检查是否是图片类型
|
||||
if (item.type.includes('image')) {
|
||||
return item.getAsFile();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 在编辑器中插入图片
|
||||
* @param {any} quill - Quill编辑器实例
|
||||
* @param {string} imageUrl - 图片URL
|
||||
*/
|
||||
const insertImageToEditor = (quill: any, imageUrl: string): void => {
|
||||
if (!quill) return;
|
||||
|
||||
// 获取当前光标位置
|
||||
const range = quill.getSelection();
|
||||
|
||||
if (range) {
|
||||
// 在光标位置插入图片
|
||||
quill.insertEmbed(range.index, 'image', imageUrl);
|
||||
// 将光标移动到图片后面
|
||||
quill.setSelection(range.index + 1);
|
||||
} else {
|
||||
// 如果没有选择范围,则在文档末尾插入
|
||||
const length = quill.getLength();
|
||||
quill.insertEmbed(length - 1, 'image', imageUrl);
|
||||
quill.setSelection(length);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 图片上传函数
|
||||
*
|
||||
* 该函数根据preferences配置的上传方式,选择使用OSS直传或后端上传:
|
||||
* - 'direct': OSS直传模式,文件直接从浏览器上传到阿里云OSS
|
||||
* - 'backend': 后端上传模式,文件先上传到后端服务器,再由后端上传到OSS
|
||||
*
|
||||
* 上传方式说明:
|
||||
* 1. OSS直传(direct):
|
||||
* - 调用uploadToOss函数,直接上传到OSS
|
||||
* - 减少服务器负载,上传速度更快
|
||||
* - 支持上传进度回调(如果需要可以添加)
|
||||
*
|
||||
* 2. 后端上传(backend):
|
||||
* - 调用uploadFile API,通过后端服务器上传
|
||||
* - 兼容现有功能,所有上传逻辑由后端统一处理
|
||||
*
|
||||
* 兼容性处理:
|
||||
* - 两种上传方式返回的数据结构保持一致:{ url: string }
|
||||
* - 上传成功或失败都会显示相应的提示信息
|
||||
* - 上传完成后,将isUpload标记设置为false,允许下次上传
|
||||
*
|
||||
* @param {File} file - 要上传的图片文件
|
||||
* @returns {Promise<string>} - 返回上传后的图片URL,如果上传失败返回空字符串
|
||||
*/
|
||||
const uploadImage = async (file: File): Promise<string> => {
|
||||
try {
|
||||
const { file: preparedFile } = await prepareImageForUpload(file);
|
||||
if (!preparedFile) {
|
||||
isUpload.value = false;
|
||||
return '';
|
||||
}
|
||||
|
||||
// 显示上传中的提示
|
||||
message.loading({ content: '图片上传中...', key: 'imageUpload' });
|
||||
|
||||
// 从preferences中读取上传方式配置
|
||||
// uploadMethod可能的值:'direct'(OSS直传)或 'backend'(后端上传)
|
||||
const uploadMethod = preferences.app.uploadMethod || 'direct';
|
||||
|
||||
let data: { url: string };
|
||||
|
||||
// 根据配置选择上传方式
|
||||
if (uploadMethod === 'direct') {
|
||||
// OSS直传模式:文件直接从浏览器上传到OSS
|
||||
// uploadToOss函数会处理签名获取、文件上传等所有逻辑
|
||||
data = await uploadToOss({
|
||||
file: preparedFile,
|
||||
// 如果需要显示上传进度,可以添加onProgress回调
|
||||
// onProgress: (percent) => {
|
||||
// message.loading({ content: `图片上传中... ${percent}%`, key: 'imageUpload' });
|
||||
// },
|
||||
});
|
||||
} else {
|
||||
// 后端上传模式:文件先上传到后端服务器,再由后端上传到OSS
|
||||
// uploadFile函数会将文件发送到后端API(/upload/image)
|
||||
data = await uploadFile({
|
||||
file: preparedFile,
|
||||
});
|
||||
}
|
||||
|
||||
// 上传成功,显示成功提示
|
||||
message.success({
|
||||
content: '图片上传成功',
|
||||
key: 'imageUpload',
|
||||
duration: 2,
|
||||
});
|
||||
|
||||
// 重置上传标记,允许下次上传
|
||||
isUpload.value = false;
|
||||
|
||||
// 返回上传后的图片URL
|
||||
return data.url;
|
||||
} catch (error) {
|
||||
// 处理上传错误
|
||||
console.error('图片上传错误:', error);
|
||||
|
||||
// 显示错误提示
|
||||
message.error({ content: '图片上传失败', key: 'imageUpload', duration: 2 });
|
||||
|
||||
// 重置上传标记,允许重试
|
||||
isUpload.value = false;
|
||||
|
||||
// 返回空字符串,表示上传失败
|
||||
return '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="editor-container-box">
|
||||
<QuillEditor
|
||||
ref="quillEditorRef"
|
||||
v-model:content="mValue"
|
||||
:options="editorOptions"
|
||||
class="editor-container"
|
||||
content-type="html"
|
||||
theme="snow"
|
||||
@ready="setupQuillPasteHandler"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-container-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* 编辑器容器样式 */
|
||||
.editor-container {
|
||||
height: 250px;
|
||||
margin-bottom: 40px;
|
||||
/* 确保编辑器有足够的空间显示工具栏和内容区域 */
|
||||
}
|
||||
|
||||
/* Markdown编辑器样式 */
|
||||
.md-editor-container {
|
||||
min-height: 400px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* 可以添加额外的样式来自定义编辑器外观 */
|
||||
:deep(.ql-editor) {
|
||||
min-height: 200px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 确保图片在编辑器中显示正常 */
|
||||
:deep(.ql-editor img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 自定义 Markdown 编辑器样式 */
|
||||
:deep(.md-editor) {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(.md-editor-dark) {
|
||||
--md-bk-color: #1e1e1e;
|
||||
--md-border-color: #333;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Empty, Input, Popover, Spin } from 'ant-design-vue';
|
||||
|
||||
import { matchExpressByTrackingNo } from '#/utils/matchExpressByTrackingNo';
|
||||
import { getExpressCompaniesOption } from '#/views/business/express/express-company/api';
|
||||
|
||||
defineOptions({
|
||||
name: 'ExpressCompanySelect',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
/** 运单号:变化时按前缀自动匹配快递公司(用户手动改选后不再覆盖) */
|
||||
trackingNo?: string;
|
||||
value?: string;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:value': [value: string | undefined];
|
||||
}>();
|
||||
|
||||
const mValue = useVModel(props, 'value', emits, { passive: true });
|
||||
|
||||
type ExpressCompanyOption = {
|
||||
code: string;
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const options = ref<ExpressCompanyOption[]>([]);
|
||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
/** 用户是否已手动选择/清空;为 true 时不再按单号自动覆盖 */
|
||||
const userPicked = ref(false);
|
||||
|
||||
function filterExpressCompanyOptions(
|
||||
keyword: string,
|
||||
list: ExpressCompanyOption[],
|
||||
) {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter((item) => {
|
||||
const name = (item.name || '').toLowerCase();
|
||||
const code = (item.code || '').toLowerCase();
|
||||
return name.includes(q) || code.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
const filteredOptions = computed(() =>
|
||||
filterExpressCompanyOptions(searchKeyword.value, options.value),
|
||||
);
|
||||
|
||||
const selectedOption = computed(() =>
|
||||
options.value.find((item) => item.code === mValue.value),
|
||||
);
|
||||
|
||||
const displayText = computed(() => {
|
||||
const item = selectedOption.value;
|
||||
if (!item) return '';
|
||||
return item.code ? `${item.name || '-'}(${item.code})` : item.name || '-';
|
||||
});
|
||||
|
||||
/**
|
||||
* 根据 trackingNo 自动选中快递公司
|
||||
* 规则:尚未手动改选,或当前选中仍与规则结果一致时才覆盖
|
||||
*/
|
||||
function tryAutoMatchByTrackingNo() {
|
||||
if (options.value.length === 0) return;
|
||||
const matched = matchExpressByTrackingNo(props.trackingNo, options.value);
|
||||
if (!matched?.code) return;
|
||||
const matchedCode = matched.code;
|
||||
if (userPicked.value && mValue.value && mValue.value !== matchedCode) {
|
||||
// 用户已选其他公司,不覆盖
|
||||
return;
|
||||
}
|
||||
if (mValue.value === matchedCode) return;
|
||||
mValue.value = matchedCode;
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getExpressCompaniesOption({});
|
||||
options.value = Array.isArray(res) ? res : [];
|
||||
tryAutoMatchByTrackingNo();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(item: ExpressCompanyOption) {
|
||||
userPicked.value = true;
|
||||
mValue.value = item.code;
|
||||
open.value = false;
|
||||
searchKeyword.value = '';
|
||||
}
|
||||
|
||||
function clearSelection(e: Event) {
|
||||
e.stopPropagation();
|
||||
userPicked.value = true;
|
||||
mValue.value = undefined;
|
||||
}
|
||||
|
||||
function onOpenChange(next: boolean) {
|
||||
if (props.disabled) return;
|
||||
open.value = next;
|
||||
if (next) {
|
||||
searchKeyword.value = '';
|
||||
if (options.value.length === 0) {
|
||||
loadOptions();
|
||||
}
|
||||
nextTick(() => searchInputRef.value?.focus?.());
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
if (disabled) open.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.trackingNo,
|
||||
() => {
|
||||
// 单号变化时允许再自动匹配(除非用户已改选为不一致公司)
|
||||
tryAutoMatchByTrackingNo();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover
|
||||
:open="open"
|
||||
overlay-class-name="express-company-select-popover"
|
||||
placement="bottomLeft"
|
||||
trigger="click"
|
||||
@open-change="onOpenChange"
|
||||
>
|
||||
<template #content>
|
||||
<div class="express-panel">
|
||||
<Input
|
||||
ref="searchInputRef"
|
||||
v-model:value="searchKeyword"
|
||||
allow-clear
|
||||
class="express-search"
|
||||
placeholder="搜索公司名称或编码"
|
||||
/>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="filteredOptions.length > 0" class="express-list">
|
||||
<button
|
||||
v-for="item in filteredOptions"
|
||||
:key="item.id"
|
||||
:class="{ active: item.code === mValue }"
|
||||
class="express-item"
|
||||
type="button"
|
||||
@click="selectOption(item)"
|
||||
>
|
||||
<div class="express-meta">
|
||||
<div class="express-name">{{ item.name || '-' }}</div>
|
||||
<div class="express-sub">{{ item.code }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<Empty v-else class="express-empty" description="无匹配快递公司" />
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
:class="{ disabled, placeholder: !displayText }"
|
||||
class="express-trigger"
|
||||
>
|
||||
<Input
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder || '请选择快递公司'"
|
||||
:value="displayText"
|
||||
class="express-trigger-input"
|
||||
readonly
|
||||
>
|
||||
<template v-if="displayText && !disabled" #suffix>
|
||||
<span class="clear-btn" @click="clearSelection">×</span>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.express-trigger {
|
||||
width: 100%;
|
||||
/* min-width: fit-content; */
|
||||
min-width: 250px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.express-trigger.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.express-trigger-input {
|
||||
pointer-events: none;
|
||||
}
|
||||
.express-trigger:not(.disabled) :deep(.ant-input) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.clear-btn {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.express-panel {
|
||||
width: 360px;
|
||||
max-width: 80vw;
|
||||
}
|
||||
.express-search {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.express-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.express-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.express-item:hover,
|
||||
.express-item.active {
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
.express-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.express-name {
|
||||
font-size: 14px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.express-sub {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.express-empty {
|
||||
margin: 12px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.express-company-select-popover .ant-popover-inner {
|
||||
padding: 12px;
|
||||
background: hsl(var(--card));
|
||||
color: hsl(var(--foreground));
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import ImageGalleryPicker from '#/components/form/components/image-gallery-picker.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 限制可选择的文件类型(透传给 image-gallery-picker,按 xk_file.type / xk_file_type.value 过滤)
|
||||
* 例如图片:[0];视频:[1];文件:[6];语音:[3]
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}>(),
|
||||
{
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
disabled: false,
|
||||
acceptTypes: () => [] as number[],
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [urls: string[]];
|
||||
}>();
|
||||
|
||||
const galleryOpen = ref(false);
|
||||
|
||||
function openGallery() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
galleryOpen.value = true;
|
||||
}
|
||||
|
||||
function onSelect(urls: string[]) {
|
||||
emit('select', urls);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gallery-pick-link-wrap">
|
||||
<a class="gallery-pick-link" :class="{ disabled }" @click.prevent="openGallery">从文件库选择</a>
|
||||
<ImageGalleryPicker
|
||||
v-model:open="galleryOpen"
|
||||
:multiple="multiple"
|
||||
:max-count="maxCount"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gallery-pick-link-wrap {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.gallery-pick-link {
|
||||
font-size: 13px;
|
||||
color: #165dff;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: #4080ff;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: #c9cdd4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .gallery-pick-link {
|
||||
color: #60a5fa;
|
||||
|
||||
&:hover {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,415 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue';
|
||||
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
import { useFileGalleryFilter } from '#/composables/use-file-gallery-filter';
|
||||
import { resolveTypeIcon } from '#/composables/use-file-type-icon';
|
||||
import type { FileGalleryItem } from '#/api/core/file-gallery';
|
||||
import { getFileGalleryList } from '#/api/core/file-gallery';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
|
||||
* - 不传:显示全部类型(默认行为,保持向后兼容)
|
||||
* - 传数组:只展示指定类型 tab,且自动锁定到第一个匹配的类型
|
||||
* 例如图片:[0];图片+视频:[0, 1];文件:[6]
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}>(),
|
||||
{
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
acceptTypes: () => [] as number[],
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
select: [urls: string[]];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const items = ref<FileGalleryItem[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(24);
|
||||
const pageSizeOptions = ['12', '24', '48'];
|
||||
const selected = ref<string[]>([]);
|
||||
|
||||
/** 按 acceptTypes 隔离 localStorage,避免图片/视频选择器互相污染 */
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`file-picker-type-${
|
||||
props.acceptTypes?.length ? props.acceptTypes.join('-') : 'all'
|
||||
}`,
|
||||
);
|
||||
|
||||
const {
|
||||
activeType,
|
||||
keyword,
|
||||
tabs,
|
||||
setActiveType,
|
||||
buildListParams,
|
||||
fileTypes,
|
||||
typesLoading,
|
||||
} = useFileGalleryFilter(() => storageKey.value);
|
||||
|
||||
/**
|
||||
* 实际渲染的 tabs(按 acceptTypes 过滤)
|
||||
*/
|
||||
const visibleTabs = computed(() => {
|
||||
if (!props.acceptTypes || props.acceptTypes.length === 0) {
|
||||
return tabs.value;
|
||||
}
|
||||
return tabs.value.filter(
|
||||
(tab) => tab.value !== -1 && props.acceptTypes.includes(Number(tab.value)),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 强制锁定到 acceptTypes 首项(打开弹窗 / 类型字典就绪时调用)
|
||||
*/
|
||||
function lockAcceptType() {
|
||||
const arr = props.acceptTypes;
|
||||
if (!arr || arr.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (activeType.value !== arr[0]) {
|
||||
setActiveType(arr[0]!);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否允许拉列表:有 acceptTypes 时需等类型字典加载完且 visibleTabs 非空
|
||||
*/
|
||||
function canLoadList() {
|
||||
if (typesLoading.value) {
|
||||
return false;
|
||||
}
|
||||
if (props.acceptTypes && props.acceptTypes.length > 0) {
|
||||
return visibleTabs.value.length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!canLoadList()) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getFileGalleryList(
|
||||
buildListParams(page.value, pageSize.value),
|
||||
);
|
||||
const data = (res as any)?.data ?? res;
|
||||
items.value = data?.items ?? [];
|
||||
total.value = data?.total ?? 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.acceptTypes,
|
||||
(arr) => {
|
||||
if (!arr || arr.length === 0) {
|
||||
return;
|
||||
}
|
||||
lockAcceptType();
|
||||
page.value = 1;
|
||||
void load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
selected.value = [];
|
||||
page.value = 1;
|
||||
// 打开时再次按 acceptTypes 重锁,避免沿用其它选择器的视频 tab
|
||||
lockAcceptType();
|
||||
void load();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(typesLoading, (val) => {
|
||||
if (!val && props.open) {
|
||||
lockAcceptType();
|
||||
void load();
|
||||
}
|
||||
});
|
||||
|
||||
function toggleSelect(url: string) {
|
||||
if (props.multiple) {
|
||||
const idx = selected.value.indexOf(url);
|
||||
if (idx >= 0) {
|
||||
selected.value = selected.value.filter((u) => u !== url);
|
||||
return;
|
||||
}
|
||||
if (selected.value.length >= props.maxCount) {
|
||||
return;
|
||||
}
|
||||
selected.value = [...selected.value, url];
|
||||
return;
|
||||
}
|
||||
selected.value = [url];
|
||||
}
|
||||
|
||||
function isSelected(url: string) {
|
||||
return selected.value.includes(url);
|
||||
}
|
||||
|
||||
function handleOk() {
|
||||
if (!selected.value.length) {
|
||||
return;
|
||||
}
|
||||
emit('select', [...selected.value]);
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function onPageChange(p: number, size?: number) {
|
||||
page.value = p;
|
||||
if (size) {
|
||||
pageSize.value = size;
|
||||
}
|
||||
void load();
|
||||
}
|
||||
|
||||
function onShowSizeChange(_current: number, size: number) {
|
||||
page.value = 1;
|
||||
pageSize.value = size;
|
||||
void load();
|
||||
}
|
||||
|
||||
function onTabChange(key: string | number) {
|
||||
setActiveType(Number(key));
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
|
||||
function onSearch(value: string) {
|
||||
keyword.value = value;
|
||||
page.value = 1;
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
void load();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function itemIcon(item: FileGalleryItem) {
|
||||
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
|
||||
}
|
||||
|
||||
/** 图片类型展示缩略图(xk_file.type:0=图片;兼容历史错误写入的 1) */
|
||||
function isImageItem(item: FileGalleryItem) {
|
||||
return Number(item.type) === 0 || Number(item.type) === 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
title="从文件库选择"
|
||||
width="820px"
|
||||
:ok-button-props="{ disabled: !selected.length }"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<Tabs :active-key="String(activeType)" size="small" @change="onTabChange">
|
||||
<Tabs.TabPane v-for="tab in visibleTabs" :key="String(tab.value)">
|
||||
<template #tab>
|
||||
<span class="tab-label">
|
||||
<MIcon v-if="tab.icon" :icon="tab.icon" size="12" />
|
||||
{{ tab.label }}
|
||||
</span>
|
||||
</template>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
<div class="picker-toolbar">
|
||||
<Input.Search
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
placeholder="按文件名搜索"
|
||||
size="small"
|
||||
@search="onSearch"
|
||||
@change="(e) => onSearch(e.target.value ?? '')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spin :spinning="loading || typesLoading">
|
||||
<div v-if="items.length" class="gallery-grid">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="gallery-card"
|
||||
:class="{ active: isSelected(item.url) }"
|
||||
@click="toggleSelect(item.url)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageItem(item)"
|
||||
:src="item.url"
|
||||
alt=""
|
||||
class="gallery-thumb"
|
||||
/>
|
||||
<div v-else class="gallery-file-icon">
|
||||
<MIcon :icon="itemIcon(item)" size="28" />
|
||||
<span class="file-name">{{
|
||||
item.file_name || item.original_name || '未命名'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="gallery-meta">
|
||||
<div class="file-title">
|
||||
<MIcon :icon="itemIcon(item)" size="11" class="meta-icon" />
|
||||
{{ item.file_name || item.original_name || '-' }}
|
||||
</div>
|
||||
<div>{{ item.file_size_text || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Empty v-else description="暂无文件" />
|
||||
<div v-if="total > 0" class="gallery-pagination">
|
||||
<Pagination
|
||||
:current="page"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:total="total"
|
||||
size="small"
|
||||
show-less-items
|
||||
show-size-changer
|
||||
:show-total="(t) => `共 ${t} 条`"
|
||||
@change="onPageChange"
|
||||
@show-size-change="onShowSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './picker-card-theme.scss' as picker;
|
||||
|
||||
.tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.picker-toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||
gap: 12px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.gallery-card {
|
||||
@include picker.picker-card-base;
|
||||
padding: 6px;
|
||||
|
||||
&.active {
|
||||
border-color: #165dff;
|
||||
background: #f2f7ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-thumb {
|
||||
width: 100%;
|
||||
height: 88px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.gallery-file-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 88px;
|
||||
border-radius: 6px;
|
||||
background: #f5f5f5;
|
||||
color: #4e5969;
|
||||
padding: 6px;
|
||||
|
||||
.file-name {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
line-height: 1.2;
|
||||
max-height: 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-meta {
|
||||
font-size: 11px;
|
||||
color: #86909c;
|
||||
|
||||
.file-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #1d2129;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.gallery-card {
|
||||
@include picker.picker-card-dark-props;
|
||||
|
||||
&.active {
|
||||
border-color: #60a5fa;
|
||||
background: #374151;
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-thumb,
|
||||
.gallery-file-icon {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.gallery-meta {
|
||||
@include picker.picker-text-secondary-dark;
|
||||
|
||||
.file-title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Select } from 'ant-design-vue';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请输入',
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['update:value']);
|
||||
const mValue = useVModel(props, 'value', emits, {
|
||||
defaultValue: props.value,
|
||||
passive: true,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Select
|
||||
v-model:value="mValue"
|
||||
:placeholder="placeholder"
|
||||
list-type="picture-card"
|
||||
mode="tags"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</template>
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,143 @@
|
||||
// Shared picker layout mixins — property-only dark variants, no global class selectors.
|
||||
|
||||
@mixin picker-card-base {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 6px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background-color 0.2s;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
border-color: #165dff;
|
||||
background: #f2f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin picker-card-name {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: #1d2129;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@mixin picker-card-sub {
|
||||
font-size: 11px;
|
||||
color: #86909c;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@mixin picker-selected-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #165dff;
|
||||
background: #f2f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin picker-trigger-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 8px 12px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #165dff;
|
||||
color: #165dff;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin picker-clear-btn {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
color: #86909c;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: #1d2129;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin picker-card-dark-props {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background: #374151;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin picker-selected-card-dark-props {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
|
||||
&:hover {
|
||||
background: #374151;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin picker-text-primary-dark {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
@mixin picker-text-secondary-dark {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@mixin picker-placeholder-dark-props {
|
||||
border-color: #4b5563;
|
||||
color: #9ca3af;
|
||||
|
||||
&:hover {
|
||||
border-color: #60a5fa;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin picker-clear-dark-props {
|
||||
color: #9ca3af;
|
||||
|
||||
&:hover {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
270
apps/web-antd/src/components/form/components/promoter-picker.vue
Normal file
270
apps/web-antd/src/components/form/components/promoter-picker.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Avatar, Empty, Input, Popover, Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue';
|
||||
import { getPromoterOptions } from '#/views/system/store-input/api';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
defineOptions({
|
||||
name: 'PromoterPicker',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:value': [value: string | undefined];
|
||||
}>();
|
||||
|
||||
const mValue = useVModel(props, 'value', emits, { passive: true });
|
||||
|
||||
type PromoterOption = {
|
||||
id: number;
|
||||
nick_name: string;
|
||||
avatar: string;
|
||||
code: string;
|
||||
phone: string;
|
||||
role_id: number;
|
||||
role_name: string;
|
||||
};
|
||||
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const options = ref<PromoterOption[]>([]);
|
||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
|
||||
function filterPromoterOptions(keyword: string, list: PromoterOption[]) {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter((item) => {
|
||||
const name = (item.nick_name || '').toLowerCase();
|
||||
const code = (item.code || '').toLowerCase();
|
||||
const phone = (item.phone || '').toLowerCase();
|
||||
const role = (item.role_name || '').toLowerCase();
|
||||
return (
|
||||
name.includes(q) ||
|
||||
code.includes(q) ||
|
||||
phone.includes(q) ||
|
||||
role.includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const filteredOptions = computed(() =>
|
||||
filterPromoterOptions(searchKeyword.value, options.value),
|
||||
);
|
||||
|
||||
const selectedOption = computed(() =>
|
||||
options.value.find((item) => item.code === mValue.value),
|
||||
);
|
||||
|
||||
async function loadOptions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
options.value = (await getPromoterOptions()) || [];
|
||||
if (options.value.length === 1 && !mValue.value) {
|
||||
mValue.value = options.value[0].code;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(item: PromoterOption) {
|
||||
mValue.value = item.code;
|
||||
open.value = false;
|
||||
searchKeyword.value = '';
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
mValue.value = undefined;
|
||||
}
|
||||
|
||||
function onOpenChange(next: boolean) {
|
||||
if (props.disabled) return;
|
||||
open.value = next;
|
||||
if (next) {
|
||||
searchKeyword.value = '';
|
||||
if (!options.value.length) {
|
||||
loadOptions();
|
||||
}
|
||||
nextTick(() => searchInputRef.value?.focus?.());
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
if (disabled) open.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover
|
||||
:open="open"
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
overlay-class-name="promoter-picker-popover"
|
||||
@open-change="onOpenChange"
|
||||
>
|
||||
<template #content>
|
||||
<div class="promoter-panel">
|
||||
<Input
|
||||
ref="searchInputRef"
|
||||
v-model:value="searchKeyword"
|
||||
allow-clear
|
||||
placeholder="搜索业务员姓名、业务码、手机号"
|
||||
class="promoter-search"
|
||||
/>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="filteredOptions.length" class="promoter-grid">
|
||||
<button
|
||||
v-for="item in filteredOptions"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="promoter-card"
|
||||
:class="{ active: item.code === mValue }"
|
||||
@click="selectOption(item)"
|
||||
>
|
||||
<Avatar :src="resolveAvatarUrl(item.avatar)" :size="30">
|
||||
{{ (item.nick_name || '?').charAt(0) }}
|
||||
</Avatar>
|
||||
<div class="promoter-card-name" :title="item.nick_name">
|
||||
{{ item.nick_name || '-' }}
|
||||
</div>
|
||||
<div class="promoter-card-sub">{{ item.code }}</div>
|
||||
<div v-if="item.phone" class="promoter-card-phone">
|
||||
<SensitiveText :record="item" field="phone" :show-eye="false" />
|
||||
</div>
|
||||
<Tag class="promoter-card-role" :bordered="false">
|
||||
{{ item.role_name || '业务员' }}
|
||||
</Tag>
|
||||
</button>
|
||||
</div>
|
||||
<Empty v-else description="无匹配业务员" class="promoter-empty" />
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
<div class="promoter-trigger" :class="{ disabled: disabled }">
|
||||
<PromoterInfoCard
|
||||
v-if="selectedOption"
|
||||
:admin="selectedOption"
|
||||
size="trigger"
|
||||
:show-clear="!disabled"
|
||||
:disabled="disabled"
|
||||
:clickable="false"
|
||||
@clear="clearSelection"
|
||||
/>
|
||||
<div v-else class="promoter-trigger-placeholder">请选择业务员</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './picker-card-theme.scss' as theme;
|
||||
|
||||
.promoter-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.promoter-trigger.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.promoter-trigger:not(.disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.promoter-trigger-placeholder {
|
||||
@include theme.picker-trigger-placeholder;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.promoter-trigger-placeholder {
|
||||
@include theme.picker-placeholder-dark-props;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@use './picker-card-theme.scss' as theme;
|
||||
|
||||
.promoter-picker-popover {
|
||||
.ant-popover-inner {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.promoter-panel {
|
||||
width: 540px;
|
||||
max-width: 86vw;
|
||||
}
|
||||
|
||||
.promoter-search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.promoter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.promoter-card {
|
||||
@include theme.picker-card-base;
|
||||
}
|
||||
|
||||
.promoter-card-name {
|
||||
@include theme.picker-card-name;
|
||||
}
|
||||
|
||||
.promoter-card-sub,
|
||||
.promoter-card-phone {
|
||||
@include theme.picker-card-sub;
|
||||
}
|
||||
|
||||
.promoter-card-role {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
padding: 0 6px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.promoter-empty {
|
||||
margin: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .promoter-picker-popover {
|
||||
.promoter-card {
|
||||
@include theme.picker-card-dark-props;
|
||||
}
|
||||
|
||||
.promoter-card-name {
|
||||
@include theme.picker-text-primary-dark;
|
||||
}
|
||||
|
||||
.promoter-card-sub,
|
||||
.promoter-card-phone {
|
||||
@include theme.picker-text-secondary-dark;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,709 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Input, Popover } from 'ant-design-vue';
|
||||
|
||||
import type {
|
||||
AddressListRow,
|
||||
AddressNode,
|
||||
AddressSearchHit,
|
||||
AddressValue,
|
||||
BreadcrumbItem,
|
||||
RegionLevel,
|
||||
SelectionContext,
|
||||
} from '#/util/address-index';
|
||||
import {
|
||||
applySearchHit,
|
||||
buildAddressOutput,
|
||||
buildBreadcrumbFromContext,
|
||||
canCompleteAfterSelect,
|
||||
formatAddressDisplay,
|
||||
getInitialPickerLevel,
|
||||
getLevelSearchPlaceholder,
|
||||
getNextLevel,
|
||||
isMunicipalityProvince,
|
||||
levelOptionsToRows,
|
||||
resolveAddressValue,
|
||||
rowHasNextLevel,
|
||||
searchAddressHits,
|
||||
searchHitsToRows,
|
||||
truncateContextToBreadcrumb,
|
||||
} from '#/util/address-index';
|
||||
|
||||
defineOptions({
|
||||
name: 'RegionAddressPicker',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
value?: AddressValue | null;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:value': [value: AddressValue | undefined];
|
||||
}>();
|
||||
|
||||
const mValue = useVModel(props, 'value', emits, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
const open = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const currentLevel = ref<RegionLevel>('province');
|
||||
const activeIndex = ref(0);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
const triggerWrapRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const ctx = ref<SelectionContext>({
|
||||
provinceId: undefined,
|
||||
cityId: undefined,
|
||||
districtId: undefined,
|
||||
});
|
||||
|
||||
const displayText = computed(() => formatAddressDisplay(mValue.value));
|
||||
|
||||
const breadcrumbItems = computed(() => buildBreadcrumbFromContext(ctx.value));
|
||||
|
||||
const isSearching = computed(() => !!searchKeyword.value.trim());
|
||||
|
||||
const listRows = computed((): AddressListRow[] => {
|
||||
if (isSearching.value) {
|
||||
return searchHitsToRows(
|
||||
searchAddressHits(searchKeyword.value, ctx.value),
|
||||
);
|
||||
}
|
||||
return levelOptionsToRows(currentLevel.value, ctx.value);
|
||||
});
|
||||
|
||||
const searchPlaceholder = computed(() =>
|
||||
getLevelSearchPlaceholder(currentLevel.value),
|
||||
);
|
||||
|
||||
function syncContextFromValue(val?: AddressValue | null) {
|
||||
const resolved = resolveAddressValue(val ?? undefined);
|
||||
ctx.value = {
|
||||
provinceId: resolved.provinceId,
|
||||
cityId: resolved.cityId,
|
||||
districtId: resolved.districtId,
|
||||
};
|
||||
}
|
||||
|
||||
function resetPickerToValue(val?: AddressValue | null) {
|
||||
syncContextFromValue(val);
|
||||
const resolved = resolveAddressValue(val ?? undefined);
|
||||
currentLevel.value = getInitialPickerLevel(resolved);
|
||||
searchKeyword.value = '';
|
||||
activeIndex.value = 0;
|
||||
}
|
||||
|
||||
function resetActiveIndex() {
|
||||
activeIndex.value = listRows.value.length > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
function closePanel(output?: AddressValue) {
|
||||
if (output !== undefined) {
|
||||
mValue.value = output;
|
||||
}
|
||||
open.value = false;
|
||||
searchKeyword.value = '';
|
||||
}
|
||||
|
||||
function completeSelection() {
|
||||
closePanel(
|
||||
buildAddressOutput(
|
||||
ctx.value.provinceId,
|
||||
ctx.value.cityId,
|
||||
ctx.value.districtId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function applySelectionAtLevel(level: RegionLevel, item: AddressNode) {
|
||||
if (level === 'province') {
|
||||
ctx.value = {
|
||||
provinceId: item.value,
|
||||
cityId: undefined,
|
||||
districtId: undefined,
|
||||
};
|
||||
} else if (level === 'city') {
|
||||
ctx.value = {
|
||||
...ctx.value,
|
||||
cityId: item.value,
|
||||
districtId: undefined,
|
||||
};
|
||||
} else {
|
||||
ctx.value = {
|
||||
...ctx.value,
|
||||
districtId: item.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function searchHitFromRow(row: AddressListRow): AddressSearchHit {
|
||||
return {
|
||||
level: row.level,
|
||||
node: row.node,
|
||||
pathLabel: row.label,
|
||||
complete: row.complete,
|
||||
};
|
||||
}
|
||||
|
||||
function advanceAfterRowSelect(row: AddressListRow) {
|
||||
searchKeyword.value = '';
|
||||
|
||||
if (row.complete) {
|
||||
completeSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const next = getNextLevel(row.level, ctx.value);
|
||||
if (next) {
|
||||
currentLevel.value = next;
|
||||
return;
|
||||
}
|
||||
|
||||
completeSelection();
|
||||
}
|
||||
|
||||
function onConfirmRow(row: AddressListRow) {
|
||||
if (isSearching.value) {
|
||||
const hit = searchHitFromRow(row);
|
||||
if (
|
||||
hit.level === 'province' &&
|
||||
(ctx.value.cityId || ctx.value.districtId)
|
||||
) {
|
||||
ctx.value = applySearchHit(hit, ctx.value);
|
||||
currentLevel.value = isMunicipalityProvince(hit.node.value)
|
||||
? 'district'
|
||||
: 'city';
|
||||
searchKeyword.value = '';
|
||||
resetActiveIndex();
|
||||
return;
|
||||
}
|
||||
ctx.value = applySearchHit(hit, ctx.value);
|
||||
advanceAfterRowSelect(row);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectItem(row.node, row.level);
|
||||
}
|
||||
|
||||
function onSelectItem(item: AddressNode, level = currentLevel.value) {
|
||||
if (level === 'province' && (ctx.value.cityId || ctx.value.districtId)) {
|
||||
ctx.value = {
|
||||
provinceId: item.value,
|
||||
cityId: undefined,
|
||||
districtId: undefined,
|
||||
};
|
||||
searchKeyword.value = '';
|
||||
currentLevel.value = isMunicipalityProvince(item.value)
|
||||
? 'district'
|
||||
: 'city';
|
||||
resetActiveIndex();
|
||||
return;
|
||||
}
|
||||
|
||||
applySelectionAtLevel(level, item);
|
||||
const row: AddressListRow = {
|
||||
level,
|
||||
node: item,
|
||||
label: item.label,
|
||||
complete: canCompleteAfterSelect(level, ctx.value, item),
|
||||
};
|
||||
advanceAfterRowSelect(row);
|
||||
}
|
||||
|
||||
function confirmActiveItem() {
|
||||
const rows = listRows.value;
|
||||
if (activeIndex.value < 0 || activeIndex.value >= rows.length) {
|
||||
return;
|
||||
}
|
||||
onConfirmRow(rows[activeIndex.value]!);
|
||||
}
|
||||
|
||||
function drillActiveRow() {
|
||||
const rows = listRows.value;
|
||||
const row = rows[activeIndex.value];
|
||||
if (!row || !rowHasNextLevel(row, ctx.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSearching.value) {
|
||||
const hit = searchHitFromRow(row);
|
||||
if (
|
||||
hit.level === 'province' &&
|
||||
(ctx.value.cityId || ctx.value.districtId)
|
||||
) {
|
||||
ctx.value = applySearchHit(hit, ctx.value);
|
||||
} else {
|
||||
ctx.value = applySearchHit(hit, ctx.value);
|
||||
}
|
||||
searchKeyword.value = '';
|
||||
} else {
|
||||
applySelectionAtLevel(row.level, row.node);
|
||||
}
|
||||
|
||||
const next = getNextLevel(row.level, ctx.value);
|
||||
if (next) {
|
||||
currentLevel.value = next;
|
||||
resetActiveIndex();
|
||||
}
|
||||
}
|
||||
|
||||
function goBackOneLevel() {
|
||||
if (currentLevel.value === 'province') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentLevel.value === 'district') {
|
||||
if (isMunicipalityProvince(ctx.value.provinceId)) {
|
||||
ctx.value = { provinceId: ctx.value.provinceId };
|
||||
currentLevel.value = 'province';
|
||||
} else {
|
||||
ctx.value = {
|
||||
provinceId: ctx.value.provinceId,
|
||||
cityId: ctx.value.cityId,
|
||||
};
|
||||
currentLevel.value = 'city';
|
||||
}
|
||||
} else if (currentLevel.value === 'city') {
|
||||
ctx.value = {};
|
||||
currentLevel.value = 'province';
|
||||
}
|
||||
|
||||
searchKeyword.value = '';
|
||||
resetActiveIndex();
|
||||
}
|
||||
|
||||
function scrollActiveIntoView() {
|
||||
nextTick(() => {
|
||||
const el = listRef.value?.querySelector(
|
||||
`[data-index="${activeIndex.value}"]`,
|
||||
);
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function getPopupContainer(triggerNode: HTMLElement): HTMLElement {
|
||||
return (
|
||||
(triggerNode.closest('[role="dialog"]') as HTMLElement | null) ??
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSearchInputEl(): HTMLInputElement | null {
|
||||
const inst = searchInputRef.value as
|
||||
| (InstanceType<typeof Input> & { input?: HTMLInputElement })
|
||||
| null;
|
||||
if (inst?.input instanceof HTMLInputElement) {
|
||||
return inst.input;
|
||||
}
|
||||
const scope =
|
||||
(triggerWrapRef.value?.closest('[role="dialog"]') as HTMLElement | null) ??
|
||||
document.body;
|
||||
return scope.querySelector(
|
||||
'.region-address-picker-popover .region-address-search input',
|
||||
);
|
||||
}
|
||||
|
||||
function focusSearchInput() {
|
||||
const tryFocus = (): boolean => {
|
||||
const inst = searchInputRef.value;
|
||||
if (inst?.focus) {
|
||||
inst.focus();
|
||||
return true;
|
||||
}
|
||||
const input = resolveSearchInputEl();
|
||||
if (input) {
|
||||
input.focus();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const schedule = (attempt: number) => {
|
||||
if (!open.value || attempt > 8) {
|
||||
return;
|
||||
}
|
||||
if (tryFocus()) {
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (tryFocus()) {
|
||||
return;
|
||||
}
|
||||
const delay = attempt < 3 ? 0 : attempt < 6 ? 50 : 100;
|
||||
setTimeout(() => schedule(attempt + 1), delay);
|
||||
});
|
||||
};
|
||||
|
||||
nextTick(() => schedule(0));
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!open.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = listRows.value;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (activeIndex.value < 0) {
|
||||
activeIndex.value = 0;
|
||||
} else {
|
||||
activeIndex.value = (activeIndex.value + 1) % rows.length;
|
||||
}
|
||||
scrollActiveIntoView();
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
activeIndex.value =
|
||||
activeIndex.value <= 0 ? rows.length - 1 : activeIndex.value - 1;
|
||||
scrollActiveIntoView();
|
||||
break;
|
||||
}
|
||||
case 'Enter': {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
confirmActiveItem();
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
e.preventDefault();
|
||||
goBackOneLevel();
|
||||
break;
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
if (rows.length === 0 || activeIndex.value < 0) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
drillActiveRow();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onBreadcrumbClick(item: BreadcrumbItem) {
|
||||
ctx.value = truncateContextToBreadcrumb(ctx.value, item);
|
||||
if (item.level === 'province') {
|
||||
currentLevel.value = isMunicipalityProvince(item.id) ? 'district' : 'city';
|
||||
} else if (item.level === 'city') {
|
||||
currentLevel.value = 'district';
|
||||
} else {
|
||||
currentLevel.value = 'district';
|
||||
}
|
||||
searchKeyword.value = '';
|
||||
resetActiveIndex();
|
||||
}
|
||||
|
||||
function goProvinceLevel() {
|
||||
ctx.value = {};
|
||||
currentLevel.value = 'province';
|
||||
searchKeyword.value = '';
|
||||
resetActiveIndex();
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
ctx.value = {};
|
||||
mValue.value = undefined;
|
||||
currentLevel.value = 'province';
|
||||
searchKeyword.value = '';
|
||||
resetActiveIndex();
|
||||
}
|
||||
|
||||
function onOpenChange(v: boolean) {
|
||||
open.value = v;
|
||||
if (v) {
|
||||
resetPickerToValue(mValue.value);
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, (v) => {
|
||||
if (v) {
|
||||
focusSearchInput();
|
||||
}
|
||||
}, { flush: 'post' });
|
||||
|
||||
watch(listRows, () => {
|
||||
if (open.value) {
|
||||
resetActiveIndex();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(val) => {
|
||||
if (!open.value) {
|
||||
syncContextFromValue(val ?? undefined);
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover
|
||||
v-model:open="open"
|
||||
:trigger="['click']"
|
||||
placement="bottomLeft"
|
||||
overlay-class-name="region-address-picker-popover"
|
||||
:get-popup-container="getPopupContainer"
|
||||
@open-change="onOpenChange"
|
||||
>
|
||||
<template #content>
|
||||
<div class="region-address-panel" @click.stop>
|
||||
<div v-if="breadcrumbItems.length" class="region-address-breadcrumb-row">
|
||||
<div class="region-address-breadcrumb">
|
||||
<template
|
||||
v-for="(item, idx) in breadcrumbItems"
|
||||
:key="`${item.level}-${item.id}`"
|
||||
>
|
||||
<span v-if="idx > 0" class="region-address-breadcrumb-sep">/</span>
|
||||
<button
|
||||
type="button"
|
||||
class="region-address-breadcrumb-item"
|
||||
@click="onBreadcrumbClick(item)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="region-address-repick-province"
|
||||
@click="goProvinceLevel"
|
||||
>
|
||||
重选省份
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
ref="searchInputRef"
|
||||
v-model:value="searchKeyword"
|
||||
:placeholder="searchPlaceholder"
|
||||
allow-clear
|
||||
class="region-address-search"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<ul ref="listRef" class="region-address-list">
|
||||
<li
|
||||
v-for="(row, index) in listRows"
|
||||
:key="`${row.level}-${row.node.value}`"
|
||||
:class="[
|
||||
'region-address-list-item',
|
||||
{ 'region-address-list-item-active': index === activeIndex },
|
||||
]"
|
||||
:data-index="index"
|
||||
@click="onConfirmRow(row)"
|
||||
@mouseenter="activeIndex = index"
|
||||
>
|
||||
{{ row.label }}
|
||||
</li>
|
||||
<li v-if="listRows.length === 0" class="region-address-list-empty">
|
||||
暂无数据
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
ref="triggerWrapRef"
|
||||
class="region-address-trigger-wrap"
|
||||
@click="!disabled && (open = true)"
|
||||
>
|
||||
<Input
|
||||
:disabled="disabled"
|
||||
:value="displayText"
|
||||
class="region-address-trigger"
|
||||
placeholder="请选择省市区"
|
||||
readonly
|
||||
>
|
||||
<template v-if="displayText && !disabled" #suffix>
|
||||
<span
|
||||
class="region-address-clear"
|
||||
@click.stop="onClear"
|
||||
>×</span>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.region-address-trigger-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.region-address-trigger {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.region-address-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.region-address-clear:hover {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.region-address-panel {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.region-address-repick-province {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.region-address-repick-province:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-sep {
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-item {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-item:hover {
|
||||
color: #4096ff;
|
||||
}
|
||||
|
||||
.region-address-search {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.region-address-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.region-address-list-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.region-address-list-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.region-address-list-item-active {
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.region-address-list-item-active:hover {
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.region-address-list-empty {
|
||||
padding: 16px 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 暗色模式适配(对齐 drug-search-select / menu-search-select) */
|
||||
.dark {
|
||||
.region-address-clear {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.region-address-clear:hover {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.region-address-repick-province {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.region-address-repick-province:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-sep {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-item {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.region-address-breadcrumb-item:hover {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.region-address-list-item {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.region-address-list-item:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.region-address-list-item-active,
|
||||
.region-address-list-item-active:hover {
|
||||
background: #1e3a5f;
|
||||
}
|
||||
|
||||
.region-address-list-empty {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 门店多选搜索组件
|
||||
* - 普通输入框输入,下方气泡卡片展示匹配结果(名称 / 拼音首拼)
|
||||
* - 选中后在输入框下方用可关闭 Tag 展示「名称【id】」
|
||||
* - 搜索结果默认高亮第一项,Enter 可直接选中
|
||||
* - 样式使用主题 CSS 变量,自动适配暗色
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||
import { useDebounceFn, useVModel } from '@vueuse/core';
|
||||
import { Empty, Input, Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { searchStoreOption } from '#/views/system/store/api';
|
||||
|
||||
defineOptions({
|
||||
name: 'StoreMultiSearch',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
export type StoreSearchItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
type?: number;
|
||||
shouzimu?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 已选门店 ID 列表 */
|
||||
value?: number[];
|
||||
/** 限定类型:0诊所 1药店;不传则诊所+药店都可搜 */
|
||||
storeType?: 0 | 1;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 外部已选门店详情(打开绑定弹窗时回填名称用)
|
||||
* 仅用于 Tag 展示,不改变 value
|
||||
*/
|
||||
initialItems?: StoreSearchItem[];
|
||||
}>(),
|
||||
{
|
||||
value: () => [],
|
||||
storeType: undefined,
|
||||
placeholder: '输入名称或拼音首拼搜索',
|
||||
disabled: false,
|
||||
initialItems: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:value': [value: number[]];
|
||||
}>();
|
||||
|
||||
const mValue = useVModel(props, 'value', emits, {
|
||||
passive: true,
|
||||
defaultValue: [],
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const searchKeyword = ref('');
|
||||
const loading = ref(false);
|
||||
const showDropdown = ref(false);
|
||||
const highlightIndex = ref(-1);
|
||||
const options = ref<StoreSearchItem[]>([]);
|
||||
/** 已选门店详情(用于 Tag 展示名称) */
|
||||
const selectedMap = ref<Record<number, StoreSearchItem>>({});
|
||||
|
||||
const selectedIds = computed(() =>
|
||||
Array.isArray(mValue.value) ? mValue.value.map(Number) : [],
|
||||
);
|
||||
|
||||
const selectedItems = computed(() =>
|
||||
selectedIds.value.map((id) => {
|
||||
const cached = selectedMap.value[id];
|
||||
return cached || { id, name: `门店${id}` };
|
||||
}),
|
||||
);
|
||||
|
||||
/** 下拉中过滤掉已选 */
|
||||
const visibleOptions = computed(() =>
|
||||
options.value.filter((item) => !selectedIds.value.includes(item.id)),
|
||||
);
|
||||
|
||||
/**
|
||||
* 将初始/回填门店写入 selectedMap,便于 Tag 显示真实名称
|
||||
*/
|
||||
function mergeInitialItems(items: StoreSearchItem[]) {
|
||||
if (!items?.length) return;
|
||||
const next = { ...selectedMap.value };
|
||||
for (const item of items) {
|
||||
if (item?.id) {
|
||||
next[item.id] = item;
|
||||
}
|
||||
}
|
||||
selectedMap.value = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求后端门店搜索;有结果时默认高亮第一项
|
||||
*/
|
||||
async function fetchOptions() {
|
||||
const keyword = searchKeyword.value.trim();
|
||||
if (!keyword) {
|
||||
options.value = [];
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
showDropdown.value = true;
|
||||
try {
|
||||
const res = await searchStoreOption({
|
||||
keyword,
|
||||
...(props.storeType === 0 || props.storeType === 1
|
||||
? { type: props.storeType }
|
||||
: {}),
|
||||
limit: 20,
|
||||
});
|
||||
options.value = res?.items ?? [];
|
||||
// 默认高亮第一项,Enter 可直接选中
|
||||
highlightIndex.value = options.value.length > 0 ? 0 : -1;
|
||||
} catch {
|
||||
options.value = [];
|
||||
highlightIndex.value = -1;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedFetch = useDebounceFn(fetchOptions, 300);
|
||||
|
||||
function handleInput() {
|
||||
debouncedFetch();
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (visibleOptions.value.length > 0) {
|
||||
showDropdown.value = true;
|
||||
if (highlightIndex.value < 0) {
|
||||
highlightIndex.value = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中一项:追加到 value,缓存名称,清空输入继续搜
|
||||
*/
|
||||
function selectOption(item: StoreSearchItem) {
|
||||
if (selectedIds.value.includes(item.id)) return;
|
||||
selectedMap.value = { ...selectedMap.value, [item.id]: item };
|
||||
mValue.value = [...selectedIds.value, item.id];
|
||||
searchKeyword.value = '';
|
||||
options.value = [];
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消某个已选门店
|
||||
*/
|
||||
function removeSelected(id: number) {
|
||||
mValue.value = selectedIds.value.filter((x) => x !== id);
|
||||
const next = { ...selectedMap.value };
|
||||
delete next[id];
|
||||
selectedMap.value = next;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!showDropdown.value || visibleOptions.value.length === 0) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
highlightIndex.value = Math.min(
|
||||
Math.max(highlightIndex.value, 0) + 1,
|
||||
visibleOptions.value.length - 1,
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
highlightIndex.value = Math.max(highlightIndex.value - 1, 0);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
{
|
||||
const idx = highlightIndex.value >= 0 ? highlightIndex.value : 0;
|
||||
const item = visibleOptions.value[idx];
|
||||
if (item) selectOption(item);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
mergeInitialItems(props.initialItems || []);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.storeType,
|
||||
() => {
|
||||
options.value = [];
|
||||
showDropdown.value = false;
|
||||
searchKeyword.value = '';
|
||||
highlightIndex.value = -1;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.initialItems,
|
||||
(items) => mergeInitialItems(items || []),
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="store-multi-search">
|
||||
<Input
|
||||
v-model:value="searchKeyword"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
allow-clear
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<template #prefix>
|
||||
<LoadingOutlined v-if="loading" class="text-muted-foreground" />
|
||||
<SearchOutlined v-else class="text-muted-foreground" />
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<!-- 气泡建议列表 -->
|
||||
<div v-if="showDropdown" class="store-multi-search__dropdown">
|
||||
<Spin :spinning="loading">
|
||||
<Empty
|
||||
v-if="!loading && visibleOptions.length === 0"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
description="暂无匹配门店"
|
||||
class="py-3"
|
||||
/>
|
||||
<div
|
||||
v-for="(item, index) in visibleOptions"
|
||||
:key="item.id"
|
||||
class="store-multi-search__option"
|
||||
:class="{ 'is-active': index === highlightIndex }"
|
||||
@mousedown.prevent="selectOption(item)"
|
||||
>
|
||||
<div class="store-multi-search__option-name">
|
||||
{{ item.name }}【{{ item.id }}】
|
||||
</div>
|
||||
<div v-if="item.shouzimu" class="store-multi-search__option-sub">
|
||||
首拼:{{ item.shouzimu }}
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
<!-- 已选 Tag -->
|
||||
<div v-if="selectedItems.length > 0" class="store-multi-search__tags">
|
||||
<Tag
|
||||
v-for="item in selectedItems"
|
||||
:key="item.id"
|
||||
closable
|
||||
color="blue"
|
||||
@close="() => removeSelected(item.id)"
|
||||
>
|
||||
{{ item.name }}【{{ item.id }}】
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 只用框架主题变量,随亮暗/主题色自动适配 */
|
||||
.store-multi-search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-multi-search__dropdown {
|
||||
position: absolute;
|
||||
z-index: 1050;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.store-multi-search__option {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
background: hsl(var(--accent-hover));
|
||||
}
|
||||
}
|
||||
|
||||
.store-multi-search__option-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.store-multi-search__option-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.store-multi-search__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
317
apps/web-antd/src/components/form/components/store-picker.vue
Normal file
317
apps/web-antd/src/components/form/components/store-picker.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Avatar, Empty, Input, Popover, Spin } from 'ant-design-vue';
|
||||
|
||||
import { getStoreOption } from '#/views/system/store/api';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
defineOptions({
|
||||
name: 'StorePicker',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
value?: number;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:value': [value: number | undefined];
|
||||
}>();
|
||||
|
||||
const mValue = useVModel(props, 'value', emits, { passive: true });
|
||||
|
||||
type StoreOption = {
|
||||
id: number;
|
||||
name: string;
|
||||
pic?: string;
|
||||
mobile?: string;
|
||||
};
|
||||
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const options = ref<StoreOption[]>([]);
|
||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
|
||||
/** 按关键词过滤诊所列表 */
|
||||
function filterStoreOptions(keyword: string, list: StoreOption[]) {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter((item) => {
|
||||
const name = (item.name || '').toLowerCase();
|
||||
const mobile = (item.mobile || '').toLowerCase();
|
||||
const idText = String(item.id);
|
||||
return name.includes(q) || mobile.includes(q) || idText.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
const filteredOptions = computed(() =>
|
||||
filterStoreOptions(searchKeyword.value, options.value),
|
||||
);
|
||||
|
||||
const selectedOption = computed(() =>
|
||||
options.value.find((item) => item.id === mValue.value),
|
||||
);
|
||||
|
||||
/** 加载诊所选项(含 pic、mobile 供卡片展示) */
|
||||
async function loadOptions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
options.value = (await getStoreOption({})) || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(item: StoreOption) {
|
||||
mValue.value = item.id;
|
||||
open.value = false;
|
||||
searchKeyword.value = '';
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
mValue.value = undefined;
|
||||
}
|
||||
|
||||
function onOpenChange(next: boolean) {
|
||||
if (props.disabled) return;
|
||||
open.value = next;
|
||||
if (next) {
|
||||
searchKeyword.value = '';
|
||||
if (!options.value.length) {
|
||||
loadOptions();
|
||||
}
|
||||
nextTick(() => searchInputRef.value?.focus?.());
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
if (disabled) open.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover
|
||||
:open="open"
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
overlay-class-name="store-picker-popover"
|
||||
@open-change="onOpenChange"
|
||||
>
|
||||
<template #content>
|
||||
<div class="store-panel">
|
||||
<Input
|
||||
ref="searchInputRef"
|
||||
v-model:value="searchKeyword"
|
||||
allow-clear
|
||||
placeholder="搜索诊所名称、电话、ID"
|
||||
class="store-search"
|
||||
/>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="filteredOptions.length" class="store-grid">
|
||||
<button
|
||||
v-for="item in filteredOptions"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="store-card"
|
||||
:class="{ active: item.id === mValue }"
|
||||
@click="selectOption(item)"
|
||||
>
|
||||
<Avatar :src="resolveAvatarUrl(item.pic)" :size="36" shape="square">
|
||||
{{ (item.name || '?').charAt(0) }}
|
||||
</Avatar>
|
||||
<div class="store-card-name" :title="item.name">
|
||||
{{ item.name || '-' }}
|
||||
</div>
|
||||
<div v-if="item.mobile" class="store-card-sub">
|
||||
<SensitiveText :record="item" field="mobile" :show-eye="false" />
|
||||
</div>
|
||||
<div class="store-card-sub">ID: {{ item.id }}</div>
|
||||
</button>
|
||||
</div>
|
||||
<Empty v-else description="无匹配诊所" class="store-empty" />
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
<div class="store-trigger" :class="{ disabled: disabled }">
|
||||
<div v-if="selectedOption" class="store-selected-card">
|
||||
<Avatar :src="resolveAvatarUrl(selectedOption.pic)" :size="36" shape="square">
|
||||
{{ (selectedOption.name || '?').charAt(0) }}
|
||||
</Avatar>
|
||||
<div class="store-selected-info">
|
||||
<div class="store-selected-name">{{ selectedOption.name }}</div>
|
||||
<div class="store-selected-sub">
|
||||
<SensitiveText
|
||||
v-if="selectedOption.mobile"
|
||||
:record="selectedOption"
|
||||
field="mobile"
|
||||
:show-eye="false"
|
||||
/>
|
||||
<span v-else>ID: {{ selectedOption.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="!disabled"
|
||||
class="store-clear-btn"
|
||||
title="清除"
|
||||
@click.stop="clearSelection"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="mValue" class="store-selected-card">
|
||||
<div class="store-selected-info">
|
||||
<div class="store-selected-name">诊所 #{{ mValue }}</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="!disabled"
|
||||
class="store-clear-btn"
|
||||
title="清除"
|
||||
@click.stop="clearSelection"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="store-trigger-placeholder">请选择诊所</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './picker-card-theme.scss' as theme;
|
||||
|
||||
.store-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-trigger.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.store-trigger:not(.disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.store-selected-card {
|
||||
@include theme.picker-selected-card;
|
||||
}
|
||||
|
||||
.store-selected-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-selected-name {
|
||||
font-size: 14px;
|
||||
color: #1d2129;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-selected-sub {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.store-clear-btn {
|
||||
@include theme.picker-clear-btn;
|
||||
}
|
||||
|
||||
.store-trigger-placeholder {
|
||||
@include theme.picker-trigger-placeholder;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.store-selected-card {
|
||||
@include theme.picker-selected-card-dark-props;
|
||||
}
|
||||
|
||||
.store-selected-name {
|
||||
@include theme.picker-text-primary-dark;
|
||||
}
|
||||
|
||||
.store-selected-sub {
|
||||
@include theme.picker-text-secondary-dark;
|
||||
}
|
||||
|
||||
.store-clear-btn {
|
||||
@include theme.picker-clear-dark-props;
|
||||
}
|
||||
|
||||
.store-trigger-placeholder {
|
||||
@include theme.picker-placeholder-dark-props;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@use './picker-card-theme.scss' as theme;
|
||||
|
||||
.store-picker-popover {
|
||||
.ant-popover-inner {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.store-panel {
|
||||
width: 540px;
|
||||
max-width: 86vw;
|
||||
}
|
||||
|
||||
.store-search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.store-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.store-card {
|
||||
@include theme.picker-card-base;
|
||||
}
|
||||
|
||||
.store-card-name {
|
||||
@include theme.picker-card-name;
|
||||
}
|
||||
|
||||
.store-card-sub {
|
||||
@include theme.picker-card-sub;
|
||||
}
|
||||
|
||||
.store-empty {
|
||||
margin: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .store-picker-popover {
|
||||
.store-card {
|
||||
@include theme.picker-card-dark-props;
|
||||
}
|
||||
|
||||
.store-card-name {
|
||||
@include theme.picker-text-primary-dark;
|
||||
}
|
||||
|
||||
.store-card-sub {
|
||||
@include theme.picker-text-secondary-dark;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,371 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用拖拽 + 粘贴上传:Dragger 点击/拖拽,下方外侧「开始监听粘贴」
|
||||
* v-model 为已上传 URL 字符串数组;列表支持图片/PDF 预览(blob,避免直接下载)
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { Button, Image, Modal, Upload, message } from 'ant-design-vue';
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { uploadToOss } from '#/utils/oss-upload';
|
||||
import {
|
||||
matchAccept,
|
||||
usePasteUploadListen,
|
||||
} from '#/utils/use-paste-upload-listen';
|
||||
|
||||
defineOptions({ name: 'UploadDraggerPaste' });
|
||||
|
||||
interface FileItem {
|
||||
uid: string;
|
||||
name: string;
|
||||
status: 'uploading' | 'done' | 'error';
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue?: string[];
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/** 单文件最大 MB */
|
||||
maxSizeMb?: number;
|
||||
tip?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
accept: '.jpg,.jpeg,.png,.gif,.webp,.pdf,image/*,application/pdf',
|
||||
multiple: true,
|
||||
maxCount: 9,
|
||||
maxSizeMb: 20,
|
||||
tip: '点击或拖拽文件到此处上传',
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]];
|
||||
}>();
|
||||
|
||||
const UploadDragger = Upload.Dragger;
|
||||
const fileList = ref<FileItem[]>([]);
|
||||
const uploading = ref(false);
|
||||
|
||||
/** 文件预览弹窗状态 */
|
||||
const previewVisible = ref(false);
|
||||
const previewTitle = ref('');
|
||||
const previewIsImage = ref(false);
|
||||
const previewImageUrl = ref('');
|
||||
const previewPdfUrl = ref('');
|
||||
const previewLoading = ref(false);
|
||||
|
||||
const maxBytes = computed(() => props.maxSizeMb * 1024 * 1024);
|
||||
|
||||
function syncFromValue(urls: string[]) {
|
||||
fileList.value = (urls || []).map((url) => ({
|
||||
uid: url,
|
||||
name: url.split('/').pop() || 'file',
|
||||
status: 'done' as const,
|
||||
url,
|
||||
}));
|
||||
}
|
||||
|
||||
syncFromValue(props.modelValue);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
const current = fileList.value
|
||||
.filter((f) => f.status === 'done')
|
||||
.map((f) => f.url);
|
||||
if (JSON.stringify(val || []) !== JSON.stringify(current)) {
|
||||
syncFromValue(val || []);
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function emitUrls() {
|
||||
const urls = fileList.value
|
||||
.filter((f) => f.status === 'done' && f.url)
|
||||
.map((f) => f.url);
|
||||
emit('update:modelValue', urls);
|
||||
}
|
||||
|
||||
function isImageUrl(url: string) {
|
||||
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
|
||||
}
|
||||
|
||||
function revokePdfBlob() {
|
||||
if (previewPdfUrl.value && previewPdfUrl.value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewPdfUrl.value);
|
||||
}
|
||||
previewPdfUrl.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览已上传文件:图片用 Image;PDF fetch 成 blob 后 iframe,避免 OSS 直接下载
|
||||
*/
|
||||
async function previewItem(item: FileItem) {
|
||||
if (!item.url) return;
|
||||
revokePdfBlob();
|
||||
previewTitle.value = item.name || '预览';
|
||||
if (isImageUrl(item.url)) {
|
||||
previewIsImage.value = true;
|
||||
previewImageUrl.value = item.url;
|
||||
previewVisible.value = true;
|
||||
return;
|
||||
}
|
||||
previewIsImage.value = false;
|
||||
previewLoading.value = true;
|
||||
previewVisible.value = true;
|
||||
try {
|
||||
const resp = await fetch(item.url);
|
||||
if (!resp.ok) throw new Error('加载失败');
|
||||
const blob = await resp.blob();
|
||||
const pdfBlob =
|
||||
blob.type && blob.type !== 'application/octet-stream'
|
||||
? blob
|
||||
: new Blob([blob], { type: 'application/pdf' });
|
||||
previewPdfUrl.value = URL.createObjectURL(pdfBlob);
|
||||
} catch {
|
||||
message.error('预览失败');
|
||||
previewVisible.value = false;
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPreviewClose() {
|
||||
previewVisible.value = false;
|
||||
revokePdfBlob();
|
||||
previewImageUrl.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单个文件并写入列表
|
||||
*/
|
||||
async function uploadOneFile(file: File) {
|
||||
if (props.disabled) return;
|
||||
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||||
if (doneCount >= props.maxCount) {
|
||||
message.warning(`最多上传 ${props.maxCount} 个文件`);
|
||||
return;
|
||||
}
|
||||
if (!matchAccept(file, props.accept)) {
|
||||
message.warning('文件类型不符合要求');
|
||||
return;
|
||||
}
|
||||
if (file.size > maxBytes.value) {
|
||||
message.warning(`文件不能超过 ${props.maxSizeMb}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
fileList.value = [
|
||||
...fileList.value,
|
||||
{ uid, name: file.name, status: 'uploading', url: '' },
|
||||
];
|
||||
uploading.value = true;
|
||||
try {
|
||||
const uploadMethod = preferences.app.uploadMethod || 'direct';
|
||||
const res =
|
||||
uploadMethod === 'direct'
|
||||
? await uploadToOss({ file })
|
||||
: await uploadFile({ file });
|
||||
const url = res?.url || '';
|
||||
if (!url) throw new Error('上传失败');
|
||||
fileList.value = fileList.value.map((item) =>
|
||||
item.uid === uid ? { ...item, status: 'done' as const, url } : item,
|
||||
);
|
||||
emitUrls();
|
||||
} catch (e: any) {
|
||||
fileList.value = fileList.value.filter((item) => item.uid !== uid);
|
||||
message.error(e?.message || '上传失败');
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ant Design Upload customRequest */
|
||||
const customRequest = async (options: any) => {
|
||||
const { file, onSuccess, onError } = options;
|
||||
try {
|
||||
await uploadOneFile(file as File);
|
||||
onSuccess?.({});
|
||||
} catch (e) {
|
||||
onError?.(e);
|
||||
}
|
||||
};
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
if (props.disabled) return Upload.LIST_IGNORE;
|
||||
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||||
if (doneCount >= props.maxCount) {
|
||||
message.warning(`最多上传 ${props.maxCount} 个文件`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (!matchAccept(file, props.accept)) {
|
||||
message.warning('文件类型不符合要求');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (file.size > maxBytes.value) {
|
||||
message.warning(`文件不能超过 ${props.maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleRemove(file: UploadFile) {
|
||||
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
|
||||
emitUrls();
|
||||
return true;
|
||||
}
|
||||
|
||||
const {
|
||||
listening: pasteListening,
|
||||
toggle: togglePaste,
|
||||
} = usePasteUploadListen({
|
||||
get accept() {
|
||||
return props.accept;
|
||||
},
|
||||
get maxSize() {
|
||||
return maxBytes.value;
|
||||
},
|
||||
onFiles: async (files) => {
|
||||
for (const file of files) {
|
||||
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||||
if (doneCount >= props.maxCount) {
|
||||
message.warning(`最多上传 ${props.maxCount} 个文件`);
|
||||
break;
|
||||
}
|
||||
await uploadOneFile(file);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function onTogglePaste() {
|
||||
if (props.disabled) return;
|
||||
togglePaste();
|
||||
if (pasteListening.value) {
|
||||
message.success('已开启粘贴监听,可 Ctrl+V 粘贴文件/截图');
|
||||
} else {
|
||||
message.info('已停止粘贴监听');
|
||||
}
|
||||
}
|
||||
|
||||
const showDragger = computed(() => {
|
||||
if (!props.multiple) {
|
||||
return fileList.value.filter((f) => f.status === 'done').length === 0;
|
||||
}
|
||||
return fileList.value.filter((f) => f.status === 'done').length < props.maxCount;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokePdfBlob();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-dragger-paste">
|
||||
<UploadDragger
|
||||
v-if="showDragger"
|
||||
:file-list="[]"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
:disabled="disabled || uploading"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:show-upload-list="false"
|
||||
:max-count="maxCount"
|
||||
>
|
||||
<p class="ant-upload-drag-icon">
|
||||
<Icon icon="ant-design:inbox-outlined" class="text-4xl text-blue-500" />
|
||||
</p>
|
||||
<p class="ant-upload-text">{{ tip }}</p>
|
||||
<p class="ant-upload-hint text-muted-foreground">
|
||||
支持拖拽或点击选择,单文件不超过 {{ maxSizeMb }}MB
|
||||
</p>
|
||||
</UploadDragger>
|
||||
|
||||
<div v-if="fileList.length" class="mt-3 space-y-2">
|
||||
<div
|
||||
v-for="item in fileList"
|
||||
:key="item.uid"
|
||||
class="flex items-center gap-2 rounded border border-border bg-muted/20 px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<Icon
|
||||
:icon="
|
||||
item.status === 'uploading'
|
||||
? 'ant-design:loading-outlined'
|
||||
: item.status === 'error'
|
||||
? 'ant-design:close-circle-outlined'
|
||||
: 'ant-design:paper-clip-outlined'
|
||||
"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<button
|
||||
v-if="item.url"
|
||||
type="button"
|
||||
class="min-w-0 flex-1 truncate text-left text-primary"
|
||||
@click="previewItem(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
<span v-else class="min-w-0 flex-1 truncate text-muted-foreground">{{ item.name }}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
:disabled="disabled"
|
||||
@click="handleRemove(item as any)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 粘贴监听按钮放在拖拽区域下方外侧 -->
|
||||
<div class="mt-3">
|
||||
<Button
|
||||
:type="pasteListening ? 'primary' : 'default'"
|
||||
:danger="pasteListening"
|
||||
:disabled="disabled"
|
||||
@click="onTogglePaste"
|
||||
>
|
||||
<Icon
|
||||
:icon="pasteListening ? 'ant-design:stop-outlined' : 'ant-design:snippets-outlined'"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
|
||||
</Button>
|
||||
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500 dark:text-orange-400">
|
||||
监听中:请将文件或截图 Ctrl+V 粘贴
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
:open="previewVisible"
|
||||
:title="previewTitle"
|
||||
:footer="null"
|
||||
width="80%"
|
||||
destroy-on-close
|
||||
@cancel="onPreviewClose"
|
||||
>
|
||||
<div v-if="previewLoading" class="py-16 text-center text-muted-foreground">加载中…</div>
|
||||
<div v-else-if="previewIsImage" class="flex justify-center rounded border border-border bg-muted/25 p-2">
|
||||
<Image :src="previewImageUrl" :preview="true" style="max-height: 70vh" />
|
||||
</div>
|
||||
<iframe
|
||||
v-else-if="previewPdfUrl"
|
||||
:src="previewPdfUrl"
|
||||
class="h-[70vh] w-full rounded border border-border bg-background"
|
||||
title="文件预览"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,382 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Modal, Upload } from 'ant-design-vue';
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
|
||||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { uploadToOss } from '#/utils/oss-upload';
|
||||
import {
|
||||
beforeImageUpload,
|
||||
resolveUploadFile,
|
||||
} from '#/utils/use-image-upload-pending';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
defineOptions({
|
||||
name: 'UploadImageSortable',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
// 定义props接口
|
||||
interface Props {
|
||||
modelValue: string[];
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 素材库可选类型(透传 GalleryPickLink)
|
||||
* xk_file.type:0=图片 1=视频;默认仅图片。不传会展示全部类型
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
maxCount: 9,
|
||||
acceptTypes: () => [0],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]];
|
||||
}>();
|
||||
|
||||
// 定义文件项接口
|
||||
interface FileItem {
|
||||
uid: string;
|
||||
name: string;
|
||||
status: 'uploading' | 'done' | 'error';
|
||||
url: string;
|
||||
}
|
||||
|
||||
const fileList = ref<FileItem[]>([]);
|
||||
|
||||
// 初始化fileList
|
||||
const initFileList = () => {
|
||||
fileList.value = props.modelValue.map((url, index) => ({
|
||||
uid: `img-${index}-${url}`,
|
||||
name: url.split('/').pop() || 'file',
|
||||
status: 'done' as const,
|
||||
url,
|
||||
}));
|
||||
};
|
||||
|
||||
// 初始化
|
||||
initFileList();
|
||||
|
||||
const uploadListRef = ref<HTMLElement>();
|
||||
let sortableInstance: any = null;
|
||||
|
||||
// 监听外部modelValue变化,同步到fileList(与upload-image.vue保持一致)
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
const currentUrls = fileList.value.filter(file => file.status === 'done').map(file => file.url);
|
||||
if (JSON.stringify(newVal) !== JSON.stringify(currentUrls)) {
|
||||
fileList.value = newVal.map((url, index) => ({
|
||||
uid: `img-${index}-${url}`,
|
||||
name: url.split('/').pop() || 'file',
|
||||
status: 'done' as const,
|
||||
url,
|
||||
}));
|
||||
}
|
||||
nextTick(() => {
|
||||
initSortable();
|
||||
});
|
||||
}, { deep: true, immediate: true });
|
||||
|
||||
// 统一通过updateModelValue函数更新modelValue(与upload-image.vue保持一致)
|
||||
const updateModelValue = () => {
|
||||
const urls = fileList.value
|
||||
.filter((file) => file.status === 'done' && file.url)
|
||||
.map((file) => file.url);
|
||||
emit('update:modelValue', urls);
|
||||
};
|
||||
|
||||
// 初始化拖拽排序(使用原生HTML5拖拽API)
|
||||
const initSortable = () => {
|
||||
if (!uploadListRef.value) return;
|
||||
|
||||
// 销毁旧的排序实例
|
||||
if (sortableInstance) {
|
||||
sortableInstance = null;
|
||||
}
|
||||
|
||||
// 查找上传列表容器
|
||||
const uploadList = uploadListRef.value.querySelector(
|
||||
'.ant-upload-list-picture-card',
|
||||
) as HTMLElement;
|
||||
if (!uploadList) return;
|
||||
|
||||
const items = uploadList.querySelectorAll(
|
||||
'.ant-upload-list-item',
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
items.forEach((item, index) => {
|
||||
item.draggable = true;
|
||||
item.style.cursor = 'move';
|
||||
|
||||
item.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
e.dataTransfer!.setData('text/html', index.toString());
|
||||
item.style.opacity = '0.5';
|
||||
});
|
||||
|
||||
item.addEventListener('dragend', () => {
|
||||
item.style.opacity = '1';
|
||||
});
|
||||
|
||||
item.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = 'move';
|
||||
});
|
||||
|
||||
item.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const dragIndex = Number.parseInt(e.dataTransfer!.getData('text/html'));
|
||||
const dropIndex = index;
|
||||
|
||||
if (dragIndex !== dropIndex) {
|
||||
const movedItem = fileList.value.splice(dragIndex, 1)[0];
|
||||
fileList.value.splice(dropIndex, 0, movedItem);
|
||||
// 拖拽排序后更新modelValue
|
||||
updateModelValue();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义上传请求处理函数
|
||||
*
|
||||
* 该函数根据preferences配置的上传方式,选择使用OSS直传或后端上传:
|
||||
* - 'direct': OSS直传模式,文件直接从浏览器上传到阿里云OSS
|
||||
* - 'backend': 后端上传模式,文件先上传到后端服务器,再由后端上传到OSS
|
||||
*
|
||||
* 两种上传方式的区别:
|
||||
* 1. OSS直传(direct):
|
||||
* - 优点:减少服务器负载,上传速度更快,用户体验更好
|
||||
* - 缺点:需要后端提供签名接口,配置相对复杂
|
||||
* - 实现:调用uploadToOss函数,直接上传到OSS
|
||||
*
|
||||
* 2. 后端上传(backend):
|
||||
* - 优点:兼容现有功能,所有上传逻辑由后端统一处理
|
||||
* - 缺点:增加服务器负载,上传速度相对较慢
|
||||
* - 实现:调用uploadFile API,通过后端服务器上传
|
||||
*
|
||||
* 兼容性处理:
|
||||
* - 两种上传方式返回的数据结构保持一致:{ url: string }
|
||||
* - 确保无论使用哪种方式,组件的行为都是一致的
|
||||
* - 上传成功后,会重新初始化拖拽排序功能
|
||||
* - 如果上传失败,会更新文件状态为error,并调用onError回调
|
||||
*
|
||||
* @param options 上传选项,包含file、onProgress、onSuccess、onError等
|
||||
*/
|
||||
const customRequest = async (options: any) => {
|
||||
const { file, onProgress, onSuccess, onError } = options;
|
||||
const actualFile = resolveUploadFile(file as File);
|
||||
|
||||
// 创建上传中的文件项,用于在UI中显示上传状态
|
||||
const uploadingFile: FileItem = {
|
||||
uid: file.uid,
|
||||
name: file.name,
|
||||
status: 'uploading',
|
||||
url: '',
|
||||
};
|
||||
|
||||
// 将上传中的文件添加到文件列表
|
||||
fileList.value = [...fileList.value, uploadingFile];
|
||||
|
||||
try {
|
||||
// 从preferences中读取上传方式配置
|
||||
// uploadMethod可能的值:'direct'(OSS直传)或 'backend'(后端上传)
|
||||
const uploadMethod = preferences.app.uploadMethod || 'direct';
|
||||
|
||||
let res: { url: string };
|
||||
|
||||
// 根据配置选择上传方式
|
||||
if (uploadMethod === 'direct') {
|
||||
// OSS直传模式:文件直接从浏览器上传到OSS
|
||||
// uploadToOss函数会:
|
||||
// 1. 从后端获取OSS签名信息
|
||||
// 2. 生成全局唯一的文件名(UUID + 时间戳)
|
||||
// 3. 使用FormData构造POST请求直接上传到OSS
|
||||
// 4. 处理上传进度和错误
|
||||
// 5. 返回上传后的文件URL
|
||||
res = await uploadToOss({
|
||||
file: actualFile,
|
||||
onProgress: (percent) => {
|
||||
// 将OSS上传进度传递给组件
|
||||
// percent范围:0-100,表示上传百分比
|
||||
if (onProgress) {
|
||||
onProgress(percent);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 后端上传模式:文件先上传到后端服务器,再由后端上传到OSS
|
||||
// uploadFile函数会:
|
||||
// 1. 将文件发送到后端API(/upload/image)
|
||||
// 2. 后端接收文件后上传到OSS
|
||||
// 3. 返回上传后的文件URL
|
||||
res = await uploadFile({
|
||||
file: actualFile,
|
||||
});
|
||||
}
|
||||
|
||||
// 上传成功,更新文件状态为完成
|
||||
// 将上传结果中的URL赋值给文件项
|
||||
const updatedFileList = fileList.value.map(item =>
|
||||
item.uid === file.uid
|
||||
? { ...item, status: 'done' as const, url: res.url }
|
||||
: item
|
||||
);
|
||||
|
||||
fileList.value = updatedFileList;
|
||||
|
||||
// 更新组件的modelValue,触发父组件的更新
|
||||
updateModelValue();
|
||||
|
||||
// 重新初始化拖拽排序功能
|
||||
// 因为文件列表发生了变化,需要重新绑定拖拽事件
|
||||
nextTick(() => {
|
||||
initSortable();
|
||||
});
|
||||
|
||||
// 调用成功回调,通知上传组件上传已完成
|
||||
onSuccess(res);
|
||||
} catch (error) {
|
||||
// 上传失败,记录错误信息
|
||||
console.error('上传失败', error);
|
||||
|
||||
// 更新文件状态为错误
|
||||
// 错误状态的文件会在UI中显示错误图标
|
||||
const updatedFileList = fileList.value.map(item =>
|
||||
item.uid === file.uid
|
||||
? { ...item, status: 'error' as const }
|
||||
: item
|
||||
);
|
||||
|
||||
fileList.value = updatedFileList;
|
||||
|
||||
// 调用错误回调,通知上传组件上传失败
|
||||
onError(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理文件删除(与upload-image.vue保持一致)
|
||||
const handleRemove = (file: UploadFile) => {
|
||||
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
|
||||
updateModelValue();
|
||||
|
||||
// 重新初始化排序
|
||||
nextTick(() => {
|
||||
initSortable();
|
||||
});
|
||||
};
|
||||
|
||||
// 预览功能(与upload-image.vue保持一致)
|
||||
const previewVisible = ref(false);
|
||||
const previewImage = ref('');
|
||||
const previewTitle = ref('');
|
||||
|
||||
const handlePreview = async (file: UploadFile) => {
|
||||
previewImage.value = file.url || '';
|
||||
previewVisible.value = true;
|
||||
previewTitle.value = file.name || file.url?.split('/').pop() || '';
|
||||
};
|
||||
|
||||
function onGallerySelect(urls: string[]) {
|
||||
const remain = props.maxCount - fileList.value.length;
|
||||
const picked = props.multiple ? urls.slice(0, remain) : urls.slice(0, 1);
|
||||
for (const url of picked) {
|
||||
if (fileList.value.some((f) => f.url === url)) {
|
||||
continue;
|
||||
}
|
||||
fileList.value = [
|
||||
...fileList.value,
|
||||
{
|
||||
uid: `gallery-${url}`,
|
||||
name: url.split('/').pop() || 'file',
|
||||
status: 'done' as const,
|
||||
url,
|
||||
},
|
||||
];
|
||||
}
|
||||
updateModelValue();
|
||||
nextTick(() => {
|
||||
initSortable();
|
||||
});
|
||||
}
|
||||
|
||||
// 计算是否显示上传按钮(与upload-image.vue保持一致)
|
||||
const showUploadButton = computed(() =>
|
||||
props.multiple
|
||||
? fileList.value.length < props.maxCount
|
||||
: fileList.value.length === 0,
|
||||
);
|
||||
|
||||
const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0));
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initSortable();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="uploadListRef" class="upload-image-wrap">
|
||||
<Upload
|
||||
:file-list="fileList"
|
||||
:before-upload="beforeImageUpload"
|
||||
:custom-request="customRequest"
|
||||
:multiple="multiple"
|
||||
:max-count="maxCount"
|
||||
:show-upload-list="{
|
||||
showPreviewIcon: true,
|
||||
showRemoveIcon: true,
|
||||
}"
|
||||
list-type="picture-card"
|
||||
@remove="handleRemove"
|
||||
@preview="handlePreview"
|
||||
accept="image/*"
|
||||
>
|
||||
<div v-if="showUploadButton" class="upload-button">
|
||||
<Icon icon="ant-design:plus-outlined" />
|
||||
<div class="ant-upload-text">上传图片</div>
|
||||
</div>
|
||||
</Upload>
|
||||
|
||||
<GalleryPickLink
|
||||
v-if="showUploadButton"
|
||||
:multiple="multiple"
|
||||
:max-count="galleryRemainCount"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onGallerySelect"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
v-model:visible="previewVisible"
|
||||
:title="previewTitle"
|
||||
footer=""
|
||||
width="60%"
|
||||
>
|
||||
<img alt="预览图片" style="width: 100%" :src="previewImage" />
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.upload-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.ant-upload-list-picture-card .ant-upload-list-item) {
|
||||
cursor: move;
|
||||
}
|
||||
</style>
|
||||
298
apps/web-antd/src/components/form/components/upload-image.vue
Normal file
298
apps/web-antd/src/components/form/components/upload-image.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 多图上传(picture-card)+ 下方「开始监听粘贴」支持截图/图片粘贴
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Button, Modal, Upload, message } from 'ant-design-vue';
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
|
||||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { uploadToOss } from '#/utils/oss-upload';
|
||||
import {
|
||||
beforeImageUpload,
|
||||
resolveUploadFile,
|
||||
} from '#/utils/use-image-upload-pending';
|
||||
import { usePasteUploadListen } from '#/utils/use-paste-upload-listen';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
defineOptions({ name: 'UploadImage' });
|
||||
|
||||
interface FileItem {
|
||||
uid: string;
|
||||
name: string;
|
||||
status: 'uploading' | 'done' | 'error';
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: string[];
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 素材库可选类型(透传 GalleryPickLink)
|
||||
* xk_file.type:0=图片 1=视频,默认仅图片
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
maxCount: 9,
|
||||
acceptTypes: () => [0],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]];
|
||||
}>();
|
||||
|
||||
const IMAGE_ACCEPT = 'image/*,.jpg,.jpeg,.png,.gif,.webp,.bmp';
|
||||
|
||||
const fileList = ref<FileItem[]>([]);
|
||||
|
||||
const initFileList = () => {
|
||||
fileList.value = props.modelValue.map((url) => ({
|
||||
uid: url,
|
||||
name: url.split('/').pop() || 'file',
|
||||
status: 'done' as const,
|
||||
url,
|
||||
}));
|
||||
};
|
||||
|
||||
initFileList();
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
const currentUrls = fileList.value
|
||||
.filter((file) => file.status === 'done')
|
||||
.map((file) => file.url);
|
||||
if (JSON.stringify(newVal) !== JSON.stringify(currentUrls)) {
|
||||
fileList.value = newVal.map((url) => ({
|
||||
uid: url,
|
||||
name: url.split('/').pop() || 'file',
|
||||
status: 'done' as const,
|
||||
url,
|
||||
}));
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const updateModelValue = () => {
|
||||
const urls = fileList.value
|
||||
.filter((file) => file.status === 'done' && file.url)
|
||||
.map((file) => file.url);
|
||||
emit('update:modelValue', urls);
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传单个图片文件(点击上传与粘贴共用)
|
||||
*/
|
||||
async function uploadOneImage(file: File) {
|
||||
const remain = props.maxCount - fileList.value.filter((f) => f.status === 'done').length;
|
||||
if (remain <= 0) {
|
||||
message.warning(`最多上传 ${props.maxCount} 张图片`);
|
||||
return;
|
||||
}
|
||||
const actualFile = resolveUploadFile(file);
|
||||
const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const uploadingFile: FileItem = {
|
||||
uid,
|
||||
name: actualFile.name || file.name || 'paste.png',
|
||||
status: 'uploading',
|
||||
url: '',
|
||||
};
|
||||
fileList.value = [...fileList.value, uploadingFile];
|
||||
try {
|
||||
const uploadMethod = preferences.app.uploadMethod || 'direct';
|
||||
const res =
|
||||
uploadMethod === 'direct'
|
||||
? await uploadToOss({ file: actualFile })
|
||||
: await uploadFile({ file: actualFile });
|
||||
fileList.value = fileList.value.map((item) =>
|
||||
item.uid === uid ? { ...item, status: 'done' as const, url: res.url } : item,
|
||||
);
|
||||
updateModelValue();
|
||||
} catch (error) {
|
||||
console.error('上传失败', error);
|
||||
fileList.value = fileList.value.filter((item) => item.uid !== uid);
|
||||
message.error('上传失败');
|
||||
}
|
||||
}
|
||||
|
||||
const customRequest = async (options: any) => {
|
||||
const { file, onProgress, onSuccess, onError } = options;
|
||||
const actualFile = resolveUploadFile(file as File);
|
||||
|
||||
const uploadingFile: FileItem = {
|
||||
uid: file.uid,
|
||||
name: file.name,
|
||||
status: 'uploading',
|
||||
url: '',
|
||||
};
|
||||
|
||||
fileList.value = [...fileList.value, uploadingFile];
|
||||
|
||||
try {
|
||||
const uploadMethod = preferences.app.uploadMethod || 'direct';
|
||||
const res =
|
||||
uploadMethod === 'direct'
|
||||
? await uploadToOss({
|
||||
file: actualFile,
|
||||
onProgress: (percent) => {
|
||||
if (onProgress) {
|
||||
onProgress(percent);
|
||||
}
|
||||
},
|
||||
})
|
||||
: await uploadFile({ file: actualFile });
|
||||
|
||||
fileList.value = fileList.value.map((item) =>
|
||||
item.uid === file.uid ? { ...item, status: 'done' as const, url: res.url } : item,
|
||||
);
|
||||
updateModelValue();
|
||||
onSuccess(res);
|
||||
} catch (error) {
|
||||
console.error('上传失败', error);
|
||||
fileList.value = fileList.value.map((item) =>
|
||||
item.uid === file.uid ? { ...item, status: 'error' as const } : item,
|
||||
);
|
||||
onError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (file: UploadFile) => {
|
||||
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
|
||||
updateModelValue();
|
||||
};
|
||||
|
||||
const previewVisible = ref(false);
|
||||
const previewImage = ref('');
|
||||
const previewTitle = ref('');
|
||||
|
||||
function onGallerySelect(urls: string[]) {
|
||||
const remain = props.maxCount - fileList.value.length;
|
||||
const picked = props.multiple ? urls.slice(0, remain) : urls.slice(0, 1);
|
||||
for (const url of picked) {
|
||||
if (fileList.value.some((f) => f.url === url)) {
|
||||
continue;
|
||||
}
|
||||
fileList.value = [
|
||||
...fileList.value,
|
||||
{
|
||||
uid: url,
|
||||
name: url.split('/').pop() || 'file',
|
||||
status: 'done' as const,
|
||||
url,
|
||||
},
|
||||
];
|
||||
}
|
||||
updateModelValue();
|
||||
}
|
||||
|
||||
const handlePreview = async (file: UploadFile) => {
|
||||
previewImage.value = file.url || '';
|
||||
previewVisible.value = true;
|
||||
previewTitle.value = file.name || file.url?.split('/').pop() || '';
|
||||
};
|
||||
|
||||
const showUploadButton = computed(() =>
|
||||
props.multiple ? fileList.value.length < props.maxCount : fileList.value.length === 0,
|
||||
);
|
||||
|
||||
const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0));
|
||||
|
||||
const { listening: pasteListening, toggle: togglePaste } = usePasteUploadListen({
|
||||
accept: IMAGE_ACCEPT,
|
||||
maxSize: 20 * 1024 * 1024,
|
||||
onFiles: async (files) => {
|
||||
for (const file of files) {
|
||||
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
|
||||
if (doneCount >= props.maxCount) {
|
||||
message.warning(`最多上传 ${props.maxCount} 张图片`);
|
||||
break;
|
||||
}
|
||||
await uploadOneImage(file);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function onTogglePaste() {
|
||||
togglePaste();
|
||||
if (pasteListening.value) {
|
||||
message.success('已开启粘贴监听,可 Ctrl+V 粘贴图片/截图');
|
||||
} else {
|
||||
message.info('已停止粘贴监听');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-image-wrap">
|
||||
<Upload
|
||||
:file-list="fileList"
|
||||
:before-upload="beforeImageUpload"
|
||||
:custom-request="customRequest"
|
||||
:multiple="multiple"
|
||||
:max-count="maxCount"
|
||||
:show-upload-list="{
|
||||
showPreviewIcon: true,
|
||||
showRemoveIcon: true,
|
||||
}"
|
||||
list-type="picture-card"
|
||||
accept="image/*"
|
||||
@remove="handleRemove"
|
||||
@preview="handlePreview"
|
||||
>
|
||||
<div v-if="showUploadButton" class="upload-button">
|
||||
<Icon icon="ant-design:plus-outlined" />
|
||||
<div class="ant-upload-text">上传图片</div>
|
||||
</div>
|
||||
</Upload>
|
||||
|
||||
<GalleryPickLink
|
||||
v-if="showUploadButton"
|
||||
:multiple="multiple"
|
||||
:max-count="galleryRemainCount"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onGallerySelect"
|
||||
/>
|
||||
|
||||
<!-- 粘贴监听放在上传区下方外侧 -->
|
||||
<div class="mt-2">
|
||||
<Button
|
||||
size="small"
|
||||
:type="pasteListening ? 'primary' : 'default'"
|
||||
:danger="pasteListening"
|
||||
@click="onTogglePaste"
|
||||
>
|
||||
<Icon
|
||||
:icon="pasteListening ? 'ant-design:stop-outlined' : 'ant-design:snippets-outlined'"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
|
||||
</Button>
|
||||
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500">
|
||||
监听中:Ctrl+V 粘贴图片
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Modal v-model:visible="previewVisible" :title="previewTitle" footer="" width="60%">
|
||||
<img alt="预览图片" style="width: 100%" :src="previewImage" />
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.upload-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
192
apps/web-antd/src/components/form/components/upload-oss-file.vue
Normal file
192
apps/web-antd/src/components/form/components/upload-oss-file.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OSS 文件上传(单文件 URL 字符串)
|
||||
* 可选:从素材库选择(acceptTypes 过滤,如文件=6、语音=3)
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { Button, Upload } from 'ant-design-vue';
|
||||
|
||||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
||||
import { Icon } from '#/components/icon';
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { uploadToOss } from '#/utils/oss-upload';
|
||||
|
||||
interface FileItem {
|
||||
uid: string;
|
||||
name: string;
|
||||
status: 'uploading' | 'done' | 'error';
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue?: string;
|
||||
maxCount?: number;
|
||||
/** 素材库类型过滤,如 [6] 文件、[3] 语音 */
|
||||
acceptTypes?: number[];
|
||||
/** input accept,如 .pdf,.doc 或 audio/* */
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
maxCount: 1,
|
||||
acceptTypes: () => [6],
|
||||
accept: '.pdf,.doc,.docx,.zip,.rar,.txt,application/pdf,audio/*,*/*',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const fileList = ref<FileItem[]>([]);
|
||||
|
||||
function syncFileListFromValue(url: string) {
|
||||
if (!url) {
|
||||
fileList.value = [];
|
||||
return;
|
||||
}
|
||||
fileList.value = [
|
||||
{
|
||||
uid: url,
|
||||
name: url.split('/').pop() || 'file',
|
||||
status: 'done',
|
||||
url,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
syncFileListFromValue(props.modelValue);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
const currentUrl =
|
||||
fileList.value.find((file) => file.status === 'done')?.url ?? '';
|
||||
if ((newVal ?? '') !== currentUrl) {
|
||||
syncFileListFromValue(newVal ?? '');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function updateModelValue() {
|
||||
const url =
|
||||
fileList.value.find((file) => file.status === 'done' && file.url)?.url ??
|
||||
'';
|
||||
emit('update:modelValue', url);
|
||||
}
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
const maxSize = 20 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const customRequest = async (options: any) => {
|
||||
const { file, onProgress, onSuccess, onError } = options;
|
||||
|
||||
fileList.value = [
|
||||
{
|
||||
uid: file.uid,
|
||||
name: file.name,
|
||||
status: 'uploading',
|
||||
url: '',
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const uploadMethod = preferences.app.uploadMethod || 'direct';
|
||||
let res: { url: string };
|
||||
|
||||
if (uploadMethod === 'direct') {
|
||||
res = await uploadToOss({
|
||||
file: file as File,
|
||||
onProgress: (percent) => {
|
||||
onProgress?.({ percent });
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res = await uploadFile({
|
||||
file: file as File,
|
||||
});
|
||||
}
|
||||
|
||||
fileList.value = [
|
||||
{
|
||||
uid: file.uid,
|
||||
name: file.name,
|
||||
status: 'done',
|
||||
url: res.url,
|
||||
},
|
||||
];
|
||||
updateModelValue();
|
||||
onSuccess?.(res);
|
||||
} catch (error) {
|
||||
fileList.value = [
|
||||
{
|
||||
uid: file.uid,
|
||||
name: file.name,
|
||||
status: 'error',
|
||||
url: '',
|
||||
},
|
||||
];
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
function handleRemove() {
|
||||
fileList.value = [];
|
||||
updateModelValue();
|
||||
return true;
|
||||
}
|
||||
|
||||
function onGallerySelect(urls: string[]) {
|
||||
const url = urls[0] || '';
|
||||
if (!url) return;
|
||||
syncFileListFromValue(url);
|
||||
updateModelValue();
|
||||
}
|
||||
|
||||
const showUploadButton = computed(
|
||||
() => fileList.value.filter((item) => item.status !== 'error').length === 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-oss-file-wrap">
|
||||
<Upload
|
||||
:file-list="fileList"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:max-count="maxCount"
|
||||
list-type="text"
|
||||
:accept="accept"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<Button v-if="showUploadButton">
|
||||
上传文件
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:cloud-upload-outlined" />
|
||||
</template>
|
||||
</Button>
|
||||
</Upload>
|
||||
<GalleryPickLink
|
||||
v-if="showUploadButton"
|
||||
:max-count="1"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onGallerySelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-oss-file-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
</style>
|
||||
170
apps/web-antd/src/components/form/components/user-tag-select.vue
Normal file
170
apps/web-antd/src/components/form/components/user-tag-select.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用 Tag 选择器(带头像)
|
||||
*
|
||||
* - 基于 antd Select,multiple 模式默认开启,单选传 multiple=false
|
||||
* - 已选项以 Tag 形式展示头像+名字:
|
||||
* - 有 avatar 用图片头像
|
||||
* - 无 avatar 用文本头像(取 label 首字符)
|
||||
* - 下拉选项同样展示头像+名字
|
||||
* - 选项数据:options: { value, label, avatar? }[]
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { Avatar, Select, Tag } from 'ant-design-vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'UserTagSelect',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
/** 单个选项结构 */
|
||||
export interface UserTagOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 已选值数组(多选)或单值(单选时仍用数组包装) */
|
||||
modelValue?: (string | number)[];
|
||||
/** 选项列表 */
|
||||
options: UserTagOption[];
|
||||
/** 是否多选,默认 true */
|
||||
multiple?: boolean;
|
||||
/** 最大选择数,超出截断 */
|
||||
maxCount?: number;
|
||||
placeholder?: string;
|
||||
allowClear?: boolean;
|
||||
showSearch?: boolean;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
maxCount: 99,
|
||||
placeholder: '请选择',
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [v: (string | number)[]];
|
||||
}>();
|
||||
|
||||
// 双向绑定:useVModel 自动处理 modelValue 的读写
|
||||
const innerValue = useVModel(props, 'modelValue', emit, {
|
||||
defaultValue: [] as (string | number)[],
|
||||
passive: true,
|
||||
});
|
||||
|
||||
/** 用于 Select 的 mode,单选时不传 mode */
|
||||
const selectMode = computed(() => (props.multiple ? 'multiple' : undefined));
|
||||
|
||||
/** 按 value 索引选项,方便 tagRender/option 插槽快速查头像与名字 */
|
||||
const optionMap = computed(() => {
|
||||
const map = new Map<string | number, UserTagOption>();
|
||||
for (const opt of props.options) {
|
||||
map.set(opt.value, opt);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** 取某 value 对应的展示名 */
|
||||
function labelOf(value: string | number) {
|
||||
return optionMap.value.get(value)?.label || String(value);
|
||||
}
|
||||
|
||||
/** 取某 value 对应的头像 */
|
||||
function avatarOf(value: string | number) {
|
||||
return optionMap.value.get(value)?.avatar;
|
||||
}
|
||||
|
||||
/** 取首字符(文本头像) */
|
||||
function firstChar(label?: string) {
|
||||
return (label || '?').trim().charAt(0) || '?';
|
||||
}
|
||||
|
||||
/** 选项过滤:按 label 包含输入关键字 */
|
||||
function filterOption(input: string, option: any) {
|
||||
const v = String(option?.label || '').toLowerCase();
|
||||
return v.includes((input || '').toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* change 处理:
|
||||
* - 单选:把单值包成数组
|
||||
* - 多选:超 maxCount 截断
|
||||
*/
|
||||
function onChange(v: any) {
|
||||
if (!props.multiple) {
|
||||
innerValue.value = v == null ? [] : [v];
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(v) && v.length > props.maxCount) {
|
||||
v = v.slice(0, props.maxCount);
|
||||
}
|
||||
innerValue.value = v;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select
|
||||
:value="innerValue as any"
|
||||
:mode="selectMode"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
:allow-clear="allowClear"
|
||||
:show-search="showSearch"
|
||||
:filter-option="filterOption"
|
||||
:disabled="disabled"
|
||||
:max-tag-count="10"
|
||||
option-filter-prop="label"
|
||||
style="width: 100%"
|
||||
@change="onChange"
|
||||
>
|
||||
<!-- 多选已选项展示头像+名字 -->
|
||||
<template v-if="multiple" #tagRender="{ value, closable, onClose }">
|
||||
<Tag :closable="closable" @close="onClose" class="user-tag-chip">
|
||||
<Avatar :size="16" :src="avatarOf(value)">
|
||||
{{ firstChar(labelOf(value)) }}
|
||||
</Avatar>
|
||||
<span class="user-tag-name">{{ labelOf(value) }}</span>
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 下拉选项展示头像+名字 -->
|
||||
<template #option="{ label, avatar }">
|
||||
<div class="user-tag-option">
|
||||
<Avatar :size="20" :src="avatar">{{ firstChar(label) }}</Avatar>
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.user-tag-name {
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-tag-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 超级仓库 Admin:选品字段,搜索尚未加入总仓(yii_drugstore_drug)的药品
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
WarehouseAvailableDrugSearch,
|
||||
type WarehouseDrugItem,
|
||||
} from '#/components/warehouse-drug-search';
|
||||
|
||||
const PRODUCT_TYPE_LABEL: Record<number, string> = {
|
||||
1: '中药',
|
||||
2: '西药',
|
||||
3: '保健食品',
|
||||
4: '中成药',
|
||||
5: '产品服务包',
|
||||
6: '非药品',
|
||||
7: '医疗器械',
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** vben-form:药品 ID */
|
||||
value?: number;
|
||||
/** 列表筛选类型(ProductTypeEnum) */
|
||||
productType?: number;
|
||||
}>(),
|
||||
{
|
||||
productType: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:value': [value: number | undefined];
|
||||
}>();
|
||||
|
||||
const selectedLabel = ref('');
|
||||
|
||||
const placeholder = computed(() => {
|
||||
const name = PRODUCT_TYPE_LABEL[props.productType] ?? '药品';
|
||||
return `输入${name}名称或拼音搜索(显示图片、供应商、规格)`;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(v) => {
|
||||
if (v == null || v === undefined) {
|
||||
selectedLabel.value = '';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function onSelect(drug: WarehouseDrugItem) {
|
||||
selectedLabel.value = drug.drug_name || '';
|
||||
emit('update:value', drug.id);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedLabel.value = '';
|
||||
emit('update:value', undefined);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-2">
|
||||
<WarehouseAvailableDrugSearch
|
||||
:product-type="productType"
|
||||
:placeholder="placeholder"
|
||||
@select="onSelect"
|
||||
/>
|
||||
<div
|
||||
v-if="value != null"
|
||||
class="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>已选:{{ selectedLabel || `ID ${value}` }}</span>
|
||||
<Button type="link" size="small" class="!p-0" @click="clearSelection">
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,4 +4,6 @@ export type CustomComponentType =
|
||||
| 'ApiRadioGroup'
|
||||
| 'ApiSelect'
|
||||
| 'ApiTreeSelect'
|
||||
| 'IconPicker';
|
||||
| 'IconPicker'
|
||||
| 'StoreMultiSearch'
|
||||
| 'WarehouseAdminDrugSearch';
|
||||
|
||||
714
apps/web-antd/src/components/loading/PrescriptionLoading.vue
Normal file
714
apps/web-antd/src/components/loading/PrescriptionLoading.vue
Normal file
@@ -0,0 +1,714 @@
|
||||
<template>
|
||||
<!--
|
||||
AI 加载动画:处方=药丸;病历=病历本
|
||||
type + text 由调用方传入;嵌入抽屉时默认不显示主题切换
|
||||
-->
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="loading-wrapper"
|
||||
:class="{ 'loading-wrapper--dark': isDark }"
|
||||
:style="{ width: '100%', height: '100%' }"
|
||||
>
|
||||
<canvas ref="canvasRef" class="loading-canvas"></canvas>
|
||||
<button
|
||||
v-if="showToggle"
|
||||
class="theme-toggle-btn"
|
||||
:title="isDark ? '切换到亮色模式' : '切换到暗色模式'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<span class="theme-icon">
|
||||
<svg
|
||||
v-if="!isDark"
|
||||
class="sun"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="moon"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</span>
|
||||
<!-- <span class="theme-label">{{ isDark ? '亮色' : '暗色' }}</span>-->
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI 生成加载动画
|
||||
* - type=prescription:中心画胶囊药丸
|
||||
* - type=medical_record:中心画病历本
|
||||
* - text:底部文案(可动态传)
|
||||
*/
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
export type AiLoadingType = 'prescription' | 'medical_record';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 动画类型:处方 / 病历 */
|
||||
type?: AiLoadingType;
|
||||
/** 底部文案 */
|
||||
text?: string;
|
||||
/** 是否显示主题切换(嵌入业务抽屉建议关闭) */
|
||||
showToggle?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: 'prescription',
|
||||
text: '',
|
||||
showToggle: false,
|
||||
},
|
||||
);
|
||||
|
||||
/** 未传文案时按 type 给默认值 */
|
||||
const displayText = computed(() => {
|
||||
const t = String(props.text || '').trim();
|
||||
if (t) return t;
|
||||
return props.type === 'medical_record'
|
||||
? 'AI正在生成病历'
|
||||
: 'AI正在生成处方';
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const isDark = ref(false);
|
||||
/** 画布循环里读取最新文案/类型,避免闭包过期 */
|
||||
const liveText = ref(displayText.value);
|
||||
const liveType = ref<AiLoadingType>(props.type);
|
||||
|
||||
watch(
|
||||
displayText,
|
||||
(v) => {
|
||||
liveText.value = v;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(
|
||||
() => props.type,
|
||||
(v) => {
|
||||
liveType.value = v || 'prescription';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function detectAppDark(): boolean {
|
||||
return (
|
||||
document.documentElement.classList.contains('dark') ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
document.documentElement.getAttribute('class')?.includes('dark') === true
|
||||
);
|
||||
}
|
||||
|
||||
function syncThemeFromApp() {
|
||||
if (localStorage.getItem('prescription-loading-theme')) {
|
||||
isDark.value = localStorage.getItem('prescription-loading-theme') === 'dark';
|
||||
return;
|
||||
}
|
||||
isDark.value = detectAppDark();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value;
|
||||
localStorage.setItem(
|
||||
'prescription-loading-theme',
|
||||
isDark.value ? 'dark' : 'light',
|
||||
);
|
||||
resetParticles?.();
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
size: number;
|
||||
color: { r: number; g: number; b: number };
|
||||
type: 'circle' | 'cross';
|
||||
}
|
||||
|
||||
interface ThemeColors {
|
||||
textColor: string;
|
||||
ringColor: string;
|
||||
glowInner: string;
|
||||
glowMiddle: string;
|
||||
glowOuter: string;
|
||||
particleBright: number;
|
||||
iconShadow: string;
|
||||
}
|
||||
|
||||
let animationId = 0;
|
||||
let resetParticles: (() => void) | null = null;
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let removeWindowResize: (() => void) | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
syncThemeFromApp();
|
||||
if (!containerRef.value || !canvasRef.value) return;
|
||||
|
||||
const canvas = canvasRef.value;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const container = containerRef.value;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let cssWidth = 0;
|
||||
let cssHeight = 0;
|
||||
let prevW = 0;
|
||||
let prevH = 0;
|
||||
let startTime: number | null = null;
|
||||
let lastTime: number | null = null;
|
||||
let particles: Particle[] = [];
|
||||
const MAX_PARTICLES = 28;
|
||||
|
||||
resetParticles = () => {
|
||||
particles = [];
|
||||
};
|
||||
|
||||
function updateSize() {
|
||||
const rect = container.getBoundingClientRect();
|
||||
cssWidth = rect.width || 300;
|
||||
cssHeight = rect.height || 280;
|
||||
const pw = Math.round(cssWidth * dpr);
|
||||
const ph = Math.round(cssHeight * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
if (
|
||||
prevW &&
|
||||
(Math.abs(cssWidth - prevW) > 2 || Math.abs(cssHeight - prevH) > 2)
|
||||
) {
|
||||
particles = [];
|
||||
}
|
||||
prevW = cssWidth;
|
||||
prevH = cssHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function createParticle(cx: number, cy: number, s: number): Particle {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = s * (0.15 + Math.random() * 0.45);
|
||||
const speed = s * (0.03 + Math.random() * 0.08);
|
||||
const colors = [
|
||||
{ r: 61, g: 214, b: 208 },
|
||||
{ r: 74, g: 195, b: 190 },
|
||||
{ r: 93, g: 173, b: 226 },
|
||||
{ r: 46, g: 180, b: 170 },
|
||||
{ r: 100, g: 200, b: 215 },
|
||||
];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)]!;
|
||||
return {
|
||||
x: cx + Math.cos(angle) * distance,
|
||||
y: cy + Math.sin(angle) * distance,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
maxLife: 1.8 + Math.random() * 2.5,
|
||||
size: 1.2 + Math.random() * 2.8,
|
||||
color,
|
||||
type: Math.random() < 0.25 ? 'cross' : 'circle',
|
||||
};
|
||||
}
|
||||
|
||||
function updateParticles(dt: number, cx: number, cy: number, s: number) {
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const p = particles[i]!;
|
||||
p.life -= dt / p.maxLife;
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.vx *= 0.997;
|
||||
p.vy *= 0.997;
|
||||
if (p.life <= 0) particles.splice(i, 1);
|
||||
}
|
||||
while (particles.length < MAX_PARTICLES) {
|
||||
const p = createParticle(cx, cy, s);
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
p.x = cx + Math.cos(ang) * s * 0.12 * Math.random();
|
||||
p.y = cy + Math.sin(ang) * s * 0.12 * Math.random();
|
||||
particles.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
function drawParticle(
|
||||
c: CanvasRenderingContext2D,
|
||||
p: Particle,
|
||||
bright: number,
|
||||
) {
|
||||
const alpha = p.life;
|
||||
const { r, g, b } = p.color;
|
||||
const size = p.size * (0.7 + 0.3 * p.life);
|
||||
const br = Math.min(255, Math.round(r * bright));
|
||||
const bg = Math.min(255, Math.round(g * bright));
|
||||
const bb = Math.min(255, Math.round(b * bright));
|
||||
c.save();
|
||||
c.globalAlpha = alpha;
|
||||
if (p.type === 'cross') {
|
||||
const len = size * 1.8;
|
||||
const w = size * 0.55;
|
||||
c.strokeStyle = `rgba(${br},${bg},${bb},${alpha})`;
|
||||
c.lineWidth = w;
|
||||
c.lineCap = 'round';
|
||||
c.beginPath();
|
||||
c.moveTo(p.x - len, p.y);
|
||||
c.lineTo(p.x + len, p.y);
|
||||
c.stroke();
|
||||
c.beginPath();
|
||||
c.moveTo(p.x, p.y - len);
|
||||
c.lineTo(p.x, p.y + len);
|
||||
c.stroke();
|
||||
c.fillStyle = `rgba(255,255,255,${alpha * 0.9})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size * 0.35, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
} else {
|
||||
c.shadowColor = `rgba(${br},${bg},${bb},${alpha * 0.7})`;
|
||||
c.shadowBlur = size * 2.5;
|
||||
c.fillStyle = `rgba(${br},${bg},${bb},${alpha})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
c.shadowBlur = 0;
|
||||
c.fillStyle = `rgba(255,255,255,${alpha * 0.6})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size * 0.4, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** 处方药丸(处方) */
|
||||
function drawPill(
|
||||
c: CanvasRenderingContext2D,
|
||||
px: number,
|
||||
py: number,
|
||||
pw: number,
|
||||
ph: number,
|
||||
floatOff: number,
|
||||
pulse: number,
|
||||
shadowCol: string,
|
||||
) {
|
||||
c.save();
|
||||
const cx = px + pw / 2;
|
||||
const cy = py + ph / 2;
|
||||
const hw = (pw / 2) * pulse;
|
||||
const hh = (ph / 2) * pulse;
|
||||
const adjY = cy + floatOff;
|
||||
const dx = cx - hw;
|
||||
const dy = adjY - hh;
|
||||
const dw = hw * 2;
|
||||
const dh = hh * 2;
|
||||
const rad = hh;
|
||||
const path = new Path2D();
|
||||
path.roundRect(dx, dy, dw, dh, rad);
|
||||
|
||||
c.shadowColor = shadowCol;
|
||||
c.shadowBlur = hh * 0.7;
|
||||
c.shadowOffsetY = hh * 0.25;
|
||||
c.fillStyle = '#ffffff';
|
||||
c.fill(path);
|
||||
c.shadowColor = 'transparent';
|
||||
c.shadowBlur = 0;
|
||||
c.shadowOffsetY = 0;
|
||||
|
||||
c.save();
|
||||
c.clip(path);
|
||||
const leftGrad = c.createLinearGradient(dx, 0, dx + dw * 0.5, 0);
|
||||
leftGrad.addColorStop(0, '#FDFBF7');
|
||||
leftGrad.addColorStop(0.7, '#F5F1EA');
|
||||
leftGrad.addColorStop(1, '#EDE8DF');
|
||||
c.fillStyle = leftGrad;
|
||||
c.fillRect(dx, dy, dw * 0.5, dh);
|
||||
|
||||
const rightGrad = c.createLinearGradient(dx + dw * 0.5, 0, dx + dw, 0);
|
||||
rightGrad.addColorStop(0, '#5DD9D1');
|
||||
rightGrad.addColorStop(0.5, '#3DD6D0');
|
||||
rightGrad.addColorStop(1, '#2CB8B0');
|
||||
c.fillStyle = rightGrad;
|
||||
c.fillRect(dx + dw * 0.5, dy, dw * 0.5, dh);
|
||||
|
||||
c.strokeStyle = 'rgba(180,180,180,0.5)';
|
||||
c.lineWidth = Math.max(0.6, hh * 0.06);
|
||||
c.beginPath();
|
||||
c.moveTo(dx + dw * 0.5, dy + 1);
|
||||
c.lineTo(dx + dw * 0.5, dy + dh - 1);
|
||||
c.stroke();
|
||||
|
||||
const hy = dy + dh * 0.15;
|
||||
const hh2 = dh * 0.38;
|
||||
const hp = new Path2D();
|
||||
hp.roundRect(dx + dw * 0.06, hy, dw * 0.88, hh2, hh2 / 2);
|
||||
c.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
c.fill(hp);
|
||||
c.restore();
|
||||
|
||||
c.strokeStyle = 'rgba(200,200,200,0.5)';
|
||||
c.lineWidth = Math.max(0.8, hh * 0.08);
|
||||
c.stroke(path);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** 病历本(病历) */
|
||||
function drawMedicalRecord(
|
||||
c: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
scale: number,
|
||||
floatOff: number,
|
||||
pulse: number,
|
||||
shadowCol: string,
|
||||
) {
|
||||
c.save();
|
||||
const w = scale * 0.28 * pulse;
|
||||
const h = scale * 0.34 * pulse;
|
||||
const x = cx - w / 2;
|
||||
const y = cy - h / 2 + floatOff;
|
||||
const r = Math.max(4, scale * 0.018);
|
||||
const path = new Path2D();
|
||||
path.roundRect(x, y, w, h, r);
|
||||
|
||||
c.shadowColor = shadowCol;
|
||||
c.shadowBlur = scale * 0.035;
|
||||
c.shadowOffsetY = scale * 0.012;
|
||||
c.fillStyle = '#ffffff';
|
||||
c.fill(path);
|
||||
c.shadowColor = 'transparent';
|
||||
c.shadowBlur = 0;
|
||||
c.shadowOffsetY = 0;
|
||||
|
||||
// 左侧装订条
|
||||
c.fillStyle = '#3DD6D0';
|
||||
c.fillRect(x, y + r * 0.2, w * 0.12, h - r * 0.4);
|
||||
|
||||
// 横线(像病历行)
|
||||
c.strokeStyle = 'rgba(61,214,208,0.35)';
|
||||
c.lineWidth = Math.max(1, scale * 0.004);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const ly = y + h * (0.32 + i * 0.14);
|
||||
c.beginPath();
|
||||
c.moveTo(x + w * 0.22, ly);
|
||||
c.lineTo(x + w * 0.88, ly);
|
||||
c.stroke();
|
||||
}
|
||||
// 顶部标题条
|
||||
c.fillStyle = 'rgba(61,214,208,0.2)';
|
||||
c.beginPath();
|
||||
c.roundRect(x + w * 0.22, y + h * 0.12, w * 0.5, h * 0.08, 2);
|
||||
c.fill();
|
||||
|
||||
c.strokeStyle = 'rgba(180,200,200,0.55)';
|
||||
c.lineWidth = Math.max(0.8, scale * 0.005);
|
||||
c.stroke(path);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function drawCross(
|
||||
c: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
alpha: number,
|
||||
rot: number,
|
||||
) {
|
||||
c.save();
|
||||
c.translate(x, y);
|
||||
c.rotate(rot);
|
||||
c.globalAlpha = alpha;
|
||||
const len = size;
|
||||
const w = size * 0.3;
|
||||
c.strokeStyle = '#3DD6D0';
|
||||
c.lineWidth = w;
|
||||
c.lineCap = 'round';
|
||||
c.beginPath();
|
||||
c.moveTo(-len, 0);
|
||||
c.lineTo(len, 0);
|
||||
c.stroke();
|
||||
c.beginPath();
|
||||
c.moveTo(0, -len);
|
||||
c.lineTo(0, len);
|
||||
c.stroke();
|
||||
c.fillStyle = '#ffffff';
|
||||
c.beginPath();
|
||||
c.arc(0, 0, size * 0.25, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function getTheme(): ThemeColors {
|
||||
const dark = isDark.value;
|
||||
return {
|
||||
textColor: dark ? '#d1d5db' : '#4a5568',
|
||||
ringColor: dark ? 'rgba(255,255,255,0.25)' : 'rgba(180,195,200,0.45)',
|
||||
glowInner: dark ? 'rgba(61,214,208,0.15)' : 'rgba(61,214,208,0.13)',
|
||||
glowMiddle: dark ? 'rgba(61,214,208,0.05)' : 'rgba(61,214,208,0.04)',
|
||||
glowOuter: 'rgba(61,214,208,0)',
|
||||
particleBright: dark ? 1.4 : 1.0,
|
||||
iconShadow: dark ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.12)',
|
||||
};
|
||||
}
|
||||
|
||||
function draw(timestamp: number) {
|
||||
updateSize();
|
||||
if (cssWidth <= 0 || cssHeight <= 0) {
|
||||
animationId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
if (startTime === null) {
|
||||
startTime = timestamp;
|
||||
lastTime = timestamp;
|
||||
}
|
||||
const elapsed = (timestamp - startTime) / 1000;
|
||||
let dt = (timestamp - (lastTime ?? timestamp)) / 1000;
|
||||
if (dt <= 0) dt = 0.016;
|
||||
if (dt > 0.2) dt = 0.2;
|
||||
lastTime = timestamp;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const cx = cssWidth / 2;
|
||||
const cy = cssHeight / 2 - cssHeight * 0.04;
|
||||
const scale = Math.min(cssWidth, cssHeight);
|
||||
const orbitR = scale * 0.26;
|
||||
const fontSize = Math.max(12, scale * 0.055);
|
||||
const textY = cy + orbitR + fontSize * 2.4;
|
||||
const text = liveText.value;
|
||||
const animType = liveType.value;
|
||||
|
||||
const dashOff = elapsed * scale * 0.025;
|
||||
const arcRot = elapsed * 3.5;
|
||||
const arcLen = (100 * Math.PI) / 180;
|
||||
const floatOff = Math.sin(elapsed * 2.5) * scale * 0.025;
|
||||
const pulseS = 1 + Math.sin(elapsed * 3.7) * 0.04;
|
||||
const textAlpha = 0.55 + 0.45 * Math.sin(elapsed * 1.6);
|
||||
const theme = getTheme();
|
||||
|
||||
const grad = ctx.createRadialGradient(
|
||||
cx,
|
||||
cy,
|
||||
orbitR * 0.15,
|
||||
cx,
|
||||
cy,
|
||||
orbitR * 1.6,
|
||||
);
|
||||
grad.addColorStop(0, theme.glowInner);
|
||||
grad.addColorStop(0.5, theme.glowMiddle);
|
||||
grad.addColorStop(1, theme.glowOuter);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash([scale * 0.018, scale * 0.04]);
|
||||
ctx.lineDashOffset = -dashOff;
|
||||
ctx.strokeStyle = theme.ringColor;
|
||||
ctx.lineWidth = Math.max(1, scale * 0.006);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, orbitR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
|
||||
const arcS = arcRot;
|
||||
const arcE = arcRot + arcLen;
|
||||
const fx = cx + orbitR * Math.cos(arcE);
|
||||
const fy = cy + orbitR * Math.sin(arcE);
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(61,214,208,0.8)';
|
||||
ctx.shadowBlur = scale * 0.04;
|
||||
ctx.strokeStyle = '#3DD6D0';
|
||||
ctx.lineWidth = Math.max(2, scale * 0.014);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, orbitR, arcS, arcE);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(255,255,255,0.9)';
|
||||
ctx.shadowBlur = scale * 0.03;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(fx, fy, Math.max(2.5, scale * 0.014), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// 中心图标:按 type 切换
|
||||
if (animType === 'medical_record') {
|
||||
drawMedicalRecord(ctx, cx, cy, scale, floatOff, pulseS, theme.iconShadow);
|
||||
} else {
|
||||
const pillW = scale * 0.34;
|
||||
const pillH = scale * 0.1;
|
||||
drawPill(
|
||||
ctx,
|
||||
cx - pillW / 2,
|
||||
cy - pillH / 2,
|
||||
pillW,
|
||||
pillH,
|
||||
floatOff,
|
||||
pulseS,
|
||||
theme.iconShadow,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ang = elapsed * 1.8 + (i * Math.PI * 2) / 3;
|
||||
const crossOrbitR = orbitR * 1.02;
|
||||
const rx = cx + crossOrbitR * Math.cos(ang);
|
||||
const ry = cy + crossOrbitR * Math.sin(ang);
|
||||
const rs = Math.max(4, scale * 0.022);
|
||||
const ra = 0.4 + 0.35 * Math.sin(elapsed * 3.2 + i * 1.7);
|
||||
drawCross(ctx, rx, ry, rs, ra, elapsed * 0.9 + i);
|
||||
}
|
||||
|
||||
updateParticles(dt, cx, cy, scale);
|
||||
for (const p of particles) {
|
||||
drawParticle(ctx, p, theme.particleBright);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = textAlpha;
|
||||
ctx.fillStyle = theme.textColor;
|
||||
ctx.font = `${fontSize}px "PingFang SC","Microsoft YaHei","Noto Sans SC","Helvetica Neue",sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.shadowColor = 'rgba(61,214,208,0.25)';
|
||||
ctx.shadowBlur = fontSize * 0.6;
|
||||
ctx.fillText(text, cx, textY);
|
||||
ctx.restore();
|
||||
|
||||
const charW = fontSize * 0.55;
|
||||
const dotsStartX = cx + (text.length / 2) * charW + fontSize * 0.5;
|
||||
const dotR = Math.max(2, fontSize * 0.16);
|
||||
const dotSpacing = fontSize * 0.7;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const phase = (elapsed * 2.8 + i * 1.0) % 3;
|
||||
let brightness = phase < 1 ? phase : phase < 2 ? 2 - phase : 0;
|
||||
brightness = brightness * brightness * (3 - 2 * brightness);
|
||||
const alpha = 0.2 + brightness * 0.75;
|
||||
const dx = dotsStartX + i * dotSpacing * 2.2;
|
||||
ctx.save();
|
||||
ctx.shadowColor = `rgba(61,214,208,${alpha * 0.8})`;
|
||||
ctx.shadowBlur = dotR * 3;
|
||||
ctx.fillStyle = `rgba(61,214,208,${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, textY, dotR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
animationId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
updateSize();
|
||||
animationId = requestAnimationFrame(draw);
|
||||
|
||||
if (window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => updateSize());
|
||||
resizeObserver.observe(container);
|
||||
} else {
|
||||
const onResize = () => updateSize();
|
||||
window.addEventListener('resize', onResize);
|
||||
removeWindowResize = () => window.removeEventListener('resize', onResize);
|
||||
}
|
||||
|
||||
// 跟随应用 dark class(未手动锁定主题时)
|
||||
themeObserver = new MutationObserver(() => {
|
||||
if (!localStorage.getItem('prescription-loading-theme')) {
|
||||
syncThemeFromApp();
|
||||
}
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(animationId);
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
removeWindowResize?.();
|
||||
removeWindowResize = null;
|
||||
themeObserver?.disconnect();
|
||||
themeObserver = null;
|
||||
resetParticles = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
//background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.loading-wrapper--dark {
|
||||
//background: #1e2227;
|
||||
}
|
||||
.loading-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.theme-toggle-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(128, 128, 128, 0.3);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.25s;
|
||||
}
|
||||
.loading-wrapper--dark .theme-toggle-btn {
|
||||
background: rgba(30, 34, 39, 0.9);
|
||||
color: #d1d5db;
|
||||
}
|
||||
.theme-toggle-btn:hover {
|
||||
border-color: #3dd6d0;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
.theme-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -105,7 +105,9 @@ const downloadQRCode = async (name: string, isQr = false) => {
|
||||
{{ doctorInfo.doctor.name }} {{ doctorInfo.doctor.title.name }}
|
||||
</div>
|
||||
<div class="qrAddress">
|
||||
<span class="addressTitle">诊所地址:</span>{{ previewAddress }}
|
||||
<span class="addressTitle">{{
|
||||
Number(doctorInfo?.store?.type) === 1 ? '药店地址:' : '诊所地址:'
|
||||
}}</span>{{ previewAddress }}
|
||||
</div>
|
||||
<Image :preview="false" :src="doctorInfo.qr_code" class="mt-3" height="30" width="30" />
|
||||
<h3 class="border-l-4 border-blue-500 pl-3 text-lg font-semibold">
|
||||
|
||||
354
apps/web-antd/src/components/modal/ImageCompressModal.vue
Normal file
354
apps/web-antd/src/components/modal/ImageCompressModal.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { Button, Image, Modal, Spin } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
compressImageFile,
|
||||
formatFileSize,
|
||||
formatSavingsPercent,
|
||||
} from '#/utils/image-compress';
|
||||
|
||||
export type ImageCompressChoice = 'compressed' | 'original' | 'cancel';
|
||||
|
||||
export interface ImageCompressModalResult {
|
||||
choice: ImageCompressChoice;
|
||||
file: File | null;
|
||||
meta?: {
|
||||
originalSize: number;
|
||||
compressedSize?: number;
|
||||
usedCompress: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
file: File;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
resolve: [result: ImageCompressModalResult];
|
||||
}>();
|
||||
|
||||
type ModalStatus = 'compressing' | 'ready' | 'gifSkipped' | 'error';
|
||||
|
||||
const visible = ref(true);
|
||||
const status = ref<ModalStatus>('compressing');
|
||||
const progressText = ref('正在压缩,请稍候…');
|
||||
const errorMessage = ref('');
|
||||
const originalPreviewUrl = ref('');
|
||||
const compressedPreviewUrl = ref('');
|
||||
const compressedFile = ref<File | null>(null);
|
||||
const originalSize = ref(0);
|
||||
const compressedSize = ref(0);
|
||||
|
||||
const sizeText = computed(() => {
|
||||
if (!originalSize.value) return '';
|
||||
const originalText = formatFileSize(originalSize.value);
|
||||
if (status.value === 'gifSkipped') {
|
||||
return `原图 ${originalText}`;
|
||||
}
|
||||
if (compressedSize.value && status.value === 'ready') {
|
||||
const savings = formatSavingsPercent(
|
||||
originalSize.value,
|
||||
compressedSize.value,
|
||||
);
|
||||
return `原图 ${originalText} → 压缩后 ${formatFileSize(compressedSize.value)}${savings}`;
|
||||
}
|
||||
if (compressedSize.value) {
|
||||
return `原图 ${originalText} → 压缩后 ${formatFileSize(compressedSize.value)}`;
|
||||
}
|
||||
return `原图 ${originalText}`;
|
||||
});
|
||||
|
||||
const originalButtonLabel = computed(() => {
|
||||
if (!originalSize.value) return '使用原图';
|
||||
return `使用原图 (${formatFileSize(originalSize.value)})`;
|
||||
});
|
||||
|
||||
const compressedButtonLabel = computed(() => {
|
||||
if (!compressedSize.value) return '使用压缩图';
|
||||
return `使用压缩图 (${formatFileSize(compressedSize.value)})`;
|
||||
});
|
||||
|
||||
function revokePreviewUrls() {
|
||||
if (originalPreviewUrl.value) {
|
||||
URL.revokeObjectURL(originalPreviewUrl.value);
|
||||
originalPreviewUrl.value = '';
|
||||
}
|
||||
if (compressedPreviewUrl.value) {
|
||||
URL.revokeObjectURL(compressedPreviewUrl.value);
|
||||
compressedPreviewUrl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function finish(choice: ImageCompressChoice) {
|
||||
const useCompressed = choice === 'compressed' && status.value === 'ready';
|
||||
const selectedFile =
|
||||
choice === 'cancel'
|
||||
? null
|
||||
: useCompressed && compressedFile.value
|
||||
? compressedFile.value
|
||||
: props.file;
|
||||
|
||||
emit('resolve', {
|
||||
choice,
|
||||
file: selectedFile,
|
||||
meta:
|
||||
choice === 'cancel'
|
||||
? undefined
|
||||
: {
|
||||
originalSize: originalSize.value,
|
||||
compressedSize: compressedSize.value || undefined,
|
||||
usedCompress: useCompressed,
|
||||
},
|
||||
});
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function startCompress() {
|
||||
originalSize.value = props.file.size;
|
||||
revokePreviewUrls();
|
||||
originalPreviewUrl.value = URL.createObjectURL(props.file);
|
||||
|
||||
if (props.file.type === 'image/gif') {
|
||||
status.value = 'gifSkipped';
|
||||
progressText.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
status.value = 'compressing';
|
||||
progressText.value = '正在压缩,请稍候…';
|
||||
|
||||
try {
|
||||
const result = await compressImageFile(props.file, {
|
||||
onProgress: (message) => {
|
||||
progressText.value = message;
|
||||
},
|
||||
});
|
||||
|
||||
compressedFile.value = result.file;
|
||||
compressedSize.value = result.file.size;
|
||||
|
||||
if (result.skipped) {
|
||||
status.value = 'gifSkipped';
|
||||
return;
|
||||
}
|
||||
|
||||
compressedPreviewUrl.value = URL.createObjectURL(result.file);
|
||||
status.value = 'ready';
|
||||
progressText.value = '';
|
||||
} catch (error) {
|
||||
status.value = 'error';
|
||||
errorMessage.value =
|
||||
error instanceof Error ? error.message : '图片压缩失败,请使用原图或取消';
|
||||
progressText.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startCompress();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokePreviewUrls();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-model:visible="visible"
|
||||
title="图片较大"
|
||||
:width="640"
|
||||
:mask-closable="false"
|
||||
:closable="false"
|
||||
:footer="null"
|
||||
@cancel="finish('cancel')"
|
||||
>
|
||||
<div class="image-compress-modal">
|
||||
<p v-if="sizeText" class="size-text">{{ sizeText }}</p>
|
||||
|
||||
<div v-if="status === 'compressing'" class="compressing-panel">
|
||||
<div class="preview-single">
|
||||
<Image
|
||||
v-if="originalPreviewUrl"
|
||||
:src="originalPreviewUrl"
|
||||
alt="原图预览"
|
||||
class="preview-image"
|
||||
/>
|
||||
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
|
||||
</div>
|
||||
<div class="compressing-status">
|
||||
<Spin />
|
||||
<span class="progress-text">{{ progressText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'ready'" class="compare-panel">
|
||||
<div class="preview-column">
|
||||
<div class="preview-label">原图</div>
|
||||
<Image
|
||||
:src="originalPreviewUrl"
|
||||
alt="原图预览"
|
||||
class="preview-image"
|
||||
/>
|
||||
<div class="preview-size">{{ formatFileSize(originalSize) }}</div>
|
||||
<span class="preview-hint">点击预览</span>
|
||||
</div>
|
||||
<div class="preview-column">
|
||||
<div class="preview-label">压缩后</div>
|
||||
<Image
|
||||
:src="compressedPreviewUrl"
|
||||
alt="压缩图预览"
|
||||
class="preview-image"
|
||||
/>
|
||||
<div class="preview-size">{{ formatFileSize(compressedSize) }}</div>
|
||||
<span class="preview-hint">点击预览</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'gifSkipped'" class="gif-panel">
|
||||
<div class="preview-single">
|
||||
<Image
|
||||
v-if="originalPreviewUrl"
|
||||
:src="originalPreviewUrl"
|
||||
alt="原图预览"
|
||||
class="preview-image"
|
||||
/>
|
||||
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
|
||||
</div>
|
||||
<p class="hint-text">GIF 不支持压缩,将上传原图。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="error-panel">
|
||||
<div class="preview-single">
|
||||
<Image
|
||||
v-if="originalPreviewUrl"
|
||||
:src="originalPreviewUrl"
|
||||
alt="原图预览"
|
||||
class="preview-image"
|
||||
/>
|
||||
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
|
||||
</div>
|
||||
<p class="error-text">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<div class="footer-actions">
|
||||
<Button @click="finish('cancel')">取消</Button>
|
||||
<Button
|
||||
v-if="status === 'ready' || status === 'error'"
|
||||
@click="finish('original')"
|
||||
>
|
||||
{{ originalButtonLabel }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="status === 'ready'"
|
||||
type="primary"
|
||||
@click="finish('compressed')"
|
||||
>
|
||||
{{ compressedButtonLabel }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="status === 'gifSkipped'"
|
||||
type="primary"
|
||||
@click="finish('original')"
|
||||
>
|
||||
{{ originalButtonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.image-compress-modal {
|
||||
.size-text {
|
||||
margin-bottom: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-single,
|
||||
.preview-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 220px;
|
||||
cursor: pointer;
|
||||
|
||||
:deep(.ant-image-img) {
|
||||
max-width: 100%;
|
||||
max-height: 220px;
|
||||
object-fit: contain;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preview-size {
|
||||
margin-top: 8px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
margin-top: 4px;
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.compressing-panel,
|
||||
.gif-panel,
|
||||
.error-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.compressing-status {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.compare-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hint-text,
|
||||
.error-text {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
70
apps/web-antd/src/components/modal/InfoModal.vue
Normal file
70
apps/web-antd/src/components/modal/InfoModal.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { InfoCircleOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const content = ref('');
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: false,
|
||||
confirmText: '我知道了',
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm() {
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<{
|
||||
content: string;
|
||||
}>();
|
||||
console.log('data', data);
|
||||
content.value = data.content || '';
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[500px]">
|
||||
<div class="info-modal-content">
|
||||
<div class="info-icon">
|
||||
<InfoCircleOutlined />
|
||||
</div>
|
||||
<div class="">{{ content }}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.info-modal-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
|
||||
.info-icon {
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
flex: 1;
|
||||
line-height: 1.6;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.dark) {
|
||||
.info-text {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
/**
|
||||
* 诊所/药店二维码预览弹窗
|
||||
* 按门店 type(0 诊所 / 1 药店)切换「诊所|药店」文案,列表页与门店卡片共用
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
@@ -11,6 +15,13 @@ const htmlToImage = ref('');
|
||||
const url = ref('');
|
||||
const previewTitle = ref('二维码');
|
||||
const previewAddress = ref('');
|
||||
/** 门店类型:0=诊所 1=药店,未传时按诊所示 */
|
||||
const storeType = ref(0);
|
||||
|
||||
const isPharmacy = computed(() => Number(storeType.value) === 1);
|
||||
const kindLabel = computed(() => (isPharmacy.value ? '药店' : '诊所'));
|
||||
const addressLabel = computed(() => `${kindLabel.value}地址:`);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
footer: false,
|
||||
@@ -28,21 +39,28 @@ const [Modal, modalApi] = useVbenModal({
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const { values, title, address } =
|
||||
modalApi.getData<Record<string, any>>();
|
||||
const { values, title, address, type, store_type } =
|
||||
modalApi.getData<Record<string, any>>() || {};
|
||||
if (values) {
|
||||
url.value = values;
|
||||
qrCodeUrl.value = values;
|
||||
previewTitle.value = title;
|
||||
previewAddress.value = address;
|
||||
// 兼容 type / store_type 两种传参
|
||||
storeType.value = Number(type ?? store_type ?? 0);
|
||||
modalApi.setState({
|
||||
confirmText: `保存${kindLabel.value}卡片图片`,
|
||||
cancelText: `保存${kindLabel.value}二维码图片`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 下载二维码
|
||||
* @param name
|
||||
* @param isQr
|
||||
* 下载二维码卡片或纯二维码图片
|
||||
* @param name 文件名后缀
|
||||
* @param isQr true=只截二维码区域,false=截整张卡片
|
||||
*/
|
||||
const downloadQRCode = async (name: string, isQr = false) => {
|
||||
const element = isQr ? qrCodeUrl.value : htmlToImage.value;
|
||||
@@ -51,25 +69,24 @@ const downloadQRCode = async (name: string, isQr = false) => {
|
||||
allowTaint: true,
|
||||
logging: false,
|
||||
}).then((canvas) => {
|
||||
// 创建a标签下载
|
||||
const link = document.createElement('a'); // 创建a标签
|
||||
link.href = canvas.toDataURL(); // 是canvas对象的一种方法,用于将canvas对象转换为base64位编码
|
||||
link.setAttribute('download', `${previewTitle.value}${name}.png`); // 利用了a标签的download 来下载 canvas图片
|
||||
link.style.display = 'none'; // 将图片隐藏起来
|
||||
document.body.append(link); // 插入到其中
|
||||
const link = document.createElement('a');
|
||||
link.href = canvas.toDataURL();
|
||||
link.setAttribute('download', `${previewTitle.value}${name}.png`);
|
||||
link.style.display = 'none';
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`${previewTitle}二维码`" class="w-[50%]">
|
||||
<Modal :title="`${previewTitle}二维码`">
|
||||
<div class="qrBox" style="text-align: center">
|
||||
<div ref="htmlToImage" class="qrModal">
|
||||
<div id="qrcode" ref="qrCodeUrl" class="qrcode">
|
||||
<div class="qrTitle">{{ previewTitle }}</div>
|
||||
<Image :preview="false" :src="url" height="30" width="30" />
|
||||
<div class="qrAddress">
|
||||
<span class="addressTitle">诊所地址:</span>{{ previewAddress }}
|
||||
<span class="addressTitle">{{ addressLabel }}</span>{{ previewAddress }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import type { BlockSchema } from './types';
|
||||
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
|
||||
/**
|
||||
* 左侧组件库:渲染所有可拖拽块类型
|
||||
* 用户从这里拖动到中间画布(SortableJS 用 pull:clone 模式,左侧库不会减少)
|
||||
*/
|
||||
defineProps<{
|
||||
/** 所有可拖拽块定义 */
|
||||
blocks: BlockSchema[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="block-library">
|
||||
<div class="panel-header">
|
||||
<span class="title">组件库</span>
|
||||
<span class="hint">拖入画布</span>
|
||||
</div>
|
||||
<div class="block-list">
|
||||
<div
|
||||
v-for="block in blocks"
|
||||
:key="block.type"
|
||||
class="block-item"
|
||||
:data-block-type="block.type"
|
||||
>
|
||||
<MIcon v-if="block.icon" :icon="block.icon" size="16" />
|
||||
<span class="block-label">{{ block.label }}</span>
|
||||
</div>
|
||||
<div v-if="blocks.length === 0" class="empty-hint">暂无可拖入组件</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.block-library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
background: var(--ant-color-fill-quaternary, #fafbfc);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.block-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.block-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px dashed var(--ant-color-border, #c9cdd4);
|
||||
border-radius: 4px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
font-size: 13px;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ant-color-primary, #165dff);
|
||||
color: var(--ant-color-primary, #165dff);
|
||||
background: var(--ant-color-primary-bg, #f2f7ff);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
color: var(--ant-color-text-quaternary, #c9cdd4);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
300
apps/web-antd/src/components/oa-template-card-editor/Canvas.vue
Normal file
300
apps/web-antd/src/components/oa-template-card-editor/Canvas.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import type { BlockInstance, BlockSchema } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import MIcon from '#/components/icon/icon.vue';
|
||||
|
||||
/**
|
||||
* 中间画布:按 targetArrayPath 分组渲染块实例
|
||||
* 每个分组是一个 SortableJS 接收区,可拖入、排序、删除
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 所有块定义(按 type 查找对应 schema) */
|
||||
blockSchemas: BlockSchema[];
|
||||
/** 所有块实例(已拖入画布的) */
|
||||
instances: BlockInstance[];
|
||||
/** 当前选中的块实例 ID(高亮显示) */
|
||||
selectedInstanceId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 选中块实例(点击时触发) */
|
||||
select: [instanceId: string];
|
||||
/** 删除块实例 */
|
||||
remove: [instanceId: string];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 按 targetArrayPath 分组的渲染结构
|
||||
* 每个分组对应一个 SortableJS 接收区
|
||||
*/
|
||||
const groupedRender = computed(() => {
|
||||
// 收集所有出现的 targetArrayPath,按 blockSchemas 顺序排序
|
||||
const orderedPaths: string[] = [];
|
||||
const labelMap: Record<string, { label: string; maxCount?: number }> = {};
|
||||
for (const schema of props.blockSchemas) {
|
||||
if (!orderedPaths.includes(schema.targetArrayPath)) {
|
||||
orderedPaths.push(schema.targetArrayPath);
|
||||
labelMap[schema.targetArrayPath] = {
|
||||
label: schema.targetArrayPath,
|
||||
maxCount: schema.maxCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 把 instances 按目标路径分组
|
||||
const grouped: Record<string, BlockInstance[]> = {};
|
||||
for (const inst of props.instances) {
|
||||
const schema = props.blockSchemas.find((s) => s.type === inst.type);
|
||||
if (!schema) continue;
|
||||
const path = schema.targetArrayPath;
|
||||
if (!grouped[path]) grouped[path] = [];
|
||||
grouped[path].push(inst);
|
||||
}
|
||||
|
||||
return orderedPaths.map((path) => ({
|
||||
path,
|
||||
label: labelMap[path]?.label ?? path,
|
||||
maxCount: labelMap[path]?.maxCount,
|
||||
items: grouped[path] ?? [],
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取块实例的 schema 定义
|
||||
*/
|
||||
function getSchema(type: string): BlockSchema | undefined {
|
||||
return props.blockSchemas.find((s) => s.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取块实例的简要描述(属性面板编辑的字段值预览)
|
||||
*/
|
||||
function getInstanceSummary(inst: BlockInstance): string {
|
||||
const schema = getSchema(inst.type);
|
||||
if (!schema) return '';
|
||||
const firstField = schema.fields[0];
|
||||
if (!firstField) return '';
|
||||
// 嵌套路径取最末级
|
||||
const path = firstField.name.split('.');
|
||||
let value: any = inst.data;
|
||||
for (const key of path) {
|
||||
value = value?.[key];
|
||||
}
|
||||
return value ? String(value) : '(未填写)';
|
||||
}
|
||||
|
||||
function handleClick(instanceId: string) {
|
||||
emit('select', instanceId);
|
||||
}
|
||||
|
||||
function handleRemove(instanceId: string) {
|
||||
emit('remove', instanceId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas">
|
||||
<div class="panel-header">
|
||||
<span class="title">画布</span>
|
||||
<span class="hint">拖入 / 排序 / 点击编辑属性</span>
|
||||
</div>
|
||||
<div class="canvas-body">
|
||||
<div
|
||||
v-for="group in groupedRender"
|
||||
:key="group.path"
|
||||
class="canvas-group"
|
||||
>
|
||||
<div class="group-header">
|
||||
<span class="group-label">{{ group.label }}</span>
|
||||
<span class="group-count">
|
||||
{{ group.items.length }}{{ group.maxCount ? `/${group.maxCount}` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="group-dropzone"
|
||||
:data-target-array-path="group.path"
|
||||
>
|
||||
<div
|
||||
v-for="inst in group.items"
|
||||
:key="inst.id"
|
||||
class="instance-item"
|
||||
:class="{ selected: inst.id === selectedInstanceId }"
|
||||
:data-instance-id="inst.id"
|
||||
@click.stop="handleClick(inst.id)"
|
||||
>
|
||||
<div class="instance-info">
|
||||
<MIcon
|
||||
v-if="getSchema(inst.type)?.icon"
|
||||
:icon="getSchema(inst.type)!.icon"
|
||||
size="14"
|
||||
/>
|
||||
<div class="instance-text">
|
||||
<div class="instance-label">{{ getSchema(inst.type)?.label }}</div>
|
||||
<div class="instance-summary">{{ getInstanceSummary(inst) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="instance-remove"
|
||||
title="删除"
|
||||
@click.stop="handleRemove(inst.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="group.items.length === 0" class="empty-dropzone">
|
||||
拖入组件
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.canvas-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
.group-label {
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
font-weight: 500;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
}
|
||||
}
|
||||
|
||||
.group-dropzone {
|
||||
min-height: 60px;
|
||||
padding: 8px;
|
||||
border: 2px dashed var(--ant-color-border, #d9d9d9);
|
||||
border-radius: 6px;
|
||||
background: var(--ant-color-fill-quaternary, #fafbfc);
|
||||
}
|
||||
|
||||
.empty-dropzone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
color: var(--ant-color-text-quaternary, #c9cdd4);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.instance-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
border-radius: 4px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ant-color-primary, #165dff);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--ant-color-primary, #165dff);
|
||||
background: var(--ant-color-primary-bg, #f2f7ff);
|
||||
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.instance-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.instance-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.instance-label {
|
||||
font-size: 13px;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.instance-summary {
|
||||
font-size: 11px;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.instance-remove {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--ant-color-error, #f53f3f);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
/**
|
||||
* JSON 实时预览面板
|
||||
* - 显示最终生成的 JSON 字符串(语法高亮,简单的 key/string 高亮)
|
||||
* - 提供「复制 JSON」按钮(一键拷贝到剪贴板)
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 最终生成的 JSON 数据 */
|
||||
value: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const formattedJson = computed(() => {
|
||||
try {
|
||||
return JSON.stringify(props.value, null, 2);
|
||||
} catch {
|
||||
return '// JSON 序列化失败';
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 简单的 JSON 语法高亮
|
||||
* 匹配 key / string / number / boolean 并套用不同颜色 span
|
||||
*/
|
||||
const highlightedHtml = computed(() => {
|
||||
const escape = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const json = escape(formattedJson.value);
|
||||
return json
|
||||
.replace(
|
||||
/("(?:\\.|[^"\\])*")(\s*:)/g,
|
||||
'<span class="json-key">$1</span>$2',
|
||||
)
|
||||
.replace(
|
||||
/:\s*("(?:\\.|[^"\\])*")/g,
|
||||
': <span class="json-string">$1</span>',
|
||||
)
|
||||
.replace(/:\s*(-?\d+(?:\.\d+)?)/g, ': <span class="json-number">$1</span>')
|
||||
.replace(/:\s*(true|false|null)/g, ': <span class="json-bool">$1</span>');
|
||||
});
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(formattedJson.value);
|
||||
message.success('JSON 已复制');
|
||||
} catch {
|
||||
message.error('复制失败,请手动选择');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="json-preview">
|
||||
<div class="panel-header">
|
||||
<span class="title">JSON 预览</span>
|
||||
<Button size="small" type="link" @click="handleCopy">复制 JSON</Button>
|
||||
</div>
|
||||
<pre class="json-body" v-html="highlightedHtml" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.json-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
/* JSON 代码面板保留 VS Code Dark+ 风格暗底(无论浅色/暗色主题) */
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #374151;
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.json-body {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #d4d4d4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* JSON 语法高亮颜色 */
|
||||
:deep(.json-key) {
|
||||
color: #9cdcfe;
|
||||
}
|
||||
|
||||
:deep(.json-string) {
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
:deep(.json-number) {
|
||||
color: #b5cea8;
|
||||
}
|
||||
|
||||
:deep(.json-bool) {
|
||||
color: #569cd6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,348 @@
|
||||
<script setup lang="ts">
|
||||
import type { BlockInstance, BlockSchema, FixedField, FormFieldSchema } from './types';
|
||||
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
import { Empty, Input, InputNumber, Radio, RadioGroup, Select, Textarea } from 'ant-design-vue';
|
||||
|
||||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
||||
|
||||
/**
|
||||
* 右侧属性面板:根据选中块的字段 schema 渲染表单
|
||||
* - 顶层固定字段(fixedFields):渲染在顶部,不可删除
|
||||
* - 选中块的 fields:渲染在下方
|
||||
* - 图片类型字段用 GalleryPickLink(透传 acceptTypes)
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 顶层固定字段定义 */
|
||||
fixedFields?: FixedField[];
|
||||
/** 顶层固定字段当前值 */
|
||||
fixedValues?: Record<string, any>;
|
||||
/** 当前选中的块定义(可能为空) */
|
||||
selectedBlockSchema?: BlockSchema;
|
||||
/** 当前选中的块实例(可能为空) */
|
||||
selectedInstance?: BlockInstance;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 顶层固定字段值变化 */
|
||||
'update:fixedValues': [value: Record<string, any>];
|
||||
/** 选中块实例数据变化 */
|
||||
'update:instance': [instance: BlockInstance];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 设置顶层固定字段的值
|
||||
* 支持 a.b.c 嵌套路径
|
||||
*/
|
||||
function setFixedValue(path: string, value: any) {
|
||||
const next = { ...(props.fixedValues || {}) };
|
||||
setByPath(next, path, value);
|
||||
emit('update:fixedValues', next);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置选中块实例的字段值
|
||||
*/
|
||||
function setInstanceValue(path: string, value: any) {
|
||||
if (!props.selectedInstance) return;
|
||||
const nextData = { ...props.selectedInstance.data };
|
||||
setByPath(nextData, path, value);
|
||||
emit('update:instance', {
|
||||
...props.selectedInstance,
|
||||
data: nextData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 a.b.c 路径设置对象嵌套属性(不可变更新)
|
||||
*/
|
||||
function setByPath(obj: Record<string, any>, path: string, value: any) {
|
||||
const keys = path.split('.');
|
||||
let cursor: any = obj;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const k = keys[i]!;
|
||||
if (!cursor[k] || typeof cursor[k] !== 'object') {
|
||||
cursor[k] = {};
|
||||
} else {
|
||||
cursor[k] = { ...cursor[k] };
|
||||
}
|
||||
cursor = cursor[k];
|
||||
}
|
||||
cursor[keys[keys.length - 1]!] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 a.b.c 路径读取对象嵌套属性
|
||||
*/
|
||||
function getByPath(obj: Record<string, any> | undefined, path: string): any {
|
||||
if (!obj) return undefined;
|
||||
const keys = path.split('.');
|
||||
let cursor: any = obj;
|
||||
for (const k of keys) {
|
||||
if (cursor == null) return undefined;
|
||||
cursor = cursor[k];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
const hasAnyField = computed(() => {
|
||||
return (
|
||||
(props.fixedFields && props.fixedFields.length > 0) ||
|
||||
(props.selectedBlockSchema && props.selectedBlockSchema.fields.length > 0)
|
||||
);
|
||||
});
|
||||
|
||||
// 当选中的块变化时,触发 watch 让父级感知(用于属性面板切换的动画/数据初始化)
|
||||
watch(
|
||||
() => props.selectedInstance?.id,
|
||||
() => {
|
||||
// 仅做依赖追踪,实际更新在用户交互时通过 emit 触发
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="property-panel">
|
||||
<div class="panel-header">
|
||||
<span class="title">属性</span>
|
||||
<span v-if="selectedBlockSchema" class="hint">{{ selectedBlockSchema.label }}</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<!-- 顶层固定字段 -->
|
||||
<div v-if="fixedFields && fixedFields.length > 0" class="field-section">
|
||||
<div class="section-title">基础信息</div>
|
||||
<div
|
||||
v-for="field in fixedFields"
|
||||
:key="field.name"
|
||||
class="form-row"
|
||||
>
|
||||
<label class="form-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<!-- 文本 -->
|
||||
<Input
|
||||
v-if="!field.type || field.type === 'string' || field.type === 'url'"
|
||||
:value="getByPath(fixedValues, field.name)"
|
||||
:placeholder="field.placeholder"
|
||||
allow-clear
|
||||
size="small"
|
||||
@update:value="setFixedValue(field.name, $event)"
|
||||
/>
|
||||
<!-- 数字 -->
|
||||
<InputNumber
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="getByPath(fixedValues, field.name)"
|
||||
:placeholder="field.placeholder"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
@update:value="setFixedValue(field.name, $event)"
|
||||
/>
|
||||
<!-- 多行文本 -->
|
||||
<Textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:value="getByPath(fixedValues, field.name)"
|
||||
:placeholder="field.placeholder"
|
||||
:rows="3"
|
||||
size="small"
|
||||
@update:value="setFixedValue(field.name, $event)"
|
||||
/>
|
||||
<!-- 下拉选择 -->
|
||||
<Select
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="getByPath(fixedValues, field.name)"
|
||||
:options="field.options"
|
||||
:placeholder="field.placeholder"
|
||||
size="small"
|
||||
@update:value="setFixedValue(field.name, $event)"
|
||||
/>
|
||||
<!-- 单选 -->
|
||||
<RadioGroup
|
||||
v-else-if="field.type === 'radio'"
|
||||
:value="getByPath(fixedValues, field.name)"
|
||||
size="small"
|
||||
@update:value="setFixedValue(field.name, $event)"
|
||||
>
|
||||
<Radio
|
||||
v-for="opt in field.options"
|
||||
:key="String(opt.value)"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
<!-- 图片素材 -->
|
||||
<div v-else-if="field.type === 'image'" class="image-picker-row">
|
||||
<Input
|
||||
:value="getByPath(fixedValues, field.name)"
|
||||
:placeholder="field.placeholder || '从素材库选择图片'"
|
||||
size="small"
|
||||
read-only
|
||||
/>
|
||||
<GalleryPickLink
|
||||
:accept-types="field.acceptTypes || [0]"
|
||||
@select="(urls) => setFixedValue(field.name, urls[0] || '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选中块的属性字段 -->
|
||||
<div v-if="selectedBlockSchema && selectedInstance" class="field-section">
|
||||
<div class="section-title">{{ selectedBlockSchema.label }}属性</div>
|
||||
<div
|
||||
v-for="field in selectedBlockSchema.fields"
|
||||
:key="field.name"
|
||||
class="form-row"
|
||||
>
|
||||
<label class="form-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<Input
|
||||
v-if="!field.type || field.type === 'string' || field.type === 'url'"
|
||||
:value="getByPath(selectedInstance.data, field.name)"
|
||||
:placeholder="field.placeholder"
|
||||
allow-clear
|
||||
size="small"
|
||||
@update:value="setInstanceValue(field.name, $event)"
|
||||
/>
|
||||
<InputNumber
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="getByPath(selectedInstance.data, field.name)"
|
||||
:placeholder="field.placeholder"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
@update:value="setInstanceValue(field.name, $event)"
|
||||
/>
|
||||
<Textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:value="getByPath(selectedInstance.data, field.name)"
|
||||
:placeholder="field.placeholder"
|
||||
:rows="3"
|
||||
size="small"
|
||||
@update:value="setInstanceValue(field.name, $event)"
|
||||
/>
|
||||
<Select
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="getByPath(selectedInstance.data, field.name)"
|
||||
:options="field.options"
|
||||
:placeholder="field.placeholder"
|
||||
size="small"
|
||||
@update:value="setInstanceValue(field.name, $event)"
|
||||
/>
|
||||
<RadioGroup
|
||||
v-else-if="field.type === 'radio'"
|
||||
:value="getByPath(selectedInstance.data, field.name)"
|
||||
size="small"
|
||||
@update:value="setInstanceValue(field.name, $event)"
|
||||
>
|
||||
<Radio
|
||||
v-for="opt in field.options"
|
||||
:key="String(opt.value)"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
<div v-else-if="field.type === 'image'" class="image-picker-row">
|
||||
<Input
|
||||
:value="getByPath(selectedInstance.data, field.name)"
|
||||
:placeholder="field.placeholder || '从素材库选择图片'"
|
||||
size="small"
|
||||
read-only
|
||||
/>
|
||||
<GalleryPickLink
|
||||
:accept-types="field.acceptTypes || [0]"
|
||||
@select="(urls) => setInstanceValue(field.name, urls[0] || '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未选中任何块时的提示 -->
|
||||
<Empty
|
||||
v-if="!hasAnyField"
|
||||
description="点击画布中的块以编辑属性"
|
||||
:image-style="{ height: '60px' }"
|
||||
style="margin-top: 60px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.property-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 11px;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.field-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--ant-color-split, #f2f3f5);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-secondary, #4e5969);
|
||||
|
||||
.required {
|
||||
margin-left: 2px;
|
||||
color: var(--ant-color-error, #f53f3f);
|
||||
}
|
||||
}
|
||||
|
||||
.image-picker-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
522
apps/web-antd/src/components/oa-template-card-editor/index.vue
Normal file
522
apps/web-antd/src/components/oa-template-card-editor/index.vue
Normal file
@@ -0,0 +1,522 @@
|
||||
<script setup lang="ts">
|
||||
import type { BlockInstance, BlockSchema, FixedField } from './types';
|
||||
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import type Sortable from 'sortablejs';
|
||||
import type { SortableOptions } from 'sortablejs';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import BlockLibrary from './BlockLibrary.vue';
|
||||
import Canvas from './Canvas.vue';
|
||||
import JsonPreview from './JsonPreview.vue';
|
||||
import PropertyPanel from './PropertyPanel.vue';
|
||||
|
||||
/**
|
||||
* 通用拖拽式可视化编辑器
|
||||
*
|
||||
* 用途:把「左侧组件库 → 中间画布拖拽组装 → 右侧属性面板 → 自动生成 JSON」
|
||||
* 做成 schema 驱动的通用组件,未来 OA / 工单 / 通知等任何场景需要可视化拼装 JSON 都能复用。
|
||||
*
|
||||
* 核心机制:
|
||||
* - 左侧库 + 画布各分组共用同一个 SortableJS group(pull:clone 模式:左侧库克隆到画布)
|
||||
* - 拖入 / 排序 / 删除时同步内部 instances 状态
|
||||
* - 通过 computed 把「fixedFields 值 + 各 targetArrayPath 数组」合并成最终 JSON,emit 给父级
|
||||
*
|
||||
* SortableJS 用法参照项目内 views/system/role/components/quick-nav-transfer.vue
|
||||
*/
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 双向绑定的 JSON 数据 */
|
||||
modelValue: Record<string, any>;
|
||||
/** 块定义 schema(决定左侧能拖入哪些块、各自有哪些字段) */
|
||||
schema: BlockSchema[];
|
||||
/** 是否显示 JSON 预览面板 */
|
||||
showJsonPreview?: boolean;
|
||||
/** 顶层固定字段(不可拖拽,固定显示在属性面板顶部) */
|
||||
fixedFields?: FixedField[];
|
||||
}>(),
|
||||
{
|
||||
showJsonPreview: true,
|
||||
fixedFields: () => [] as FixedField[],
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Record<string, any>];
|
||||
}>();
|
||||
|
||||
// ====== 内部状态 ======
|
||||
|
||||
/** 所有块实例(按拖入顺序) */
|
||||
const instances = ref<BlockInstance[]>([]);
|
||||
/** 当前选中的块实例 ID */
|
||||
const selectedInstanceId = ref<string>('');
|
||||
/** 顶层固定字段值 */
|
||||
const fixedValues = ref<Record<string, any>>({});
|
||||
/** 顶层固定字段 + 块数组组装后的最终 JSON(双向同步给父级) */
|
||||
const assembledJson = computed<Record<string, any>>(() => {
|
||||
const result: Record<string, any> = { ...fixedValues.value };
|
||||
// 按 targetArrayPath 收集块实例数据
|
||||
for (const schema of props.schema) {
|
||||
const path = schema.targetArrayPath;
|
||||
const items = instances.value
|
||||
.filter((inst) => inst.type === schema.type)
|
||||
.map((inst) => {
|
||||
// 深拷贝避免引用问题
|
||||
const data = JSON.parse(JSON.stringify(inst.data));
|
||||
// 移除内部使用的 id 字段(如果有)
|
||||
delete data._blockType;
|
||||
return data;
|
||||
});
|
||||
if (items.length > 0) {
|
||||
// 设置嵌套路径
|
||||
setByPath(result, path, items);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
/** 当前选中的块实例对象 */
|
||||
const selectedInstance = computed<BlockInstance | undefined>(() => {
|
||||
return instances.value.find((i) => i.id === selectedInstanceId.value);
|
||||
});
|
||||
|
||||
/** 当前选中的块 schema */
|
||||
const selectedBlockSchema = computed<BlockSchema | undefined>(() => {
|
||||
if (!selectedInstance.value) return undefined;
|
||||
return props.schema.find((s) => s.type === selectedInstance.value!.type);
|
||||
});
|
||||
|
||||
// ====== SortableJS 实例管理 ======
|
||||
|
||||
const editorRootRef = ref<HTMLElement>();
|
||||
const libraryRef = ref<HTMLElement>();
|
||||
/** 画布中各分组的 dropzone DOM 引用(key = targetArrayPath) */
|
||||
const dropzoneRefs = ref<Record<string, HTMLElement>>({});
|
||||
const librarySortable = ref<Sortable | null>(null);
|
||||
const dropzoneSortables = ref<Record<string, Sortable>>({});
|
||||
|
||||
/**
|
||||
* 动态导入 SortableJS(参照 quick-nav-transfer.vue 模式)
|
||||
*/
|
||||
async function createSortable(
|
||||
el: HTMLElement,
|
||||
options: SortableOptions = {},
|
||||
): Promise<Sortable> {
|
||||
const mod = await import(
|
||||
// @ts-expect-error sortablejs modular esm path
|
||||
'sortablejs/modular/sortable.complete.esm.js'
|
||||
);
|
||||
return mod.default.create(el, {
|
||||
animation: 200,
|
||||
...options,
|
||||
}) as Sortable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 SortableJS 实例
|
||||
* 1. 左侧组件库:pull:clone 模式(拖出后元素保留)
|
||||
* 2. 画布各分组:pull:clone/put,跨组可移动
|
||||
*/
|
||||
async function initSortables() {
|
||||
destroySortables();
|
||||
await nextTick();
|
||||
|
||||
if (libraryRef.value) {
|
||||
librarySortable.value = await createSortable(libraryRef.value, {
|
||||
group: {
|
||||
name: 'template-editor',
|
||||
pull: 'clone',
|
||||
put: false, // 左侧库不接受拖入
|
||||
},
|
||||
sort: false, // 左侧库不排序
|
||||
onEnd: handleLibraryDragEnd,
|
||||
});
|
||||
}
|
||||
|
||||
// 通过 querySelector 直接查询画布中的所有 dropzone DOM(避免父子组件 DOM 引用传递)
|
||||
const editorRoot = editorRootRef.value;
|
||||
const dropzoneEls = editorRoot?.querySelectorAll('.group-dropzone[data-target-array-path]') ?? [];
|
||||
dropzoneEls.forEach((el) => {
|
||||
const path = el.getAttribute('data-target-array-path');
|
||||
if (!path) return;
|
||||
dropzoneRefs.value[path] = el as HTMLElement;
|
||||
void createSortable(el as HTMLElement, {
|
||||
group: {
|
||||
name: 'template-editor',
|
||||
pull: true,
|
||||
put: true,
|
||||
},
|
||||
onAdd: (evt) => handleDropzoneAdd(evt, path),
|
||||
onRemove: (evt) => handleDropzoneRemove(evt, path),
|
||||
onUpdate: (evt) => handleDropzoneUpdate(evt, path),
|
||||
}).then((s) => {
|
||||
dropzoneSortables.value[path] = s;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function destroySortables() {
|
||||
librarySortable.value?.destroy();
|
||||
librarySortable.value = null;
|
||||
for (const sortable of Object.values(dropzoneSortables.value)) {
|
||||
sortable.destroy();
|
||||
}
|
||||
dropzoneRefs.value = {};
|
||||
dropzoneSortables.value = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 dropzone DOM 引用(Canvas 子组件通过回调注入)
|
||||
*/
|
||||
function setDropzoneRef(path: string, el: HTMLElement | null) {
|
||||
if (el) {
|
||||
dropzoneRefs.value[path] = el;
|
||||
} else {
|
||||
delete dropzoneRefs.value[path];
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 拖拽事件处理(在 SortableJS 修改 DOM 后,同步到 Vue 状态) ======
|
||||
|
||||
/**
|
||||
* 左侧库 → 画布的拖拽结束回调
|
||||
* SortableJS 会把左侧库的元素 clone 一份移到画布 DOM,我们要:
|
||||
* 1. 移除 DOM 中被克隆过去的元素(由 Vue 重新渲染)
|
||||
* 2. 在 instances 中追加新块
|
||||
*/
|
||||
function handleLibraryDragEnd(evt: any) {
|
||||
// 如果只是库内拖动(没拖到画布),不做处理
|
||||
if (!evt.to || !evt.to.classList.contains('group-dropzone')) {
|
||||
return;
|
||||
}
|
||||
const blockType = evt.item?.dataset?.blockType;
|
||||
if (!blockType) return;
|
||||
|
||||
// 检查 maxCount 限制
|
||||
const schema = props.schema.find((s) => s.type === blockType);
|
||||
if (!schema) return;
|
||||
|
||||
const targetPath = schema.targetArrayPath;
|
||||
const currentCount = instances.value.filter(
|
||||
(i) => props.schema.find((s) => s.type === i.type)?.targetArrayPath === targetPath,
|
||||
).length;
|
||||
if (schema.maxCount && currentCount >= schema.maxCount) {
|
||||
// 移除 SortableJS 已经克隆到画布 DOM 的元素
|
||||
evt.item?.remove();
|
||||
message.warning(`「${schema.label}」最多 ${schema.maxCount} 项`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 移除 SortableJS 已经克隆到画布 DOM 的元素(Vue 会重新渲染)
|
||||
evt.item?.remove();
|
||||
|
||||
// 根据 SortableJS 在画布 DOM 中的位置,计算插入位置
|
||||
// 简化处理:直接追加到末尾(实现严格的位置同步太复杂,先保留追加行为)
|
||||
const newInstance: BlockInstance = {
|
||||
id: generateId(),
|
||||
type: blockType,
|
||||
data: schema.defaultValue ? JSON.parse(JSON.stringify(schema.defaultValue)) : {},
|
||||
};
|
||||
instances.value = [...instances.value, newInstance];
|
||||
selectedInstanceId.value = newInstance.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布分组间或分组内的添加事件(块从其他分组拖入)
|
||||
*/
|
||||
function handleDropzoneAdd(_evt: any, _path: string) {
|
||||
// SortableJS 自动处理 DOM 顺序,但因为 Vue 控制渲染,需要同步状态
|
||||
syncInstancesFromDom();
|
||||
}
|
||||
|
||||
function handleDropzoneRemove(_evt: any, _path: string) {
|
||||
syncInstancesFromDom();
|
||||
}
|
||||
|
||||
function handleDropzoneUpdate(_evt: any, _path: string) {
|
||||
syncInstancesFromDom();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从画布 DOM 同步块实例状态
|
||||
* SortableJS 修改了 DOM 后,从 DOM 重新读取 instanceId 顺序
|
||||
*/
|
||||
function syncInstancesFromDom() {
|
||||
const newInstances: BlockInstance[] = [];
|
||||
for (const path of Object.keys(dropzoneRefs.value)) {
|
||||
const el = dropzoneRefs.value[path];
|
||||
if (!el) continue;
|
||||
const items = el.querySelectorAll('[data-instance-id]');
|
||||
items.forEach((item) => {
|
||||
const id = item.getAttribute('data-instance-id');
|
||||
if (!id) return;
|
||||
const existing = instances.value.find((i) => i.id === id);
|
||||
if (existing) {
|
||||
newInstances.push(existing);
|
||||
}
|
||||
});
|
||||
}
|
||||
instances.value = newInstances;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除块实例
|
||||
*/
|
||||
function removeInstance(id: string) {
|
||||
instances.value = instances.value.filter((i) => i.id !== id);
|
||||
if (selectedInstanceId.value === id) {
|
||||
selectedInstanceId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中块实例
|
||||
*/
|
||||
function selectInstance(id: string) {
|
||||
selectedInstanceId.value = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新顶层固定字段值(PropertyPanel 触发)
|
||||
*/
|
||||
function updateFixedValues(values: Record<string, any>) {
|
||||
fixedValues.value = values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新块实例数据(PropertyPanel 触发)
|
||||
*/
|
||||
function updateInstance(updated: BlockInstance) {
|
||||
instances.value = instances.value.map((i) =>
|
||||
i.id === updated.id ? updated : i,
|
||||
);
|
||||
}
|
||||
|
||||
// ====== 工具函数 ======
|
||||
|
||||
function generateId(): string {
|
||||
return `inst_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 a.b.c 路径设置对象嵌套属性(不可变更新)
|
||||
*/
|
||||
function setByPath(obj: Record<string, any>, path: string, value: any) {
|
||||
const keys = path.split('.');
|
||||
let cursor: any = obj;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const k = keys[i]!;
|
||||
if (!cursor[k]) cursor[k] = {};
|
||||
cursor = cursor[k];
|
||||
}
|
||||
cursor[keys[keys.length - 1]!] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 a.b.c 路径读取对象嵌套属性
|
||||
*/
|
||||
function getByPath(obj: Record<string, any> | undefined, path: string): any {
|
||||
if (!obj) return undefined;
|
||||
const keys = path.split('.');
|
||||
let cursor: any = obj;
|
||||
for (const k of keys) {
|
||||
if (cursor == null) return undefined;
|
||||
cursor = cursor[k];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
// ====== modelValue 双向同步 ======
|
||||
|
||||
/**
|
||||
* 监听 modelValue 变化,反向初始化内部状态(用于编辑场景回显)
|
||||
*/
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (!val || Object.keys(val).length === 0) {
|
||||
// 重置
|
||||
instances.value = [];
|
||||
fixedValues.value = {};
|
||||
selectedInstanceId.value = '';
|
||||
return;
|
||||
}
|
||||
// 简化处理:只在 modelValue 引用变化时才反解析(避免无限循环)
|
||||
// 从 modelValue 还原 fixedValues 和 instances
|
||||
const newFixed: Record<string, any> = {};
|
||||
for (const field of props.fixedFields || []) {
|
||||
const v = getByPath(val, field.name);
|
||||
if (v !== undefined) {
|
||||
setByPath(newFixed, field.name, v);
|
||||
}
|
||||
}
|
||||
fixedValues.value = newFixed;
|
||||
|
||||
// 从各 targetArrayPath 还原 instances
|
||||
const newInstances: BlockInstance[] = [];
|
||||
for (const schema of props.schema) {
|
||||
const arr = getByPath(val, schema.targetArrayPath);
|
||||
if (Array.isArray(arr)) {
|
||||
for (const item of arr) {
|
||||
newInstances.push({
|
||||
id: generateId(),
|
||||
type: schema.type,
|
||||
data: JSON.parse(JSON.stringify(item)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
instances.value = newInstances;
|
||||
},
|
||||
{ immediate: false, deep: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听 assembledJson 变化,emit 给父级
|
||||
*/
|
||||
watch(
|
||||
assembledJson,
|
||||
(val) => {
|
||||
// 浅比较避免不必要的 emit
|
||||
const current = JSON.stringify(props.modelValue || {});
|
||||
const next = JSON.stringify(val);
|
||||
if (current !== next) {
|
||||
emit('update:modelValue', val);
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// 监听 schema 变化时重置 SortableJS(画布 DOM 可能重建)
|
||||
watch(
|
||||
() => props.schema,
|
||||
() => {
|
||||
void nextTick(() => initSortables());
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
// 初始化 fixedValues(从 props.fixedFields 的 defaultValue)
|
||||
for (const field of props.fixedFields || []) {
|
||||
if (field.defaultValue !== undefined) {
|
||||
setByPath(fixedValues.value, field.name, field.defaultValue);
|
||||
}
|
||||
}
|
||||
// 从 modelValue 回显数据
|
||||
if (props.modelValue && Object.keys(props.modelValue).length > 0) {
|
||||
for (const field of props.fixedFields || []) {
|
||||
const v = getByPath(props.modelValue, field.name);
|
||||
if (v !== undefined) {
|
||||
setByPath(fixedValues.value, field.name, v);
|
||||
}
|
||||
}
|
||||
for (const schema of props.schema) {
|
||||
const arr = getByPath(props.modelValue, schema.targetArrayPath);
|
||||
if (Array.isArray(arr)) {
|
||||
for (const item of arr) {
|
||||
instances.value.push({
|
||||
id: generateId(),
|
||||
type: schema.type,
|
||||
data: JSON.parse(JSON.stringify(item)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await nextTick();
|
||||
await initSortables();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
destroySortables();
|
||||
});
|
||||
|
||||
// 暴露给父组件
|
||||
defineExpose({
|
||||
initSortables,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="editorRootRef" class="template-editor">
|
||||
<div class="editor-toolbar">
|
||||
<span class="title">模板编辑器</span>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
@click="initSortables"
|
||||
>
|
||||
重置拖拽
|
||||
</Button>
|
||||
</div>
|
||||
<div class="editor-main">
|
||||
<!-- 左侧:组件库(用 ref 暴露给 SortableJS 初始化) -->
|
||||
<div ref="libraryRef">
|
||||
<BlockLibrary :blocks="schema" />
|
||||
</div>
|
||||
|
||||
<!-- 中间:画布 -->
|
||||
<Canvas
|
||||
:block-schemas="schema"
|
||||
:instances="instances"
|
||||
:selected-instance-id="selectedInstanceId"
|
||||
@select="selectInstance"
|
||||
@remove="removeInstance"
|
||||
/>
|
||||
|
||||
<!-- 右侧:属性面板 -->
|
||||
<PropertyPanel
|
||||
:fixed-fields="fixedFields"
|
||||
:fixed-values="fixedValues"
|
||||
:selected-block-schema="selectedBlockSchema"
|
||||
:selected-instance="selectedInstance"
|
||||
@update:fixed-values="updateFixedValues"
|
||||
@update:instance="updateInstance"
|
||||
/>
|
||||
|
||||
<!-- 最右侧:JSON 预览(可选) -->
|
||||
<JsonPreview
|
||||
v-if="showJsonPreview"
|
||||
:value="assembledJson"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.template-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||||
background: var(--ant-color-fill-quaternary, #fafbfc);
|
||||
font-size: 13px;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 通用拖拽式可视化编辑器类型定义
|
||||
*
|
||||
* 设计目标:把「左侧组件库 → 中间画布拖拽 → 右侧属性面板 → 自动生成 JSON」
|
||||
* 做成 schema 驱动的通用编辑器,未来任何需要拼装 JSON 结构的场景都能复用。
|
||||
*
|
||||
* 核心概念:
|
||||
* - BlockSchema:可拖拽块定义(左侧组件库中展示,决定属性面板渲染什么字段)
|
||||
* - FormFieldSchema:单个字段定义(驱动右侧属性面板表单渲染)
|
||||
* - 块的 instances 按 targetArrayPath 分组存到画布,每组数量不超过 maxCount
|
||||
*/
|
||||
|
||||
/**
|
||||
* 字段类型枚举
|
||||
* - string:单行文本
|
||||
* - number:数字
|
||||
* - textarea:多行文本
|
||||
* - select:下拉选择
|
||||
* - radio:单选按钮组
|
||||
* - image:图片素材选择(用 GalleryPickLink,acceptTypes 限定图片)
|
||||
* - url:URL 链接
|
||||
*/
|
||||
export type FormFieldType =
|
||||
| 'image'
|
||||
| 'number'
|
||||
| 'radio'
|
||||
| 'select'
|
||||
| 'string'
|
||||
| 'textarea'
|
||||
| 'url';
|
||||
|
||||
/**
|
||||
* 单个字段定义(驱动属性面板表单渲染)
|
||||
*/
|
||||
export interface FormFieldSchema {
|
||||
/** 字段名(在 payload 对象中的 key,支持 a.b.c 嵌套路径) */
|
||||
name: string;
|
||||
/** 中文标签 */
|
||||
label: string;
|
||||
/** 字段类型 */
|
||||
type: FormFieldType;
|
||||
/** 占位提示 */
|
||||
placeholder?: string;
|
||||
/** 是否必填 */
|
||||
required?: boolean;
|
||||
/** select / radio 类型的选项列表 */
|
||||
options?: { label: string; value: any }[];
|
||||
/** 图片字段:限定素材库文件类型(如 [0] 只显示图片) */
|
||||
acceptTypes?: number[];
|
||||
/** 最大长度 */
|
||||
maxLength?: number;
|
||||
/** 默认值 */
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可拖拽块定义(左侧组件库中展示,决定画布上能放什么块、各块有哪些字段)
|
||||
*/
|
||||
export interface BlockSchema {
|
||||
/** 块类型标识(唯一) */
|
||||
type: string;
|
||||
/** 块显示名(如「二级标题+文本项」「跳转链接项」) */
|
||||
label: string;
|
||||
/** 块图标(Iconify) */
|
||||
icon: string;
|
||||
/** 该块的字段定义(驱动右侧属性面板表单渲染) */
|
||||
fields: FormFieldSchema[];
|
||||
/**
|
||||
* 该块在最终 JSON 中放到的数组路径
|
||||
* 例如 'horizontal_content_list' 表示拖入的块实例会被收集到 result.horizontal_content_list 数组
|
||||
* 多个块共用同一个 targetArrayPath 即被收集到同一数组
|
||||
*/
|
||||
targetArrayPath: string;
|
||||
/** 该数组路径下最多允许的块数量(如企微 jump_list 最多 3 项) */
|
||||
maxCount?: number;
|
||||
/** 块实例的默认值(拖入时初始化) */
|
||||
defaultValue?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布中的块实例(拖入后生成的实际数据)
|
||||
*/
|
||||
export interface BlockInstance {
|
||||
/** 块实例唯一 ID(用于 SortableJS 拖拽排序追踪) */
|
||||
id: string;
|
||||
/** 关联的块类型(对应 BlockSchema.type) */
|
||||
type: string;
|
||||
/** 块实例的当前数据(属性面板编辑后会更新这里) */
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层固定字段(不可拖拽删除,固定显示在属性面板顶部)
|
||||
* 用于 card_type / main_title / source 等必填顶层字段
|
||||
*/
|
||||
export interface FixedField extends FormFieldSchema {
|
||||
/** 字段在最终 JSON 中的路径(支持 a.b.c 嵌套,如 'main_title.title') */
|
||||
name: string;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 处方溯源展示内容(对齐小程序药师溯源:就诊→开具→审核)
|
||||
* 由审方/业务处方两个 Modal 共用,避免双份漂移
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Timeline, TimelineItem } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
FileTextOutlined,
|
||||
MedicineBoxOutlined,
|
||||
ShopOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
data: Record<string, any> | null;
|
||||
}>();
|
||||
|
||||
const formatTime = (timestamp: unknown) => {
|
||||
if (!timestamp) return '--';
|
||||
if (typeof timestamp === 'string' && timestamp.includes('-')) return timestamp;
|
||||
const n = Number(timestamp);
|
||||
if (!n) return '--';
|
||||
return new Date(n * 1000).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const prescriptionTypeMap: Record<number, string> = {
|
||||
1: '中药处方',
|
||||
2: '西药处方',
|
||||
3: '保健食品',
|
||||
5: '产品服务包',
|
||||
6: '非药品',
|
||||
7: '医疗器械',
|
||||
};
|
||||
|
||||
const statusMap: Record<number, string> = {
|
||||
0: '待审核',
|
||||
1: '已通过',
|
||||
2: '已拒绝',
|
||||
3: '已过期',
|
||||
};
|
||||
|
||||
const prescriptionTypeText = computed(() => {
|
||||
if (!props.data) return '--';
|
||||
return prescriptionTypeMap[props.data.prescription_type] || '未知';
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (!props.data) return '--';
|
||||
return statusMap[props.data.status] || '未知';
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (!props.data) return 'gray';
|
||||
const map: Record<number, string> = {
|
||||
0: 'orange',
|
||||
1: 'green',
|
||||
2: 'red',
|
||||
3: 'gray',
|
||||
};
|
||||
return map[props.data.status] || 'gray';
|
||||
});
|
||||
|
||||
const expireTime = computed(() =>
|
||||
props.data ? formatTime(props.data.auto_expire_time) : '--',
|
||||
);
|
||||
|
||||
const patientSexText = computed(() => {
|
||||
const sex = props.data?.user_patient?.sex;
|
||||
if (sex === 1) return '男';
|
||||
if (sex === 2) return '女';
|
||||
return '--';
|
||||
});
|
||||
|
||||
const doctorInfo = computed(
|
||||
() => props.data?.doctor_info || props.data?.doctorInfo || null,
|
||||
);
|
||||
const pharmacistInfo = computed(
|
||||
() => props.data?.pharmacist_info || props.data?.pharmacistInfo || null,
|
||||
);
|
||||
const userPatient = computed(
|
||||
() => props.data?.user_patient || props.data?.userPatient || null,
|
||||
);
|
||||
const registerInfo = computed(
|
||||
() => props.data?.register || null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="data" class="prescription-source-container">
|
||||
<div class="mb-6 rounded-lg p-6 shadow-md">
|
||||
<div class="mb-4 flex items-center">
|
||||
<ShopOutlined class="mr-2 text-xl text-purple-500" />
|
||||
<h2 class="text-xl font-bold">开具机构</h2>
|
||||
</div>
|
||||
<div v-if="data.store" class="grid grid-cols-1 gap-4">
|
||||
<div class="info-item">
|
||||
<span class="label">机构名称:</span>
|
||||
<span class="value">{{ data.store.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="italic text-gray-500">暂无机构信息</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 rounded-lg p-6 shadow-md">
|
||||
<div class="mb-4 flex items-center">
|
||||
<FileTextOutlined class="mr-2 text-xl text-blue-500" />
|
||||
<h2 class="text-xl font-bold">时间线</h2>
|
||||
</div>
|
||||
<Timeline>
|
||||
<TimelineItem>
|
||||
<div class="font-medium">就诊</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ formatTime(registerInfo?.created_at) }}
|
||||
· {{ userPatient?.name || '--' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm">
|
||||
性别:{{ patientSexText }} · 年龄:{{ userPatient?.age ?? '--' }}
|
||||
</div>
|
||||
<div v-if="data.clinical_diagnose" class="mt-1 text-sm">
|
||||
诊断:{{ data.clinical_diagnose }}
|
||||
</div>
|
||||
</TimelineItem>
|
||||
<TimelineItem>
|
||||
<div class="font-medium">开具</div>
|
||||
<div class="text-sm text-gray-500">{{ data.created_at || '--' }}</div>
|
||||
<div class="mt-1 text-sm">
|
||||
医生:{{ doctorInfo?.name || '--' }} · 科室:{{
|
||||
doctorInfo?.depart?.name || '--'
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-1 text-sm">
|
||||
处方编号:{{ data.prescription_no }} · 类型:{{ prescriptionTypeText }}
|
||||
</div>
|
||||
</TimelineItem>
|
||||
<TimelineItem>
|
||||
<div class="font-medium">审核</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ data.pharmacist_view_time || '--' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm">
|
||||
状态:
|
||||
<span :class="`text-${statusColor}-500`">{{ statusText }}</span>
|
||||
· 药师:{{ pharmacistInfo?.name || '--' }}
|
||||
</div>
|
||||
<div v-if="data.reject_reason" class="mt-1 text-sm text-red-500">
|
||||
驳回原因:{{ data.reject_reason }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="data.cancel_remark && Number(data.status) === 2"
|
||||
class="mt-1 text-sm text-red-500"
|
||||
>
|
||||
驳回原因:{{ data.cancel_remark }}
|
||||
</div>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 rounded-lg p-6 shadow-md">
|
||||
<div class="mb-4 flex items-center">
|
||||
<FileTextOutlined class="mr-2 text-xl text-blue-500" />
|
||||
<h2 class="text-xl font-bold">处方基本信息</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="info-item">
|
||||
<span class="label">处方编号:</span>
|
||||
<span class="value">{{ data.prescription_no }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">处方类型:</span>
|
||||
<span class="value">{{ prescriptionTypeText }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">处方状态:</span>
|
||||
<span :class="`text-${statusColor}-500`" class="value">{{
|
||||
statusText
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">总金额:</span>
|
||||
<span class="value font-bold text-red-500"
|
||||
>¥{{ data.total_pay_price }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">过期时间:</span>
|
||||
<span class="value">{{ expireTime }}</span>
|
||||
</div>
|
||||
<div v-if="data.reject_reason" class="info-item">
|
||||
<span class="label">驳回原因:</span>
|
||||
<span class="value text-red-500">{{ data.reject_reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 rounded-lg p-6 shadow-md">
|
||||
<div class="mb-4 flex items-center">
|
||||
<MedicineBoxOutlined class="mr-2 text-xl text-green-500" />
|
||||
<h2 class="text-xl font-bold">医生信息</h2>
|
||||
</div>
|
||||
<div v-if="doctorInfo" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="info-item">
|
||||
<span class="label">开具医生:</span>
|
||||
<span class="value">{{ doctorInfo.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">开具科室:</span>
|
||||
<span class="value">{{ doctorInfo.depart?.name || '--' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">开具机构:</span>
|
||||
<span class="value">{{ data.store?.name || '--' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">开具时间:</span>
|
||||
<span class="value">{{ data.created_at || '--' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="italic text-gray-500">暂无医生信息</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg p-6 shadow-md">
|
||||
<div class="mb-4 flex items-center">
|
||||
<UserOutlined class="mr-2 text-xl text-amber-500" />
|
||||
<h2 class="text-xl font-bold">患者信息</h2>
|
||||
</div>
|
||||
<div
|
||||
v-if="userPatient"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<div class="info-item">
|
||||
<span class="label">患者姓名:</span>
|
||||
<span class="value">{{ userPatient.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">年龄:</span>
|
||||
<span class="value">{{ userPatient.age }}岁</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">性别:</span>
|
||||
<span class="value">{{ patientSexText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="italic text-gray-500">暂无患者信息</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex h-64 items-center justify-center">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-b-2 border-t-2 border-blue-500"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.prescription-source-container {
|
||||
@apply max-h-[70vh] overflow-auto p-4;
|
||||
}
|
||||
.info-item {
|
||||
@apply flex flex-col rounded-md p-3;
|
||||
}
|
||||
.label {
|
||||
@apply mb-1 text-sm text-gray-500;
|
||||
}
|
||||
.value {
|
||||
@apply font-medium;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user