21 Commits

Author SHA1 Message Date
李琦
cfc8390573 feat: 中药导入模块修复、部门财务的优化 2026-08-06 20:15:42 +08:00
李琦
174693f6db feat: 病历模块优化、AI辅助问诊、接入deepseek 2026-08-06 17:56:28 +08:00
李琦
c1bc234509 feat: 病历模块优化、AI辅助问诊 2026-08-06 10:08:02 +08:00
李琦
dc9f9c5c13 feat: 病历模块优化、AI辅助问诊 2026-08-06 08:14:33 +08:00
李琦
0331325929 feat: 病历模块优化 2026-08-05 17:54:17 +08:00
李琦
9c629ff8ee feat: 病历模块优化 2026-08-05 17:50:32 +08:00
李琦
bd5b0b2de1 feat: 病历模块优化 2026-08-05 16:22:46 +08:00
李琦
029b45d0bb feat: VIP功能绑定、VIP病历模块、字典模块、传方按钮移动到顶部导航栏 2026-08-05 10:45:58 +08:00
李琦
212b16a8ec feat: VIP模块 2026-08-04 14:53:11 +08:00
李琦
feebf4b14e feat: 1. 数脉api鉴别身份证。 2. 后台+微信登陆优化 2026-08-04 13:28:43 +08:00
李琦
3bc22d02e6 feat:诊所问题优化,气泡卡片优化,修复了导入药品的时候无法打开开方组件并且切换tab的问题 2026-08-01 08:04:31 +08:00
李琦
371594bfda feat:诊所问题优化,气泡卡片优化,修复了导入药品的时候无法打开开方组件并且切换tab的问题 2026-07-30 07:37:51 +08:00
李琦
52a1cb1ce5 feat:诊所问题优化,气泡卡片优化,修复了导入药品的时候无法打开开方组件并且切换tab的问题
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-07-28 16:53:31 +08:00
李琦
5506c8917f feat:诊所问题优化,气泡卡片优化,修复了导入药品的时候无法打开开方组件并且切换tab的问题 2026-07-28 14:23:50 +08:00
李琦
05885ed405 Merge remote-tracking branch 'origin/dev/message-oa' into feature/invoice
# Conflicts:
#	apps/web-antd/src/views/system/system-config/index.vue
2026-07-28 08:25:45 +08:00
李琦
c8045e1da8 feat:优化了模态框的大小
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
2026-07-28 07:59:10 +08:00
李琦
14414bf9b0 feat:优化了门店部门财务功能,新增开票功能 2026-07-24 09:54:44 +08:00
lq
777737d063 feat: 钉钉、企业微信webbook、企业微信应用api,问题:交互 2026-07-23 14:15:17 +08:00
lq
2b13a19731 feat: 钉钉、企业微信webbook、企业微信应用api,问题:交互 2026-07-21 14:01:41 +08:00
lq
4224130ad4 feat: 钉钉、企业微信webbook、企业微信应用api,问题:交互 2026-07-21 14:01:21 +08:00
lq
2e7bc80a11 feat: 钉钉、企业微信webbook 2026-07-20 15:05:34 +08:00
330 changed files with 39967 additions and 1317 deletions

View File

@@ -1,10 +0,0 @@
---
description:
alwaysApply: true
---
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
2. 注意不要生成太多的空行上一部分代码和下一部分代码中间的空行不要大于2行
3. 有封装好的方法、组件需要复用,不要重复造轮子
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面记得使用v-if而不是v-show
5. 数据库的created_at、updated_at、deleted_at统一使用时间戳不要使用字符串并且我在查询器中一级格式化成字符串了无需再次格式化

View File

@@ -1,10 +1,155 @@
---
description:
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 # 本模块 APICRUD
├── config/
│ ├── table.ts # vxe-grid 列定义 + proxyConfig
│ ├── search.ts # 顶部搜索表单 schema
│ └── form.ts # 新增/编辑弹窗 form schema
├── components/
│ └── modal.vue # 新增/编辑弹窗
└── utils/ # 模块工具(可选)
```
- 全局复用组件放 `apps/web-antd/src/components/`
- 表单内可用组件放 `apps/web-antd/src/components/form/components/`,文件名 kebab-case自动注册为 schema 的 `component` 名PascalCase 引用)
## 强制复用封装(禁止重复造轮子)
- 表格统一用 `useVbenVxeGrid`(来自 `#/adapter/vxe-table`**不要直接 new VxeGrid**
- 表单统一用 `useVbenForm`(来自 `#/adapter/form`**不要直接写 `<Form>` + 手动校验**
- 弹窗统一用 `useVbenModal`(来自 `@vben/common-ui`**不要用原生 `<Modal>`**
- 页面外壳统一用 `<Page>` 组件(来自 `@vben/common-ui`
- HTTP 请求统一用 `requestClient`(来自 `#/api/request`**禁止直接 axios / fetch**
- 行操作和工具栏按钮统一用 `<TableAction>` 组件(来自 `#/components/table-action`
- 业务选择器(员工、医生、门店、推广员、地区等)必须复用 `#/components/form/components/*-picker.vue`,不要重写
## API 层规范
- API 文件统一放在 `<模块>/api/index.ts`
- 函数命名约定:`get{Entity}List` / `get{Entity}Option` / `get{Entity}Info` / `create{Entity}` / `update{Entity}` / `delete{Entity}`
- URL 使用 kebab-case统一前缀变量 `const prefix = 'xxx/'`**URL 末尾不补 `/`**
- GET 用 `params`POST 用 `data`,例如:
```ts
import { requestClient } from '#/api/request';
const prefix = 'oa-robot/';
export async function getOaRobotList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
```
## 字段命名对齐后端
- 数据库字段在前端 schema 的 `field` / `fieldName` 中保持 snake_case如 `created_at`、`store_id`、`platform_code`**不要转换为 camelCase**
- 时间戳字段(`created_at` 等)后端查询器已格式化为字符串,前端**无需再次格式化**
- TS 变量用 camelCase常量用 UPPER_SNAKE_CASE类型用 PascalCase
## 表单校验
- 必填用字符串规则 `rules: 'required'`(选择项用 `'selectRequired'`),规则定义在 `#/adapter/form.ts` 的 `defineRules`
- 校验调用统一用 `formApi.validate().then(e => { if (e.valid) {...} })`
- 必填字段在 schema 中加 `rules: 'required'`,禁止散落 `validator` 写法
## 字典/枚举管理
- 模块内建 `config/constants.ts`,导出 `{ label, value }[]` 数组,禁止在 schema 中散落字面量
- 例如:
```ts
export const OA_MESSAGE_TYPE_OPTIONS = [
{ label: '文本', value: 'text' },
{ label: 'Markdown', value: 'markdown' },
];
```
## 路由规范
- 业务路由通过后端动态菜单驱动(`xk_menu` 表),**不要在前端 `router/routes/modules/` 静态注册业务路由**
- `defineOptions({ name: 'PascalCaseName' })` 必须与 `xk_menu.name` 字段一致
## Tab 用法
- 统一用 antd 原生 `Tabs` + `Tabs.TabPane`**不要找 Vben 自封装的 Tabs**
- Tab 选中状态持久化走 `localStorage`(参考 `system-config/index.vue` 的 `TAB_STORAGE_KEY` 模式)
## 抽屉用法小程序端admin 不涉及)
- 小程序端的抽屉用 `page-container` + `v-if`(不是 `v-show`)防止用户意外退出页面
## 中文注释
- 每个 `<script setup>` 顶部说明模块用途
- 每个主要函数(特别是有业务逻辑的)必须有中文注释
- 复杂的 schema 字段配置要注释意图
## 空行控制
- 上一部分代码和下一部分代码中间空行不超过 2 行
## Vben 组件使用规范
### VbenModal来自 @vben/common-ui
- 创建弹窗必须用 `useVbenModal`,禁止直接用 antd `Modal` 或原生 `<dialog>`
- 抽离弹窗内容到子组件时,外层用 `connectedComponent` 参数连接,内外组件共享 `modalApi`
- 底部按钮扩展 slot 优先级:`prepend-footer`(取消按钮左侧)> `center-footer`(取消/确定之间)> `append-footer`(确定右侧)
- **严禁覆盖 `#footer` slot**,会丢失默认的取消/确定按钮和 loading 行为
- 表单提交 loading 走 `modalApi.setState({ confirmLoading: true })`,禁止自己写 button loading
- 提交防抖/防重复用 `modalApi.lock()` / `unlock()`>5.5.3),禁止手动 disabled
- 拖拽开 `draggable: true`,需要拖出可视区时同时开 `overflow: true`
- 数据回填走 `modalApi.setData()` + `onOpenChange(isOpen) { const data = modalApi.getData() }`
### VbenDrawer来自 @vben/common-ui
- 创建抽屉必须用 `useVbenDrawer`,禁止直接用 antd `Drawer`
- 底部按钮扩展 slot 与 Modal 一致:`prepend-footer` / `center-footer` / `append-footer`
- **同样严禁覆盖 `#footer` slot**
- 关闭前校验用 `onBeforeClose`>5.5.2 支持 Promise禁止自己拦截
- 提交防抖/防重复用 `drawerApi.lock()` / `unlock()`>5.5.3
- 小程序端的「抽屉」概念与此无关,小程序端按现有规则用 `page-container`
### VbenVxeTable来自 #/adapter/vxe-table
- 表格必须用 `useVbenVxeGrid`,禁止直接 `new VxeGrid` 或 antd `Table`
- 远程数据加载走 `gridOptions.proxyConfig.ajax.query`**禁止自己 fetch 后 `data = []` 赋值**
- query 接口返回结构必须匹配适配器配置:`{ items: [], total: number }`(在 `apps/web-antd/src/adapter/vxe-table.ts` 的 `response` 字段统一配置)
- 分页参数从 `{ page }` 解构取:`page.currentPage` / `page.pageSize`,禁止自己读 URL
- 刷新表格用 `gridApi.query()`(保留当前页)或 `gridApi.reload()`(回到第一页),禁止整页 `window.location.reload()`
- 工具栏按钮插 slot`toolbar-actions`(标题左侧)、`toolbar-tools`(工具按钮左侧),禁止改 vxe-grid 内部模板
- 表格 loading 用 `gridApi.setLoading(bool)`,禁止自己遮罩
- 搜索表单由 `formOptions` 配置(底层是 Vben Form开关走 `gridOptions.toolbarConfig.search = true`
- 自定义列渲染走 `slots: { default: 'action' }` + Grid 子组件内 `<template #action="{ row }">`,禁止 column 渲染函数里写 JSX
### VbenForm来自 #/adapter/form
- 表单必须用 `useVbenForm`,禁止直接 `<form>` 或 antd `Form`
- 动态修改 schema 必须用 `formApi.updateSchema([...])`**vben form 没有 `setComponentProps` 这个方法**(这是已踩过的坑)
- 重置表单用 `formApi.resetForm()`**严禁 `formApi.resetFields()`**(那是 antd Form 的 APIVben 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` 注入,禁止写死常量

View File

@@ -61,6 +61,7 @@ export type ComponentType =
| 'DatePicker'
| 'DefaultButton'
| 'Divider'
| 'GalleryPickLink' // OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型)
| 'IconPicker'
| 'Input'
| 'InputNumber'
@@ -81,6 +82,7 @@ export type ComponentType =
| 'UploadImage'
| 'UploadImageSortable'
| 'UploadOssFile'
| 'UploadDraggerPaste'
| BaseFormComponentType;
async function initComponentAdapter() {

View File

@@ -54,12 +54,15 @@ export namespace AuthApi {
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
// PC 管理端固定 web供单机登录按端区分会话
const clientType = 'web';
if (data.account) {
return requestClient.post<AuthApi.LoginResult>('login', {
account: data.account,
password: data.password,
code: data.code,
admin_id: data.admin_id,
client_type: clientType,
});
}
@@ -67,6 +70,7 @@ export async function loginApi(data: AuthApi.LoginParams) {
login_method: data.login_method || 'phone',
password: data.password,
code: data.code,
client_type: clientType,
};
if (data.login_method === 'phone') {

View File

@@ -12,7 +12,7 @@ import {
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { message, Modal } from 'ant-design-vue';
// eslint-disable-next-line n/no-extraneous-import
import { Base64 } from 'js-base64';
@@ -37,6 +37,8 @@ function createRequestClient(baseURL: string) {
/**
* 重新认证逻辑
*/
/** 防重复:并发 401 只弹一次重新登录提示 */
let authModalShowing = false;
async function doReAuthenticate() {
const accessStore = useAccessStore();
const authStore = useAuthStore();
@@ -47,6 +49,21 @@ function createRequestClient(baseURL: string) {
) {
accessStore.setLoginExpired(true);
} else {
// 被踢/登录失效时用模态框提示,确认后再退出
if (!authModalShowing) {
authModalShowing = true;
await new Promise<void>((resolve) => {
Modal.warning({
title: '登录失效',
content: '账号需重新登录',
okText: '重新登录',
centered: true,
onOk: () => resolve(),
onCancel: () => resolve(),
});
});
authModalShowing = false;
}
await authStore.logout(true);
}
}

View File

@@ -9,7 +9,6 @@ import { App, ConfigProvider, theme } from 'ant-design-vue';
import { antdLocale } from '#/locales';
import { GlobalContextMenu } from '#/components/context-menu';
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
import { useWebSocket } from '#/views/business/chat/composables/useWebSocket';
import { useChatStore } from '#/views/business/chat/stores/chat';
import { useUserStore } from '#/views/business/chat/stores/user';
@@ -76,13 +75,6 @@ watch(userInfoLoaded, (loaded) => {
initWebsocket();
}
});
/** 诊所医生账号显示传方悬浮窗(有 doctor_id 且绑定门店) */
const showDoctorTransferFloat = computed(() => {
const info = vbenUserStore.userInfo as Record<string, any> | null;
if (!info) return false;
return !!(info.doctor_id && info.store_id);
});
</script>
<template>
@@ -90,7 +82,6 @@ const showDoctorTransferFloat = computed(() => {
<App>
<RouterView />
<GlobalContextMenu />
<DoctorTransferFloat v-if="showDoctorTransferFloat" />
</App>
</ConfigProvider>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
/**
* 医生端 PC 传方悬浮窗:悬浮按钮 + 子窗口列表 + 状态操作
* 医生端 PC 传方入口:顶栏按钮 + 子窗口列表 + 状态操作
* 权限:有 doctor_id 且绑定 store_id 时由外层 v-if 控制,本组件只负责展示与操作
*/
import {
computed,
@@ -11,6 +12,7 @@ import {
} from 'vue';
import {
FileTextOutlined,
ReloadOutlined,
ZoomInOutlined,
ZoomOutOutlined,
@@ -19,6 +21,7 @@ import { usePreferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import {
Badge,
Button,
Dropdown,
Image,
@@ -29,10 +32,10 @@ import {
Spin,
Tag,
Textarea,
Tooltip,
message,
} from 'ant-design-vue';
import FloatFab from '#/components/float-fab/FloatFab.vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import {
DEFAULT_SUB_WINDOW_Z_INDEX,
@@ -355,12 +358,20 @@ defineExpose({ loadList });
</script>
<template>
<FloatFab
storage-key="pc_doctor_transfer_fab"
label="传方"
:badge="pendingCount"
<div class="doctor-transfer-header-entry">
<Tooltip title="门店传方">
<Badge :count="pendingCount" :overflow-count="99" size="small">
<Button
type="text"
class="transfer-header-btn"
@click="openWindow"
/>
>
<FileTextOutlined />
<span class="transfer-header-btn__text">传方</span>
</Button>
</Badge>
</Tooltip>
</div>
<SubWindow
v-model:open="windowOpen"
title="门店传方"
@@ -848,4 +859,25 @@ defineExpose({ loadList });
margin: 0 auto;
user-select: none;
}
.doctor-transfer-header-entry {
display: flex;
align-items: center;
margin-right: 4px;
}
.transfer-header-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 32px;
padding: 0 8px;
color: hsl(var(--foreground));
}
.transfer-header-btn__text {
font-size: 13px;
}
@media (max-width: 640px) {
.transfer-header-btn__text {
display: none;
}
}
</style>

View File

@@ -79,7 +79,7 @@ const handleRemove = (e: Event) => {
</div>
<Icon v-else icon="ant-design:plus-outlined" />
</Upload>
<GalleryPickLink @select="onGallerySelect" />
<GalleryPickLink :accept-types="[0]" @select="onGallerySelect" />
</div>
</template>
<style lang="less" scoped>

View File

@@ -8,11 +8,17 @@ const props = withDefaults(
multiple?: boolean;
maxCount?: number;
disabled?: boolean;
/**
* 限制可选择的文件类型(透传给 image-gallery-picker按 xk_file.type / xk_file_type.value 过滤)
* 例如图片:[0];视频:[1];文件:[6];语音:[3]
*/
acceptTypes?: number[];
}>(),
{
multiple: false,
maxCount: 1,
disabled: false,
acceptTypes: () => [] as number[],
},
);
@@ -41,6 +47,7 @@ function onSelect(urls: string[]) {
v-model:open="galleryOpen"
:multiple="multiple"
:max-count="maxCount"
:accept-types="acceptTypes"
@select="onSelect"
/>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { Empty, Input, Modal, Pagination, Spin, Tabs } from 'ant-design-vue';
@@ -14,10 +14,18 @@ const props = withDefaults(
open: boolean;
multiple?: boolean;
maxCount?: number;
/**
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
* - 不传:显示全部类型(默认行为,保持向后兼容)
* - 传数组:只展示指定类型 tab且自动锁定到第一个匹配的类型
* 例如图片:[0];图片+视频:[0, 1];文件:[6]
*/
acceptTypes?: number[];
}>(),
{
multiple: false,
maxCount: 1,
acceptTypes: () => [] as number[],
},
);
@@ -34,6 +42,14 @@ const pageSize = ref(24);
const pageSizeOptions = ['12', '24', '48'];
const selected = ref<string[]>([]);
/** 按 acceptTypes 隔离 localStorage避免图片/视频选择器互相污染 */
const storageKey = computed(
() =>
`file-picker-type-${
props.acceptTypes?.length ? props.acceptTypes.join('-') : 'all'
}`,
);
const {
activeType,
keyword,
@@ -42,14 +58,55 @@ const {
buildListParams,
fileTypes,
typesLoading,
} = useFileGalleryFilter('file-picker-active-type');
} = useFileGalleryFilter(() => storageKey.value);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
/**
* 实际渲染的 tabs按 acceptTypes 过滤)
*/
const visibleTabs = computed(() => {
if (!props.acceptTypes || props.acceptTypes.length === 0) {
return tabs.value;
}
return tabs.value.filter(
(tab) => tab.value !== -1 && props.acceptTypes.includes(Number(tab.value)),
);
});
/**
* 强制锁定到 acceptTypes 首项(打开弹窗 / 类型字典就绪时调用)
*/
function lockAcceptType() {
const arr = props.acceptTypes;
if (!arr || arr.length === 0) {
return;
}
if (activeType.value !== arr[0]) {
setActiveType(arr[0]!);
}
}
/**
* 是否允许拉列表:有 acceptTypes 时需等类型字典加载完且 visibleTabs 非空
*/
function canLoadList() {
if (typesLoading.value) {
return false;
}
if (props.acceptTypes && props.acceptTypes.length > 0) {
return visibleTabs.value.length > 0;
}
return true;
}
async function load() {
if (!canLoadList()) {
return;
}
loading.value = true;
try {
const res = await getFileGalleryList(buildListParams(page.value, pageSize.value));
const res = await getFileGalleryList(
buildListParams(page.value, pageSize.value),
);
const data = (res as any)?.data ?? res;
items.value = data?.items ?? [];
total.value = data?.total ?? 0;
@@ -58,21 +115,37 @@ async function load() {
}
}
watch(
() => props.acceptTypes,
(arr) => {
if (!arr || arr.length === 0) {
return;
}
lockAcceptType();
page.value = 1;
void load();
},
{ immediate: true },
);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => props.open,
(val) => {
if (val) {
selected.value = [];
page.value = 1;
if (!typesLoading.value) {
// 打开时再次按 acceptTypes 重锁,避免沿用其它选择器的视频 tab
lockAcceptType();
void load();
}
}
},
);
watch(typesLoading, (val) => {
if (!val && props.open) {
lockAcceptType();
void load();
}
});
@@ -143,6 +216,11 @@ function onSearch(value: string) {
function itemIcon(item: FileGalleryItem) {
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
}
/** 图片类型展示缩略图xk_file.type0=图片;兼容历史错误写入的 1 */
function isImageItem(item: FileGalleryItem) {
return Number(item.type) === 0 || Number(item.type) === 1;
}
</script>
<template>
@@ -155,7 +233,7 @@ function itemIcon(item: FileGalleryItem) {
@cancel="handleCancel"
>
<Tabs :active-key="String(activeType)" size="small" @change="onTabChange">
<Tabs.TabPane v-for="tab in tabs" :key="String(tab.value)">
<Tabs.TabPane v-for="tab in visibleTabs" :key="String(tab.value)">
<template #tab>
<span class="tab-label">
<MIcon v-if="tab.icon" :icon="tab.icon" size="12" />
@@ -176,7 +254,7 @@ function itemIcon(item: FileGalleryItem) {
/>
</div>
<Spin :spinning="loading">
<Spin :spinning="loading || typesLoading">
<div v-if="items.length" class="gallery-grid">
<div
v-for="item in items"
@@ -185,10 +263,17 @@ function itemIcon(item: FileGalleryItem) {
:class="{ active: isSelected(item.url) }"
@click="toggleSelect(item.url)"
>
<img v-if="item.type === 0" :src="item.url" alt="" class="gallery-thumb" />
<img
v-if="isImageItem(item)"
:src="item.url"
alt=""
class="gallery-thumb"
/>
<div v-else class="gallery-file-icon">
<MIcon :icon="itemIcon(item)" size="28" />
<span class="file-name">{{ item.file_name || item.original_name || '未命名' }}</span>
<span class="file-name">{{
item.file_name || item.original_name || '未命名'
}}</span>
</div>
<div class="gallery-meta">
<div class="file-title">

View File

@@ -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 = '';
}
/**
* 预览已上传文件:图片用 ImagePDF 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-gray-400">
支持拖拽或点击选择单文件不超过 {{ 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-gray-100 px-3 py-2 text-sm"
>
<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-blue-600"
@click="previewItem(item)"
>
{{ item.name }}
</button>
<span v-else class="min-w-0 flex-1 truncate text-gray-500">{{ 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">
监听中请将文件或截图 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-gray-500">加载中</div>
<div v-else-if="previewIsImage" class="flex justify-center">
<Image :src="previewImageUrl" :preview="true" style="max-height: 70vh" />
</div>
<iframe
v-else-if="previewPdfUrl"
:src="previewPdfUrl"
class="h-[70vh] w-full border-0"
title="文件预览"
/>
</Modal>
</div>
</template>

View File

@@ -24,12 +24,18 @@ interface Props {
modelValue: string[];
multiple?: boolean;
maxCount?: number;
/**
* 素材库可选类型(透传 GalleryPickLink
* xk_file.type0=图片 1=视频;默认仅图片。不传会展示全部类型
*/
acceptTypes?: number[];
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: true,
maxCount: 9,
acceptTypes: () => [0],
});
const emit = defineEmits<{
@@ -345,6 +351,7 @@ onMounted(() => {
v-if="showUploadButton"
:multiple="multiple"
:max-count="galleryRemainCount"
:accept-types="acceptTypes"
@select="onGallerySelect"
/>

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
/**
* 多图上传picture-card+ 下方「开始监听粘贴」支持截图/图片粘贴
*/
import { computed, ref, watch } from 'vue';
import { Modal, Upload } from 'ant-design-vue';
import { Button, Modal, Upload, message } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
@@ -10,9 +13,12 @@ import {
beforeImageUpload,
resolveUploadFile,
} from '#/utils/use-image-upload-pending';
import { usePasteUploadListen } from '#/utils/use-paste-upload-listen';
import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon';
defineOptions({ name: 'UploadImage' });
interface FileItem {
uid: string;
name: string;
@@ -24,18 +30,26 @@ interface Props {
modelValue: string[];
multiple?: boolean;
maxCount?: number;
/**
* 素材库可选类型(透传 GalleryPickLink
* xk_file.type0=图片 1=视频,默认仅图片
*/
acceptTypes?: number[];
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: true,
maxCount: 9,
acceptTypes: () => [0],
});
const emit = defineEmits<{
'update:modelValue': [value: string[]];
}>();
const IMAGE_ACCEPT = 'image/*,.jpg,.jpeg,.png,.gif,.webp,.bmp';
const fileList = ref<FileItem[]>([]);
const initFileList = () => {
@@ -74,6 +88,41 @@ const updateModelValue = () => {
emit('update:modelValue', urls);
};
/**
* 上传单个图片文件(点击上传与粘贴共用)
*/
async function uploadOneImage(file: File) {
const remain = props.maxCount - fileList.value.filter((f) => f.status === 'done').length;
if (remain <= 0) {
message.warning(`最多上传 ${props.maxCount} 张图片`);
return;
}
const actualFile = resolveUploadFile(file);
const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const uploadingFile: FileItem = {
uid,
name: actualFile.name || file.name || 'paste.png',
status: 'uploading',
url: '',
};
fileList.value = [...fileList.value, uploadingFile];
try {
const uploadMethod = preferences.app.uploadMethod || 'direct';
const res =
uploadMethod === 'direct'
? await uploadToOss({ file: actualFile })
: await uploadFile({ file: actualFile });
fileList.value = fileList.value.map((item) =>
item.uid === uid ? { ...item, status: 'done' as const, url: res.url } : item,
);
updateModelValue();
} catch (error) {
console.error('上传失败', error);
fileList.value = fileList.value.filter((item) => item.uid !== uid);
message.error('上传失败');
}
}
const customRequest = async (options: any) => {
const { file, onProgress, onSuccess, onError } = options;
const actualFile = resolveUploadFile(file as File);
@@ -155,6 +204,30 @@ const showUploadButton = computed(() =>
);
const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0));
const { listening: pasteListening, toggle: togglePaste } = usePasteUploadListen({
accept: IMAGE_ACCEPT,
maxSize: 20 * 1024 * 1024,
onFiles: async (files) => {
for (const file of files) {
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
if (doneCount >= props.maxCount) {
message.warning(`最多上传 ${props.maxCount} 张图片`);
break;
}
await uploadOneImage(file);
}
},
});
function onTogglePaste() {
togglePaste();
if (pasteListening.value) {
message.success('已开启粘贴监听,可 Ctrl+V 粘贴图片/截图');
} else {
message.info('已停止粘贴监听');
}
}
</script>
<template>
@@ -184,9 +257,29 @@ const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.val
v-if="showUploadButton"
:multiple="multiple"
:max-count="galleryRemainCount"
:accept-types="acceptTypes"
@select="onGallerySelect"
/>
<!-- 粘贴监听放在上传区下方外侧 -->
<div class="mt-2">
<Button
size="small"
:type="pasteListening ? 'primary' : 'default'"
:danger="pasteListening"
@click="onTogglePaste"
>
<Icon
:icon="pasteListening ? 'ant-design:stop-outlined' : 'ant-design:snippets-outlined'"
class="mr-1"
/>
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
</Button>
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500">
监听中Ctrl+V 粘贴图片
</span>
</div>
<Modal v-model:visible="previewVisible" :title="previewTitle" footer="" width="60%">
<img alt="预览图片" style="width: 100%" :src="previewImage" />
</Modal>

View File

@@ -1,8 +1,14 @@
<script setup lang="ts">
/**
* OSS 文件上传(单文件 URL 字符串)
* 可选从素材库选择acceptTypes 过滤,如文件=6、语音=3
*/
import { computed, ref, watch } from 'vue';
import { preferences } from '@vben/preferences';
import { Button, Upload } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
import { Icon } from '#/components/icon';
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
@@ -17,11 +23,17 @@ interface FileItem {
interface Props {
modelValue?: string;
maxCount?: number;
/** 素材库类型过滤,如 [6] 文件、[3] 语音 */
acceptTypes?: number[];
/** input accept如 .pdf,.doc 或 audio/* */
accept?: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
maxCount: 1,
acceptTypes: () => [6],
accept: '.pdf,.doc,.docx,.zip,.rar,.txt,application/pdf,audio/*,*/*',
});
const emit = defineEmits<{
@@ -131,19 +143,27 @@ function handleRemove() {
return true;
}
function onGallerySelect(urls: string[]) {
const url = urls[0] || '';
if (!url) return;
syncFileListFromValue(url);
updateModelValue();
}
const showUploadButton = computed(
() => fileList.value.filter((item) => item.status !== 'error').length === 0,
);
</script>
<template>
<div class="upload-oss-file-wrap">
<Upload
:file-list="fileList"
:before-upload="beforeUpload"
:custom-request="customRequest"
:max-count="maxCount"
list-type="text"
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,image/*,application/pdf"
:accept="accept"
@remove="handleRemove"
>
<Button v-if="showUploadButton">
@@ -153,4 +173,20 @@ const showUploadButton = computed(
</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>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
/**
* 通用 Tag 选择器(带头像)
*
* - 基于 antd Selectmultiple 模式默认开启,单选传 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>

View 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>

View File

@@ -62,7 +62,7 @@ const downloadQRCode = async (name: string, isQr = false) => {
};
</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">

View File

@@ -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>

View 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>

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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>

View File

@@ -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>

View 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 grouppull:clone 模式:左侧库克隆到画布)
* - 拖入 / 排序 / 删除时同步内部 instances 状态
* - 通过 computed 把「fixedFields 值 + 各 targetArrayPath 数组」合并成最终 JSONemit 给父级
*
* 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>

View File

@@ -0,0 +1,99 @@
/**
* 通用拖拽式可视化编辑器类型定义
*
* 设计目标:把「左侧组件库 → 中间画布拖拽 → 右侧属性面板 → 自动生成 JSON」
* 做成 schema 驱动的通用编辑器,未来任何需要拼装 JSON 结构的场景都能复用。
*
* 核心概念:
* - BlockSchema可拖拽块定义左侧组件库中展示决定属性面板渲染什么字段
* - FormFieldSchema单个字段定义驱动右侧属性面板表单渲染
* - 块的 instances 按 targetArrayPath 分组存到画布,每组数量不超过 maxCount
*/
/**
* 字段类型枚举
* - string单行文本
* - number数字
* - textarea多行文本
* - select下拉选择
* - radio单选按钮组
* - image图片素材选择用 GalleryPickLinkacceptTypes 限定图片)
* - urlURL 链接
*/
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;
}

View File

@@ -8,6 +8,7 @@ import type { Dayjs } from 'dayjs';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import dayjs from 'dayjs';
import {
@@ -35,9 +36,15 @@ import {
} from '#/views/system/store/api';
import { getSalespersonList } from '#/views/system/store/api/salesperson';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
import AddDoctorFromStoreModal from '#/views/system/store/components/AddDoctorFromStoreModal.vue';
import { canAddStoreDoctor } from '#/views/system/admin/_shared/platform-admin-role';
const RangePicker = DatePicker.RangePicker;
const userStore = useUserStore();
// 是否允许「添加医生」:超管/管理员/省经理/市经理/业务员
const canAddDoctor = computed(() => canAddStoreDoctor(userStore.userInfo));
const storeId = ref(0);
const loading = ref(false);
const activeTab = ref('basic');
@@ -121,6 +128,25 @@ const [DoctorModal, DoctorModalApi] = useVbenModal({
connectedComponent: DoctorCardModal,
});
/** 添加医生弹窗:诊所详情医生团队 Tab 内入口 */
const [AddDoctorModal, AddDoctorModalApi] = useVbenModal({
connectedComponent: AddDoctorFromStoreModal,
});
/**
* 打开「添加医生」弹窗:默认绑定当前门店,提交成功后刷新医生团队列表
*/
const openAddDoctorModal = () => {
AddDoctorModalApi.setData({
storeId: storeId.value,
storeName: (store.value as any)?.name || '',
onSuccess: () => {
loadCard();
},
});
AddDoctorModalApi.open();
};
function searchTimeParam(): [string, string] {
return [
searchTime.value[0].format('YYYY-MM-DD 00:00:00'),
@@ -256,6 +282,7 @@ function clinicTypeText(v: number) {
<template>
<Modal :title="modalTitle">
<AddDoctorModal />
<Spin :spinning="loading">
<div v-if="cardData" class="mb-4 flex flex-wrap items-center justify-between gap-3 px-1">
<div class="text-sm text-muted-foreground">统计时间范围</div>
@@ -399,6 +426,15 @@ function clinicTypeText(v: number) {
<Tabs.TabPane v-if="isClinic" key="doctors" tab="诊所医生团队">
<div class="pl-4">
<div v-if="canAddDoctor" class="mb-3">
<Button
type="primary"
size="small"
@click="openAddDoctorModal"
>
添加医生
</Button>
</div>
<Empty v-if="doctorList.length === 0" description="暂无医生" />
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div

View File

@@ -0,0 +1,30 @@
<script lang="ts" setup>
/**
* 顶栏 VIP 徽标:等级徽标 + 期限丝带叠加(丝带透明底绑在徽标上),放大显示
*/
import { computed } from 'vue';
import { useUserStore } from '@vben/stores';
import VipBadgeCombo from './VipBadgeCombo.vue';
const userStore = useUserStore();
const vip = computed(() => (userStore.userInfo as any)?.vip || null);
const show = computed(() => {
if (!vip.value) return false;
return !!(vip.value.duration_badge_url || vip.value.badge_url);
});
</script>
<template>
<div v-if="show" class="mr-2 flex items-center">
<VipBadgeCombo
size="sm"
:badge-url="vip.badge_url"
:duration-badge-url="vip.duration_badge_url"
:level-name="vip.level_name"
:duration-label="vip.duration_label"
/>
</div>
</template>

View File

@@ -0,0 +1,83 @@
<script lang="ts" setup>
/**
* 诊所列表 VIP 单元格:徽标+会员名,点击打开升级弹窗
*/
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
defineProps<{
vip?: Record<string, any> | null;
}>();
const emit = defineEmits<{
click: [];
}>();
</script>
<template>
<div
class="store-vip-cell"
role="button"
tabindex="0"
@click.stop="emit('click')"
@keydown.enter.prevent="emit('click')"
>
<VipBadgeCombo
size="sm"
:badge-url="vip?.badge_url"
:duration-badge-url="vip?.duration_badge_url"
:level-name="vip?.level_name"
:duration-label="vip?.duration_label"
/>
<div class="store-vip-cell__text">
<div class="store-vip-cell__name">{{ vip?.level_name || '普通会员' }}</div>
<div class="store-vip-cell__sub">
{{ vip?.duration_label || '点击升级' }}
</div>
</div>
</div>
</template>
<style scoped>
.store-vip-cell {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px 4px 4px;
border-radius: 10px;
transition: background 0.15s ease;
max-width: 100%;
}
.store-vip-cell:hover {
background: rgba(106, 205, 187, 0.12);
}
.store-vip-cell__text {
min-width: 0;
line-height: 1.25;
}
.store-vip-cell__name {
font-size: 13px;
font-weight: 600;
color: var(--foreground, #1f1f1f);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.store-vip-cell__sub {
font-size: 11px;
color: #8c8c8c;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
html.dark .store-vip-cell:hover,
.dark .store-vip-cell:hover {
background: rgba(106, 205, 187, 0.2);
}
html.dark .store-vip-cell__name,
.dark .store-vip-cell__name {
color: #f4f4f5;
}
html.dark .store-vip-cell__sub,
.dark .store-vip-cell__sub {
color: #a1a1aa;
}
</style>

View File

@@ -0,0 +1,107 @@
<script lang="ts" setup>
/**
* 诊所设置「基础资料」会员卡片
* 大尺寸等级徽标 + 期限丝带叠加,展示等级名与到期时间
*/
import { computed } from 'vue';
import VipBadgeCombo from './VipBadgeCombo.vue';
const props = defineProps<{
vip?: Record<string, any> | null;
}>();
const hasVipVisual = computed(() => {
const v = props.vip;
return !!(v && (v.badge_url || v.duration_badge_url));
});
const expireText = computed(() => {
const v = props.vip;
if (!v) return '默认会员';
if (v.is_lifetime) return '永久有效';
const expireAt = Number(v.expire_at || 0);
if (!expireAt) {
if ((v.level_code || 'V0') === 'V0') return '默认会员';
return '永久有效';
}
if (typeof v.expire_at === 'string' && String(v.expire_at).includes('-')) {
return `有效期至 ${v.expire_at}`;
}
const d = new Date(expireAt * 1000);
const pad = (n: number) => String(n).padStart(2, '0');
return `有效期至 ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
});
</script>
<template>
<div class="store-vip-card">
<VipBadgeCombo
v-if="hasVipVisual"
size="xl"
:badge-url="vip?.badge_url"
:duration-badge-url="vip?.duration_badge_url"
:level-name="vip?.level_name"
:duration-label="vip?.duration_label"
/>
<div v-else class="store-vip-card__placeholder">V</div>
<div class="store-vip-card__info">
<div class="store-vip-card__title">
{{ vip?.level_name || '普通会员' }}
</div>
<div class="store-vip-card__sub">
{{ vip?.duration_label || '普通会员' }}
· {{ vip?.level_code || 'V0' }}
</div>
<div class="store-vip-card__expire">{{ expireText }}</div>
</div>
</div>
</template>
<style scoped>
.store-vip-card {
display: flex;
align-items: center;
gap: 24px;
padding: 20px 28px;
border-radius: 16px;
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
box-shadow: 0 8px 24px rgba(44, 62, 80, 0.16);
margin-bottom: 24px;
}
.store-vip-card__placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
color: #fff;
font-size: 48px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.store-vip-card__info {
min-width: 0;
color: #fff;
}
.store-vip-card__title {
font-size: 22px;
font-weight: 700;
line-height: 1.3;
margin-bottom: 6px;
}
.store-vip-card__sub {
font-size: 14px;
opacity: 0.9;
margin-bottom: 6px;
}
.store-vip-card__expire {
font-size: 13px;
opacity: 0.78;
}
html.dark .store-vip-card,
.dark .store-vip-card {
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
</style>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
/**
* VIP 组合徽标:等级徽标在下,期限丝带(透明底)叠在上方绑定
* size: sm 顶栏 / md 表格 / lg 详情 / xl 诊所设置会员卡
*/
withDefaults(
defineProps<{
badgeUrl?: string;
durationBadgeUrl?: string;
levelName?: string;
durationLabel?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
}>(),
{
badgeUrl: '',
durationBadgeUrl: '',
levelName: '',
durationLabel: '',
size: 'md',
},
);
</script>
<template>
<div
v-if="badgeUrl || durationBadgeUrl"
class="vip-badge-combo"
:class="[`vip-badge-combo--${size}`]"
:title="[durationLabel, levelName].filter(Boolean).join(' · ')"
>
<img
v-if="badgeUrl"
class="vip-badge-combo__level"
:src="badgeUrl"
:alt="levelName || '会员等级'"
/>
<img
v-if="durationBadgeUrl"
class="vip-badge-combo__ribbon"
:src="durationBadgeUrl"
:alt="durationLabel || '会员类型'"
/>
</div>
<span v-else>-</span>
</template>
<style scoped>
.vip-badge-combo {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
vertical-align: middle;
}
.vip-badge-combo__level {
object-fit: contain;
display: block;
position: relative;
z-index: 1;
}
.vip-badge-combo__ribbon {
position: absolute;
left: 50%;
bottom: 8%;
transform: translateX(-50%);
z-index: 2;
object-fit: contain;
pointer-events: none;
}
.vip-badge-combo--sm {
width: 52px;
height: 52px;
}
.vip-badge-combo--sm .vip-badge-combo__level {
width: 48px;
height: 48px;
}
.vip-badge-combo--sm .vip-badge-combo__ribbon {
width: 72px;
height: 28px;
}
.vip-badge-combo--md {
width: 64px;
height: 64px;
}
.vip-badge-combo--md .vip-badge-combo__level {
width: 56px;
height: 56px;
}
.vip-badge-combo--md .vip-badge-combo__ribbon {
width: 84px;
height: 30px;
}
.vip-badge-combo--lg {
width: 96px;
height: 96px;
}
.vip-badge-combo--lg .vip-badge-combo__level {
width: 84px;
height: 84px;
}
.vip-badge-combo--lg .vip-badge-combo__ribbon {
width: 120px;
height: 42px;
}
.vip-badge-combo--xl {
width: 140px;
height: 140px;
}
.vip-badge-combo--xl .vip-badge-combo__level {
width: 120px;
height: 120px;
}
.vip-badge-combo--xl .vip-badge-combo__ribbon {
width: 168px;
height: 58px;
}
</style>

View File

@@ -0,0 +1,41 @@
<script lang="ts" setup>
/**
* VIP 权限显隐口子组件
* 用法:<VipGate code="xxx" :min-level="'V5'">内容</VipGate>
* 后续业务权益接入时直接包一层即可;当前从 userInfo.vip 判断
*/
import { computed } from 'vue';
import { hasVipPermission, levelAtLeast } from '#/utils/vip';
const props = withDefaults(
defineProps<{
/** 权限码(与等级 permissions 对齐) */
code?: string;
/** 最低等级编码 */
minLevel?: string;
/** 无权限时是否渲染占位(默认不渲染) */
fallback?: boolean;
}>(),
{
code: '',
minLevel: '',
fallback: false,
},
);
const allowed = computed(() => {
if (props.code) {
return hasVipPermission(props.code);
}
if (props.minLevel) {
return levelAtLeast(props.minLevel);
}
// 未指定条件时默认展示,方便后续逐步接入
return true;
});
</script>
<template>
<slot v-if="allowed" />
<slot v-else-if="fallback" name="fallback" />
</template>

View File

@@ -0,0 +1,440 @@
<script lang="ts" setup>
/**
* VIP 升级/开通弹窗
* 等级/时长用两行横向滑动单选卡片,预览丝带随所选时长自动切换
*/
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, InputNumber, message } from 'ant-design-vue';
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
import { getVipDurationOption } from '#/views/system/vip/duration/api';
import { getVipLevelOption } from '#/views/system/vip/level/api';
import {
getVipDurationPresets,
openVipStore,
} from '#/views/system/vip/store/api';
/** 时长预设 → 时效类型 code与后端 VipDurationHelper 对齐) */
const PRESET_TO_DURATION: Record<string, string> = {
'7d': 'trial',
'1w': 'week',
'1m': 'month',
'3m': 'month',
'6m': 'month',
'1y': 'year',
xy: 'year',
lifetime: 'lifetime',
};
const gridApi = ref<any>();
const onSuccess = ref<null | (() => void)>(null);
const storeId = ref(0);
const storeName = ref('');
const levelId = ref<number | undefined>();
const durationPreset = ref<string | undefined>();
const customYears = ref<number | undefined>();
const remark = ref('');
const levels = ref<any[]>([]);
const presets = ref<{ label: string; value: string }[]>([]);
const durationTypes = ref<any[]>([]);
const selectedLevel = computed(() =>
levels.value.find((i) => i.id === levelId.value),
);
/** 按时长预设解析对应丝带资源 */
function ribbonByPreset(preset?: string) {
const code = PRESET_TO_DURATION[preset || ''] || '';
if (!code) return null;
return durationTypes.value.find((i) => i.code === code) || null;
}
const selectedDurationType = computed(() =>
ribbonByPreset(durationPreset.value),
);
/** 时长卡片数据:附带丝带图,避免模板重复查找 */
const durationCards = computed(() =>
presets.value.map((p) => {
const ribbon = ribbonByPreset(p.value);
return {
...p,
ribbonUrl: ribbon?.badge_url || '',
ribbonName: ribbon?.name || '',
};
}),
);
const previewBadgeUrl = computed(() => selectedLevel.value?.badge_url || '');
const previewRibbonUrl = computed(
() => selectedDurationType.value?.badge_url || '',
);
const previewLevelName = computed(
() => selectedLevel.value?.name || '请选择等级',
);
const previewDurationLabel = computed(() => {
if (!durationPreset.value) return '请选择时长';
const preset = presets.value.find((p) => p.value === durationPreset.value);
return preset?.label || selectedDurationType.value?.name || '请选择时长';
});
const title = computed(() => `升级VIP - ${storeName.value || storeId.value}`);
/** 选择会员等级 */
function selectLevel(id: number) {
levelId.value = id;
}
/** 选择开通时长(同步驱动预览丝带) */
function selectDuration(value: string) {
durationPreset.value = value;
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (!levelId.value) {
message.warning('请选择会员等级');
return;
}
if (!durationPreset.value) {
message.warning('请选择开通时长');
return;
}
if (
durationPreset.value === 'xy' &&
!(Number(customYears.value) > 0)
) {
message.warning('请填写有效年数');
return;
}
modalApi.setState({ confirmLoading: true });
try {
await openVipStore({
store_id: storeId.value,
level_id: levelId.value,
duration_preset: durationPreset.value,
custom_years: customYears.value || 0,
remark: remark.value || '',
});
message.success('开通成功');
gridApi.value?.query?.();
onSuccess.value?.();
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
levelId.value = undefined;
durationPreset.value = undefined;
customYears.value = undefined;
remark.value = '';
return;
}
const data = modalApi.getData<Record<string, any>>() || {};
gridApi.value = data.gridApi;
onSuccess.value = data.onSuccess || null;
storeId.value = Number(data.store_id || 0);
storeName.value = data.store_name || '';
const [levelList, presetList, durationList] = await Promise.all([
getVipLevelOption(),
getVipDurationPresets(),
getVipDurationOption(),
]);
levels.value = levelList || [];
presets.value = (presetList || []).map((i: any) => ({
label: i.label,
value: i.value,
}));
durationTypes.value = durationList || [];
// 回填当前 VIP 等级(便于续期)
if (data.vip?.level_id) {
levelId.value = Number(data.vip.level_id);
}
},
});
watch(durationPreset, (v) => {
if (v !== 'xy') customYears.value = undefined;
});
</script>
<template>
<Modal :title="title" class="w-[50%]">
<!-- 预览徽标卡片 + 丝带随时长自动适配 -->
<div class="vip-upgrade-preview mb-5">
<VipBadgeCombo
size="xl"
:badge-url="previewBadgeUrl"
:duration-badge-url="previewRibbonUrl"
:level-name="previewLevelName"
:duration-label="previewDurationLabel"
/>
<div class="vip-upgrade-preview__text">
<div class="vip-upgrade-preview__title">{{ previewLevelName }}</div>
<div class="vip-upgrade-preview__sub">{{ previewDurationLabel }}</div>
<div v-if="selectedLevel" class="vip-upgrade-preview__price">
原价 ¥{{ selectedLevel.price }} · 管理员开通实付 ¥0
</div>
</div>
</div>
<div class="space-y-4">
<div>
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">
会员等级
</div>
<div class="vip-pick-scroll" role="listbox" aria-label="会员等级">
<button
v-for="item in levels"
:key="item.id"
type="button"
class="vip-pick-card"
:class="{ 'vip-pick-card--active': levelId === item.id }"
role="option"
:aria-selected="levelId === item.id"
@click="selectLevel(item.id)"
>
<img
v-if="item.badge_url"
:src="item.badge_url"
alt=""
class="vip-pick-card__badge"
/>
<div
v-else
class="vip-pick-card__badge vip-pick-card__badge--empty"
>
{{ item.code }}
</div>
<div class="vip-pick-card__name">{{ item.name }}</div>
<div class="vip-pick-card__meta">
{{ item.code }} · ¥{{ item.price }}
</div>
</button>
</div>
</div>
<div>
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">
开通时长
</div>
<div class="vip-pick-scroll" role="listbox" aria-label="开通时长">
<button
v-for="item in durationCards"
:key="item.value"
type="button"
class="vip-pick-card vip-pick-card--duration"
:class="{ 'vip-pick-card--active': durationPreset === item.value }"
role="option"
:aria-selected="durationPreset === item.value"
@click="selectDuration(item.value)"
>
<img
v-if="item.ribbonUrl"
:src="item.ribbonUrl"
alt=""
class="vip-pick-card__ribbon"
/>
<div
v-else
class="vip-pick-card__ribbon vip-pick-card__ribbon--empty"
>
丝带
</div>
<div class="vip-pick-card__name">{{ item.label }}</div>
</button>
</div>
</div>
<div v-if="durationPreset === 'xy'">
<div class="mb-1 text-sm text-gray-600 dark:text-gray-300">
自定义年数
</div>
<InputNumber
v-model:value="customYears"
class="w-full"
:min="1"
:max="99"
placeholder="请输入年数"
/>
</div>
<div>
<div class="mb-1 text-sm text-gray-600 dark:text-gray-300">备注</div>
<Input.TextArea v-model:value="remark" :rows="2" placeholder="选填" />
</div>
</div>
</Modal>
</template>
<style scoped>
.vip-upgrade-preview {
display: flex;
align-items: center;
gap: 20px;
padding: 20px 24px;
border-radius: 14px;
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
color: #fff;
}
.vip-upgrade-preview__title {
font-size: 20px;
font-weight: 700;
margin-bottom: 4px;
}
.vip-upgrade-preview__sub {
font-size: 13px;
opacity: 0.9;
margin-bottom: 6px;
}
.vip-upgrade-preview__price {
font-size: 12px;
opacity: 0.8;
}
/* 两行 + 横向滑动的单选卡片轨道 */
.vip-pick-scroll {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(2, minmax(0, auto));
grid-auto-columns: 112px;
gap: 10px;
overflow-x: auto;
overflow-y: hidden;
padding: 4px 2px 10px;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
}
.vip-pick-scroll::-webkit-scrollbar {
height: 6px;
}
.vip-pick-scroll::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.18);
border-radius: 999px;
}
.vip-pick-card {
scroll-snap-align: start;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 108px;
padding: 10px 8px;
border: 1.5px solid #e8e8e8;
border-radius: 12px;
background: #fff;
cursor: pointer;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background 0.15s ease;
text-align: center;
}
.vip-pick-card:hover {
border-color: #6acdbb;
}
.vip-pick-card--active {
border-color: #6acdbb;
background: rgba(106, 205, 187, 0.1);
box-shadow: 0 0 0 2px rgba(106, 205, 187, 0.25);
}
.vip-pick-card__badge {
width: 48px;
height: 48px;
object-fit: contain;
}
.vip-pick-card__badge--empty {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f5f5f5;
color: #999;
font-size: 12px;
font-weight: 600;
}
.vip-pick-card__ribbon {
width: 88px;
height: 28px;
object-fit: contain;
}
.vip-pick-card__ribbon--empty {
display: flex;
align-items: center;
justify-content: center;
width: 88px;
height: 28px;
border-radius: 6px;
background: #f5f5f5;
color: #bbb;
font-size: 11px;
}
.vip-pick-card--duration {
min-height: 88px;
}
.vip-pick-card__name {
font-size: 13px;
font-weight: 600;
color: #1f1f1f;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vip-pick-card__meta {
font-size: 11px;
color: #8c8c8c;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ========== 暗色适配html.dark / .dark========== */
html.dark .vip-upgrade-preview,
.dark .vip-upgrade-preview {
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
box-shadow: inset 0 0 0 1px rgba(106, 205, 187, 0.18);
}
html.dark .vip-pick-scroll::-webkit-scrollbar-thumb,
.dark .vip-pick-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.22);
}
html.dark .vip-pick-card,
.dark .vip-pick-card {
border-color: #3f3f46;
background: #1f1f23;
}
html.dark .vip-pick-card:hover,
.dark .vip-pick-card:hover {
border-color: #6acdbb;
background: #27272a;
}
html.dark .vip-pick-card--active,
.dark .vip-pick-card--active {
border-color: #6acdbb;
background: rgba(106, 205, 187, 0.16);
box-shadow: 0 0 0 2px rgba(106, 205, 187, 0.28);
}
html.dark .vip-pick-card__badge--empty,
.dark .vip-pick-card__badge--empty,
html.dark .vip-pick-card__ribbon--empty,
.dark .vip-pick-card__ribbon--empty {
background: #2a2a2e;
color: #a1a1aa;
}
html.dark .vip-pick-card__name,
.dark .vip-pick-card__name {
color: #f4f4f5;
}
html.dark .vip-pick-card__meta,
.dark .vip-pick-card__meta {
color: #a1a1aa;
}
</style>

View File

@@ -1,16 +1,15 @@
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, toValue, type MaybeRefOrGetter } from 'vue';
import type { FileTypeItem } from '#/api/core/file-gallery';
import { getFileTypes } from '#/api/core/file-gallery';
import type { FileGroupOptionItem } from '#/api/core/file-group';
import { useFileGroupCache } from '#/composables/use-file-group-cache';
import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon';
export const ALL_TYPE_VALUE = -1;
export const ALL_GROUP_VALUE = 0;
import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon';
export interface FileGalleryTab {
key: string;
value: number;
@@ -18,7 +17,13 @@ export interface FileGalleryTab {
icon?: string;
}
export function useFileGalleryFilter(storageKey: string) {
/**
* 文件库筛选(类型 tab + 分组 + 关键字)
* @param storageKeyInput localStorage 键;支持动态 getter便于按 acceptTypes 隔离记忆
*/
export function useFileGalleryFilter(
storageKeyInput: MaybeRefOrGetter<string> = 'file-picker-active-type',
) {
const fileTypes = ref<FileTypeItem[]>([]);
const activeType = ref<number>(ALL_TYPE_VALUE);
const activeGroupId = ref<number>(ALL_GROUP_VALUE);
@@ -27,6 +32,10 @@ export function useFileGalleryFilter(storageKey: string) {
const { groupOptions, loadGroupOptions } = useFileGroupCache();
function resolveStorageKey() {
return toValue(storageKeyInput);
}
const tabs = computed<FileGalleryTab[]>(() => [
{ key: 'all', value: ALL_TYPE_VALUE, label: '全部', icon: ALL_TYPE_ICON },
...fileTypes.value.map((item) => ({
@@ -48,7 +57,7 @@ export function useFileGalleryFilter(storageKey: string) {
]);
function restoreActiveType() {
const saved = localStorage.getItem(storageKey);
const saved = localStorage.getItem(resolveStorageKey());
if (saved === null) {
activeType.value = ALL_TYPE_VALUE;
return;
@@ -64,7 +73,7 @@ export function useFileGalleryFilter(storageKey: string) {
function setActiveType(value: number) {
activeType.value = value;
localStorage.setItem(storageKey, String(value));
localStorage.setItem(resolveStorageKey(), String(value));
}
function setActiveGroupId(value: number) {

View File

@@ -17,6 +17,8 @@ export const ENCRYPT_FIELDS = new Set([
'password',
'patient_mobile',
'phone',
'open_api_key',
'api_key',
]);
/** 需要生成 _tm_text 并默认脱敏展示的字段encrypt_only */
@@ -96,7 +98,7 @@ export function maskSensitiveField(field: string, plainText: unknown): string {
return '';
}
const str = String(plainText);
if (field === 'password') {
if (field === 'password' || field === 'api_key' || field === 'open_api_key') {
return '*'.repeat(Math.max(str.length, 6));
}
if (PHONE_FIELDS.has(field)) {

View File

@@ -27,6 +27,8 @@ import {
} from 'lucide-vue-next';
// import { $t } from '#/locales';
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
import { useAuthStore } from '#/store';
import LoginForm from '#/views/_core/authentication/login.vue';
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
@@ -179,6 +181,13 @@ const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
/** 与原先悬浮窗一致:有 doctor_id 且绑定门店才显示传方入口 */
const showDoctorTransfer = computed(() => {
const info = userStore.userInfo as Record<string, any> | null | undefined;
if (!info) return false;
return !!(info.doctor_id && info.store_id);
});
const menus = computed(() => [
{
handler: () => {
@@ -233,7 +242,13 @@ watch(
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<!-- index=55紧挨全局搜索(50)右侧 -->
<template #header-right-55>
<DoctorTransferFloat v-if="showDoctorTransfer" />
</template>
<template #user-dropdown>
<div class="flex items-center">
<HeaderVipBadge />
<UserDropdown
:avatar
:description="userStore.userInfo?.email"
@@ -242,6 +257,7 @@ watch(
:text="userStore.userInfo?.nick_name"
@logout="handleLogout"
/>
</div>
</template>
<template #notification>
<Notification

View File

@@ -65,6 +65,11 @@ export const usePrescriptionStore = defineStore('prescription', () => {
{ label: '医疗器械', value: 7, icon: '', icon_text: '' },
]);
const prescriptionTypeDefault = ref(2);
/** 开方种类权限校验中(进入开方页先 loading */
const prescriptionTypeLoading = ref(false);
/** 当前门店是否有可开方种类 */
const prescriptionTypeAllowed = ref(true);
const prescriptionTypeDenyMessage = ref('');
const diagnosis = ref('');
const medicalAdvice = ref('');
const treatmentPrice = ref(0);
@@ -204,9 +209,10 @@ export const usePrescriptionStore = defineStore('prescription', () => {
};
/**
* 拉取后端处方类型列表与默认选中
* 拉取后端处方类型列表与默认选中;无绑定时清空 list 并返回 false
*/
const loadPrescriptionTypeOptions = async (registerId?: number | string) => {
prescriptionTypeLoading.value = true;
try {
const params: { register_id?: number; store_id?: number } = {};
const rid = Number(registerId || currentRegisterId.value);
@@ -214,19 +220,34 @@ export const usePrescriptionStore = defineStore('prescription', () => {
if (myStoreId.value) params.store_id = myStoreId.value;
const res = await getPrescriptionTypeOptionsApi(params);
const list = Array.isArray(res?.list) ? res.list : [];
if (list.length) {
categories.value = list.map((item) => ({
value: Number(item.value),
label: item.label || '',
icon: item.icon || '',
icon_text: item.icon_text || '',
}));
const def = Number(res?.default) || 0;
prescriptionTypeDefault.value = def;
const allowed =
res?.allowed !== false && list.length > 0;
prescriptionTypeAllowed.value = allowed;
prescriptionTypeDenyMessage.value = allowed
? ''
: (res?.message || '当前门店未开通开方权限');
// 当前 Tab 不在 allowed 内时强制切到 default
if (allowed && def && !categories.value.some((c) => c.value === activeCategory.value)) {
activeCategory.value = def;
}
const def = Number(res?.default);
if (def) prescriptionTypeDefault.value = def;
return allowed;
} catch (error) {
console.error('加载处方类型失败:', error);
categories.value = [];
prescriptionTypeAllowed.value = false;
prescriptionTypeDenyMessage.value = '处方类型加载失败,请稍后重试';
message.warning('处方类型加载失败,请稍后重试');
return false;
} finally {
prescriptionTypeLoading.value = false;
}
};
@@ -582,8 +603,25 @@ export const usePrescriptionStore = defineStore('prescription', () => {
}
}, 300);
// 药品操作
/**
* 从商品/药品数据解析处方分类 tab与 activeCategory 对齐)
* 1 中药 / 2 西药 / 3 保健食品 / 5 产品服务包 / 6 非药品 / 7 医疗器械
* 缺省按西药 2避免挂号选药误入中药开方
*/
const resolveDrugCategory = (data: any): number => {
const raw = data?.drug?.type ?? data?.type;
const n = Number(raw);
if (Number.isFinite(n) && n > 0) return n;
return 2;
};
// 药品操作:写入前按药型自动切到对应 tab避免西药落入中药开方组件
const addProducts = (data: any) => {
const targetCategory = resolveDrugCategory(data);
if (Number(activeCategory.value) !== targetCategory) {
changeCategory(targetCategory);
}
const existItem = currentDrugs.value.find(
(item) => item.index_id === data.id,
);
@@ -1077,6 +1115,38 @@ export const usePrescriptionStore = defineStore('prescription', () => {
},
userStore.currentUser.doctor_id,
);
// 在线复诊开方后自动连发购药指引与用药温馨提示(小助手发送)
const tipTexts = [
'根据您的病情描述,已为您开具电子处方,请点击上方的‘立即支付’,进行购药;如有商品价格和订单等问题请咨询平台客服。',
'温馨提示:用药前请详细阅读药品说明书,并严格按照线下医嘱用药。如用药过程中出现病情变化或其它不适症状,请立即停药并及时就医。(请您注意,如果您尚未在医院就诊或未曾使用过本次申请的药品,请暂时不要支付订单。我们建议您在医生的指导下使用药品,以确保用药安全。)',
];
tipTexts.forEach((tip, tipIdx) => {
sendMessage({
roomId: chatStore.currentFriend.room_id,
senderId: 'doctor_assistant',
receiverId: chatStore.currentFriend.id,
type: 'text',
content: tip,
});
chatStore.addMessage(
{
id: `${Date.now()}_tip_${tipIdx}`,
room_id: chatStore.currentFriend.room_id,
sender_user_id: 'doctor_assistant',
receiver_user_id: chatStore.currentFriend.id,
message_type: 0,
message_content: tip,
messageTypeName: 'text',
created_at: Date.now() + tipIdx + 1,
created_at_text: new Date().toLocaleTimeString().slice(0, 5),
timestamp: Date.now() + tipIdx + 1,
isSent: true,
read: true,
duration: 0,
},
userStore.currentUser.doctor_id,
);
});
resetForm();
// 发送成功后清空本挂号下全部分类草稿(含另一分类与 activeCategory
clearLocalPrescriptionCache();
@@ -1200,6 +1270,9 @@ export const usePrescriptionStore = defineStore('prescription', () => {
activeCategory,
categories,
prescriptionTypeDefault,
prescriptionTypeLoading,
prescriptionTypeAllowed,
prescriptionTypeDenyMessage,
diagnosis,
medicalAdvice,
treatmentPrice,
@@ -1240,6 +1313,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
resetInitializationState,
getDrugList,
addProducts,
resolveDrugCategory,
removeDrug,
updateDrugQuantity,
increment,

View File

@@ -0,0 +1,165 @@
/**
* 字典/词条搜索:匹配度打分、排序、关键字高亮拆分
* 与后端 DictSearchRankService 规则对齐mode=frontend 时由本工具排序
*/
export type DictSearchRankMode = 'backend' | 'frontend';
export type HighlightPart = { text: string; hit: boolean };
/** 与 PHP DictSearchRankService::score 对齐 */
export function scoreDictMatch(
text: string,
keyword: string,
extra = '',
): number {
const kw = String(keyword || '').trim();
if (!kw) return 0;
const main = String(text || '').trim();
if (!main) return 0;
let s = scoreOne(main, kw);
if (extra) {
s = Math.max(s, Math.floor(scoreOne(String(extra), kw) * 0.9));
}
return s;
}
function scoreOne(haystack: string, needle: string): number {
if (haystack === needle) return 100;
const lowerH = haystack.toLowerCase();
const lowerN = needle.toLowerCase();
const pos = lowerH.indexOf(lowerN);
if (pos < 0) return 0;
if (pos === 0) {
const lenBonus = Math.max(0, 10 - Math.abs(haystack.length - needle.length));
return 80 + Math.min(15, lenBonus);
}
return Math.max(20, 60 - Math.min(40, pos));
}
/**
* 按匹配度降序;无关键字时保持原序
*/
export function rankByMatchScore<T>(
items: T[],
keyword: string,
getText: (item: T) => string,
getExtra?: (item: T) => string,
): Array<T & { match_score: number }> {
const kw = String(keyword || '').trim();
const scored = items.map((item) => {
const score = kw
? scoreDictMatch(getText(item), kw, getExtra?.(item) || '')
: 0;
return Object.assign({}, item, { match_score: score }) as T & {
match_score: number;
};
});
if (!kw) return scored;
return scored.sort((a, b) => {
if (b.match_score !== a.match_score) return b.match_score - a.match_score;
return 0;
});
}
/**
* 将文本按关键字拆成高亮片段(不做 HTML便于 PC/小程序安全渲染)
*/
export function splitHighlight(
text: string,
keyword: string,
): HighlightPart[] {
const raw = String(text ?? '');
const kw = String(keyword || '').trim();
if (!raw || !kw) return [{ text: raw, hit: false }];
const lower = raw.toLowerCase();
const needle = kw.toLowerCase();
const parts: HighlightPart[] = [];
let start = 0;
let idx = lower.indexOf(needle, start);
while (idx >= 0) {
if (idx > start) {
parts.push({ text: raw.slice(start, idx), hit: false });
}
parts.push({ text: raw.slice(idx, idx + kw.length), hit: true });
start = idx + kw.length;
idx = lower.indexOf(needle, start);
}
if (start < raw.length) {
parts.push({ text: raw.slice(start), hit: false });
}
return parts.length ? parts : [{ text: raw, hit: false }];
}
let cachedRankMode: DictSearchRankMode | null = null;
/** 多气泡实例共享同一请求,避免进页 N 次 get-by-keys */
let rankModeFetchPromise: Promise<DictSearchRankMode> | null = null;
/** 写入缓存(从系统配置或接口响应读取后调用) */
export function setDictSearchRankMode(mode: string | undefined | null) {
cachedRankMode = mode === 'frontend' ? 'frontend' : 'backend';
}
export function getDictSearchRankMode(): DictSearchRankMode {
return cachedRankMode || 'backend';
}
export function isDictSearchRankModeLoaded(): boolean {
return cachedRankMode != null;
}
/**
* 确保已加载匹配度模式:已缓存则跳过;并发调用共享同一 Promise
* @param loader 真正打网取配置的函数,返回 frontend|backend
*/
export async function ensureDictSearchRankMode(
loader: () => Promise<string | undefined | null>,
): Promise<DictSearchRankMode> {
if (cachedRankMode != null) return cachedRankMode;
if (!rankModeFetchPromise) {
rankModeFetchPromise = (async () => {
try {
const mode = await loader();
setDictSearchRankMode(mode);
} catch {
setDictSearchRankMode('backend');
}
return cachedRankMode || 'backend';
})();
}
return rankModeFetchPromise;
}
/**
* 按当前模式决定是否前端重排;有关键字时保证按匹配度降序,并尽量带上可展示的 match_score
*/
export function applyDictRankIfNeeded<T>(
items: T[],
keyword: string,
getText: (item: T) => string,
getExtra?: (item: T) => string,
modeFromApi?: string,
): Array<T & { match_score: number }> {
if (modeFromApi === 'frontend' || modeFromApi === 'backend') {
setDictSearchRankMode(modeFromApi);
}
const mode = getDictSearchRankMode();
const kw = String(keyword || '').trim();
if (mode === 'frontend') {
return rankByMatchScore(items, keyword, getText, getExtra);
}
// backend保留接口分数若有关键字却全是 0漏打分/合并未打分),前端补算
const mapped = items.map((item: any) => ({
...item,
match_score: Number(item?.match_score || 0),
})) as Array<T & { match_score: number }>;
if (!kw) return mapped;
const hasPositive = mapped.some((i) => Number(i.match_score) > 0);
if (!hasPositive) {
return rankByMatchScore(items, keyword, getText, getExtra);
}
return mapped.sort((a, b) => {
if (b.match_score !== a.match_score) return b.match_score - a.match_score;
return 0;
});
}

View File

@@ -115,6 +115,23 @@ export function generateObjectName(file: File): string {
return objectName;
}
/**
* 根据 File 推断上传用 Content-Type浏览器可能给空 type
*/
function resolveUploadContentType(file: File): string {
if (file.type && file.type !== 'application/octet-stream') {
return file.type;
}
const name = (file.name || '').toLowerCase();
if (name.endsWith('.pdf')) return 'application/pdf';
if (name.endsWith('.png')) return 'image/png';
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg';
if (name.endsWith('.gif')) return 'image/gif';
if (name.endsWith('.webp')) return 'image/webp';
if (name.endsWith('.bmp')) return 'image/bmp';
return file.type || 'application/octet-stream';
}
/**
* 从后端获取OSS上传签名信息
*
@@ -253,6 +270,12 @@ export async function uploadToOss(options: UploadOptions): Promise<UploadResult>
// 注意此字段必须与后端Policy中的条件一致否则会上传失败
formData.append('x-oss-object-acl', 'public-read');
// Content-Type按文件 MIME 写入,避免 PDF 被当成 octet-stream 导致浏览器直接下载
const contentType = resolveUploadContentType(file);
formData.append('Content-Type', contentType);
// Content-Disposition: inline —— 浏览器倾向预览而非附件下载
formData.append('Content-Disposition', 'inline');
// file: 要上传的文件
// 必须是最后一个字段OSS要求file字段在FormData的最后
formData.append('file', file);

View File

@@ -0,0 +1,160 @@
import { onUnmounted, ref, type Ref } from 'vue';
import { message } from 'ant-design-vue';
export interface UsePasteUploadListenOptions {
/** accept 字符串,如 `.jpg,.png,image/*,application/pdf`;支持 getter */
accept?: string | (() => string);
/** 单文件最大体积(字节);支持 getter */
maxSize?: number | (() => number);
/** 过滤后的文件回调 */
onFiles: (files: File[]) => void | Promise<void>;
}
/**
* 将 accept 规则解析为便于匹配的片段列表
*/
function parseAccept(accept: string): string[] {
return accept
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
/**
* 判断文件是否匹配 accept扩展名 / mime / image/* 等)
*/
export function matchAccept(file: File, accept: string): boolean {
const rules = parseAccept(accept);
if (!rules.length) return true;
const name = (file.name || '').toLowerCase();
const type = (file.type || '').toLowerCase();
const ext = name.includes('.') ? `.${name.split('.').pop()}` : '';
return rules.some((rule) => {
if (rule === '*/*') return true;
if (rule.endsWith('/*')) {
const prefix = rule.slice(0, -1); // image/
return type.startsWith(prefix);
}
if (rule.startsWith('.')) {
return ext === rule;
}
return type === rule;
});
}
/**
* 从粘贴事件中收集 Filefiles + items 截图等)
*/
export function collectPasteFiles(event: ClipboardEvent): File[] {
const result: File[] = [];
const seen = new Set<string>();
const push = (file: File | null | undefined) => {
if (!file) return;
const key = `${file.name}|${file.size}|${file.type}|${file.lastModified}`;
if (seen.has(key)) return;
seen.add(key);
result.push(file);
};
const files = event.clipboardData?.files;
if (files?.length) {
for (let i = 0; i < files.length; i++) {
push(files.item(i));
}
}
const items = event.clipboardData?.items;
if (items?.length) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item?.kind === 'file') {
push(item.getAsFile());
}
}
}
return result;
}
/**
* 开始/停止监听窗口粘贴,用于上传组件在拖拽区外主动开启粘贴上传
*/
export function usePasteUploadListen(options: UsePasteUploadListenOptions) {
const listening: Ref<boolean> = ref(false);
let handler: ((e: ClipboardEvent) => void) | null = null;
function stop() {
if (handler) {
window.removeEventListener('paste', handler);
handler = null;
}
listening.value = false;
}
function start() {
stop();
handler = (event: ClipboardEvent) => {
const acceptRaw = options.accept;
const accept =
typeof acceptRaw === 'function' ? acceptRaw() : acceptRaw || '';
const maxSizeRaw = options.maxSize;
const maxSize =
typeof maxSizeRaw === 'function'
? maxSizeRaw()
: (maxSizeRaw ?? 20 * 1024 * 1024);
const raw = collectPasteFiles(event);
if (!raw.length) {
message.warning('剪贴板中没有可上传的文件');
return;
}
const filtered: File[] = [];
for (const file of raw) {
if (accept && !matchAccept(file, accept)) {
continue;
}
if (file.size > maxSize) {
message.warning(`${file.name || '文件'} 超过大小限制`);
continue;
}
// 截图常无名为空,补一个默认名便于上传
if (!file.name) {
const ext =
file.type === 'image/png'
? 'png'
: file.type === 'image/jpeg'
? 'jpg'
: file.type === 'application/pdf'
? 'pdf'
: 'bin';
filtered.push(new File([file], `paste-${Date.now()}.${ext}`, { type: file.type }));
} else {
filtered.push(file);
}
}
if (!filtered.length) {
message.warning('剪贴板文件类型不符合要求');
return;
}
event.preventDefault();
void options.onFiles(filtered);
};
window.addEventListener('paste', handler);
listening.value = true;
}
function toggle() {
if (listening.value) stop();
else start();
}
onUnmounted(() => {
stop();
});
return {
listening,
start,
stop,
toggle,
};
}

View File

@@ -0,0 +1,68 @@
/**
* VIP 权限判断工具(前端口子)
* 当前从登录态 userInfo.vip 读取;后续可扩展为按 storeId 请求接口
*/
import { useUserStore } from '@vben/stores';
export interface StoreVipInfo {
level_code?: string;
level_name?: string;
level_weight?: number;
badge_url?: string;
duration_type?: string;
duration_label?: string;
duration_badge_url?: string;
permissions?: string[];
expire_at?: number;
is_lifetime?: boolean;
}
/** 读取当前登录用户 VIP 信息 */
export function getCurrentVip(): StoreVipInfo | null {
const userStore = useUserStore();
return ((userStore.userInfo as any)?.vip as StoreVipInfo) || null;
}
/** 是否拥有指定权限码 */
export function hasVipPermission(code: string): boolean {
if (!code) return false;
const vip = getCurrentVip();
const list = vip?.permissions;
return Array.isArray(list) && list.includes(code);
}
/**
* 等级是否不低于目标(按 level_weight若无 weight 则仅精确匹配编码时返回 true
* 简化:仅当当前 level_code 与 minCode 相同,或 weight 足够时通过
*/
export function levelAtLeast(minCode: string): boolean {
const vip = getCurrentVip();
if (!vip?.level_code) return false;
if (vip.level_code === minCode) return true;
// 无完整等级表时,仅做字符串相等判断;业务侧应以后端 Gate 为准
const weight = Number(vip.level_weight ?? 0);
const minWeight = parseLevelWeight(minCode);
if (minWeight >= 0) {
return weight >= minWeight;
}
return false;
}
/** 粗略解析 V0-V11 / BLACK_GOLD / CUSTOM 权重 */
function parseLevelWeight(code: string): number {
if (/^V\d+$/i.test(code)) {
return Number(code.slice(1));
}
if (code === 'BLACK_GOLD') return 12;
if (code === 'CUSTOM') return 13;
return -1;
}
/** 组合式:供组件调用 */
export function useVipGate() {
return {
vip: getCurrentVip(),
hasVipPermission,
levelAtLeast,
};
}

View File

@@ -1,13 +1,29 @@
<script setup>
/**
* 诊所复诊 type11 follow_up_drug 助理用药确认卡
* 展示问题、药品、是否用过、适用症/补充;可一键导入处方单
*/
import { computed } from 'vue';
import { Button } from 'ant-design-vue';
const props = defineProps({
content: {
type: Object,
required: true,
},
/** 是否展示「添加到处方单」(接诊中且有药) */
showAddToRx: {
type: Boolean,
default: false,
},
addToRxLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['add-to-rx']);
const data = computed(() =>
typeof props.content === 'string'
? (() => {
@@ -27,9 +43,36 @@ const answeredLabel = computed(() => {
return '';
});
/** 是否就诊过:有明确 0/1 才展示 */
const visitedLabel = computed(() => {
const v = data.value.has_visited;
if (v === 1 || v === '1') return '是';
if (v === 0 || v === '0') return '否';
return '';
});
const drugs = computed(() =>
Array.isArray(data.value.drugs) ? data.value.drugs : [],
);
/** 适用症文案(有值才展示) */
const illnessInfo = computed(() => {
const v = data.value.illness_info;
return v != null && String(v).trim() ? String(v).trim() : '';
});
/**
* 补充信息:优先独立字段;若 illness_info 已含「;补充:」且无独立字段则不重复展示
*/
const supplementText = computed(() => {
const v = data.value.supplement;
if (v != null && String(v).trim()) return String(v).trim();
return '';
});
const canShowAddBtn = computed(
() => props.showAddToRx && drugs.value.length > 0,
);
</script>
<template>
@@ -63,12 +106,41 @@ const drugs = computed(() =>
<span class="value">{{ answeredLabel }}</span>
</div>
<div v-if="visitedLabel" class="answer-row">
<span class="label">是否就诊过</span>
<span class="value">{{ visitedLabel }}</span>
</div>
<div v-if="illnessInfo" class="info-block">
<div class="info-block-label">适用症/症状</div>
<div class="info-block-content">{{ illnessInfo }}</div>
</div>
<div v-if="supplementText" class="info-block">
<div class="info-block-label">补充信息</div>
<div class="info-block-content">{{ supplementText }}</div>
</div>
<div
v-else-if="data.answer_status === 'pending'"
v-else-if="!answeredLabel && data.answer_status === 'pending'"
class="pending-tip"
>
待患者在小程序端填写用药信息
</div>
<div v-if="canShowAddBtn" class="add-rx-wrap">
<Button
:loading="addToRxLoading"
block
size="small"
type="primary"
ghost
@click.stop="emit('add-to-rx')"
>
<i class="fas fa-plus-circle mr-1"></i>
添加到处方单
</Button>
</div>
</div>
</template>
@@ -122,7 +194,7 @@ const drugs = computed(() =>
}
.answer-row {
@apply flex items-center justify-between rounded-lg bg-emerald-50 px-3 py-2 text-sm dark:bg-emerald-900/30;
@apply mb-3 flex items-center justify-between rounded-lg bg-emerald-50 px-3 py-2 text-sm dark:bg-emerald-900/30;
}
.answer-row .label {
@@ -133,7 +205,23 @@ const drugs = computed(() =>
@apply font-medium text-emerald-800 dark:text-emerald-300;
}
.info-block {
@apply mb-3 rounded-lg bg-slate-50 px-3 py-2 dark:bg-slate-900/50;
}
.info-block-label {
@apply mb-1 text-xs text-slate-500 dark:text-slate-400;
}
.info-block-content {
@apply text-sm leading-relaxed text-slate-800 dark:text-slate-100;
}
.pending-tip {
@apply rounded-lg border border-dashed border-amber-200 bg-amber-50 px-3 py-2 text-center text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200;
}
.add-rx-wrap {
@apply mt-3 border-t border-slate-100 pt-3 dark:border-slate-600;
}
</style>

View File

@@ -251,10 +251,15 @@ const canAddRegisterDrugsToRx = computed(() => {
const handleAddRegisterDrugsToPrescription = async () => {
if (registerAddToRxLoading.value) return;
const cardRegisterId = registerCardDisplay.value?.id;
const followUpRegisterId =
isFollowUpDrugAssistant.value && parsedContent.value?.register_id
? parsedContent.value.register_id
: '';
const registerId =
prescriptionStore.currentRegisterId ||
chatStore.currentFriend?.register_id ||
cardRegisterId;
cardRegisterId ||
followUpRegisterId;
if (!registerId) {
antdMessage.warning('请先接诊患者');
return;
@@ -308,6 +313,22 @@ const isFollowUpDrugAssistant = computed(() => {
return !!(pc && typeof pc === 'object' && pc.flow === 'follow_up_drug');
});
/**
* 复诊用药卡「添加到处方单」:有药、非本人发送、当前会话有挂号
* 与挂号卡加方共用 handleAddRegisterDrugsToPrescription
*/
const canAddFollowUpDrugsToRx = computed(() => {
if (!isFollowUpDrugAssistant.value || props.isSent) return false;
const pc = parsedContent.value;
const drugs = pc && Array.isArray(pc.drugs) ? pc.drugs : [];
if (!drugs.length) return false;
return !!(
prescriptionStore.currentRegisterId ||
chatStore.currentFriend?.register_id ||
pc.register_id
);
});
function fetchRegisterCard() {
if (props.message.message_type !== 10) return;
const content = getParsedContent(
@@ -726,9 +747,13 @@ const getSexText = (sex) => {
</div>
</div>
<!-- 主诉 / 所选药品逐药缩略图 + 规格 + 数量对齐小程序 -->
<!-- 主诉 / 适用症 / 所选药品逐药缩略图 + 规格 + 数量对齐小程序 -->
<div
v-if="registerCardDisplay.chief_complaint || registerCardDrugRows.length"
v-if="
registerCardDisplay.chief_complaint ||
registerCardDisplay.illnessInfo ||
registerCardDrugRows.length
"
class="mt-3 space-y-2 rounded-lg border border-gray-100 p-3 text-sm dark:border-gray-600"
>
<div v-if="registerCardDisplay.chief_complaint">
@@ -737,6 +762,33 @@ const getSexText = (sex) => {
registerCardDisplay.chief_complaint
}}</span>
</div>
<div v-if="registerCardDisplay.illnessInfo">
<span class="font-medium text-gray-600 dark:text-gray-300">适用症/症状:</span>
<span class="text-gray-800 dark:text-gray-100">{{
registerCardDisplay.illnessInfo
}}</span>
</div>
<div
v-if="registerCardDisplay.illnessInfo"
class="flex flex-wrap gap-x-4 gap-y-1 text-gray-700 dark:text-gray-200"
>
<span>
是否就诊过:{{
registerCardDisplay.has_visited === 1 ||
registerCardDisplay.has_visited === '1'
? '是'
: '否'
}}
</span>
<span>
是否使用过药品:{{
registerCardDisplay.has_used_drug === 1 ||
registerCardDisplay.has_used_drug === '1'
? '是'
: '否'
}}
</span>
</div>
<div v-if="registerCardDrugRows.length" class="space-y-2">
<div class="font-medium text-gray-600 dark:text-gray-300">所选药品:</div>
<div
@@ -1044,6 +1096,9 @@ const getSexText = (sex) => {
<FollowUpDrugAssistantCard
v-else-if="message.message_type === 11 && isFollowUpDrugAssistant"
:content="parsedContent"
:show-add-to-rx="canAddFollowUpDrugsToRx"
:add-to-rx-loading="registerAddToRxLoading"
@add-to-rx="handleAddRegisterDrugsToPrescription"
/>
<!-- 患者就诊经历卡片 (type=11) -->

View File

@@ -134,9 +134,12 @@ export const sendMessage = (data) => {
const receiverId = data.receiverId.toString();
const normalizedReceiverId = receiverId.startsWith('user-') ? receiverId : `user-${receiverId}`;
// 确保 sender_user_id doctor- 前缀(医生端)
// 确保 sender_user_id:小助手保持 doctor_assistant医生端补 doctor- 前缀
const senderId = data.senderId.toString();
const normalizedSenderId = senderId.startsWith('doctor-') ? senderId : `doctor-${senderId}`;
const normalizedSenderId =
senderId === 'doctor_assistant' || senderId.startsWith('doctor-')
? senderId
: `doctor-${senderId}`;
const requestData = {
room_id: data.roomId,

View File

@@ -0,0 +1,35 @@
<script lang="ts" setup>
/**
* 病历门店词条-按字段拆分页(从路由 path 末段解析 field_code
*/
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import EntryListPage from '#/views/doctor/medical-record/components/EntryListPage.vue';
import { MEDICAL_RECORD_FIELD_OPTIONS } from '#/views/doctor/medical-record/config/constants';
defineOptions({ name: 'MedicalRecordEntryStoreField' });
const route = useRoute();
const fieldCode = computed(() => {
const seg = (route.path.split('/').filter(Boolean).pop() || '').trim();
return seg.replace(/-/g, '_');
});
const fieldLabel = computed(() => {
const hit = MEDICAL_RECORD_FIELD_OPTIONS.find((o) => o.value === fieldCode.value);
return hit?.label || fieldCode.value;
});
const pageTitle = computed(() => `门店词条·${fieldLabel.value}`);
</script>
<template>
<EntryListPage
:key="fieldCode"
scope="store"
:fixed-field-code="fieldCode"
:page-title="pageTitle"
:page-name="String(route.name || 'MedicalRecordEntryStoreField')"
/>
</template>

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
/**
* 病历门店词条管理(当前登录门店)
*/
import EntryListPage from '#/views/doctor/medical-record/components/EntryListPage.vue';
defineOptions({ name: 'MedicalRecordEntryStore' });
</script>
<template>
<EntryListPage
scope="store"
page-title="病历门店词条"
page-name="MedicalRecordEntryStore"
/>
</template>

View File

@@ -9,6 +9,20 @@ export async function simulatePayApi(data: {
return requestClient.post<any>(`${prefix}simulate-pay`, data);
}
/**
* 产品订单重新结算:作废残缺分账后按规则重建并入账(防重复加账)
*/
export async function retrySettleApi(data: { order_id: number }) {
return requestClient.post<any>(`${prefix}retry-settle`, data);
}
/**
* 超管手动确认收货 / 重试解冻(自动确认收货队列失败后的人工补偿)
*/
export async function retryConfirmReceiveApi(data: { order_id: number }) {
return requestClient.post<any>(`${prefix}retry-confirm-receive`, data);
}
export async function getOrderTraceApi(params: {
order_id: number;
scene: 'product' | 'register';

View File

@@ -37,7 +37,7 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="处方溯源">
<Modal class="w-[80%] h-[80%]" title="处方溯源">
<PrescriptionSourceContent :data="data" />
</Modal>
</template>

View File

@@ -1,6 +1,7 @@
import type { VbenFormProps } from '#/adapter/form';
import dayjs from 'dayjs';
import { getOrderPrescriptionTypeOption } from '#/views/business/order/product-order/api';
export const formOptions: VbenFormProps = {
// 默认展开
@@ -16,29 +17,19 @@ export const formOptions: VbenFormProps = {
label: '订单号',
},
{
component: 'VbenSelect',
// 与商品订单共用后端 ProductTypeEnum 开方类型,保证筛选项完整
component: 'ApiSelect',
componentProps: {
allowClear: true,
filterOption: true,
showSearch: true,
options: [
{
label: '中药',
value: 1,
afterFetch: (data: { id: number; name: string }[]) => {
return data.map((item) => ({
label: item.name,
value: item.id,
}));
},
{
label: '西药',
value: 2,
},
{
label: '中成药',
value: 3,
},
{
label: '产品服务包',
value: 5,
},
],
api: getOrderPrescriptionTypeOption,
placeholder: '请选择',
},
fieldName: 'prescription_type',
@@ -47,9 +38,14 @@ export const formOptions: VbenFormProps = {
{
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD', // 确保格式与你需要的一致
format: 'YYYY-MM-DD',
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
valueFormat: 'YYYY-MM-DD',
},
defaultValue: [dayjs().startOf('month'), dayjs()],
defaultValue: [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
fieldName: 'search_time',
label: '时间范围',
},

View File

@@ -15,6 +15,16 @@ export async function getOrderList(data: any) {
export async function getOrderStatusOption(data: any) {
return requestClient.get<any>(`${prefix}status-option`, { params: data });
}
/**
* 订单/处方类型筛选项(与后端 ProductTypeEnum 开方类型对齐)
*/
export async function getOrderPrescriptionTypeOption(data?: any) {
return requestClient.get<Array<{ id: number; name: string }>>(
`${prefix}prescription-type-option`,
{ params: data },
);
}
/**
* 统计金额
* @param data

View File

@@ -28,6 +28,7 @@ import {
deleteOrderExportScheme,
getOrderExportDataApi,
getOrderExportSchemeList,
getOrderPrescriptionTypeOption,
getOrderStatusOption,
type OrderExportDataParams,
type OrderExportSchemeItem,
@@ -69,13 +70,6 @@ const DELIVERY_OPTIONS = [
{ label: '诊所自提', value: 1 },
];
const PRESCRIPTION_TYPE_OPTIONS = [
{ label: '中药', value: 1 },
{ label: '西药', value: 2 },
{ label: '中成药', value: 3 },
{ label: '产品服务包', value: 5 },
];
const searchTime = ref<[Dayjs, Dayjs]>([dayjs().startOf('month'), dayjs()]);
const orderNo = ref('');
const storeId = ref<number | undefined>(undefined);
@@ -94,6 +88,8 @@ const loadingStep = ref('');
const storeOptions = ref<{ label: string; value: number }[]>([]);
const statusOptions = ref<{ label: string; value: number }[]>([]);
/** 订单类型选项:打开弹窗时从后端 ProductTypeEnum 拉取 */
const prescriptionTypeOptions = ref<{ label: string; value: number }[]>([]);
const fetchingStores = ref(false);
const storeExportLocked = ref(false);
const lockedStoreName = ref('');
@@ -331,6 +327,18 @@ const loadStatusOptions = () => {
});
};
/** 拉取完整订单类型(含非药品、医疗器械等) */
const loadPrescriptionTypeOptions = () => {
getOrderPrescriptionTypeOption({}).then(
(res: { id: number; name: string }[]) => {
prescriptionTypeOptions.value = (res || []).map((item) => ({
label: item.name,
value: item.id,
}));
},
);
};
function onSelectedKeysChange() {
markSchemeModified();
}
@@ -572,7 +580,7 @@ const [Modal, modalApi] = useVbenModal({
status_text: labelOf(statusOptions.value, status.value),
delivery_method_text: labelOf(DELIVERY_OPTIONS, deliveryMethod.value),
prescription_type_text: labelOf(
PRESCRIPTION_TYPE_OPTIONS,
prescriptionTypeOptions.value,
prescriptionType.value,
),
export_fields_text: selectedFieldLabels(),
@@ -620,6 +628,7 @@ const [Modal, modalApi] = useVbenModal({
loadStores();
}
loadStatusOptions();
loadPrescriptionTypeOptions();
void loadSchemes(true);
}
},
@@ -749,7 +758,7 @@ const [Modal, modalApi] = useVbenModal({
v-model:value="prescriptionType"
allow-clear
class="w-full"
:options="PRESCRIPTION_TYPE_OPTIONS"
:options="prescriptionTypeOptions"
placeholder="全部"
/>
</div>

View File

@@ -4,9 +4,10 @@ import { computed, ref } from 'vue';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { Button, Card, Descriptions, Image, Space, Tabs, Tag, message } from 'ant-design-vue';
import { Button, Card, Descriptions, Image, Modal as AntdModal, Space, Tabs, Tag, message } from 'ant-design-vue';
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
import { retrySettleApi } from '#/views/business/order/api/order-ops';
import OrderPricePercentAdjustDrawer from '#/views/business/order/components/OrderPricePercentAdjustDrawer.vue';
import { getOrderPriceAdjustConfig, adjustOrderPercent } from '#/api/order/priceAdjust';
@@ -30,6 +31,26 @@ const expressDetail = ref({});
// 订单发货方式
const deliveryMethod = ref(-1);
const syncingLegacy = ref(false);
/** 重新结算请求中,防止重复点击 */
const retrySettling = ref(false);
/**
* 超管可见:已支付且未取消/未退款时可重新结算(补齐残缺分账)
* 与后端一致:仅 role_id=1SUPPER_ADMIN
*/
const canRetrySettle = computed(() => {
const roleId = Number(
userStore.userInfo?.role_id ?? userStore.userInfo?.roles?.id,
);
if (roleId !== 1) return false;
const row = data.value;
if (!row?.id) return false;
return (
Number(row.is_pay) === 1 &&
Number(row.cancel_status) === 0 &&
Number(row.refund_status) === 0
);
});
/**
* 超管可见:上门快递且订单有运单时,可手动把历史运单同步到 shipment
@@ -123,6 +144,31 @@ function openOrderTrace() {
traceDrawerApi.open();
}
/**
* 确认后触发后端重新结算:作废残缺分账并按规则重建入账,避免重复加钱
*/
function handleRetrySettle() {
if (!data.value?.id || retrySettling.value) {
return;
}
AntdModal.confirm({
title: '重新结算',
content: `确认为订单 ${data.value.order_no} 重新结算分账?将作废旧分账后按当前规则重建,已完整的订单不会重复加账。`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
retrySettling.value = true;
try {
const res = await retrySettleApi({ order_id: Number(data.value.id) });
message.success(res?.message || '重新结算成功');
await reloadOrder();
} finally {
retrySettling.value = false;
}
},
});
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
@@ -263,12 +309,20 @@ function prescriptionStatusColor() {
}
</script>
<template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="订单详情">
<Modal class="h-[80%] w-[80%]" title="订单详情">
<div v-if="data" class="flex flex-col gap-4">
<Space>
<Button type="primary" size="small" @click="openOrderTrace">
查看溯源
</Button>
<Button
v-if="canRetrySettle"
size="small"
:loading="retrySettling"
@click="handleRetrySettle"
>
重新结算
</Button>
</Space>
<h3 class="mt-4">订单信息</h3>
<Descriptions

View File

@@ -1,7 +1,10 @@
import type { VbenFormProps } from '#/adapter/form';
import dayjs from 'dayjs';
import { getOrderStatusOption } from '#/views/business/order/product-order/api';
import {
getOrderPrescriptionTypeOption,
getOrderStatusOption,
} from '#/views/business/order/product-order/api';
export const formOptions: VbenFormProps = {
// 默认展开
@@ -65,29 +68,19 @@ export const formOptions: VbenFormProps = {
label: '发货方式',
},
{
component: 'VbenSelect',
// 类型从后端枚举拉取,避免前端写死漏掉非药品/医疗器械等
component: 'ApiSelect',
componentProps: {
allowClear: true,
filterOption: true,
showSearch: true,
options: [
{
label: '中药',
value: 1,
afterFetch: (data: { id: number; name: string }[]) => {
return data.map((item) => ({
label: item.name,
value: item.id,
}));
},
{
label: '西药',
value: 2,
},
{
label: '中成药',
value: 3,
},
{
label: '产品服务包',
value: 5,
},
],
api: getOrderPrescriptionTypeOption,
placeholder: '请选择',
},
fieldName: 'prescription_type',
@@ -106,8 +99,13 @@ export const formOptions: VbenFormProps = {
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD',
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
valueFormat: 'YYYY-MM-DD',
},
defaultValue: [dayjs().startOf('month'), dayjs()],
defaultValue: [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
fieldName: 'search_time',
label: '时间范围',
},

View File

@@ -43,7 +43,12 @@ import {
updateFreeShipping,
} from '#/views/business/order/product-order/api';
import { getDeliveryWarehouseOptionsByDrugs } from '#/views/doctor/doctor-reception/api';
import { simulatePayApi, accrueSalespersonCommissionApi, reverseSalespersonCommissionApi } from '#/views/business/order/api/order-ops';
import {
accrueSalespersonCommissionApi,
retryConfirmReceiveApi,
reverseSalespersonCommissionApi,
simulatePayApi,
} from '#/views/business/order/api/order-ops';
import ChinaErpSyncLogDrawer from '#/views/business/order/components/china-erp-sync-log-drawer.vue';
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
import OrderPricePercentAdjustDrawer from '#/views/business/order/components/OrderPricePercentAdjustDrawer.vue';
@@ -446,6 +451,29 @@ function handleSimulatePay(row: Record<string, any>) {
});
}
/**
* 超管手动确认收货 / 重试解冻(自动确认队列失败后的人工补偿)
* 仅 Super Admin 可见;待收货走完整签收,已确认收货仅重试解冻
*/
function handleRetryConfirmReceive(row: Record<string, any>) {
const isWaitReceive = Number(row.status) === 2;
AntdModal.confirm({
title: isWaitReceive ? '确认收货' : '重试解冻',
content: isWaitReceive
? `确认为订单 ${row.order_no} 手动确认收货?将更新为已确认收货并解冻供应商/配送仓分账冻结。`
: `订单 ${row.order_no} 已确认收货,是否重试解冻分账冻结?(幂等,不会重复加账)`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
const res = await retryConfirmReceiveApi({ order_id: Number(row.id) });
message.success(
res?.message || (isWaitReceive ? '确认收货成功' : '解冻重试成功'),
);
await gridApi.query();
},
});
}
const accruingOrderIds = ref<number[]>([]);
const reversingOrderIds = ref<number[]>([]);
@@ -1303,6 +1331,19 @@ const openOrderAmountVerify = () => {
ifShow: row.is_pay === 0,
onClick: handleSimulatePay.bind(null, row),
},
{
// 仅超管:待收货确认收货 / 已确认收货重试解冻
label: Number(row.status) === 2 ? '确认收货' : '重试解冻',
type: 'link',
icon: 'mdi:package-check',
auth: ['Super Admin'],
ifShow:
Number(row.is_pay) === 1 &&
Number(row.cancel_status) === 0 &&
Number(row.refund_status) === 0 &&
(Number(row.status) === 2 || Number(row.status) === 7),
onClick: handleRetryConfirmReceive.bind(null, row),
},
{
label: '发货',
type: 'link',

View File

@@ -18,9 +18,14 @@ export const formOptions: VbenFormProps = {
{
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD', // 确保格式与你需要的一致
format: 'YYYY-MM-DD',
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
valueFormat: 'YYYY-MM-DD',
},
defaultValue: [dayjs().startOf('month'), dayjs()],
defaultValue: [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
fieldName: 'search_time',
label: '时间范围',
},

View File

@@ -0,0 +1,12 @@
import { requestClient } from '#/api/request';
/**
* 切换药品单品包邮(非中药商品共用)
* @param drugId 药品 ID
*/
export async function toggleDrugFreeShipping(drugId: number) {
return requestClient.post<{ is_free_shipping: number }>(
'drug-free-shipping/toggle',
{ drug_id: drugId },
);
}

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { message, Tag } from 'ant-design-vue';
import { toggleDrugFreeShipping } from './drug-free-shipping-api';
const props = defineProps<{
/** 列表行:需含 id、is_free_shipping */
row: {
id: number | string;
is_free_shipping?: number;
};
}>();
const emit = defineEmits<{
/** 切换成功后通知父级刷新列表 */
(e: 'changed'): void;
}>();
const loading = ref(false);
/**
* 点击 Tag 切换单品包邮;成功后回写行内状态并通知父级
*/
const onToggle = async () => {
if (loading.value) {
return;
}
const drugId = Number(props.row.id);
if (!drugId) {
return;
}
loading.value = true;
try {
const res = await toggleDrugFreeShipping(drugId);
const next = Number(res?.is_free_shipping ?? 0);
props.row.is_free_shipping = next;
message.success(next === 1 ? '已设为单品包邮' : '已取消单品包邮');
emit('changed');
} finally {
loading.value = false;
}
};
</script>
<template>
<Tag
:color="row.is_free_shipping === 1 ? 'success' : 'default'"
class="cursor-pointer"
:class="{ 'opacity-60': loading }"
@click="onToggle"
>
{{ row.is_free_shipping === 1 ? '包邮' : '不包邮' }}
</Tag>
</template>

View File

@@ -80,7 +80,7 @@ const handleChange = (info: { file: UploadFile }) => {
};
</script>
<template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
<Modal class="w-[80%] h-[80%]" title="上传Excel">
<!-- 新增显示文件选择状态 -->
<div v-if="isFileSelected" class="mb-4 p-3 bg-green-50 border border-green-200 rounded">
<p class="text-green-700 text-sm">已选择文件{{ selectedFile?.name }}</p>

View File

@@ -102,7 +102,7 @@ const handleChange = (info: { file: UploadFile }) => {
};
</script>
<template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
<Modal class="w-[80%] h-[80%]" title="上传Excel">
<!-- 显示文件选择状态提示 -->
<div
v-if="isFileSelected"

View File

@@ -57,6 +57,12 @@ export const gridOptions: VxeGridProps<RowType> = {
},
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
{ field: 'specification', title: '规格', width: 120 },
{
field: 'is_free_shipping',
title: '单品包邮',
width: 100,
slots: { default: 'is_free_shipping' },
},
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
],

View File

@@ -20,6 +20,7 @@ import { downloadByData } from '#/util/tool';
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
@@ -303,6 +304,9 @@ const openExcelUploadModal = () => {
<template #supplier="{ row }">
{{ row.supplier?.name || row.source }}
</template>
<template #is_free_shipping="{ row }">
<DrugFreeShippingCell :row="row" />
</template>
<template #status="{ row }">
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
</template>

View File

@@ -73,7 +73,7 @@ const handleChange = (info: { file: UploadFile }) => {
};
</script>
<template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
<Modal class="w-[80%] h-[80%]" title="上传Excel">
<div
v-if="isFileSelected"
class="mb-4 rounded border border-green-200 bg-green-50 p-3"

View File

@@ -50,6 +50,12 @@ export const gridOptions: VxeGridProps<RowType> = {
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
{ field: 'function', title: '器械功能', width: 200 },
{ field: 'specification', title: '规格型号', width: 120 },
{
field: 'is_free_shipping',
title: '单品包邮',
width: 100,
slots: { default: 'is_free_shipping' },
},
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
],

View File

@@ -13,6 +13,7 @@ import { downloadByData } from '#/util/tool';
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
@@ -250,6 +251,9 @@ const openExcelUploadModal = () => {
<template #supplier="{ row }">
{{ row.supplier?.name || row.source }}
</template>
<template #is_free_shipping="{ row }">
<DrugFreeShippingCell :row="row" />
</template>
<template #status="{ row }">
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
</template>

View File

@@ -73,7 +73,7 @@ const handleChange = (info: { file: UploadFile }) => {
};
</script>
<template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
<Modal class="w-[80%] h-[80%]" title="上传Excel">
<div
v-if="isFileSelected"
class="mb-4 rounded border border-green-200 bg-green-50 p-3"

View File

@@ -50,6 +50,12 @@ export const gridOptions: VxeGridProps<RowType> = {
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
{ field: 'function', title: '产品功能', width: 200 },
{ field: 'specification', title: '规格', width: 120 },
{
field: 'is_free_shipping',
title: '单品包邮',
width: 100,
slots: { default: 'is_free_shipping' },
},
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
],

View File

@@ -13,6 +13,7 @@ import { downloadByData } from '#/util/tool';
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
@@ -250,6 +251,9 @@ const openExcelUploadModal = () => {
<template #supplier="{ row }">
{{ row.supplier?.name || row.source }}
</template>
<template #is_free_shipping="{ row }">
<DrugFreeShippingCell :row="row" />
</template>
<template #status="{ row }">
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
</template>

View File

@@ -50,6 +50,12 @@ export const gridOptions: VxeGridProps<RowType> = {
{ field: 'supplier.name', title: '供应商', width: 130 },
{ field: 'function', title: '主要功能', width: 200 },
{ field: 'specification', title: '规格', width: 120 },
{
field: 'is_free_shipping',
title: '单品包邮',
width: 100,
slots: { default: 'is_free_shipping' },
},
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
],

View File

@@ -12,6 +12,7 @@ import { TableAction } from '#/components/table-action';
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
@@ -219,6 +220,9 @@ const deleteApi = (row: any) => {
@stock-in="openStockIn"
/>
</template>
<template #is_free_shipping="{ row }">
<DrugFreeShippingCell :row="row" />
</template>
<template #status="{ row }">
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
</template>

View File

@@ -77,7 +77,11 @@ export async function updateZoneCategory(data: Record<string, any>) {
* 更新最低调整金额
* @param data
*/
export async function updateMinAdjustPrice(data: { id: number; min_adjust_price: number | null }) {
export async function updateMinAdjustPrice(data: {
id: number;
min_adjust_price: number | null;
max_adjust_price?: number | null;
}) {
return requestClient.post<any>(`${prefix}update-min-adjust-price`, data);
}

View File

@@ -80,7 +80,7 @@ const handleChange = (info: { file: UploadFile }) => {
};
</script>
<template>
<Modal class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]" title="上传Excel">
<Modal class="w-[80%] h-[80%]" title="上传Excel">
<!-- 新增显示文件选择状态 -->
<div
v-if="isFileSelected"

View File

@@ -1,13 +1,23 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Form, InputNumber, message } from 'ant-design-vue';
/**
* 设置药品改价底价与最高价
* 商户改价、订单调价须落在该区间;可按总仓售价快捷填入上下 30%
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Form, InputNumber, message } from 'ant-design-vue';
import { updateMinAdjustPrice } from '../api';
defineOptions({
name: 'MinPriceModal',
});
/** 相对总仓售价的浮动比例(上下各 30% */
const CENTRAL_PRICE_RANGE_RATIO = 0.3;
const formRef = ref();
const loading = ref(false);
@@ -15,9 +25,44 @@ const formData = ref<{
id?: number;
drugName?: string;
currentMinPrice?: number | null;
currentMaxPrice?: number | null;
/** 总仓售价,用于快捷填区间 */
centralPrice?: number | null;
}>({});
const minPrice = ref<number | null>(null);
const maxPrice = ref<number | null>(null);
/** 是否有可用总仓售价 */
const hasCentralPrice = computed(() => {
const p = Number(formData.value.centralPrice);
return Number.isFinite(p) && p > 0;
});
/** 预览:底价 = 售价×0.7,最高价 = 售价×1.3 */
const previewRange = computed(() => {
if (!hasCentralPrice.value) return null;
const base = Number(formData.value.centralPrice);
const min = Number((base * (1 - CENTRAL_PRICE_RANGE_RATIO)).toFixed(2));
const max = Number((base * (1 + CENTRAL_PRICE_RANGE_RATIO)).toFixed(2));
return { min, max, base };
});
/**
* 快捷填入总仓售价上下 30% 区间
* 为什么:运营批量设价时以总仓建议售价为基准,避免手工算错
*/
function fillByCentralPriceRange() {
if (!previewRange.value) {
message.warning('暂无总仓售价,无法快捷填入');
return;
}
minPrice.value = previewRange.value.min;
maxPrice.value = previewRange.value.max;
message.success(
`已填入:¥${previewRange.value.min.toFixed(2)} ~ ¥${previewRange.value.max.toFixed(2)}`,
);
}
const [ModalComponent, modalApi] = useVbenModal({
class: 'w-[500px]',
@@ -30,19 +75,30 @@ const [ModalComponent, modalApi] = useVbenModal({
}
try {
await formRef.value.validate();
} catch (error) {
} catch {
return;
}
if (!formData.value.id || minPrice.value === null) {
message.error('参数不完整');
return;
}
if (
maxPrice.value !== null &&
maxPrice.value > 0 &&
minPrice.value > maxPrice.value
) {
message.error('最高价不能低于底价');
return;
}
loading.value = true;
try {
await updateMinAdjustPrice({
id: formData.value.id,
min_adjust_price: minPrice.value,
// 始终提交最高价字段null/0 表示清空上限
max_adjust_price:
maxPrice.value && maxPrice.value > 0 ? maxPrice.value : 0,
});
message.success('设置成功');
modalApi.close();
@@ -59,45 +115,76 @@ const [ModalComponent, modalApi] = useVbenModal({
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<typeof formData.value & { gridApi?: any }>();
const data = modalApi.getData<
typeof formData.value & {
gridApi?: any;
min_adjust_price?: number | null;
max_adjust_price?: number | null;
central_price?: number | null;
drug_name?: string;
}
>();
if (data) {
const central = Number(
data.centralPrice ?? data.central_price ?? 0,
);
formData.value = {
id: data.id,
drugName: data.drugName || data.drug_name,
currentMinPrice: data.currentMinPrice || data.min_adjust_price || null,
currentMaxPrice: data.currentMaxPrice || data.max_adjust_price || null,
centralPrice: central > 0 ? central : null,
};
minPrice.value = formData.value.currentMinPrice;
maxPrice.value = formData.value.currentMaxPrice;
}
} else {
formData.value = {};
minPrice.value = null;
maxPrice.value = null;
}
},
});
</script>
<template>
<ModalComponent
title="设置最低调整金额"
:loading="loading"
>
<ModalComponent title="设置改价区间(底价 / 最高价)" :loading="loading">
<div v-if="formData.drugName" class="min-price-modal">
<div class="mb-4">
<p><strong>药品名称</strong>{{ formData.drugName }}</p>
<p v-if="formData.currentMinPrice" class="mt-2">
<strong>当前最低调整金额</strong>{{ Number(formData.currentMinPrice).toFixed(2) }}
<p v-if="hasCentralPrice" class="mt-1 text-sm text-gray-600">
总仓售价{{ Number(formData.centralPrice).toFixed(2) }}
</p>
<p v-else class="mt-2" style="color: #999">
<strong>当前最低调整金额</strong>未设置
<p class="mt-2 text-sm text-gray-500">
商户改价与订单调价须落在底价最高价之间最高价留空表示不限制上限
</p>
</div>
<Form ref="formRef" :model="{ minPrice }" layout="vertical">
<div class="mb-4">
<Button
type="primary"
ghost
:disabled="!hasCentralPrice"
@click="fillByCentralPriceRange"
>
快捷填入总仓售价 ±30%
</Button>
<p v-if="previewRange" class="mt-2 text-xs text-gray-500">
将填入 {{ previewRange.min.toFixed(2) }} ~
{{ previewRange.max.toFixed(2) }}
售价 {{ previewRange.base.toFixed(2) }} × 0.7 / 1.3
</p>
<p v-else class="mt-2 text-xs text-orange-500">
该药品暂无总仓售价请先入库总仓后再使用快捷填入
</p>
</div>
<Form ref="formRef" :model="{ minPrice, maxPrice }" layout="vertical">
<Form.Item
label="最低调整金额"
label="底价(最低调整金额"
name="minPrice"
:rules="[
{ required: true, message: '请输入最低调整金额' },
{ required: true, message: '请输入底价' },
{ type: 'number', min: 0, message: '金额不能小于0' },
]"
>
@@ -107,12 +194,19 @@ const [ModalComponent, modalApi] = useVbenModal({
:precision="2"
:step="0.01"
style="width: 100%"
placeholder="请输入最低调整金额设置为0或留空表示不允许调"
placeholder="请输入底价0 表示不允许调"
/>
</Form.Item>
<Form.Item label="最高价" name="maxPrice">
<InputNumber
v-model:value="maxPrice"
:min="0"
:precision="2"
:step="0.01"
style="width: 100%"
placeholder="请输入最高价留空或0表示不限制"
/>
</Form.Item>
<p class="text-gray-500 text-sm mt-2">
提示设置为0或留空表示该商品不允许调整价格
</p>
</Form>
</div>
</ModalComponent>

View File

@@ -55,11 +55,17 @@ export const gridOptions: VxeGridProps<RowType> = {
width: 100,
slots: { default: 'is_otc' },
},
{
field: 'is_free_shipping',
title: '单品包邮',
width: 100,
slots: { default: 'is_free_shipping' },
},
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{
field: 'min_adjust_price',
title: '最低调整金额',
width: 120,
title: '改价区间',
width: 140,
slots: { default: 'min_adjust_price' },
},
{

View File

@@ -13,6 +13,7 @@ import { downloadByData } from '#/util/tool';
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
@@ -156,6 +157,9 @@ const openMinPriceModal = (row: any) => {
id: row.id,
drugName: row.drug_name,
min_adjust_price: row.min_adjust_price,
max_adjust_price: row.max_adjust_price,
// 总仓售价供弹窗「±30%」快捷填入
central_price: resolveCentralPrice(row),
gridApi,
});
minPriceModalApi.open();
@@ -298,6 +302,9 @@ const openExcelUploadModal = () => {
{{ row.is_otc === 0 ? '处方药' : '非处方药' }}
</Tag>
</template>
<template #is_free_shipping="{ row }">
<DrugFreeShippingCell :row="row" />
</template>
<template #status="{ row }">
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
</template>
@@ -307,9 +314,13 @@ const openExcelUploadModal = () => {
class="text-primary cursor-pointer hover:underline"
@click="openMinPriceModal(row)"
>
<span v-if="row.min_adjust_price && row.min_adjust_price > 0">
<template v-if="row.min_adjust_price && row.min_adjust_price > 0">
{{ Number(row.min_adjust_price).toFixed(2) }}
<span v-if="row.max_adjust_price && row.max_adjust_price > 0">
~ {{ Number(row.max_adjust_price).toFixed(2) }}
</span>
<span v-else> ~ 不限</span>
</template>
<span v-else style="color: #999">未设置</span>
</a>
</template>
@@ -340,7 +351,7 @@ const openExcelUploadModal = () => {
onClick: openBindList.bind(null, row),
},
{
label: '设置底价',
label: '设置改价区间',
type: 'link',
icon: 'ant-design:dollar-outlined',
size: 'small',

View File

@@ -23,6 +23,8 @@ import {
import html2canvas from 'html2canvas';
import DoctorQrCodePreview from '#/components/modal/DoctorQrCodePreview.vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import StoreVipMemberCard from '#/components/vip/StoreVipMemberCard.vue';
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
import {
deleteNavApi,
@@ -31,7 +33,6 @@ import {
updateNavSortApi,
updateClinicTypeApi,
} from '#/views/business/store/settings/api';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import UploadModal from './components/uploadModal.vue';
import SalespersonCard from "#/views/business/store/settings/components/SalespersonCard.vue";
@@ -63,6 +64,8 @@ interface StoreInfo {
qr_code: string;
doctor_list: any[];
nav: { id: number; pic: string }[];
/** 门店 VIP等级徽标 + 期限丝带 */
vip?: Record<string, any> | null;
}
const [DoctorQrCodePreviewModal, DoctorQrCodePreviewModalApi] = useVbenModal({
@@ -375,6 +378,8 @@ const handleSwitchClinicType = () => {
</RadioGroup>
</div>
<Card v-if="activeTabBar === 1" :title="isPharmacy ? '药店资料' : '诊所资料'">
<!-- 门店 VIP 会员卡大尺寸徽标 + 期限丝带 -->
<StoreVipMemberCard :vip="data.vip" />
<!-- 头部信息 -->
<div class="mb-8 flex flex-col gap-6 md:flex-row md:gap-8">
<div class="flex-1">
@@ -576,16 +581,15 @@ const handleSwitchClinicType = () => {
</p>
<p class="text-sm text-gray-500">
专业:
<span
:class="
doctor.identity === 1
doctor.doctor?.identity === 1
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
"
class="rounded px-2 py-1"
>
{{ doctor.identity === 1 ? '中医' : '西医' }}
{{ doctor.doctor?.identity === 1 ? '中医' : '西医' }}
</span>
</p>
</div>

View File

@@ -44,11 +44,21 @@ export async function getWarehouseDrugManagementInfo(id: number) {
}
/**
* 导出仓库药品药品
* 导出仓库药品(旧:后端二进制,保留兼容)
*/
export async function exportWarehouseDrugManagementApi(params?: { type?: number }) {
return requestClient.download(`${prefix}export`, { params });
}
/**
* 导出 JSON 数据(供前端 ExcelJS对齐订单 export-data
*/
export async function getWarehouseExportDataApi(params?: { type?: number }) {
return requestClient.get<{
total: number;
rows: Array<Record<string, unknown>>;
}>(`${prefix}export-data`, { params });
}
/**
* 导出仓库药品药品 - 模板
*/
@@ -110,22 +120,25 @@ export interface WarehouseBatchPriceItem {
drug_name: string;
drug_number: string;
pinyin_simple: string;
unit_id?: number;
unit_name?: string;
market_price: number | string;
price: number | string;
}
/** 批量改价预览:全量仓库药品 */
export async function getAllForBatchPriceApi(params: { type: number }) {
return requestClient.get<{ items: WarehouseBatchPriceItem[] }>(
`${prefix}list-all-for-batch-price`,
{ params },
);
return requestClient.get<{
items: WarehouseBatchPriceItem[];
unit_options?: { value: number; label: string }[];
}>(`${prefix}list-all-for-batch-price`, { params });
}
export interface BatchPriceUpdateItem {
id: number;
market_price: number;
price: number;
unit_name?: string;
}
export interface BatchPriceCreateItem {
@@ -133,6 +146,7 @@ export interface BatchPriceCreateItem {
drug_number: string;
market_price: number;
price: number;
unit_name?: string;
}
/** 批量改价预览:应用更新与新增 */

View File

@@ -19,17 +19,23 @@ import { downloadByData } from '#/util/tool';
import {
applyBatchPriceApi,
getAllForBatchPriceApi,
getWarehouseExportDataApi,
type WarehouseBatchPriceItem,
} from '../api';
import { exportWarehousePriceExcel } from '../utils/exportWarehousePriceExcel';
import {
exportWarehouseListExcel,
type WarehouseExportRow,
} from '../utils/exportWarehousePriceExcel';
import { parseWarehousePriceExcelBuffer } from '../utils/parseWarehousePriceExcel';
import {
getPriceDelta,
isFieldChanged,
isMarketGtPrice,
isPriceChanged,
isUnitChanged,
normalizePricePair,
parsePriceValue,
reconcileExcelPrice,
type PricePair,
} from '../utils/priceCompare';
import PriceCompareCardGrid, {
@@ -60,10 +66,13 @@ const newRows = ref<
drug_number: string;
market_price: number;
price: number;
unit_name: string;
}>
>([]);
const unmatchedRows = ref<UnmatchedRow[]>([]);
const excelTouchedIds = ref<Set<number>>(new Set());
/** 已知单位名称集合,用于标记「待建」 */
const knownUnitNames = ref<Set<string>>(new Set());
let tempIdCounter = -1;
let loadAllPromise: Promise<void> | null = null;
@@ -74,6 +83,7 @@ function buildSnapshot(items: WarehouseBatchPriceItem[]) {
const pair = normalizePricePair({
market_price: parsePriceValue(item.market_price),
price: parsePriceValue(item.price),
unit_name: item.unit_name ?? '',
});
init[item.id] = { ...pair };
loc[item.id] = { ...pair };
@@ -91,6 +101,7 @@ function resetState() {
newRows.value = [];
unmatchedRows.value = [];
excelTouchedIds.value = new Set();
knownUnitNames.value = new Set();
parseHint.value = '';
parsedReady.value = false;
filterTab.value = 'all';
@@ -109,22 +120,44 @@ function buildCompareRow(
orig: PricePair,
isNew: boolean,
): PriceCompareRow {
const normalizedOrig = normalizePricePair(orig);
const normalizedCurrent = normalizePricePair(current);
const unitChanged = isUnitChanged(
normalizedOrig.unit_name,
normalizedCurrent.unit_name,
);
const unitName = (normalizedCurrent.unit_name ?? '').trim();
const pendingCreate =
unitName !== '' && !knownUnitNames.value.has(unitName);
return {
id: item.id,
drug_id: item.drug_id,
drug_name: item.drug_name,
drug_number: item.drug_number,
pinyin_simple: item.pinyin_simple,
orig_market_price: orig.market_price,
orig_price: orig.price,
market_price: current.market_price,
price: current.price,
_marketDelta: getPriceDelta(orig.market_price, current.market_price),
_priceDelta: getPriceDelta(orig.price, current.price),
_marketChanged: isFieldChanged(orig.market_price, current.market_price),
_priceChanged: isFieldChanged(orig.price, current.price),
_changed: isNew || isPriceChanged(orig, current),
_marketGtPrice: isMarketGtPrice(current),
orig_market_price: normalizedOrig.market_price,
orig_price: normalizedOrig.price,
orig_unit_name: normalizedOrig.unit_name ?? '',
market_price: normalizedCurrent.market_price,
price: normalizedCurrent.price,
unit_name: normalizedCurrent.unit_name ?? '',
_marketDelta: getPriceDelta(
normalizedOrig.market_price,
normalizedCurrent.market_price,
),
_priceDelta: getPriceDelta(normalizedOrig.price, normalizedCurrent.price),
_marketChanged: isFieldChanged(
normalizedOrig.market_price,
normalizedCurrent.market_price,
),
_priceChanged: isFieldChanged(
normalizedOrig.price,
normalizedCurrent.price,
),
_unitChanged: unitChanged,
_unitPendingCreate: pendingCreate,
_changed: isNew || isPriceChanged(normalizedOrig, normalizedCurrent),
_marketGtPrice: isMarketGtPrice(normalizedCurrent),
_isNew: isNew,
_fromExcel: isNew ? true : undefined,
};
@@ -134,11 +167,13 @@ const compareRows = computed<PriceCompareRow[]>(() => {
const existingRows: PriceCompareRow[] = warehouseItems.value
.filter((item) => excelTouchedIds.value.has(item.id))
.map((item) => {
const current = localPrices.value[item.id] ?? normalizePricePair({
const fallback = normalizePricePair({
market_price: parsePriceValue(item.market_price),
price: parsePriceValue(item.price),
unit_name: item.unit_name ?? '',
});
const orig = initialSnapshot.value[item.id] ?? current;
const current = localPrices.value[item.id] ?? fallback;
const orig = initialSnapshot.value[item.id] ?? fallback;
return buildCompareRow(item, current, orig, false);
});
@@ -149,8 +184,12 @@ const compareRows = computed<PriceCompareRow[]>(() => {
drug_name: row.drug_name,
drug_number: row.drug_number,
},
{ market_price: row.market_price, price: row.price },
{ market_price: 0, price: 0 },
{
market_price: row.market_price,
price: row.price,
unit_name: row.unit_name,
},
{ market_price: 0, price: 0, unit_name: '' },
true,
),
);
@@ -225,6 +264,9 @@ async function loadAllDrugs() {
try {
const res = await getAllForBatchPriceApi({ type: productType.value });
warehouseItems.value = res?.items ?? [];
knownUnitNames.value = new Set(
(res?.unit_options ?? []).map((u) => String(u.label).trim()).filter(Boolean),
);
buildSnapshot(warehouseItems.value);
} catch {
message.error('加载仓库药品失败');
@@ -255,24 +297,37 @@ async function handleFileChange(info: { file: UploadFile }) {
}
}
/**
* 下载标准模板:与列表页「导出」同一套 export-data + 列结构,
* 避免改价模板列与导出不一致导致解析误报变更
*/
async function downloadTemplate() {
if (warehouseItems.value.length === 0) {
try {
const res = await getWarehouseExportDataApi({ type: productType.value });
const rows = (res?.rows ?? []) as WarehouseExportRow[];
if (rows.length === 0) {
message.warning('暂无药品数据');
return;
}
try {
const buffer = await exportWarehousePriceExcel(warehouseItems.value);
const buffer = await exportWarehouseListExcel(rows, '仓库商品');
downloadByData(
buffer,
'萧康云医-中药仓库改价模板.xlsx',
'萧康云医-仓库商品导出.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
message.success('模板已下载');
message.success(`模板已下载${res?.total ?? rows.length} 条,与导出一致)`);
} catch {
message.error('模板下载失败');
}
}
function findWarehouseByDrugId(drugId: number) {
if (drugId <= 0) {
return undefined;
}
return warehouseItems.value.find((d) => d.drug_id === drugId);
}
function findWarehouseByNumber(number: string) {
const n = number.trim();
if (!n) {
@@ -300,7 +355,7 @@ async function runParse() {
syncModalFooterState();
try {
const buf = await selectedFile.value.arrayBuffer();
const { items, invalidCount, rowCount } =
const { items, invalidCount, rowCount, hasUnitColumn } =
await parseWarehousePriceExcelBuffer(buf);
if (items.length === 0) {
@@ -312,6 +367,9 @@ async function runParse() {
rowCount > 5000
? `${rowCount} 行,已按上限处理前 5000 行。`
: `共解析 ${items.length} 条有效行。`;
if (!hasUnitColumn) {
parseHint.value += '(未识别到单位列,单位保持原值)';
}
const nextUnmatched: UnmatchedRow[] = [];
const nextNew: typeof newRows.value = [];
@@ -320,31 +378,41 @@ async function runParse() {
let changedExistingCount = 0;
for (const row of items) {
let matched = findWarehouseByNumber(row.drug_number);
// 优先药品ID避免同名/编码漂移误匹配
let matched =
findWarehouseByDrugId(row.drug_id) ||
findWarehouseByNumber(row.drug_number);
if (!matched) {
matched = findWarehouseByName(row.drug_name);
}
if (matched) {
nextTouched.add(matched.id);
const orig = initialSnapshot.value[matched.id];
nextLocal[matched.id] = {
market_price: row.market_price,
price: row.price,
const orig = initialSnapshot.value[matched.id] ?? {
market_price: parsePriceValue(matched.market_price),
price: parsePriceValue(matched.price),
unit_name: matched.unit_name ?? '',
};
if (
orig &&
isPriceChanged(orig, {
market_price: row.market_price,
price: row.price,
})
) {
// 无单位列或单元格为空:保留原单位,避免误报单位变更
const excelUnit = (row.unit_name ?? '').trim();
const unitName =
hasUnitColumn && excelUnit !== ''
? excelUnit
: (orig.unit_name ?? matched.unit_name ?? '');
// 价格与快照对账,消化 Excel 一分钱浮点误差
const nextPair = normalizePricePair({
market_price: reconcileExcelPrice(row.market_price, orig.market_price),
price: reconcileExcelPrice(row.price, orig.price),
unit_name: unitName,
});
nextLocal[matched.id] = nextPair;
if (isPriceChanged(orig, nextPair)) {
changedExistingCount += 1;
}
continue;
}
if (row.drug_name || row.drug_number) {
if (row.drug_name || row.drug_number || row.drug_id > 0) {
tempIdCounter -= 1;
nextNew.push({
tempId: tempIdCounter,
@@ -352,6 +420,7 @@ async function runParse() {
drug_number: row.drug_number,
market_price: row.market_price,
price: row.price,
unit_name: (row.unit_name ?? '').trim(),
});
} else {
nextUnmatched.push(row);
@@ -368,7 +437,7 @@ async function runParse() {
} else if (nextNew.length > 0) {
filterTab.value = 'new';
} else {
filterTab.value = 'all';
filterTab.value = 'unchanged';
}
if (invalidCount > 0) {
@@ -377,6 +446,9 @@ async function runParse() {
if (nextNew.length > 0) {
message.info(`${nextNew.length} 条药品将自动新增入库`);
}
if (changedExistingCount === 0 && nextNew.length === 0) {
message.info('未检测到价格或单位变更');
}
parsedReady.value = true;
} catch {
@@ -391,6 +463,7 @@ function syncRowFromGrid(row: PriceCompareRow) {
const normalized = normalizePricePair({
market_price: parsePriceValue(row.market_price),
price: parsePriceValue(row.price),
unit_name: row.unit_name,
});
if (row._isNew) {
newRows.value = newRows.value.map((item) =>
@@ -399,6 +472,7 @@ function syncRowFromGrid(row: PriceCompareRow) {
...item,
market_price: normalized.market_price,
price: normalized.price,
unit_name: normalized.unit_name ?? '',
}
: item,
);
@@ -435,6 +509,7 @@ async function handleRowSave(row: PriceCompareRow) {
const saved = normalizePricePair({
market_price: parsePriceValue(row.market_price),
price: parsePriceValue(row.price),
unit_name: row.unit_name,
});
savingRowId.value = row.id;
@@ -449,6 +524,7 @@ async function handleRowSave(row: PriceCompareRow) {
drug_number: row.drug_number,
market_price: saved.market_price,
price: saved.price,
unit_name: saved.unit_name,
},
],
notify: false,
@@ -462,6 +538,7 @@ async function handleRowSave(row: PriceCompareRow) {
id: row.id,
market_price: saved.market_price,
price: saved.price,
unit_name: saved.unit_name,
},
],
creates: [],
@@ -475,9 +552,17 @@ async function handleRowSave(row: PriceCompareRow) {
...localPrices.value,
[row.id]: { ...saved },
};
if (saved.unit_name) {
knownUnitNames.value.add(saved.unit_name);
}
warehouseItems.value = warehouseItems.value.map((item) =>
item.id === row.id
? { ...item, market_price: saved.market_price, price: saved.price }
? {
...item,
market_price: saved.market_price,
price: saved.price,
unit_name: saved.unit_name,
}
: item,
);
}
@@ -499,12 +584,14 @@ async function runBatchApply() {
const prices = normalizePricePair({
market_price: row.market_price,
price: row.price,
unit_name: row.unit_name,
});
return {
drug_name: row.drug_name,
drug_number: row.drug_number,
market_price: prices.market_price,
price: prices.price,
unit_name: prices.unit_name,
};
});
@@ -522,8 +609,12 @@ async function runBatchApply() {
drug_name: c.drug_name,
drug_number: c.drug_number,
},
{ market_price: c.market_price, price: c.price },
{ market_price: 0, price: 0 },
{
market_price: c.market_price,
price: c.price,
unit_name: c.unit_name,
},
{ market_price: 0, price: 0, unit_name: '' },
true,
),
),
@@ -541,11 +632,13 @@ async function runBatchApply() {
const prices = normalizePricePair({
market_price: parsePriceValue(row.market_price),
price: parsePriceValue(row.price),
unit_name: row.unit_name,
});
return {
id: row.id,
market_price: prices.market_price,
price: prices.price,
unit_name: prices.unit_name,
};
}),
creates,
@@ -568,7 +661,7 @@ async function runBatchApply() {
</script>
<template>
<ModalComp title="批量改价预览(中药总仓库)">
<ModalComp title="批量改价预览(总仓库)">
<!-- Wrap main content in transition for smooth View swapping -->
<Transition name="fade-slide" mode="out-in">
@@ -699,6 +792,7 @@ async function runBatchApply() {
:rows="compareRows"
:saving-row-id="savingRowId"
:unmatched-rows="unmatchedRows"
:show-unit="true"
@cell-change="syncRowFromGrid"
@row-save="handleRowSave"
/>

View File

@@ -15,7 +15,7 @@ import { downloadByData } from '#/util/tool';
import { useWarehouseDrugTypeRoute } from '../shared/useWarehouseDrugTypeRoute';
import {
deleteWarehouseDrugManagement,
exportWarehouseDrugManagementApi,
getWarehouseExportDataApi,
updateWarehouseDrugManagementStatusApi
} from './api';
import FormModalDemo from './components/modal.vue';
@@ -26,6 +26,10 @@ import ViewModeFloatButton from './components/ViewModeFloatButton.vue';
import WarehouseDrugExcelView from './components/WarehouseDrugExcelView.vue';
import { formOptions } from './config/search';
import { createGridOptions } from './config/table';
import {
exportWarehouseListExcel,
type WarehouseExportRow,
} from './utils/exportWarehousePriceExcel';
import {
readWarehouseViewMode,
writeWarehouseViewMode,
@@ -129,13 +133,26 @@ const deleteApi = (row: any) => {
});
};
/**
* 导出
* 导出:后端 export-data 出 JSON前端 ExcelJS 生成文件(对齐订单导出)
*/
const passApplication = () => {
exportWarehouseDrugManagementApi({ type: productType.value }).then((res) => {
downloadByData(res.data, '萧康云医-仓库商品导出.xlsx');
message.success('导出成功!');
});
const passApplication = async () => {
try {
const res = await getWarehouseExportDataApi({ type: productType.value });
const rows = (res?.rows ?? []) as WarehouseExportRow[];
if (rows.length === 0) {
message.warning('暂无药品数据');
return;
}
const buffer = await exportWarehouseListExcel(rows, '仓库商品');
downloadByData(
buffer,
'萧康云医-仓库商品导出.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
message.success(`导出成功(${res?.total ?? rows.length} 条)`);
} catch {
message.error('导出失败');
}
};
const openExcelUploadModal = (type = 1) => {
@@ -215,13 +232,14 @@ function onSpreadsheetConfigSaved() {
// auth: ['超级西(中成)药', 'sys:user:save'],
onClick: openExcelUploadModal.bind(null, 1),
},
{
label: '批量修改价格',
type: 'primary',
icon: 'ix:export-check',
// auth: ['超级西(中成)药', 'sys:user:save'],
onClick: openExcelUploadModal.bind(null, 2),
},
// 旧「批量修改价格」直传入口已停用,改走「批量改价预览」
// {
// label: '批量修改价格',
// type: 'primary',
// icon: 'ix:export-check',
// // auth: ['超级西(中成)药', 'sys:user:save'],
// onClick: openExcelUploadModal.bind(null, 2),
// },
{
label: '批量改价预览',
type: 'primary',

View File

@@ -1,3 +1,9 @@
/**
* 总仓 ExcelJS 导出
* - list列表「导出」完整列来自 export-data
* - price改价模板列预览弹窗下载模板
* 价格/单位一律文本原样写出,避免浮点改写
*/
import ExcelJS from 'exceljs';
import type { WarehouseBatchPriceItem } from '../api';
@@ -9,33 +15,73 @@ const THIN_BORDER: Partial<ExcelJS.Borders> = {
right: { style: 'thin', color: { argb: 'FF000000' } },
};
const HEADERS = [
/** export-data 行结构 */
export type WarehouseExportRow = {
drug_id?: number | string;
drug_number?: string;
drug_name?: string;
pinyin_simple?: string;
unit_name?: string;
type_text?: string;
status_text?: string;
market_price?: string | number;
price?: string | number;
created_at?: string;
};
const LIST_HEADERS = [
'药品ID',
'商品编码',
'通用名',
'拼音首拼',
'单位',
'类型',
'状态',
'每克供货价',
'每克零售价',
'创建时间',
] as const;
const PRICE_HEADERS = [
'药品ID',
'商品编码',
'通用名',
'拼音首拼',
'单位',
'每克供货价',
'每克零售价',
] as const;
export async function exportWarehousePriceExcel(
items: WarehouseBatchPriceItem[],
): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('中药价格');
function priceToCellText(value: unknown): string {
if (value === null || value === undefined || value === '') {
return '';
}
return String(value).trim();
}
sheet.columns = [
{ width: 10 },
{ width: 14 },
{ width: 18 },
{ width: 12 },
{ width: 14 },
{ width: 14 },
];
function resolveUnitName(item: { unit_name?: string; drug?: { unit?: { name?: string } } }): string {
const direct = (item.unit_name ?? '').trim();
if (direct) {
return direct;
}
const nested = item.drug?.unit?.name;
return nested != null ? String(nested).trim() : '';
}
const headerRow = sheet.addRow([...HEADERS]);
headerRow.height = 28;
headerRow.eachCell((cell) => {
/**
* 强制单元格为文本ExcelJS addRow 遇「0.14」可能自动转 number
* 再经 IEEE 回读会漂成 0.139999… / 0.90,造成假变更
*/
function forceTextCell(cell: ExcelJS.Cell, text: string) {
cell.value = text;
cell.numFmt = '@';
cell.border = THIN_BORDER;
cell.alignment = { horizontal: 'center', vertical: 'middle' };
}
function styleHeader(row: ExcelJS.Row) {
row.height = 28;
row.eachCell((cell) => {
cell.font = { bold: true };
cell.fill = {
type: 'pattern',
@@ -45,26 +91,12 @@ export async function exportWarehousePriceExcel(
cell.alignment = { horizontal: 'center', vertical: 'middle' };
cell.border = THIN_BORDER;
});
items.forEach((item, index) => {
const row = sheet.addRow([
item.drug_id,
item.drug_number ?? '',
item.drug_name ?? '',
item.pinyin_simple ?? '',
item.market_price ?? 0,
item.price ?? 0,
]);
row.eachCell((cell, colNumber) => {
cell.border = THIN_BORDER;
cell.alignment = { horizontal: 'center', vertical: 'middle' };
if (colNumber === 2) {
cell.numFmt = '@';
} else if (colNumber >= 5) {
cell.numFmt = '#,##0.00';
}
});
if (index % 2 === 1) {
function styleDataRowFill(row: ExcelJS.Row, index: number) {
if (index % 2 !== 1) {
return;
}
row.eachCell((cell) => {
cell.fill = {
type: 'pattern',
@@ -73,10 +105,85 @@ export async function exportWarehousePriceExcel(
};
});
}
/**
* 列表导出export-data → ExcelJS
*/
export async function exportWarehouseListExcel(
rows: WarehouseExportRow[],
sheetName = '仓库商品',
): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(sheetName);
sheet.columns = [
{ width: 10 },
{ width: 14 },
{ width: 18 },
{ width: 12 },
{ width: 10 },
{ width: 12 },
{ width: 10 },
{ width: 14 },
{ width: 14 },
{ width: 18 },
];
styleHeader(sheet.addRow([...LIST_HEADERS]));
rows.forEach((item, index) => {
const row = sheet.addRow([]);
const values = [
String(item.drug_id ?? ''),
String(item.drug_number ?? ''),
String(item.drug_name ?? ''),
String(item.pinyin_simple ?? ''),
resolveUnitName(item),
String(item.type_text ?? ''),
String(item.status_text ?? ''),
priceToCellText(item.market_price),
priceToCellText(item.price),
String(item.created_at ?? ''),
];
values.forEach((text, i) => forceTextCell(row.getCell(i + 1), text));
styleDataRowFill(row, index);
});
sheet.views = [{ state: 'frozen', ySplit: 1 }];
const buffer = await workbook.xlsx.writeBuffer();
return buffer as ArrayBuffer;
}
/**
* 改价模板导出(预览弹窗)
*/
export async function exportWarehousePriceExcel(
items: WarehouseBatchPriceItem[] | WarehouseExportRow[],
sheetName = '仓库改价',
): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(sheetName);
sheet.columns = [
{ width: 10 },
{ width: 14 },
{ width: 18 },
{ width: 12 },
{ width: 10 },
{ width: 14 },
{ width: 14 },
];
styleHeader(sheet.addRow([...PRICE_HEADERS]));
items.forEach((item, index) => {
const row = sheet.addRow([]);
const values = [
String(item.drug_id ?? ''),
String(item.drug_number ?? ''),
String(item.drug_name ?? ''),
String(item.pinyin_simple ?? ''),
resolveUnitName(item),
priceToCellText(item.market_price),
priceToCellText(item.price),
];
values.forEach((text, i) => forceTextCell(row.getCell(i + 1), text));
styleDataRowFill(row, index);
});
sheet.views = [{ state: 'frozen', ySplit: 1 }];
const buffer = await workbook.xlsx.writeBuffer();
return buffer as ArrayBuffer;
}

View File

@@ -1,28 +1,71 @@
/**
* 总仓改价 Excel 解析
* - 解析药品ID便于精确匹配
* - 价格优先读单元格「原始字符串 / 显示文本」,避免 IEEE 浮点
*/
import ExcelJS from 'exceljs';
import { formatPriceLikeBackend, parsePriceValue } from './priceCompare';
import { roundPrice4 } from './priceCompare';
const MAX_ROWS = 5000;
export type ParsedWarehousePriceRow = {
drug_id: number;
drug_name: string;
drug_number: string;
market_price: number;
price: number;
unit_name: string;
};
export type ParseWarehousePriceResult = {
items: ParsedWarehousePriceRow[];
invalidCount: number;
rowCount: number;
hasUnitColumn: boolean;
};
function normalizeHeader(value: string): string {
return value.replace(/\s+/g, '').replace(/\u3000/g, '');
}
function cellRawString(cell: ExcelJS.Cell): string {
const v = cell.value;
if (v === null || v === undefined) {
return '';
}
// 导出时我们写的是字符串,打开后若仍是字符串则最可信
if (typeof v === 'string') {
return v.trim().replace(/,/g, '').replace(/¥/g, '');
}
if (typeof v === 'number') {
// 不用直接 Number 展示;用足量有效位再交给 roundPrice4
return String(v);
}
if (typeof v === 'object') {
if ('result' in v && v.result !== null && v.result !== undefined) {
return String((v as { result: unknown }).result).trim();
}
if ('text' in v && (v as { text?: string }).text != null) {
return String((v as { text?: string }).text).trim();
}
if ('richText' in v && Array.isArray((v as { richText: { text: string }[] }).richText)) {
return (v as { richText: { text: string }[] }).richText
.map((p) => p.text)
.join('')
.trim();
}
}
const display = String(cell.text ?? '').trim().replace(/,/g, '');
return display;
}
function normalizeCell(value: ExcelJS.CellValue): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object' && 'text' in value) {
return String(value.text ?? '').trim();
if (typeof value === 'object' && value && 'text' in value) {
return String((value as { text?: string }).text ?? '').trim();
}
if (typeof value === 'number') {
return Number.isInteger(value) ? String(value) : String(value).trim();
@@ -30,14 +73,12 @@ function normalizeCell(value: ExcelJS.CellValue): string {
return String(value).trim();
}
function normalizeHeader(value: string): string {
return value.replace(/\s+/g, '').replace(/\u3000/g, '');
}
const NUMBER_ALIASES = ['商品编码', '药品编码', '编码', 'drug_number'];
const DRUG_ID_ALIASES = ['药品ID', '药品Id', 'drug_id', 'DrugId'];
const NUMBER_ALIASES = ['商品编码', '药品编码', 'ErpCode', 'drug_number'];
const NAME_ALIASES = ['通用名', '药品名称', '药名', 'drug_name'];
const MARKET_ALIASES = ['每克供货价', '供货价', 'market_price'];
const PRICE_ALIASES = ['每克零售价', '建议售价', '零售价', 'price'];
const PRICE_ALIASES = ['每克零售价', '建议售价', '零售价', '当前零售价', 'price'];
const UNIT_ALIASES = ['单位', '计量单位', 'unit', 'unit_name'];
function buildHeaderIndexMap(headerRow: ExcelJS.Row): Record<string, number> {
const map: Record<string, number> = {};
@@ -67,14 +108,23 @@ function getCellNumber(row: ExcelJS.Row, col?: number): number {
if (!col) {
return 0;
}
return parsePriceValue(row.getCell(col).value);
const raw = cellRawString(row.getCell(col));
if (raw === '' || !Number.isFinite(Number(raw))) {
return 0;
}
return roundPrice4(raw);
}
function getCellText(row: ExcelJS.Row, col?: number): string {
if (!col) {
return '';
}
return normalizeCell(row.getCell(col).value);
const cell = row.getCell(col);
const fromValue = normalizeCell(cell.value);
if (fromValue) {
return fromValue;
}
return String(cell.text ?? '').trim();
}
function rowIsEmpty(row: ExcelJS.Row): boolean {
@@ -95,16 +145,19 @@ export async function parseWarehousePriceExcelBuffer(
const sheet = workbook.worksheets[0];
if (!sheet) {
return { items: [], invalidCount: 0, rowCount: 0 };
return { items: [], invalidCount: 0, rowCount: 0, hasUnitColumn: false };
}
const headerRow = sheet.getRow(1);
const headerMap = buildHeaderIndexMap(headerRow);
const drugIdCol = pickColumn(headerMap, DRUG_ID_ALIASES);
const numberCol = pickColumn(headerMap, NUMBER_ALIASES);
const nameCol = pickColumn(headerMap, NAME_ALIASES);
const marketCol = pickColumn(headerMap, MARKET_ALIASES);
const priceCol = pickColumn(headerMap, PRICE_ALIASES);
const unitCol = pickColumn(headerMap, UNIT_ALIASES);
const hasUnitColumn = !!unitCol;
const items: ParsedWarehousePriceRow[] = [];
let invalidCount = 0;
@@ -116,25 +169,37 @@ export async function parseWarehousePriceExcelBuffer(
continue;
}
const drugIdRaw = getCellText(row, drugIdCol);
const drugId = Number.parseInt(drugIdRaw, 10) || 0;
const drugNumber = getCellText(row, numberCol);
const drugName = getCellText(row, nameCol);
const marketPrice = getCellNumber(row, marketCol);
const price = getCellNumber(row, priceCol);
const unitName = getCellText(row, unitCol);
if (!drugName && !drugNumber && marketPrice === 0 && price === 0) {
if (
drugId <= 0 &&
!drugName &&
!drugNumber &&
marketPrice === 0 &&
price === 0 &&
!unitName
) {
continue;
}
if (!drugName && !drugNumber) {
if (drugId <= 0 && !drugName && !drugNumber) {
invalidCount++;
continue;
}
items.push({
drug_id: drugId,
drug_name: drugName,
drug_number: drugNumber,
market_price: formatPriceLikeBackend(marketPrice),
price: formatPriceLikeBackend(price),
market_price: marketPrice,
price,
unit_name: unitName,
});
}
@@ -142,5 +207,6 @@ export async function parseWarehousePriceExcelBuffer(
items,
invalidCount,
rowCount: Math.max(0, rowCount - 1),
hasUnitColumn,
};
}

View File

@@ -1,18 +1,60 @@
/**
* 仓库改价对比工具
* 库内价格 decimal(10,4)。预览对比禁止 format_price。
* Excel IEEE 浮点常见把 0.91 读成 ≈0.8999→0.90,需在合并时与快照对账。
*/
export type PricePair = {
market_price: number;
price: number;
unit_name?: string;
};
export function parsePriceValue(value: unknown): number {
if (value === null || value === undefined || value === '') {
return 0;
}
if (typeof value === 'string') {
const cleaned = value.trim().replace(/,/g, '').replace(/¥/g, '');
if (cleaned === '') {
return 0;
}
const num = Number(cleaned);
return Number.isFinite(num) ? num : 0;
}
const num = Number(value);
return Number.isFinite(num) ? num : 0;
}
/** 对齐库字段 decimal(10,4) */
export function roundPrice4(value: unknown): number {
return Math.round(parsePriceValue(value) * 10000) / 10000;
}
/**
* 与后端 format_price 一致:第三位小数非 0 则分位进 1再保留两位小数。
* Excel 读入价与快照对账:未改到「分」或仅 1 分浮点漂移时,强制用快照价
* 避免 0.91→0.90、0.09→0.08 这类误报
*/
export function reconcileExcelPrice(excel: unknown, snapshot: unknown): number {
const e = roundPrice4(excel);
const s = roundPrice4(snapshot);
if (e === s) {
return s;
}
const fenE = Math.round(e * 100);
const fenS = Math.round(s * 100);
// 分位相同 → 未改价
if (fenE === fenS) {
return s;
}
// 仅差 1 分且更像浮点下漂/上漂,视为未改(用户真改 1 分极少见于每克价误触)
if (Math.abs(fenE - fenS) === 1 && Math.abs(e - s) <= 0.011) {
return s;
}
return e;
}
/**
* 与后端 format_price 一致:仅确认落库时用
*/
export function formatPriceLikeBackend(value: unknown): number {
const price = parsePriceValue(value);
@@ -26,17 +68,20 @@ export function formatPriceLikeBackend(value: unknown): number {
export function normalizePricePair(pair: PricePair): PricePair {
return {
market_price: formatPriceLikeBackend(pair.market_price),
price: formatPriceLikeBackend(pair.price),
market_price: roundPrice4(pair.market_price),
price: roundPrice4(pair.price),
unit_name: (pair.unit_name ?? '').trim(),
};
}
export function toPriceUnits(value: unknown): number {
return Math.round(formatPriceLikeBackend(value) * 100);
return Math.round(roundPrice4(value) * 10000);
}
export function formatPriceDisplay(value: unknown): string {
return formatPriceLikeBackend(value).toFixed(2);
const n = roundPrice4(value);
const fixed = n.toFixed(4);
return fixed.replace(/\.?0+$/, '') || '0';
}
export type PriceDelta = 'up' | 'down' | 'same';
@@ -57,6 +102,13 @@ export function isFieldChanged(orig: number, next: number): boolean {
return getPriceDelta(orig, next) !== 'same';
}
export function isUnitChanged(
orig: string | undefined,
next: string | undefined,
): boolean {
return (orig ?? '').trim() !== (next ?? '').trim();
}
export function isPriceChanged(
initial: PricePair | undefined,
current: PricePair,
@@ -66,7 +118,8 @@ export function isPriceChanged(
}
return (
isFieldChanged(initial.market_price, current.market_price) ||
isFieldChanged(initial.price, current.price)
isFieldChanged(initial.price, current.price) ||
isUnitChanged(initial.unit_name, current.unit_name)
);
}

View File

@@ -9,6 +9,7 @@ import {
isFieldChanged,
isMarketGtPrice,
isPriceChanged,
isUnitChanged,
type PriceDelta,
type PricePair,
} from '../../admin/utils/priceCompare';
@@ -18,6 +19,8 @@ export type PriceCompareFilterTab =
| 'changed'
| 'unchanged'
| 'marketGtPrice'
| 'unitChanged'
| 'priceLocked'
| 'new'
| 'unmatched';
@@ -29,12 +32,19 @@ export type PriceCompareRow = {
pinyin_simple?: string;
orig_market_price: number;
orig_price: number;
orig_unit_name?: string;
market_price: number;
price: number;
unit_name?: string;
_changed: boolean;
_marketGtPrice: boolean;
_unitChanged?: boolean;
/** 单位名不在字典中,确认时将自动新建 */
_unitPendingCreate?: boolean;
_isNew: boolean;
_fromExcel?: boolean;
/** 配送仓锁定,不可改价(门店) */
_priceLocked?: boolean;
_marketDelta: PriceDelta;
_priceDelta: PriceDelta;
_marketChanged: boolean;
@@ -46,6 +56,7 @@ export type UnmatchedRow = {
drug_number: string;
market_price: number;
price: number;
unit_name?: string;
};
const props = withDefaults(
@@ -54,11 +65,26 @@ const props = withDefaults(
unmatchedRows?: UnmatchedRow[];
filterTab?: PriceCompareFilterTab;
savingRowId?: number | null;
/** 是否展示单位编辑(平台 true门店 false */
showUnit?: boolean;
/** 是否展示供货价(门店仅零售价时为 false */
showMarketPrice?: boolean;
/** 售价列文案 */
priceLabel?: string;
/** 是否显示单行保存按钮 */
showRowSave?: boolean;
/** 是否展示「不可改价」筛选 Tab */
showPriceLockedTab?: boolean;
}>(),
{
unmatchedRows: () => [],
filterTab: 'all',
savingRowId: null,
showUnit: false,
showMarketPrice: true,
priceLabel: '售价',
showRowSave: true,
showPriceLockedTab: false,
},
);
@@ -84,12 +110,56 @@ watch(innerFilterTab, (v) => {
const tabCounts = computed(() => {
const all = props.rows.length;
const changed = props.rows.filter((r) => r._changed).length;
const unchanged = all - changed;
const changed = props.rows.filter((r) => r._changed && !r._priceLocked).length;
const unchanged = props.rows.filter(
(r) => !r._changed && !r._priceLocked && !r._isNew,
).length;
const marketGtPrice = props.rows.filter((r) => r._marketGtPrice).length;
const unitChanged = props.rows.filter((r) => r._unitChanged).length;
const priceLocked = props.rows.filter((r) => r._priceLocked).length;
const newCount = props.rows.filter((r) => r._isNew).length;
const unmatched = props.unmatchedRows.length;
return { all, changed, unchanged, marketGtPrice, new: newCount, unmatched };
return {
all,
changed,
unchanged,
marketGtPrice,
unitChanged,
priceLocked,
new: newCount,
unmatched,
};
});
const filterOptions = computed(() => {
const opts: { label: string; value: PriceCompareFilterTab }[] = [
{ label: `全部 ${tabCounts.value.all}`, value: 'all' },
{ label: `有修改 ${tabCounts.value.changed}`, value: 'changed' },
{ label: `无修改 ${tabCounts.value.unchanged}`, value: 'unchanged' },
];
if (props.showMarketPrice) {
opts.push({
label: `供价超售价 ${tabCounts.value.marketGtPrice}`,
value: 'marketGtPrice',
});
}
if (props.showUnit) {
opts.push({
label: `单位变更 ${tabCounts.value.unitChanged}`,
value: 'unitChanged',
});
}
if (props.showPriceLockedTab) {
opts.push({
label: `不可改价 ${tabCounts.value.priceLocked}`,
value: 'priceLocked',
});
}
opts.push(
{ label: `待新增 ${tabCounts.value.new}`, value: 'new' },
{ label: `未匹配 ${tabCounts.value.unmatched}`, value: 'unmatched' },
);
return opts;
});
const filteredRows = computed(() => {
@@ -97,11 +167,15 @@ const filteredRows = computed(() => {
let list = props.rows;
if (innerFilterTab.value === 'changed') {
list = list.filter((r) => r._changed);
list = list.filter((r) => r._changed && !r._priceLocked);
} else if (innerFilterTab.value === 'unchanged') {
list = list.filter((r) => !r._changed);
list = list.filter((r) => !r._changed && !r._priceLocked && !r._isNew);
} else if (innerFilterTab.value === 'marketGtPrice') {
list = list.filter((r) => r._marketGtPrice);
} else if (innerFilterTab.value === 'unitChanged') {
list = list.filter((r) => r._unitChanged);
} else if (innerFilterTab.value === 'priceLocked') {
list = list.filter((r) => r._priceLocked);
} else if (innerFilterTab.value === 'new') {
list = list.filter((r) => r._isNew);
}
@@ -139,7 +213,11 @@ function cardClass(row: PriceCompareRow): string[] {
const classes = [
'relative group rounded-xl border p-3 shadow-sm transition-all duration-300 ease-out hover:-translate-y-1 hover:shadow-md dark:bg-gray-800/80',
];
if (row._isNew) {
if (row._priceLocked) {
classes.push(
'border-orange-200 bg-orange-50/40 dark:border-orange-500/30 dark:bg-orange-900/10',
);
} else if (row._isNew) {
classes.push('border-blue-200 bg-blue-50/30 dark:border-blue-500/30 dark:bg-blue-900/10');
} else if (row._marketGtPrice) {
classes.push(
@@ -159,10 +237,12 @@ function refreshRowFlags(row: PriceCompareRow) {
const snapshot: PricePair = {
market_price: row.orig_market_price,
price: row.orig_price,
unit_name: row.orig_unit_name,
};
const current: PricePair = {
market_price: row.market_price,
price: row.price,
unit_name: row.unit_name,
};
row._marketDelta = getPriceDelta(snapshot.market_price, current.market_price);
row._priceDelta = getPriceDelta(snapshot.price, current.price);
@@ -171,6 +251,7 @@ function refreshRowFlags(row: PriceCompareRow) {
current.market_price,
);
row._priceChanged = isFieldChanged(snapshot.price, current.price);
row._unitChanged = isUnitChanged(snapshot.unit_name, current.unit_name);
row._changed = row._isNew || isPriceChanged(snapshot, current);
row._marketGtPrice = isMarketGtPrice(current);
}
@@ -185,6 +266,12 @@ function onPriceChange(
emit('cellChange', { ...row });
}
function onUnitChange(row: PriceCompareRow, value: string) {
row.unit_name = value;
refreshRowFlags(row);
emit('cellChange', { ...row });
}
function handleRowSave(row: PriceCompareRow) {
emit('rowSave', row);
}
@@ -209,17 +296,7 @@ function handleRowSave(row: PriceCompareRow) {
<Segmented
v-model:value="innerFilterTab"
class="mb-4 flex-shrink-0 flex-wrap !rounded-lg !p-1 shadow-sm"
:options="[
{ label: `全部 ${tabCounts.all}`, value: 'all' },
{ label: `有修改 ${tabCounts.changed}`, value: 'changed' },
{ label: `无修改 ${tabCounts.unchanged}`, value: 'unchanged' },
{
label: `供价超售价 ${tabCounts.marketGtPrice}`,
value: 'marketGtPrice',
},
{ label: `待新增 ${tabCounts.new}`, value: 'new' },
{ label: `未匹配 ${tabCounts.unmatched}`, value: 'unmatched' },
]"
:options="filterOptions"
/>
<!-- Main Content Area -->
@@ -264,14 +341,30 @@ function handleRowSave(row: PriceCompareRow) {
</div>
<!-- Status Tags -->
<div v-if="row._isNew" class="mb-2">
<Tag color="blue" class="!mr-0 !border-blue-200 !text-[11px] shadow-sm">待新增</Tag>
<div v-if="row._isNew || row._priceLocked" class="mb-2 flex flex-wrap gap-1">
<Tag
v-if="row._isNew"
color="blue"
class="!mr-0 !border-blue-200 !text-[11px] shadow-sm"
>
待新增
</Tag>
<Tag
v-if="row._priceLocked"
color="orange"
class="!mr-0 !text-[11px] shadow-sm"
>
不可改价
</Tag>
</div>
<!-- Price Inputs Area -->
<div class="space-y-2.5 text-xs">
<!-- Market Price Row -->
<div class="flex items-center justify-between group/row">
<div
v-if="showMarketPrice"
class="flex items-center justify-between group/row"
>
<span class="w-12 flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">
供货价
</span>
@@ -288,6 +381,7 @@ function handleRowSave(row: PriceCompareRow) {
:precision="4"
:step="0.0001"
size="small"
:disabled="!!row._priceLocked"
:value="row.market_price"
@update:value="
(v) => onPriceChange(row, 'market_price', v as number)
@@ -307,7 +401,7 @@ function handleRowSave(row: PriceCompareRow) {
<!-- Sell Price Row -->
<div class="flex items-center justify-between group/row">
<span class="w-12 flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">
售价
{{ priceLabel }}
</span>
<div class="flex flex-1 items-center justify-end gap-2">
<template v-if="!row._isNew">
@@ -322,6 +416,7 @@ function handleRowSave(row: PriceCompareRow) {
:precision="4"
:step="0.0001"
size="small"
:disabled="!!row._priceLocked"
:value="row.price"
@update:value="
(v) => onPriceChange(row, 'price', v as number)
@@ -337,6 +432,47 @@ function handleRowSave(row: PriceCompareRow) {
<span v-else class="w-4"></span>
</div>
</div>
<!-- Unit Row平台 -->
<div
v-if="showUnit"
class="flex items-center justify-between group/row"
>
<span class="w-12 flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">
单位
</span>
<div class="flex flex-1 items-center justify-end gap-2">
<template v-if="!row._isNew">
<span class="text-gray-400 line-through decoration-gray-300 dark:text-gray-500">
{{ row.orig_unit_name || '—' }}
</span>
<span class="text-gray-300 dark:text-gray-600"></span>
</template>
<Input
class="!w-[85px] !rounded-md shadow-sm"
size="small"
:value="row.unit_name"
placeholder="单位"
@update:value="(v) => onUnitChange(row, String(v ?? ''))"
/>
<Tag
v-if="row._unitPendingCreate"
color="orange"
class="!mr-0 !text-[10px]"
>
待建
</Tag>
<span v-else class="w-4"></span>
</div>
</div>
<!-- 门店只读单位对照 -->
<div
v-else-if="row.unit_name || row.orig_unit_name"
class="flex items-center justify-between text-gray-400"
>
<span class="w-12 flex-shrink-0">单位</span>
<span>{{ row.unit_name || row.orig_unit_name || '—' }}</span>
</div>
</div>
<!-- Action Area -->
@@ -345,7 +481,7 @@ function handleRowSave(row: PriceCompareRow) {
>
<Transition name="slide-up">
<Button
v-if="row._changed"
v-if="showRowSave && row._changed && !row._priceLocked"
:loading="savingRowId === row.id"
size="small"
type="primary"

View File

@@ -1,10 +1,13 @@
<script lang="ts" setup>
import { ref } from 'vue';
/**
* 门店仓库药品编辑弹窗
* 绑定配送仓不可改价;有底价/最高价时提示改价区间
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
@@ -13,10 +16,7 @@ import {
updateWarehouseDrugManagementStore,
} from '../api';
import { modalFormProps } from '../config/form';
import {getDrugUseList} from "#/views/doctor/doctor-reception/api";
const userStore = useUserStore();
import { getDrugUseList } from '#/views/doctor/doctor-reception/api';
const drugTime = ref([]);
const drugType = ref([]);
@@ -52,10 +52,13 @@ getDrugUseList().then((res) => {
const isUpdate = ref(false);
const gridApi = ref();
/** 是否绑定配送仓(不可改价) */
const priceLocked = ref(false);
/** 改价区间展示文案 */
const priceRangeTip = ref('');
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
@@ -63,6 +66,10 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
if (priceLocked.value) {
message.warning('该商品不可改价');
return;
}
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
@@ -86,7 +93,6 @@ const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
formApi.updateSchema([
{
componentProps: {
@@ -115,19 +121,61 @@ const [Modal, modalApi] = useVbenModal({
]);
const { values, update } = modalApi.getData<Record<string, any>>();
if (values) {
console.log(values, 'sssssssssssss')
isUpdate.value = update;
formApi.setValues(values);
// 从列表行带入配送仓标记与价格区间drug 嵌套字段展平到表单)
const hasDelivery =
Number(values.has_delivery_warehouse) === 1 ||
Number(values.drug?.has_delivery_warehouse) === 1;
priceLocked.value = hasDelivery;
const min = Number(
values.min_adjust_price ?? values.drug?.min_adjust_price ?? 0,
);
const max = Number(
values.max_adjust_price ?? values.drug?.max_adjust_price ?? 0,
);
if (min > 0 || max > 0) {
const minTxt = min > 0 ? `${min.toFixed(2)}` : '不限';
const maxTxt = max > 0 ? `${max.toFixed(2)}` : '不限';
priceRangeTip.value = `改价区间:${minTxt} ~ ${maxTxt}`;
} else {
priceRangeTip.value = '';
}
formApi.setValues({
...values,
has_delivery_warehouse: hasDelivery ? 1 : 0,
min_adjust_price: min || null,
max_adjust_price: max || null,
});
}
} else {
priceLocked.value = false;
priceRangeTip.value = '';
}
},
});
const modalTitle = computed(() =>
isUpdate.value === true ? '编辑仓库药品' : '新增仓库药品',
);
</script>
<template>
<Modal
:title="`${isUpdate === true ? '编辑' : '新增'}仓库药品`"
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
>
<Modal :title="modalTitle" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
<Alert
v-if="priceLocked"
type="warning"
show-icon
class="mb-3"
message="该商品不可改价"
description="已绑定配送仓库,商家侧不允许修改售价。"
/>
<Alert
v-else-if="priceRangeTip"
type="info"
show-icon
class="mb-3"
:message="priceRangeTip"
description="售价必须落在平台设置的底价~最高价区间内。"
/>
<Form />
</Modal>
</template>

View File

@@ -65,6 +65,40 @@ export const modalFormProps: VbenFormProps = {
formItemClass: 'col-span-12',
label: '价格',
rules: 'required',
// 绑定配送仓时禁用改价;区间提示由弹窗文案补充
dependencies: {
disabled(values: any) {
return Number(values?.has_delivery_warehouse) === 1;
},
triggerFields: ['has_delivery_warehouse'],
},
},
{
component: 'VbenInput',
fieldName: 'has_delivery_warehouse',
label: '配送仓',
dependencies: {
show: false,
triggerFields: ['has_delivery_warehouse'],
},
},
{
component: 'VbenInput',
fieldName: 'min_adjust_price',
label: '底价',
dependencies: {
show: false,
triggerFields: ['min_adjust_price'],
},
},
{
component: 'VbenInput',
fieldName: 'max_adjust_price',
label: '最高价',
dependencies: {
show: false,
triggerFields: ['max_adjust_price'],
},
},
],
showDefaultActions: false,

View File

@@ -62,9 +62,12 @@ const [ExcelUploadModal, ExcelUploadModalApi] = useVbenModal({
connectedComponent: ExcelUpload,
});
const showModal = (data = {}, isUpdate = false) => {
const showModal = (data: any = {}, isUpdate = false) => {
if (isUpdate && Number(data?.has_delivery_warehouse) === 1) {
message.warning('该商品不可改价');
return;
}
formModalApi.setData({
// 表单值
values: data,
update: isUpdate,
gridApi,
@@ -254,31 +257,15 @@ checkSubscribe();
<template #action="{ row }">
<TableAction
:actions="[
// {
// label: '上架/下架(仓库)',
// type: 'link',
// icon: 'uil:edit',
// size: 'small',
// // auth: ['western-medicine', 'sys:role:detail'],
// onClick: updateStatus.bind(null, row.id),
// },
// {
// label: '上架/下架(商城)',
// type: 'link',
// icon: 'uil:edit',
// size: 'small',
// // auth: ['western-medicine', 'sys:role:detail'],
// onClick: updateWarehouseDrugManagementShopUpdateStatus.bind(
// null,
// row.id,
// ),
// },
{
label: '编辑',
label:
Number(row.has_delivery_warehouse) === 1
? '不可改价'
: '编辑',
type: 'link',
icon: 'uil:edit',
size: 'small',
// auth: ['western-medicine', 'sys:role:detail'],
disabled: Number(row.has_delivery_warehouse) === 1,
onClick: showModal.bind(null, row, true),
},
]"

View File

@@ -50,11 +50,21 @@ export async function updateWarehouseDrugManagementShopUpdateStatusApi(id: numbe
}
/**
* 导出仓库药品药品
* 导出仓库药品(旧:后端二进制,保留兼容)
*/
export async function exportWarehouseDrugManagementStoreApi(params?: { type?: number }) {
return requestClient.download(`${prefix}export`, { params });
}
/**
* 导出 JSON 数据(供前端 ExcelJS
*/
export async function getStoreExportDataApi(params?: { type?: number }) {
return requestClient.get<{
total: number;
rows: Array<Record<string, unknown>>;
}>(`${prefix}export-data`, { params });
}
/**
* 导出仓库药品药品 - 模板
*/
@@ -95,13 +105,65 @@ export async function importWarehouseDrugManagementStoreApi(data: Record<string,
}
/**
* 导入仓库药品修改价格
* 导入仓库药品修改价格(兼容旧直传入口)
* @param data
*/
export async function importUpdatePriceApi(data: Record<string, any>) {
return requestClient.upload(`${prefix}import-update-price`, data);
}
export interface StoreBatchPriceItem {
id: number;
drug_id: number;
drug_name: string;
drug_number: string;
pinyin_simple: string;
unit_name?: string;
price: number | string;
buy_price?: number | string;
has_delivery_warehouse?: number;
}
export interface StoreCentralAvailableItem {
drug_id: number;
drug_name: string;
drug_number: string;
pinyin_simple: string;
unit_name?: string;
price: number | string;
buy_price?: number | string;
}
/** 门店批量改价预览:全量快照 */
export async function getStoreAllForBatchPriceApi(params: { type: number }) {
return requestClient.get<{
items: StoreBatchPriceItem[];
central_available?: StoreCentralAvailableItem[];
}>(`${prefix}list-all-for-batch-price`, { params });
}
export interface StoreBatchPriceUpdateItem {
id: number;
price: number;
}
export interface StoreBatchPriceCreateItem {
drug_id: number;
price: number;
}
/** 门店批量改价预览:确认落库(仅零售价) */
export async function applyStoreBatchPriceApi(data: {
type: number;
updates?: StoreBatchPriceUpdateItem[];
creates?: StoreBatchPriceCreateItem[];
}) {
return requestClient.post<{
update_count: number;
new_count: number;
}>(`${prefix}apply-batch-price`, data);
}
/**
* 同步总仓库api
* @param data

View File

@@ -0,0 +1,687 @@
<script lang="ts" setup>
/**
* 门店仓库批量改价预览ExcelJS 本地解析 → 分类对比 → 确认落库
* 仅改零售价Excel 含单位列时忽略并提示;配送仓锁定行不可提交
*/
import type { UploadFile } from 'ant-design-vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message, Spin, Tag, UploadDragger } from 'ant-design-vue';
import { downloadByData } from '#/util/tool';
import {
applyStoreBatchPriceApi,
getStoreAllForBatchPriceApi,
getStoreExportDataApi,
type StoreBatchPriceItem,
type StoreCentralAvailableItem,
} from '../api';
import {
exportStoreListExcel,
type StoreExportRow,
} from '../utils/exportStorePriceExcel';
import { parseStorePriceExcelBuffer } from '../utils/parseStorePriceExcel';
import {
getPriceDelta,
isFieldChanged,
parsePriceValue,
reconcileExcelPrice,
roundPrice4,
} from '../../admin/utils/priceCompare';
import PriceCompareCardGrid, {
type PriceCompareFilterTab,
type PriceCompareRow,
type UnmatchedRow,
} from '../../shared/components/PriceCompareCardGrid.vue';
const gridApi = ref();
const productType = ref(1);
const fileList = ref<UploadFile[]>([]);
const selectedFile = ref<File | null>(null);
const loadingAll = ref(false);
const parseLoading = ref(false);
const applyLoading = ref(false);
const parseHint = ref('');
const parsedReady = ref(false);
const filterTab = ref<PriceCompareFilterTab>('all');
const storeItems = ref<StoreBatchPriceItem[]>([]);
const centralAvailable = ref<StoreCentralAvailableItem[]>([]);
/** relation.id → 原始零售价 */
const initialPriceMap = ref<Record<number, number>>({});
/** relation.id → 当前编辑零售价 */
const localPriceMap = ref<Record<number, number>>({});
const newRows = ref<
Array<{
tempId: number;
drug_id: number;
drug_name: string;
drug_number: string;
unit_name: string;
price: number;
}>
>([]);
const unmatchedRows = ref<UnmatchedRow[]>([]);
const excelTouchedIds = ref<Set<number>>(new Set());
let tempIdCounter = -1;
let loadAllPromise: Promise<void> | null = null;
function resetState() {
selectedFile.value = null;
fileList.value = [];
storeItems.value = [];
centralAvailable.value = [];
initialPriceMap.value = {};
localPriceMap.value = {};
newRows.value = [];
unmatchedRows.value = [];
excelTouchedIds.value = new Set();
parseHint.value = '';
parsedReady.value = false;
filterTab.value = 'all';
tempIdCounter = -1;
}
function clearParseResult() {
newRows.value = [];
unmatchedRows.value = [];
excelTouchedIds.value = new Set();
parseHint.value = '';
parsedReady.value = false;
const next: Record<number, number> = {};
for (const [id, price] of Object.entries(initialPriceMap.value)) {
next[Number(id)] = price;
}
localPriceMap.value = next;
}
function buildSnapshot(items: StoreBatchPriceItem[]) {
const init: Record<number, number> = {};
const loc: Record<number, number> = {};
for (const item of items) {
// 与 Excel 解析同一套 4 位小数,避免 format_price 把浮点误差打成「降一分」
const price = roundPrice4(item.price);
init[item.id] = price;
loc[item.id] = price;
}
initialPriceMap.value = init;
localPriceMap.value = loc;
}
/**
* 组装对比行:门店仅零售价;配送仓锁定标记 _priceLocked
*/
function buildCompareRow(
item: {
id: number;
drug_id?: number;
drug_name: string;
drug_number: string;
pinyin_simple?: string;
unit_name?: string;
has_delivery_warehouse?: number;
},
currentPrice: number,
origPrice: number,
isNew: boolean,
): PriceCompareRow {
const priceLocked = !isNew && Number(item.has_delivery_warehouse) === 1;
const priceChanged = isFieldChanged(origPrice, currentPrice);
// 锁定行即使 Excel 改了价也不算「有修改」,避免误提交
const changed = isNew || (!priceLocked && priceChanged);
return {
id: item.id,
drug_id: item.drug_id,
drug_name: item.drug_name,
drug_number: item.drug_number,
pinyin_simple: item.pinyin_simple ?? '',
orig_market_price: 0,
orig_price: origPrice,
orig_unit_name: item.unit_name ?? '',
market_price: 0,
price: currentPrice,
unit_name: item.unit_name ?? '',
_changed: changed,
_marketGtPrice: false,
_unitChanged: false,
_isNew: isNew,
_fromExcel: excelTouchedIds.value.has(item.id) || isNew,
_priceLocked: priceLocked,
_marketDelta: 'same',
_priceDelta: getPriceDelta(origPrice, currentPrice),
_marketChanged: false,
_priceChanged: priceChanged,
};
}
const compareRows = computed<PriceCompareRow[]>(() => {
const existing = storeItems.value.map((item) => {
const orig = initialPriceMap.value[item.id] ?? parsePriceValue(item.price);
const current = localPriceMap.value[item.id] ?? orig;
return buildCompareRow(item, current, orig, false);
});
const news = newRows.value.map((row) =>
buildCompareRow(
{
id: row.tempId,
drug_id: row.drug_id,
drug_name: row.drug_name,
drug_number: row.drug_number,
unit_name: row.unit_name,
},
row.price,
0,
true,
),
);
return [...existing, ...news];
});
const changedCount = computed(
() =>
compareRows.value.filter((r) => r._changed && !r._isNew && !r._priceLocked)
.length,
);
const newCount = computed(
() => compareRows.value.filter((r) => r._isNew).length,
);
const [ModalComp, modalApi] = useVbenModal({
fullscreen: true,
fullscreenButton: true,
draggable: true,
confirmText: '批量确认更新',
cancelText: '关闭',
showConfirmButton: true,
onConfirm: runBatchApply,
onCancel() {
resetState();
modalApi.close();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
productType.value = isOpen ? modalApi.getData()?.productType ?? 1 : 1;
if (isOpen) {
modalApi.setState({ fullscreen: true });
syncModalFooterState();
loadAllDrugs();
} else {
resetState();
}
},
});
function syncModalFooterState() {
modalApi.setState({
confirmDisabled:
!parsedReady.value ||
(changedCount.value === 0 && newCount.value === 0),
confirmLoading: applyLoading.value,
});
}
watch(
[changedCount, newCount, applyLoading, parseLoading, parsedReady],
syncModalFooterState,
);
async function loadAllDrugs() {
loadingAll.value = true;
const task = (async () => {
try {
const res = await getStoreAllForBatchPriceApi({ type: productType.value });
storeItems.value = res?.items ?? [];
centralAvailable.value = res?.central_available ?? [];
buildSnapshot(storeItems.value);
} catch {
message.error('加载门店药品失败');
storeItems.value = [];
centralAvailable.value = [];
initialPriceMap.value = {};
localPriceMap.value = {};
} finally {
loadingAll.value = false;
loadAllPromise = null;
}
})();
loadAllPromise = task;
await task;
}
async function handleFileChange(info: { file: UploadFile }) {
const { file } = info;
if (file.status === 'removed') {
selectedFile.value = null;
clearParseResult();
filterTab.value = 'all';
return;
}
const raw = file.originFileObj ?? file;
if (raw instanceof File) {
selectedFile.value = raw;
await runParse();
}
}
/**
* 下载标准模板:与列表页「导出」同一套 export-data + 列结构
*/
async function downloadTemplate() {
try {
const res = await getStoreExportDataApi({ type: productType.value });
const rows = (res?.rows ?? []) as StoreExportRow[];
if (rows.length === 0) {
message.warning('暂无药品数据');
return;
}
const buffer = await exportStoreListExcel(rows, '门店仓库');
downloadByData(
buffer,
'萧康云医-门店仓库商品导出.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
message.success(`模板已下载(${res?.total ?? rows.length} 条,与导出一致)`);
} catch {
message.error('模板下载失败');
}
}
function findStoreByDrugId(drugId: number) {
return storeItems.value.find((d) => d.drug_id === drugId);
}
function findCentralByDrugId(drugId: number) {
return centralAvailable.value.find((d) => d.drug_id === drugId);
}
async function runParse() {
if (!selectedFile.value) {
return;
}
if (loadingAll.value && loadAllPromise) {
await loadAllPromise;
}
parseLoading.value = true;
syncModalFooterState();
try {
const buf = await selectedFile.value.arrayBuffer();
const { items, invalidCount, rowCount, hasUnitColumn } =
await parseStorePriceExcelBuffer(buf);
if (hasUnitColumn) {
message.info('门店导入已忽略单位列(单位仅平台总仓可改)');
}
if (items.length === 0) {
message.warning(
invalidCount > 0
? `未解析到有效行(${invalidCount} 行缺少合法药品ID`
: '未解析到有效的价格行',
);
return;
}
const hints: string[] = [
rowCount > 5000
? `${rowCount} 行,已按上限处理前 5000 行。`
: `共解析 ${items.length} 条有效行。`,
];
if (hasUnitColumn) {
hints.push('已忽略单位列。');
}
if (invalidCount > 0) {
hints.push(`${invalidCount} 行因药品ID无效已跳过。`);
}
parseHint.value = hints.join(' ');
const nextUnmatched: UnmatchedRow[] = [];
const nextNew: typeof newRows.value = [];
const nextLocal = { ...localPriceMap.value };
const nextTouched = new Set<number>();
for (const row of items) {
const matched = findStoreByDrugId(row.drug_id);
if (matched) {
nextTouched.add(matched.id);
const orig = initialPriceMap.value[matched.id] ?? parsePriceValue(matched.price);
// 与快照对账,消化 Excel 一分钱浮点误差
nextLocal[matched.id] = reconcileExcelPrice(row.price, orig);
continue;
}
const central = findCentralByDrugId(row.drug_id);
if (central) {
nextNew.push({
tempId: tempIdCounter--,
drug_id: central.drug_id,
drug_name: central.drug_name || row.drug_name,
drug_number: central.drug_number || row.drug_number,
unit_name: central.unit_name ?? '',
price: row.price,
});
continue;
}
nextUnmatched.push({
drug_name: row.drug_name || `ID:${row.drug_id}`,
drug_number: row.drug_number || String(row.drug_id),
market_price: 0,
price: row.price,
unit_name: '',
});
}
localPriceMap.value = nextLocal;
excelTouchedIds.value = nextTouched;
newRows.value = nextNew;
unmatchedRows.value = nextUnmatched;
parsedReady.value = true;
const changedN = storeItems.value.filter((item) => {
if (!nextTouched.has(item.id)) {
return false;
}
const orig = initialPriceMap.value[item.id];
const cur = nextLocal[item.id];
return (
Number(item.has_delivery_warehouse) !== 1 &&
isFieldChanged(orig, cur)
);
}).length;
if (changedN > 0) {
filterTab.value = 'changed';
} else if (nextNew.length > 0) {
filterTab.value = 'new';
} else {
filterTab.value = 'unchanged';
message.info('未检测到零售价变更');
}
} catch {
message.error('解析失败,请检查 Excel 格式');
} finally {
parseLoading.value = false;
syncModalFooterState();
}
}
function syncRowFromGrid(row: PriceCompareRow) {
if (row._isNew) {
newRows.value = newRows.value.map((item) =>
item.tempId === row.id ? { ...item, price: row.price } : item,
);
return;
}
localPriceMap.value = {
...localPriceMap.value,
[row.id]: row.price,
};
}
async function runBatchApply() {
const changedExisting = compareRows.value.filter(
(r) => r._changed && !r._isNew && !r._priceLocked,
);
const creates = newRows.value.map((row) => ({
drug_id: row.drug_id,
price: row.price,
}));
if (changedExisting.length === 0 && creates.length === 0) {
message.info('没有需要保存的变更');
return;
}
applyLoading.value = true;
modalApi.setState({ loading: true, confirmLoading: true });
try {
const res = await applyStoreBatchPriceApi({
type: productType.value,
updates: changedExisting.map((row) => ({
id: row.id,
price: parsePriceValue(row.price),
})),
creates,
});
const updateN = res?.update_count ?? 0;
const newN = res?.new_count ?? 0;
message.success(`已更新 ${updateN} 条,新增 ${newN}`);
gridApi.value?.reload?.();
resetState();
modalApi.close();
} catch {
message.error('批量更新失败');
} finally {
applyLoading.value = false;
modalApi.setState({ loading: false, confirmLoading: false });
syncModalFooterState();
}
}
</script>
<template>
<ModalComp title="批量改价预览(门店仓库)">
<Transition name="fade-slide" mode="out-in">
<div
v-if="!parsedReady"
class="flex h-[calc(100vh-220px)] w-full flex-col items-center justify-center px-4 sm:px-12 md:px-24"
>
<div class="relative w-full max-w-[85vw] xl:max-w-screen-xl">
<div
v-if="loadingAll || parseLoading"
class="absolute inset-0 z-50 flex items-center justify-center rounded-3xl bg-white/70 backdrop-blur-[2px] dark:bg-gray-900/70"
>
<Spin size="large" tip="解析数据中..." />
</div>
<div class="flex w-full flex-col items-center gap-8">
<div class="text-center">
<h2 class="mb-3 text-3xl font-medium text-gray-800 dark:text-gray-100">
导入门店零售价
</h2>
<p class="text-lg text-gray-500">
上传 Excel 预览有变动 / 无变动 / 待新增后再确认单位列会被忽略
</p>
</div>
<div class="batch-price-upload batch-price-upload--hero w-full transition-transform duration-300 hover:scale-[1.01]">
<UploadDragger
v-model:file-list="fileList"
:before-upload="() => false"
:max-count="1"
:show-upload-list="false"
accept=".xlsx,.xls"
name="file"
@change="handleFileChange"
>
<div class="flex min-h-[420px] w-full flex-col items-center justify-center py-16">
<div class="mb-8 rounded-full bg-blue-50 p-6 text-blue-500 ring-8 ring-blue-50/50 dark:bg-blue-900/30 dark:ring-blue-900/20">
<svg class="h-14 w-14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<p class="text-2xl font-medium text-gray-700 dark:text-gray-200">
点击或将 Excel 文件拖拽至此
</p>
<p class="mt-4 text-lg text-gray-400">
纯前端本地解析,确认后才提交服务器
</p>
</div>
</UploadDragger>
</div>
<div class="flex min-h-[28px] items-center gap-2">
<p
v-if="selectedFile"
class="text-lg font-medium text-blue-600 dark:text-blue-400"
>
已选文件:{{ selectedFile.name }}
</p>
</div>
<Button
:disabled="loadingAll || storeItems.length === 0"
size="large"
type="dashed"
class="!rounded-full px-10 py-2 shadow-sm"
@click="downloadTemplate"
>
下载标准模板
</Button>
</div>
</div>
</div>
<div
v-else
class="flex w-full flex-col gap-4"
style="height: calc(100vh - 200px)"
>
<div
class="flex shrink-0 flex-col gap-4 rounded-xl border border-gray-100 bg-gray-50/50 p-4 shadow-sm backdrop-blur-sm dark:border-gray-800 dark:bg-gray-900/30 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex items-center gap-4">
<div class="batch-price-upload batch-price-upload--mini">
<UploadDragger
v-model:file-list="fileList"
:before-upload="() => false"
:max-count="1"
:show-upload-list="false"
accept=".xlsx,.xls"
name="file"
class="!rounded-lg"
@change="handleFileChange"
>
<div class="flex items-center gap-2 px-4 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300">
重新上传
</div>
</UploadDragger>
</div>
<div v-if="selectedFile" class="text-sm text-gray-500">
当前:
<span class="font-medium text-gray-700 dark:text-gray-200">
{{ selectedFile.name }}
</span>
</div>
</div>
<div class="flex items-center gap-3">
<Button size="small" type="link" @click="downloadTemplate">
下载模板
</Button>
<div class="h-4 w-px bg-gray-300 dark:bg-gray-700"></div>
<Button
v-if="selectedFile"
:disabled="parseLoading"
size="small"
type="link"
@click="runParse"
>
重新解析
</Button>
</div>
</div>
<div
class="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800"
>
<div
v-if="loadingAll || parseLoading"
class="absolute inset-0 z-50 flex items-center justify-center bg-white/70 backdrop-blur-[2px] dark:bg-gray-900/70"
>
<Spin size="large" tip="数据处理中..." />
</div>
<div class="custom-scrollbar relative w-full flex-1 overflow-y-auto p-4">
<PriceCompareCardGrid
v-model:filter-tab="filterTab"
:rows="compareRows"
:unmatched-rows="unmatchedRows"
:show-unit="false"
:show-market-price="false"
:show-row-save="false"
:show-price-locked-tab="true"
price-label="零售价"
@cell-change="syncRowFromGrid"
/>
</div>
</div>
</div>
</Transition>
<template v-if="parsedReady" #prepend-footer>
<div class="flex items-center gap-2">
<Tag
:color="changedCount > 0 ? 'warning' : 'default'"
class="!rounded-md shadow-sm"
>
待保存修改 {{ changedCount }}
</Tag>
<Tag
:color="newCount > 0 ? 'processing' : 'default'"
class="!rounded-md shadow-sm"
>
待新增数据 {{ newCount }}
</Tag>
<span v-if="parseHint" class="ml-2 text-xs text-gray-400">
{{ parseHint }}
</span>
</div>
</template>
</ModalComp>
</template>
<style scoped>
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(20px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-20px);
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 9999px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.8);
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.batch-price-upload {
width: 100%;
display: block;
}
.batch-price-upload :deep(.ant-upload-wrapper),
.batch-price-upload :deep(.ant-upload-list),
.batch-price-upload :deep(.ant-upload-drag) {
width: 100% !important;
display: block;
}
.batch-price-upload--hero :deep(.ant-upload-drag) {
background: rgba(249, 250, 251, 0.5);
border: 2px dashed #d1d5db;
border-radius: 1.5rem;
transition: all 0.3s ease;
}
.batch-price-upload--hero :deep(.ant-upload-drag:hover) {
border-color: #3b82f6;
background: rgba(239, 246, 255, 0.5);
}
.batch-price-upload--mini :deep(.ant-upload-drag) {
border: 1px dashed #d1d5db;
border-radius: 0.5rem;
background: transparent;
transition: all 0.2s;
}
.batch-price-upload--mini :deep(.ant-upload-drag:hover) {
border-color: #3b82f6;
background: rgba(239, 246, 255, 0.5);
}
</style>

View File

@@ -1,10 +1,13 @@
<script lang="ts" setup>
import { ref } from 'vue';
/**
* 门店仓库药品编辑弹窗
* 绑定配送仓不可改价;有底价/最高价时提示改价区间
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
@@ -13,10 +16,7 @@ import {
updateWarehouseDrugManagementStore,
} from '../api';
import { modalFormProps } from '../config/form';
import {getDrugUseList} from "#/views/doctor/doctor-reception/api";
const userStore = useUserStore();
import { getDrugUseList } from '#/views/doctor/doctor-reception/api';
const drugTime = ref([]);
const drugType = ref([]);
@@ -52,10 +52,13 @@ getDrugUseList().then((res) => {
const isUpdate = ref(false);
const gridApi = ref();
/** 是否绑定配送仓(不可改价) */
const priceLocked = ref(false);
/** 改价区间展示文案 */
const priceRangeTip = ref('');
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
@@ -63,6 +66,10 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
if (priceLocked.value) {
message.warning('该商品不可改价');
return;
}
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
@@ -86,7 +93,6 @@ const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
formApi.updateSchema([
{
componentProps: {
@@ -115,19 +121,61 @@ const [Modal, modalApi] = useVbenModal({
]);
const { values, update } = modalApi.getData<Record<string, any>>();
if (values) {
console.log(values, 'sssssssssssss')
isUpdate.value = update;
formApi.setValues(values);
// 从列表行带入配送仓标记与价格区间drug 嵌套字段展平到表单)
const hasDelivery =
Number(values.has_delivery_warehouse) === 1 ||
Number(values.drug?.has_delivery_warehouse) === 1;
priceLocked.value = hasDelivery;
const min = Number(
values.min_adjust_price ?? values.drug?.min_adjust_price ?? 0,
);
const max = Number(
values.max_adjust_price ?? values.drug?.max_adjust_price ?? 0,
);
if (min > 0 || max > 0) {
const minTxt = min > 0 ? `${min.toFixed(2)}` : '不限';
const maxTxt = max > 0 ? `${max.toFixed(2)}` : '不限';
priceRangeTip.value = `改价区间:${minTxt} ~ ${maxTxt}`;
} else {
priceRangeTip.value = '';
}
formApi.setValues({
...values,
has_delivery_warehouse: hasDelivery ? 1 : 0,
min_adjust_price: min || null,
max_adjust_price: max || null,
});
}
} else {
priceLocked.value = false;
priceRangeTip.value = '';
}
},
});
const modalTitle = computed(() =>
isUpdate.value === true ? '编辑仓库药品' : '新增仓库药品',
);
</script>
<template>
<Modal
:title="`${isUpdate === true ? '编辑' : '新增'}仓库药品`"
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
>
<Modal :title="modalTitle" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
<Alert
v-if="priceLocked"
type="warning"
show-icon
class="mb-3"
message="该商品不可改价"
description="已绑定配送仓库,商家侧不允许修改售价。"
/>
<Alert
v-else-if="priceRangeTip"
type="info"
show-icon
class="mb-3"
:message="priceRangeTip"
description="售价必须落在平台设置的底价~最高价区间内。"
/>
<Form />
</Modal>
</template>

View File

@@ -65,6 +65,40 @@ export const modalFormProps: VbenFormProps = {
formItemClass: 'col-span-12',
label: '价格',
rules: 'required',
// 绑定配送仓时禁用改价;区间提示由弹窗文案补充
dependencies: {
disabled(values: any) {
return Number(values?.has_delivery_warehouse) === 1;
},
triggerFields: ['has_delivery_warehouse'],
},
},
{
component: 'VbenInput',
fieldName: 'has_delivery_warehouse',
label: '配送仓',
dependencies: {
show: false,
triggerFields: ['has_delivery_warehouse'],
},
},
{
component: 'VbenInput',
fieldName: 'min_adjust_price',
label: '底价',
dependencies: {
show: false,
triggerFields: ['min_adjust_price'],
},
},
{
component: 'VbenInput',
fieldName: 'max_adjust_price',
label: '最高价',
dependencies: {
show: false,
triggerFields: ['max_adjust_price'],
},
},
],
showDefaultActions: false,

View File

@@ -37,6 +37,12 @@ export function createGridOptions(productType: number): VxeGridProps<RowType> {
{ field: 'buy_price', title: '供货价' },
{ field: 'drug.drug_store_drug.price', title: '建议售价' },
{ field: 'price', title: '售价' },
{
field: 'has_delivery_warehouse',
title: '改价',
width: 100,
slots: { default: 'price_lock' },
},
{ field: 'status', title: '状态', slots: { default: 'status' } },
{
type: 'html',

View File

@@ -15,17 +15,21 @@ import { useWarehouseDrugTypeRoute } from '../shared/useWarehouseDrugTypeRoute';
import {
checkSubscribeApi,
deleteWarehouseDrugManagementStore,
exportWarehouseDrugManagementStoreApi,
getStoreExportDataApi,
subscribeApi,
syncDrugApi,
syncDrugPriceApi,
updateWarehouseDrugManagementShopUpdateStatusApi,
updateWarehouseDrugManagementStoreStatusApi,
} from './api';
import ExcelUpload from './components/ExcelUpload.vue';
import StorePriceBatchModal from './components/StorePriceBatchModal.vue';
import FormModalDemo from './components/modal.vue';
import { formOptions } from './config/search';
import { createGridOptions } from './config/table';
import {
exportStoreListExcel,
type StoreExportRow,
} from './utils/exportStorePriceExcel';
const { productType, typeLabel } = useWarehouseDrugTypeRoute(
'/warehouse-drug-management-store/type/1',
@@ -58,13 +62,17 @@ const [FormModal, formModalApi] = useVbenModal({
connectedComponent: FormModalDemo,
});
const [ExcelUploadModal, ExcelUploadModalApi] = useVbenModal({
connectedComponent: ExcelUpload,
const [StorePriceBatchModalComp, StorePriceBatchModalApi] = useVbenModal({
connectedComponent: StorePriceBatchModal,
});
const showModal = (data = {}, isUpdate = false) => {
const showModal = (data: any = {}, isUpdate = false) => {
// 绑定配送仓:直接提示不可改价,不打开编辑
if (isUpdate && Number(data?.has_delivery_warehouse) === 1) {
message.warning('该商品不可改价');
return;
}
formModalApi.setData({
// 表单值
values: data,
update: isUpdate,
gridApi,
@@ -84,24 +92,35 @@ const deleteApi = (row: any) => {
gridApi.query();
});
};
/**
* 导出
*/
const passApplication = () => {
exportWarehouseDrugManagementStoreApi({ type: productType.value }).then((res) => {
downloadByData(res.data, '萧康云医-仓库商品导出.xlsx');
message.success('导出成功!');
/** 打开门店批量改价预览ExcelJS 解析后确认) */
const openStorePriceBatchModal = () => {
StorePriceBatchModalApi.setData({
gridApi,
productType: productType.value,
});
StorePriceBatchModalApi.open();
};
const openExcelUploadModal = (type = 1) => {
ExcelUploadModalApi.setData({
gridApi,
type,
uploadType: productType.value,
passApplication,
});
ExcelUploadModalApi.open();
/** 门店导出export-data + ExcelJS */
const exportStoreDrugs = async () => {
try {
const res = await getStoreExportDataApi({ type: productType.value });
const rows = (res?.rows ?? []) as StoreExportRow[];
if (rows.length === 0) {
message.warning('暂无药品数据');
return;
}
const buffer = await exportStoreListExcel(rows, '门店仓库');
downloadByData(
buffer,
'萧康云医-门店仓库商品导出.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
message.success(`导出成功(${res?.total ?? rows.length} 条)`);
} catch {
message.error('导出失败');
}
};
const syncDrugs = () => {
@@ -153,17 +172,23 @@ checkSubscribe();
<template>
<Page auto-content-height :title="typeLabel || '门店仓库管理'">
<ExcelUploadModal />
<StorePriceBatchModalComp />
<FormModal />
<Grid v-if="productType">
<template #toolbar-buttons>
<TableAction
:actions="[
{
label: '批量改价',
label: '批量改价预览',
type: 'primary',
icon: 'ix:export-check',
onClick: openExcelUploadModal.bind(null, 2),
icon: 'mdi:file-compare',
onClick: openStorePriceBatchModal,
},
{
label: '导出',
type: 'primary',
icon: 'lucide:download',
onClick: exportStoreDrugs,
},
{
label: '更新总仓库商品(只更新未同步的商品)',
@@ -237,6 +262,12 @@ checkSubscribe();
@click="updateStatus(row.id)"
/>
</template>
<template #price_lock="{ row }">
<Tag v-if="Number(row.has_delivery_warehouse) === 1" color="orange">
不可改价
</Tag>
<span v-else class="text-slate-400">可改价</span>
</template>
<template #is_shop="{ row }">
<Switch
:checked="row.is_shop"
@@ -251,31 +282,15 @@ checkSubscribe();
<template #action="{ row }">
<TableAction
:actions="[
// {
// label: '上架/下架(仓库)',
// type: 'link',
// icon: 'uil:edit',
// size: 'small',
// // auth: ['western-medicine', 'sys:role:detail'],
// onClick: updateStatus.bind(null, row.id),
// },
// {
// label: '上架/下架(商城)',
// type: 'link',
// icon: 'uil:edit',
// size: 'small',
// // auth: ['western-medicine', 'sys:role:detail'],
// onClick: updateWarehouseDrugManagementShopUpdateStatus.bind(
// null,
// row.id,
// ),
// },
{
label: '编辑',
label:
Number(row.has_delivery_warehouse) === 1
? '不可改价'
: '编辑',
type: 'link',
icon: 'uil:edit',
size: 'small',
// auth: ['western-medicine', 'sys:role:detail'],
disabled: Number(row.has_delivery_warehouse) === 1,
onClick: showModal.bind(null, row, true),
},
]"

View File

@@ -0,0 +1,171 @@
/**
* 门店仓库 ExcelJS 导出(数据来自 export-data
* 价格/单位强制文本写出,避免 IEEE 浮点回读假变更
*/
import ExcelJS from 'exceljs';
const THIN_BORDER: Partial<ExcelJS.Borders> = {
top: { style: 'thin', color: { argb: 'FF000000' } },
left: { style: 'thin', color: { argb: 'FF000000' } },
bottom: { style: 'thin', color: { argb: 'FF000000' } },
right: { style: 'thin', color: { argb: 'FF000000' } },
};
export type StoreExportRow = {
drug_id?: number | string;
drug_number?: string;
drug_name?: string;
pinyin_simple?: string;
unit_name?: string;
type_text?: string;
status_text?: string;
buy_price?: string | number;
price?: string | number;
created_at?: string;
};
const LIST_HEADERS = [
'药品ID',
'ErpCode',
'药名',
'拼音首拼',
'单位',
'类型',
'状态',
'供货价',
'当前零售价',
'创建时间',
] as const;
const PRICE_HEADERS = [
'药品ID',
'ErpCode',
'药名',
'拼音首拼',
'单位',
'当前零售价',
] as const;
function priceToCellText(value: unknown): string {
if (value === null || value === undefined || value === '') {
return '';
}
return String(value).trim();
}
function forceTextCell(cell: ExcelJS.Cell, text: string) {
cell.value = text;
cell.numFmt = '@';
cell.border = THIN_BORDER;
cell.alignment = { horizontal: 'center', vertical: 'middle' };
}
function styleHeader(row: ExcelJS.Row) {
row.height = 28;
row.eachCell((cell) => {
cell.font = { bold: true };
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFFF00' },
};
cell.alignment = { horizontal: 'center', vertical: 'middle' };
cell.border = THIN_BORDER;
});
}
function styleDataRowFill(row: ExcelJS.Row, index: number) {
if (index % 2 !== 1) {
return;
}
row.eachCell((cell) => {
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF2F2F2' },
};
});
}
/** 门店列表导出 */
export async function exportStoreListExcel(
rows: StoreExportRow[],
sheetName = '门店仓库',
): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(sheetName);
sheet.columns = [
{ width: 10 },
{ width: 14 },
{ width: 18 },
{ width: 12 },
{ width: 10 },
{ width: 12 },
{ width: 10 },
{ width: 12 },
{ width: 14 },
{ width: 18 },
];
styleHeader(sheet.addRow([...LIST_HEADERS]));
rows.forEach((item, index) => {
const row = sheet.addRow([]);
const values = [
String(item.drug_id ?? ''),
String(item.drug_number ?? ''),
String(item.drug_name ?? ''),
String(item.pinyin_simple ?? ''),
String(item.unit_name ?? ''),
String(item.type_text ?? ''),
String(item.status_text ?? ''),
priceToCellText(item.buy_price),
priceToCellText(item.price),
String(item.created_at ?? ''),
];
values.forEach((text, i) => forceTextCell(row.getCell(i + 1), text));
styleDataRowFill(row, index);
});
sheet.views = [{ state: 'frozen', ySplit: 1 }];
const buffer = await workbook.xlsx.writeBuffer();
return buffer as ArrayBuffer;
}
/** 门店改价模板(预览弹窗) */
export async function exportStorePriceExcel(
items: Array<{
drug_id?: number | string;
drug_number?: string;
drug_name?: string;
pinyin_simple?: string;
unit_name?: string;
price?: string | number;
}>,
sheetName = '门店改价',
): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(sheetName);
sheet.columns = [
{ width: 10 },
{ width: 14 },
{ width: 18 },
{ width: 12 },
{ width: 10 },
{ width: 14 },
];
styleHeader(sheet.addRow([...PRICE_HEADERS]));
items.forEach((item, index) => {
const row = sheet.addRow([]);
const values = [
String(item.drug_id ?? ''),
String(item.drug_number ?? ''),
String(item.drug_name ?? ''),
String(item.pinyin_simple ?? ''),
String(item.unit_name ?? ''),
priceToCellText(item.price),
];
values.forEach((text, i) => forceTextCell(row.getCell(i + 1), text));
styleDataRowFill(row, index);
});
sheet.views = [{ state: 'frozen', ySplit: 1 }];
const buffer = await workbook.xlsx.writeBuffer();
return buffer as ArrayBuffer;
}

View File

@@ -0,0 +1,195 @@
/**
* 门店批量改价 Excel 解析ExcelJS
* 只认药品ID + 当前零售价;若含单位列则标记 hasUnitColumn由上层丢弃并提示
* 价格优先读单元格原始字符串,避免 IEEE 浮点
*/
import ExcelJS from 'exceljs';
import { roundPrice4 } from '../../admin/utils/priceCompare';
const MAX_ROWS = 5000;
export type ParsedStorePriceRow = {
drug_id: number;
drug_name: string;
drug_number: string;
price: number;
};
export type ParseStorePriceResult = {
items: ParsedStorePriceRow[];
invalidCount: number;
rowCount: number;
/** Excel 是否包含单位列(门店导入须忽略) */
hasUnitColumn: boolean;
};
function normalizeCell(value: ExcelJS.CellValue): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object' && value && 'text' in value) {
return String((value as { text?: string }).text ?? '').trim();
}
if (typeof value === 'number') {
return Number.isInteger(value) ? String(value) : String(value).trim();
}
return String(value).trim();
}
function normalizeHeader(value: string): string {
return value.replace(/\s+/g, '').replace(/\u3000/g, '');
}
function cellRawString(cell: ExcelJS.Cell): string {
const v = cell.value;
if (v === null || v === undefined) {
return '';
}
if (typeof v === 'string') {
return v.trim().replace(/,/g, '').replace(/¥/g, '');
}
if (typeof v === 'number') {
return String(v);
}
if (typeof v === 'object') {
if ('result' in v && v.result !== null && v.result !== undefined) {
return String((v as { result: unknown }).result).trim();
}
if ('text' in v && (v as { text?: string }).text != null) {
return String((v as { text?: string }).text).trim();
}
if ('richText' in v && Array.isArray((v as { richText: { text: string }[] }).richText)) {
return (v as { richText: { text: string }[] }).richText
.map((p) => p.text)
.join('')
.trim();
}
}
return String(cell.text ?? '').trim().replace(/,/g, '');
}
const DRUG_ID_ALIASES = ['药品ID', '药品Id', 'drug_id', 'DrugId'];
const NUMBER_ALIASES = ['ErpCode', '商品编码', '药品编码', 'drug_number'];
const NAME_ALIASES = ['药名', '通用名', '药品名称', 'drug_name'];
const PRICE_ALIASES = ['当前零售价', '零售价', '建议售价', '每克零售价', 'price'];
const UNIT_ALIASES = ['单位', '计量单位', 'unit', 'unit_name'];
function buildHeaderIndexMap(headerRow: ExcelJS.Row): Record<string, number> {
const map: Record<string, number> = {};
headerRow.eachCell({ includeEmpty: true }, (cell, colNumber) => {
const key = normalizeHeader(normalizeCell(cell.value));
if (key) {
map[key] = colNumber;
}
});
return map;
}
function pickColumn(
headerMap: Record<string, number>,
aliases: string[],
): number | undefined {
for (const alias of aliases) {
const col = headerMap[normalizeHeader(alias)];
if (col) {
return col;
}
}
return undefined;
}
function getCellNumber(row: ExcelJS.Row, col?: number): number {
if (!col) {
return 0;
}
const raw = cellRawString(row.getCell(col));
if (raw === '' || !Number.isFinite(Number(raw))) {
return 0;
}
return roundPrice4(raw);
}
function getCellText(row: ExcelJS.Row, col?: number): string {
if (!col) {
return '';
}
const cell = row.getCell(col);
const fromValue = normalizeCell(cell.value);
if (fromValue) {
return fromValue;
}
return String(cell.text ?? '').trim();
}
function rowIsEmpty(row: ExcelJS.Row): boolean {
let hasValue = false;
row.eachCell({ includeEmpty: false }, (cell) => {
if (normalizeCell(cell.value) !== '') {
hasValue = true;
}
});
return !hasValue;
}
export async function parseStorePriceExcelBuffer(
buffer: ArrayBuffer,
): Promise<ParseStorePriceResult> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer);
const sheet = workbook.worksheets[0];
if (!sheet) {
return { items: [], invalidCount: 0, rowCount: 0, hasUnitColumn: false };
}
const headerRow = sheet.getRow(1);
const headerMap = buildHeaderIndexMap(headerRow);
const drugIdCol = pickColumn(headerMap, DRUG_ID_ALIASES);
const numberCol = pickColumn(headerMap, NUMBER_ALIASES);
const nameCol = pickColumn(headerMap, NAME_ALIASES);
const priceCol = pickColumn(headerMap, PRICE_ALIASES);
const unitCol = pickColumn(headerMap, UNIT_ALIASES);
const hasUnitColumn = !!unitCol;
const items: ParsedStorePriceRow[] = [];
let invalidCount = 0;
const rowCount = Math.min(sheet.rowCount, MAX_ROWS + 1);
for (let rowIndex = 2; rowIndex <= rowCount; rowIndex++) {
const row = sheet.getRow(rowIndex);
if (rowIsEmpty(row)) {
continue;
}
const drugIdRaw = getCellText(row, drugIdCol);
const drugId = Number.parseInt(drugIdRaw, 10) || 0;
const drugNumber = getCellText(row, numberCol);
const drugName = getCellText(row, nameCol);
const price = getCellNumber(row, priceCol);
if (drugId <= 0 && !drugName && !drugNumber && price === 0) {
continue;
}
if (drugId <= 0) {
invalidCount++;
continue;
}
items.push({
drug_id: drugId,
drug_name: drugName,
drug_number: drugNumber,
price,
});
}
return {
items,
invalidCount,
rowCount: Math.max(0, rowCount - 1),
hasUnitColumn,
};
}

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
/**
* AI 醒目提示条:病历/处方生成记录与模态框顶部展示
* 样式仅用主题变量,禁止 :global(.dark) 以免污染全局配色
*/
defineProps<{
/** 完整提示文案(可含换行) */
text?: string;
}>();
</script>
<template>
<div v-if="text" class="ai-disclaimer" role="note">
<div class="ai-disclaimer__title">重要提示</div>
<div class="ai-disclaimer__body">{{ text }}</div>
</div>
</template>
<style scoped>
.ai-disclaimer {
border: 1px solid hsl(var(--warning) / 0.45);
border-radius: 10px;
background: hsl(var(--warning) / 0.12);
padding: 10px 12px;
color: hsl(var(--foreground));
}
.ai-disclaimer__title {
margin-bottom: 4px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--warning));
}
.ai-disclaimer__body {
white-space: pre-wrap;
font-size: 12px;
line-height: 1.55;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -119,6 +119,8 @@ export async function getPrescriptionTypeOptionsApi(params?: {
icon?: string;
icon_text?: string;
}>;
allowed?: boolean;
message?: string;
}>(`${prefix}prescription-type-options`, {
params,
});
@@ -142,6 +144,31 @@ export async function withdrawPrescriptionApi(data: {
return requestClient.post<any>(`${prefix}withdraw-prescription`, data);
}
/**
* 本次挂号处方列表(分页,支持 keyword 按订单号/订单ID筛选
*/
export async function getRegisterPrescriptionListApi(params: {
register_id: number;
page?: number;
page_size?: number;
keyword?: string;
}) {
return requestClient.get<any>(`${prefix}register-prescription-list`, { params });
}
/**
* 患者历史处方(排除当前挂号),支持 keyword 按订单号/订单ID筛选
*/
export async function getPatientPrescriptionHistoryApi(params: {
patient_id: number;
page?: number;
page_size?: number;
exclude_register_id?: number;
keyword?: string;
}) {
return requestClient.get<any>(`${prefix}patient-prescription-history`, { params });
}
/**
* 接诊
*/
@@ -401,3 +428,86 @@ export async function saveGranularCommonPrescriptionApi(data: {
data,
);
}
/** AI 给处方 */
export async function aiGeneratePrescriptionApi(data: {
register_id: number;
store_id?: number;
prescription_type: number;
/** 前端确认过的主诉(可编辑) */
chief_complaint?: string;
/** 当前病历表单快照(可未保存),供 AI 综合参考 */
medical_record?: Record<string, any>;
/** 中药:是否委托调剂 */
use_entrusted_process?: 0 | 1 | boolean;
/** 中药委托调剂:制剂要求 idyii_process_rule pid=0 */
process_rule_id?: number;
}) {
return requestClient.post<any>(`${prefix}ai-generate-prescription`, data);
}
/** AI 功能门闸(醒目提示 + 是否已签署知情同意) */
export async function aiFeatureGateApi(data: { feature_code: string }) {
return requestClient.get<any>(`${prefix}ai-feature-gate`, { params: data });
}
/** AI 知情同意签署(按 VIP 功能码,签署一次即可) */
export async function aiAgreeConsentApi(data: {
feature_code: string;
store_id?: number;
}) {
return requestClient.post<any>(`${prefix}ai-agree-consent`, data);
}
/** 本挂号 AI 生成历史(轻量列表) */
export async function aiListGenerationsApi(data: {
register_id: number;
scene?: string;
limit?: number;
}) {
return requestClient.get<any>(`${prefix}ai-list-generations`, { params: data });
}
/** AI 生成详情(点选历史) */
export async function aiGenerationDetailApi(data: { id: number }) {
return requestClient.get<any>(`${prefix}ai-generation-detail`, { params: data });
}
/** AI 处方药名模糊对照 */
export async function aiMatchPrescriptionDrugsApi(data: {
generation_id?: number;
register_id?: number;
store_id?: number;
prescription_type?: number;
items?: Array<Record<string, any>>;
}) {
return requestClient.post<any>(`${prefix}ai-match-prescription-drugs`, data);
}
/** 金方列表VIPgolden_formula */
export async function goldenFormulaListApi(data: {
keyword?: string;
page?: number;
pageSize?: number;
store_id?: number;
}) {
return requestClient.get<any>(`${prefix}golden-formula-list`, { params: data });
}
/** 金方详情 */
export async function goldenFormulaDetailApi(data: {
id: number;
store_id?: number;
}) {
return requestClient.get<any>(`${prefix}golden-formula-detail`, { params: data });
}
/** 金方导入药名对照 */
export async function goldenFormulaApplyApi(data: {
id: number;
store_id?: number;
register_id?: number;
prescription_type?: number;
}) {
return requestClient.post<any>(`${prefix}golden-formula-apply`, data);
}

View File

@@ -0,0 +1,910 @@
<script lang="ts" setup>
/**
* PC 接诊AI 辅助出方
* 主抽屉:历史列表 + 详情对照 + 确认导入
* 二级弹窗:紧凑可编辑病历预览 → 生成处方(编辑同步回病历面板)
*/
import { computed, ref } from 'vue';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { Button, Checkbox, Descriptions, Input, Select, Spin, Switch, Tag, message } from 'ant-design-vue';
import {
aiGeneratePrescriptionApi,
aiListGenerationsApi,
aiMatchPrescriptionDrugsApi,
getProcessRuleList,
} from '../api';
import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue';
import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate';
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
/** 二级弹窗通栏字段Descriptions span=2 */
const FULL_SPAN_KEYS = new Set([
'chief_complaint',
'present_illness',
'tcm_case',
'treatment_advice',
'doctor_order',
'auxiliary_exam',
'physical_exam',
]);
/** 二级弹窗可编辑的病历字段(主诉单独置顶) */
const MR_EDIT_FIELDS: Array<{ key: string; label: string }> = [
{ key: 'present_illness', label: '现病史' },
{ key: 'tongue', label: '舌象' },
{ key: 'pulse', label: '脉象' },
{ key: 'tcm_case', label: '中医病案' },
{ key: 'tcm_disease', label: '中医疾病' },
{ key: 'tcm_syndrome', label: '中医证候' },
{ key: 'tcm_method', label: '中医治法' },
{ key: 'diagnosis', label: '临床诊断' },
{ key: 'treatment_advice', label: '治疗意见' },
{ key: 'doctor_order', label: '医嘱' },
{ key: 'allergy_history', label: '过敏史' },
{ key: 'past_history', label: '既往史' },
{ key: 'family_history', label: '家族史' },
{ key: 'epidemic_history', label: '流行病学史' },
{ key: 'personal_history', label: '个人史' },
{ key: 'menstrual_history', label: '月经史' },
{ key: 'marital_history', label: '婚育史' },
{ key: 'physical_exam', label: '体征检查' },
{ key: 'auxiliary_exam', label: '辅助检查' },
];
const emit = defineEmits<{
(
e: 'import',
payload: {
rows: Array<Record<string, any>>;
dosage?: number;
day_dosage?: number;
process_rule_id?: number;
child_process_rule_id?: number;
process_rule_note_id?: number;
rule_type?: number;
},
): void;
(e: 'search-drug', keyword: string): void;
/** 预览里改病历字段时回写父页病历面板 */
(e: 'sync-medical-record', payload: Record<string, any>): void;
}>();
const registerId = ref(0);
const storeId = ref(0);
const prescriptionType = ref(0);
const patientName = ref('');
const patientSex = ref(0);
const patientAge = ref(0);
const chiefComplaint = ref('');
const medicalRecord = ref<Record<string, any>>({});
const histLoading = ref(false);
const generating = ref(false);
const matchLoading = ref(false);
const historyList = ref<any[]>([]);
const activeId = ref(0);
const matchedList = ref<any[]>([]);
const unmatchedList = ref<any[]>([]);
const conflictMessages = ref<string[]>([]);
const rxDosage = ref(0);
const rxDayDosage = ref(0);
const rxReason = ref('');
const rxBasis = ref('');
const rxPrescriptionName = ref('');
/** 最近一次生成耗时(毫秒) */
const lastDurationMs = ref(0);
/** AI 返回的委托调剂制剂信息(导入时传给开方区) */
const rxProcessRuleId = ref(0);
const rxChildProcessRuleId = ref(0);
const rxProcessRuleNoteId = ref(0);
const rxProcessRuleName = ref('');
const rxChildProcessRuleName = ref('');
const rxProcessRuleNote = ref('');
/** 生成弹窗:中药委托调剂选项 */
const useEntrustedProcess = ref(false);
const genProcessRuleId = ref<number | undefined>(undefined);
const processRuleOptions = ref<Array<{ label: string; value: number }>>([]);
const processRuleLoading = ref(false);
/** 二级弹窗:当前正在编辑的字段 key空表示浏览态 */
const editingKey = ref('');
/** 醒目提示文案(系统配置 / 门闸下发) */
const disclaimerText = ref('');
const sexLabel = computed(() => {
const s = Number(patientSex.value);
if (s === 1) return '男';
if (s === 2) return '女';
return '未知';
});
const typeLabel = computed(() => {
const t = Number(prescriptionType.value);
if (t === 1) return '中药';
if (t === 2) return '西(中成)药';
return `类型 ${t || '—'}`;
});
const isTcmRx = computed(() => Number(prescriptionType.value) === 1);
/** 二级弹窗紧凑展示:有内容的字段 + 主诉 */
const genPreviewRows = computed(() => {
const mr = medicalRecord.value || {};
const rows: Array<{ key: string; label: string; value: string }> = [
{
key: 'chief_complaint',
label: '主诉',
value: String(chiefComplaint.value || mr.chief_complaint || '').trim(),
},
];
for (const f of MR_EDIT_FIELDS) {
const val = String(mr[f.key] ?? '').trim();
if (val) rows.push({ key: f.key, label: f.label, value: val });
}
return rows;
});
const [Drawer, drawerApi] = useVbenDrawer({
title: 'AI辅助出方',
class: 'w-[920px]',
// 按钮放内容区,关掉默认 footer避免底部留白占高
footer: false,
showCancelButton: false,
showConfirmButton: false,
// 内容区改 flex 铺满,由内部滚动,避免双重裁切
contentClass: '!flex !flex-col !overflow-hidden',
async onOpenChange(isOpen) {
if (!isOpen) {
resetState();
return;
}
const data = drawerApi.getData<{
registerId: number;
storeId?: number;
prescriptionType: number;
patientName?: string;
patientSex?: number;
patientAge?: number;
chiefComplaint?: string;
medicalRecord?: Record<string, any>;
}>();
registerId.value = Number(data?.registerId || 0);
storeId.value = Number(data?.storeId || 0);
prescriptionType.value = Number(data?.prescriptionType || 0);
patientName.value = String(data?.patientName || '');
patientSex.value = Number(data?.patientSex || 0);
patientAge.value = Number(data?.patientAge || 0);
chiefComplaint.value = String(data?.chiefComplaint || '');
medicalRecord.value =
data?.medicalRecord && typeof data.medicalRecord === 'object'
? { ...data.medicalRecord }
: {};
resetPreview();
// 首次使用对应 VIP 功能需签署知情同意;拒绝则关闭抽屉
try {
const gate = await ensureAiFeatureConsent({
featureCode: 'ai_prescription',
storeId: storeId.value || undefined,
});
if (!gate) {
drawerApi.close();
return;
}
disclaimerText.value = gate.disclaimer_text || '';
} catch (e: any) {
message.error(e?.message || e?.msg || '无法校验 AI 使用规范');
drawerApi.close();
return;
}
void bootstrapHistory();
},
});
const [GenModal, genModalApi] = useVbenModal({
title: '生成处方',
class: 'w-[920px]',
confirmText: '开始生成',
cancelText: '取消',
// 校验失败不关;通过后立刻关弹窗,生成在抽屉内进行
onConfirm: async () => confirmGenerateAndClose(),
});
function resetPreview() {
matchedList.value = [];
unmatchedList.value = [];
conflictMessages.value = [];
rxDosage.value = 0;
rxDayDosage.value = 0;
rxReason.value = '';
rxBasis.value = '';
rxPrescriptionName.value = '';
lastDurationMs.value = 0;
rxProcessRuleId.value = 0;
rxChildProcessRuleId.value = 0;
rxProcessRuleNoteId.value = 0;
rxProcessRuleName.value = '';
rxChildProcessRuleName.value = '';
rxProcessRuleNote.value = '';
activeId.value = 0;
editingKey.value = '';
}
function resetState() {
generating.value = false;
matchLoading.value = false;
histLoading.value = false;
historyList.value = [];
medicalRecord.value = {};
chiefComplaint.value = '';
disclaimerText.value = '';
useEntrustedProcess.value = false;
genProcessRuleId.value = undefined;
processRuleOptions.value = [];
resetPreview();
genModalApi.close();
}
function formatTime(ts: number) {
const n = Number(ts || 0);
if (!n) return '—';
const d = new Date(n * 1000);
const pad = (x: number) => (x < 10 ? `0${x}` : `${x}`);
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** 毫秒转可读耗时850ms / 1.2s */
function formatDurationMs(ms: number | undefined | null) {
const n = Number(ms || 0);
if (!n) return '';
if (n < 1000) return `${Math.round(n)}ms`;
const sec = n / 1000;
return sec >= 10 ? `${Math.round(sec)}s` : `${sec.toFixed(1)}s`;
}
function typeLabelOf(t: number | string) {
const n = Number(t || 0);
if (n === 1) return '中药';
if (n === 2) return '西药(中成药)';
return n ? `类型 ${n}` : '—';
}
function applyRxMeta(data: any) {
rxDosage.value = Number(data?.dosage || 0);
rxDayDosage.value = Number(data?.day_dosage || 0);
rxReason.value = String(data?.reason || '').trim();
rxBasis.value = String(data?.basis || '').trim();
rxPrescriptionName.value = String(data?.prescription_name || '').trim();
if (data?.duration_ms != null) {
lastDurationMs.value = Number(data.duration_ms || 0);
}
rxProcessRuleId.value = Number(data?.process_rule_id || 0);
rxChildProcessRuleId.value = Number(data?.child_process_rule_id || 0);
rxProcessRuleNoteId.value = Number(data?.process_rule_note_id || 0);
rxProcessRuleName.value = String(data?.process_rule_name || '').trim();
rxChildProcessRuleName.value = String(data?.child_process_rule_name || '').trim();
rxProcessRuleNote.value = String(data?.process_rule_note || '').trim();
}
/** 加载制剂要求列表pid=0供生成前委托调剂选择 */
async function loadTopProcessRules() {
if (!isTcmRx.value) return;
processRuleLoading.value = true;
try {
const list = await getProcessRuleList({ pid: 0 });
const arr = Array.isArray(list) ? list : [];
processRuleOptions.value = arr.map((item: any) => ({
label: String(item.name || item.title || `#${item.id}`),
value: Number(item.id),
}));
} catch {
processRuleOptions.value = [];
} finally {
processRuleLoading.value = false;
}
}
function onEntrustedToggle(checked: boolean) {
if (checked && !processRuleOptions.value.length) {
void loadTopProcessRules();
}
if (!checked) {
genProcessRuleId.value = undefined;
}
}
function applyConflict(conflict: any) {
const msgs = Array.isArray(conflict?.messages) ? conflict.messages : [];
conflictMessages.value = msgs
.map((m: any) => String(m || '').trim())
.filter(Boolean);
}
function applyMatch(match: any) {
const matched = Array.isArray(match?.matched) ? match.matched : [];
matchedList.value = matched.map((m: any) => ({
...m,
_import: true,
}));
unmatchedList.value = Array.isArray(match?.unmatched) ? match.unmatched : [];
}
async function loadHistory() {
histLoading.value = true;
try {
const list = await aiListGenerationsApi({
register_id: registerId.value,
scene: 'prescription',
limit: 30,
});
historyList.value = Array.isArray(list) ? list : [];
} catch (e: any) {
historyList.value = [];
message.error(e?.message || e?.msg || '加载历史失败');
} finally {
histLoading.value = false;
}
}
/** 打开抽屉后加载历史;有则选最新,无则自动打开生成框 */
async function bootstrapHistory() {
if (!registerId.value) return;
await loadHistory();
if (historyList.value.length) {
await onSelectHistory(historyList.value[0]);
} else {
openGenerateModal();
}
}
function openGenerateModal() {
if (!prescriptionType.value) {
message.warning('请先选择处方类型');
return;
}
editingKey.value = '';
if (isTcmRx.value && useEntrustedProcess.value && !processRuleOptions.value.length) {
void loadTopProcessRules();
}
genModalApi.open();
}
/**
* 点字段进入编辑;改完 blur 后同步病历
*/
function startEdit(key: string) {
editingKey.value = key;
}
function fieldValue(key: string) {
if (key === 'chief_complaint') return chiefComplaint.value;
return String(medicalRecord.value?.[key] ?? '');
}
function onFieldInput(key: string, val: string) {
if (key === 'chief_complaint') {
chiefComplaint.value = val;
medicalRecord.value = {
...(medicalRecord.value || {}),
chief_complaint: val,
};
} else {
medicalRecord.value = {
...(medicalRecord.value || {}),
[key]: val,
};
}
emit('sync-medical-record', { [key]: val });
}
function endEdit() {
editingKey.value = '';
}
/**
* 二级弹窗点「开始生成」:先关弹窗,回抽屉等待 AI
*/
async function confirmGenerateAndClose(): Promise<boolean> {
if (generating.value) return false;
const chief = String(chiefComplaint.value || '').trim();
if (!chief) {
message.warning('请先填写主诉后再生成');
return false;
}
if (!prescriptionType.value) {
message.warning('请先选择处方类型');
return false;
}
if (!registerId.value) {
message.warning('挂号无效');
return false;
}
if (isTcmRx.value && useEntrustedProcess.value && !genProcessRuleId.value) {
message.warning('请选择制剂要求');
return false;
}
// 先关模态在抽屉内展示「AI 处理中」
genModalApi.close();
void runGenerateInDrawer(chief);
return true;
}
async function runGenerateInDrawer(chief: string) {
generating.value = true;
try {
const req: Record<string, any> = {
register_id: registerId.value,
store_id: storeId.value || undefined,
prescription_type: prescriptionType.value,
chief_complaint: chief,
medical_record: {
...(medicalRecord.value || {}),
chief_complaint: chief,
},
};
// 中药委托调剂:传制剂要求,后端会补全煎法/规格
if (isTcmRx.value && useEntrustedProcess.value && genProcessRuleId.value) {
req.use_entrusted_process = 1;
req.process_rule_id = genProcessRuleId.value;
}
const data = await aiGeneratePrescriptionApi(req);
const durationText = formatDurationMs(data?.duration_ms);
// 业务软失败HTTP 成功但 ok=false警告提示而非 error
if (data?.ok === false) {
matchedList.value = [];
unmatchedList.value = [];
conflictMessages.value = [];
applyRxMeta({
duration_ms: data.duration_ms,
});
if (data?.generation_id) {
activeId.value = Number(data.generation_id);
await loadHistory();
}
message.warning(String(data?.message || '生成未成功'));
return;
}
await loadHistory();
activeId.value = Number(data?.generation_id || 0);
applyMatch(data?.match || null);
applyConflict(data?.conflict || null);
applyRxMeta(data);
message.success(
durationText ? `已生成(耗时 ${durationText}` : '已生成,请确认导入',
);
} catch (e: any) {
message.error(e?.message || e?.msg || '生成失败');
} finally {
generating.value = false;
}
}
async function onSelectHistory(row: any) {
if (!row?.id) return;
activeId.value = Number(row.id);
matchLoading.value = true;
try {
const data = await aiMatchPrescriptionDrugsApi({
generation_id: Number(row.id),
register_id: registerId.value,
store_id: storeId.value || undefined,
prescription_type: Number(
prescriptionType.value || row.prescription_type || 0,
),
});
applyMatch(data);
applyConflict(data?.conflict || null);
applyRxMeta({
...data,
duration_ms: row.duration_ms,
prescription_name:
data?.prescription_name || row?.name || row?.prescription_name || '',
});
} catch (e: any) {
matchedList.value = [];
unmatchedList.value = [];
conflictMessages.value = [];
message.error(e?.message || e?.msg || '对照失败');
} finally {
matchLoading.value = false;
}
}
function onConfirmImport() {
const drugs: any[] = [];
for (const m of matchedList.value) {
if (!m || m._import === false) continue;
const sid = Number(m.selected_drug_id || 0);
if (!sid) continue;
const picked = (m.candidates || []).find(
(c: any) => Number(c.drug_id) === sid,
);
if (!picked) continue;
drugs.push({
candidate: picked,
dose: m.dose || '',
unit: m.unit || '',
usage: m.usage || '',
ai_name: m.ai_name || '',
});
}
if (!drugs.length) {
message.warning('请先勾选已对照药品');
return;
}
const importPayload: {
rows: typeof drugs;
dosage?: number;
day_dosage?: number;
rule_type?: number;
process_rule_id?: number;
child_process_rule_id?: number;
process_rule_note_id?: number;
} = {
rows: drugs,
dosage: rxDosage.value || undefined,
day_dosage: rxDayDosage.value || undefined,
};
if (rxProcessRuleId.value > 0) {
importPayload.rule_type = 2;
importPayload.process_rule_id = rxProcessRuleId.value;
if (rxChildProcessRuleId.value > 0) {
importPayload.child_process_rule_id = rxChildProcessRuleId.value;
}
if (rxProcessRuleNoteId.value > 0) {
importPayload.process_rule_note_id = rxProcessRuleNoteId.value;
}
}
emit('import', importPayload);
drawerApi.close();
}
defineExpose({
open(payload: {
registerId: number;
storeId?: number;
prescriptionType: number;
patientName?: string;
patientSex?: number;
patientAge?: number;
chiefComplaint?: string;
medicalRecord?: Record<string, any>;
}) {
drawerApi.setData(payload);
drawerApi.open();
},
});
</script>
<template>
<Drawer>
<!-- 铺满抽屉内容区头部/禁忌/按钮固定中间历史+对照可滚动 -->
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden text-foreground">
<div class="shrink-0 text-sm text-muted-foreground">
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }} ·
{{ typeLabel }}
</div>
<AiDisclaimerBanner class="shrink-0" :text="disclaimerText" />
<div
v-if="conflictMessages.length"
class="shrink-0 rounded-lg border border-orange-500/35 bg-orange-500/10 px-3 py-2 text-xs"
>
<div class="mb-1 font-medium">配伍禁忌提示</div>
<div v-for="(line, i) in conflictMessages" :key="i" class="text-muted-foreground">
· {{ line }}
</div>
</div>
<div class="flex min-h-0 flex-1 gap-3 overflow-hidden">
<div class="w-44 shrink-0 overflow-y-auto border-r border-border pr-2">
<div class="mb-2 text-sm font-medium">历史记录</div>
<div v-if="histLoading" class="text-xs text-muted-foreground">加载中</div>
<div v-else-if="!historyList.length" class="text-xs text-muted-foreground">
暂无记录可点击下方生成处方
</div>
<div
v-for="row in historyList"
:key="row.id"
class="mb-1 cursor-pointer rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-accent"
:class="{
'bg-primary/10 ring-1 ring-primary/20': activeId === Number(row.id),
}"
@click="onSelectHistory(row)"
>
<div class="truncate font-medium">
{{ row.name || row.prescription_name || `方案 #${row.id}` }}
</div>
<div class="mt-0.5 text-muted-foreground">
{{ row.prescription_type_label || typeLabelOf(row.prescription_type) }}
· {{ row.item_count || 0 }} · {{ formatTime(row.created_at) }}
<span v-if="row.duration_ms"> · {{ formatDurationMs(row.duration_ms) }}</span>
</div>
</div>
</div>
<div class="min-h-0 min-w-0 flex-1 overflow-y-auto pl-1 pb-2">
<!-- AI 专属动画仅真正调用生成接口时展示历史/对照加载用普通 Spin -->
<div
v-if="generating"
class="flex h-full min-h-[260px] items-center justify-center"
>
<PrescriptionLoading
type="prescription"
text="AI正在生成处方"
class="h-[280px] w-full max-w-[420px]"
/>
</div>
<div
v-else-if="matchLoading"
class="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-muted-foreground"
>
<Spin />
<span class="text-sm">加载历史处方</span>
</div>
<template v-else>
<div
v-if="!matchedList.length && !unmatchedList.length && !lastDurationMs"
class="py-8 text-center text-muted-foreground"
>
请选择左侧历史或点击生成处方
</div>
<div
v-else-if="!matchedList.length && !unmatchedList.length && lastDurationMs"
class="py-8 text-center text-muted-foreground"
>
本次生成未产出对照结果
<span v-if="formatDurationMs(lastDurationMs)">
耗时 {{ formatDurationMs(lastDurationMs) }}
</span>
</div>
<template v-else>
<div class="mb-1.5 text-xs font-medium text-muted-foreground">
已对照{{ matchedList.length }}
</div>
<!-- 中药一行多味网格西药仍用较完整卡片 -->
<div
v-if="isTcmRx"
class="grid grid-cols-2 gap-1.5 xl:grid-cols-3"
>
<div
v-for="(m, mi) in matchedList"
:key="mi"
class="rounded border border-border bg-card px-1.5 py-1"
>
<div class="flex items-start gap-1">
<div class="min-w-0 flex-1">
<div class="truncate text-xs font-medium">{{ m.ai_name }}</div>
<div class="text-[11px] text-muted-foreground">
{{ m.dose }}{{ m.unit }}{{ m.usage ? `·${m.usage}` : '' }}
</div>
</div>
<Switch v-model:checked="m._import" size="small" />
</div>
<div class="mt-1 flex flex-wrap gap-0.5">
<Tag
v-for="c in m.candidates || []"
:key="c.drug_id"
class="cursor-pointer !m-0 !px-1 !text-[10px] !leading-4"
:color="
Number(m.selected_drug_id) === Number(c.drug_id)
? 'success'
: undefined
"
@click="m.selected_drug_id = Number(c.drug_id)"
>
{{ c.drug_name }}
</Tag>
</div>
</div>
</div>
<div v-else class="space-y-2">
<div
v-for="(m, mi) in matchedList"
:key="mi"
class="rounded-lg border border-border bg-card p-2.5"
>
<div class="flex items-center gap-2">
<span class="min-w-0 flex-1 truncate font-medium">{{ m.ai_name }}</span>
<span class="text-xs text-muted-foreground">
{{ m.dose }}{{ m.unit }}{{ m.usage ? `·${m.usage}` : '' }}
</span>
<Switch v-model:checked="m._import" size="small" />
</div>
<div class="mt-1 flex flex-wrap gap-1">
<Tag
v-for="c in m.candidates || []"
:key="c.drug_id"
class="cursor-pointer !m-0 !text-[11px]"
:color="
Number(m.selected_drug_id) === Number(c.drug_id)
? 'success'
: undefined
"
@click="m.selected_drug_id = Number(c.drug_id)"
>
{{ c.drug_name }} {{ Number(c.price || 0).toFixed(2) }}
</Tag>
</div>
</div>
</div>
<div
v-if="unmatchedList.length"
class="mb-1.5 mt-3 text-xs font-medium text-muted-foreground"
>
未对照{{ unmatchedList.length }}
</div>
<div
v-for="(u, ui) in unmatchedList"
:key="ui"
class="mb-1.5 rounded border border-orange-500/30 bg-orange-500/10 px-2 py-1.5 text-sm"
>
<div class="font-medium">{{ u.ai_name }}</div>
<Button
type="link"
size="small"
class="!h-auto !px-0"
@click="
emit('search-drug', u.ai_name || '');
drawerApi.close();
"
>
去手动搜索
</Button>
</div>
<!-- 药方摘要Descriptions 双列展示名称/剂数/出方理由 -->
<Descriptions
v-if="
rxPrescriptionName ||
rxReason ||
rxBasis ||
rxDosage ||
rxDayDosage ||
lastDurationMs ||
rxProcessRuleName
"
:column="2"
bordered
size="small"
class="ai-rx-meta-desc mt-3"
title="出方说明"
>
<Descriptions.Item label="药方名" :span="2">
{{ rxPrescriptionName || '未命名药方' }}
</Descriptions.Item>
<Descriptions.Item label="推荐剂数">
{{ rxDosage || '—' }}剂
</Descriptions.Item>
<Descriptions.Item label="每天几次">
{{ rxDayDosage || '—' }}次
</Descriptions.Item>
<Descriptions.Item v-if="lastDurationMs" label="生成耗时">
{{ formatDurationMs(lastDurationMs) }}
</Descriptions.Item>
<Descriptions.Item
v-if="rxProcessRuleName"
label="制剂要求"
:span="2"
>
{{ rxProcessRuleName }}
<span v-if="rxChildProcessRuleName"> · {{ rxChildProcessRuleName }}</span>
<span v-if="rxProcessRuleNote"> · {{ rxProcessRuleNote }}</span>
</Descriptions.Item>
<Descriptions.Item
v-if="rxReason"
label="为什么出这个方"
:span="2"
>
<div class="max-h-28 overflow-y-auto whitespace-pre-wrap leading-relaxed">
{{ rxReason }}
</div>
</Descriptions.Item>
<Descriptions.Item
v-if="rxBasis"
label="根据哪些"
:span="2"
>
<div class="max-h-28 overflow-y-auto whitespace-pre-wrap leading-relaxed">
{{ rxBasis }}
</div>
</Descriptions.Item>
</Descriptions>
</template>
</template>
</div>
</div>
<div class="flex shrink-0 justify-end gap-2 border-t border-border pt-3">
<Button @click="drawerApi.close()">关闭</Button>
<Button type="default" @click="openGenerateModal">生成处方</Button>
<Button
type="primary"
:disabled="generating || matchLoading || !matchedList.length"
@click="onConfirmImport"
>
确认导入
</Button>
</div>
</div>
</Drawer>
<!-- 二级:病历预览(可点改)+ 生成Descriptions 双列 -->
<GenModal>
<div class="space-y-2 text-sm text-foreground">
<AiDisclaimerBanner :text="disclaimerText" />
<div class="text-xs text-muted-foreground">
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁 ·
{{ typeLabel }} · 点击文字可编辑,将同步到病历
</div>
<div class="max-h-[58vh] overflow-y-auto">
<Descriptions
v-if="genPreviewRows.length"
:column="2"
bordered
size="small"
class="ai-rx-gen-desc"
>
<Descriptions.Item
v-for="row in genPreviewRows"
:key="row.key"
:label="row.label"
:span="FULL_SPAN_KEYS.has(row.key) ? 2 : 1"
>
<Input.TextArea
v-if="editingKey === row.key"
:value="fieldValue(row.key)"
:rows="row.key === 'chief_complaint' ? 3 : 2"
autofocus
@update:value="(v) => onFieldInput(row.key, String(v ?? ''))"
@blur="endEdit"
/>
<div
v-else
class="max-h-24 cursor-text overflow-y-auto whitespace-pre-wrap leading-relaxed"
title="点击编辑"
@click="startEdit(row.key)"
>
{{ fieldValue(row.key) || '(空,点击填写)' }}
</div>
</Descriptions.Item>
</Descriptions>
<div
v-if="genPreviewRows.length <= 1"
class="px-2 py-3 text-center text-xs text-muted-foreground"
>
仅有主诉。建议先在病历区补充现病史、舌脉等,再生成。
</div>
</div>
<!-- 中药:生成前可选委托调剂 + 制剂要求 -->
<div
v-if="isTcmRx"
class="rounded-lg border border-border bg-muted/30 p-3"
>
<Checkbox
v-model:checked="useEntrustedProcess"
@update:checked="onEntrustedToggle"
>
委托调剂
</Checkbox>
<div v-if="useEntrustedProcess" class="mt-2">
<div class="mb-1 text-xs text-muted-foreground">制剂要求</div>
<Select
v-model:value="genProcessRuleId"
:loading="processRuleLoading"
placeholder="请选择制剂要求"
class="w-full"
:options="processRuleOptions"
allow-clear
/>
</div>
</div>
</div>
</GenModal>
</template>
<style scoped>
.ai-rx-gen-desc :deep(.ant-descriptions-item-label),
.ai-rx-meta-desc :deep(.ant-descriptions-item-label) {
width: 110px;
white-space: nowrap;
}
.ai-rx-gen-desc :deep(.ant-descriptions-item-content),
.ai-rx-meta-desc :deep(.ant-descriptions-item-content) {
font-size: 12px;
}
</style>

View File

@@ -23,6 +23,10 @@ import {
addDoctorMyDiseaseApi,
deleteDoctorMyDiseaseApi,
} from "#/views/doctor/settings/api";
import {
applyDictRankIfNeeded,
splitHighlight,
} from '#/utils/dictSearchRank';
const searchKey = ref('');
// 存储接口返回的所有诊断数据
@@ -90,32 +94,37 @@ const [Modal, modalApi] = useVbenModal({
});
/**
* 获取诊断列表
* 获取诊断列表(按配置匹配度排序 + 关键字高亮片段)
* @param searchKey
*/
function getDiagnosisList(searchKey = '') {
getDiseaseList(searchKey).then((res) => {
// 处理所有数据
const processedList = res.map((item) => {
const kw = String(searchKey || '').trim();
getDiseaseList(kw).then((res) => {
const rows = Array.isArray(res) ? res : [];
const modeFromApi = rows[0]?.rank_mode;
const ranked = applyDictRankIfNeeded(
rows,
kw,
(item: any) => String(item?.name || ''),
(item: any) => String(item?.pinyin || ''),
modeFromApi,
);
const processedList = ranked.map((item: any) => {
item.isSelect = 0;
// 检查是否已在常用诊断中
item.isInMyList = doctorMyDiseaseList.value.some(
(myItem) => myItem.disease.id === item.id
(myItem: any) => myItem.disease.id === item.id,
);
if (selectDiagnosisList.value) {
// 把values的字符串转为数组根据分解
const valuesArr = selectDiagnosisList.value.split('');
// 判断valuesArr数组中是否有item.name
valuesArr.forEach((value) => {
if (value === item.name) {
item.isSelect = 1;
}
});
}
item._hlParts = splitHighlight(item.name || '', kw);
return item;
});
// 更新总数据源并重置页码
allDiagnosisList.value = processedList;
currentPage.value = 1;
});
@@ -123,7 +132,7 @@ function getDiagnosisList(searchKey = '') {
const getDiagnosisListChage = debounce(async (searchText = '') => {
getDiagnosisList(searchText);
}, 300)
}, 500)
/**
* 选择诊断
@@ -255,7 +264,12 @@ function handlePageChange(page: number, size: number) {
:class="{ 'is-selected': item.isSelect === 1 }"
@click="selectDiagnosis(item)"
>
<span class="tag-text">{{ item.name }}</span>
<span class="tag-text">
<template v-for="(p, pi) in (item._hlParts || [{ text: item.name, hit: false }])" :key="pi">
<em v-if="p.hit" class="hit">{{ p.text }}</em>
<template v-else>{{ p.text }}</template>
</template>
</span>
<div class="tag-action-wrapper" @click.stop>
<Tooltip v-if="!item.isInMyList" title="加入常用">
@@ -500,4 +514,12 @@ function handlePageChange(page: number, size: number) {
.dark .pagination-wrapper {
border-color: #374151;
}
.hit {
font-style: normal;
color: #cf1322;
background: rgba(255, 214, 102, 0.55);
border-radius: 2px;
padding: 0 1px;
}
</style>

View File

@@ -0,0 +1,290 @@
<script lang="ts" setup>
/**
* 金方导入弹窗PC 接诊 / 在线复诊共用)
* - VIPgolden_formula
* - 选方后调用药名对照,确认导入时抛出与 AI 出方相同结构的 rows
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Button,
Empty,
Input,
Spin,
Tag,
message,
} from 'ant-design-vue';
import {
goldenFormulaApplyApi,
goldenFormulaListApi,
} from '../api';
const emit = defineEmits<{
(
e: 'import',
payload: {
rows: Array<Record<string, any>>;
formulaName?: string;
},
): void;
}>();
const loading = ref(false);
const applying = ref(false);
const keyword = ref('');
const list = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = 20;
const storeId = ref(0);
const registerId = ref(0);
/** 处方类型1中药 2西药与 ProductTypeEnum 对齐;接诊 activeCategory 1=中 2=西) */
const prescriptionType = ref(1);
const selectedId = ref(0);
const matchResult = ref<any | null>(null);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
footer: false,
class: 'w-[880px]',
onCancel() {
modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) {
keyword.value = '';
list.value = [];
matchResult.value = null;
selectedId.value = 0;
return;
}
const data = modalApi.getData<{
storeId?: number;
registerId?: number;
/** 开方页 activeCategory1中药 2西药 → ProductType 同值 */
category?: number;
}>() || {};
storeId.value = Number(data.storeId || 0);
registerId.value = Number(data.registerId || 0);
// activeCategory 1=中药 2=西药,与 ProductTypeEnum 一致
prescriptionType.value = Number(data.category || 1) === 2 ? 2 : 1;
page.value = 1;
loadList();
},
});
/** 加载金方列表 */
async function loadList() {
loading.value = true;
matchResult.value = null;
selectedId.value = 0;
try {
const res = await goldenFormulaListApi({
keyword: keyword.value,
page: page.value,
pageSize,
store_id: storeId.value || undefined,
});
list.value = res?.items || [];
total.value = Number(res?.total || 0);
} catch (e: any) {
message.error(e?.message || '加载金方失败');
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
loadList();
}
/**
* 选中金方并做药名对照
*/
async function handleSelect(row: any) {
selectedId.value = Number(row.id);
applying.value = true;
matchResult.value = null;
try {
const res = await goldenFormulaApplyApi({
id: selectedId.value,
store_id: storeId.value || undefined,
register_id: registerId.value || undefined,
prescription_type: prescriptionType.value,
});
matchResult.value = res;
} catch (e: any) {
message.error(e?.message || '对照失败');
} finally {
applying.value = false;
}
}
/**
* 确认导入:把 matched 转成 AI 导入同构 rows候选默认取第一项
*/
function handleConfirmImport() {
const matched = matchResult.value?.matched || [];
if (!matched.length) {
message.warning('没有可导入的已匹配药品');
return;
}
const rows = matched.map((m: any) => {
const candidates = m.candidates || [];
const selectedId = Number(m.selected_drug_id || 0);
const candidate =
candidates.find((c: any) => Number(c.drug_id) === selectedId) ||
candidates[0] ||
{};
return {
ai_name: m.ai_name,
dose: m.dose,
unit: m.unit,
usage: m.usage,
frequency: m.frequency,
candidate,
};
});
emit('import', {
rows,
formulaName: matchResult.value?.formula?.name || '',
});
const unmatched = matchResult.value?.unmatched || [];
if (unmatched.length) {
message.warning(
`已导入 ${rows.length} 味;未匹配 ${unmatched.length} 味请手动补录`,
);
} else {
message.success(`已导入金方:${matchResult.value?.formula?.name || ''}`);
}
modalApi.close();
}
defineExpose({
open: (data: Record<string, any>) => {
modalApi.setData(data);
modalApi.open();
},
});
</script>
<template>
<Modal title="导入金方">
<div class="flex gap-3" style="min-height: 420px">
<div class="w-[340px] shrink-0 border-r pr-3">
<div class="mb-2 flex gap-2">
<Input
v-model:value="keyword"
allow-clear
placeholder="名称/首拼/来源/速览"
@press-enter="onSearch"
/>
<Button type="primary" @click="onSearch">搜索</Button>
</div>
<Spin :spinning="loading">
<div v-if="!list.length" class="py-8">
<Empty description="暂无金方" />
</div>
<div v-else class="max-h-[360px] space-y-2 overflow-y-auto">
<div
v-for="item in list"
:key="item.id"
class="cursor-pointer rounded border p-2 transition hover:border-primary"
:class="selectedId === item.id ? 'border-primary bg-primary/5' : ''"
@click="handleSelect(item)"
>
<div class="font-medium">{{ item.name }}</div>
<div class="mt-1 text-xs text-gray-500 line-clamp-2">
{{ item.herb_overview || '—' }}
</div>
<div class="mt-1 flex flex-wrap gap-1">
<Tag v-if="item.source" color="gold">{{ item.source }}</Tag>
</div>
</div>
</div>
<div v-if="total > pageSize" class="mt-2 text-center text-xs text-gray-400">
{{ total }} 当前第 {{ page }}
<Button
v-if="page * pageSize < total"
type="link"
size="small"
@click="page += 1; loadList()"
>
下一页
</Button>
</div>
</Spin>
</div>
<div class="min-w-0 flex-1">
<Spin :spinning="applying">
<div v-if="!matchResult" class="py-16">
<Empty description="请选择左侧金方进行药名对照" />
</div>
<div v-else>
<div class="mb-2 text-base font-medium">
{{ matchResult.formula?.name }}
<Tag v-if="matchResult.formula?.source" class="ml-2" color="gold">
{{ matchResult.formula.source }}
</Tag>
</div>
<div
v-if="matchResult.formula?.indication_translation || matchResult.formula?.indication_original"
class="mb-2 text-xs text-gray-500"
>
主治
{{
matchResult.formula.indication_translation ||
matchResult.formula.indication_original
}}
</div>
<div
v-if="matchResult.formula?.original_formula"
class="mb-2 max-h-24 overflow-y-auto rounded bg-amber-50 px-2 py-1 text-xs text-gray-700 whitespace-pre-wrap"
>
<span class="font-medium text-amber-700">原方</span>
{{ matchResult.formula.original_formula }}
</div>
<div class="mb-2 text-sm">
已匹配
<Tag color="success">{{ (matchResult.matched || []).length }}</Tag>
未匹配
<Tag color="warning">{{ (matchResult.unmatched || []).length }}</Tag>
</div>
<div class="max-h-[280px] space-y-1 overflow-y-auto text-sm">
<div
v-for="(m, idx) in matchResult.matched || []"
:key="'m' + idx"
class="rounded bg-green-50 px-2 py-1"
>
{{ m.show_dose || m.display_text || `${m.ai_name} ${m.dose || ''}${m.unit || ''}${m.usage ? `${m.usage}` : ''}` }}
{{ (m.candidates && m.candidates[0] && m.candidates[0].drug_name) || '—' }}
</div>
<div
v-for="(u, idx) in matchResult.unmatched || []"
:key="'u' + idx"
class="rounded bg-orange-50 px-2 py-1"
>
{{ u.show_dose || u.display_text || `${u.ai_name} ${u.dose || ''}${u.unit || ''}` }}
未匹配
</div>
</div>
<div class="mt-4 text-right">
<Button
type="primary"
:disabled="!(matchResult.matched || []).length"
@click="handleConfirmImport"
>
确认导入到处方
</Button>
</div>
</div>
</Spin>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,194 @@
<script lang="ts" setup>
/**
* 接诊台顶部患者简要信息条
* 上方居中展示诊所名 + 处方类型;下方展示门诊号/日期/费别/姓名等
* 诊断只在处方/病历编辑区一处维护
*/
import { computed } from 'vue';
import { Tag } from 'ant-design-vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { formatStoreNameWithHu } from '#/utils/formatStoreNameWithHu';
defineOptions({ name: 'PatientBriefBar' });
const props = withDefaults(
defineProps<{
/** 挂号订单(含 order_no、created_at */
register?: Record<string, any> | null;
/** 就诊人 */
patient?: Record<string, any> | null;
/** 当前费别1 自费 2 医保 */
category?: number;
/** 诊所名称(优先;也可从 register.store.name 兜底) */
storeName?: string;
/**
* 是否在线复诊开方
* false/0门诊普通处方笺true/1/2/3在线处方
*/
isOnline?: boolean | number;
}>(),
{
register: null,
patient: null,
category: 1,
storeName: '',
isOnline: false,
},
);
const onlineFlag = computed(() => {
if (props.isOnline === true) return 1;
if (props.isOnline === false) return 0;
const fromProp = Number(props.isOnline);
if (fromProp > 0) return fromProp;
// 挂号单上可能带 is_online
return Number(props.register?.is_online || 0);
});
/** 居中诊所名:在线渠道按既有规则追加(互) */
const displayStoreName = computed(() => {
const raw =
String(props.storeName || '').trim() ||
String(props.register?.store?.name || props.register?.store_name || '').trim();
return formatStoreNameWithHu(raw, onlineFlag.value) || '诊所';
});
/** 处方类型标题:线下普通 / 线上复诊 */
const prescriptionTypeTitle = computed(() => {
const online = onlineFlag.value;
if (online === 1 || online === 2 || online === 3) {
return '在线处方';
}
return '门诊普通处方笺';
});
const sexText = computed(() => {
const sex = Number(props.patient?.sex || 0);
if (sex === 1) return '男';
if (sex === 2) return '女';
return '未填';
});
const categoryText = computed(() => {
return Number(props.category) === 2 ? '医保' : '自费';
});
const visitDate = computed(() => {
const raw = props.register?.created_at;
if (!raw) return '—';
// 后端查询器已格式化为字符串时直接展示
if (typeof raw === 'string') return raw;
if (typeof raw === 'number' && raw > 0) {
const d = new Date(raw * 1000);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
return '—';
});
</script>
<template>
<div class="patient-brief-bar">
<!-- 居中抬头诊所名 + 处方类型 -->
<div class="brief-header">
<div class="brief-clinic">{{ displayStoreName }}</div>
<div class="brief-rx-type">{{ prescriptionTypeTitle }}</div>
</div>
<div class="brief-meta">
<div class="brief-item">
<span class="brief-label">门诊号</span>
<span class="brief-value">{{ register?.order_no || '—' }}</span>
</div>
<div class="brief-item">
<span class="brief-label">日期</span>
<span class="brief-value">{{ visitDate }}</span>
</div>
<div class="brief-item">
<span class="brief-label">费别</span>
<Tag :color="Number(category) === 2 ? 'blue' : 'default'" class="m-0">
{{ categoryText }}
</Tag>
</div>
<div class="brief-item">
<span class="brief-label">姓名</span>
<span class="brief-value brief-name">{{ patient?.name || '—' }}</span>
</div>
<div class="brief-item">
<span class="brief-label">性别</span>
<span class="brief-value">{{ sexText }}</span>
</div>
<div class="brief-item">
<span class="brief-label">年龄</span>
<span class="brief-value">{{ patient?.age ?? '—' }}</span>
</div>
<div class="brief-item">
<span class="brief-label">手机</span>
<span class="brief-value">
<SensitiveText
:record="patient"
field="mobile"
:show-eye="false"
/>
</span>
</div>
</div>
</div>
</template>
<style scoped>
.patient-brief-bar {
padding: 10px 14px 12px;
margin-bottom: 12px;
background: hsl(var(--muted) / 0.35);
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.brief-header {
text-align: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid hsl(var(--border) / 0.7);
}
.brief-clinic {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: hsl(var(--foreground));
letter-spacing: 0.02em;
}
.brief-rx-type {
margin-top: 2px;
font-size: 18px;
font-weight: 700;
line-height: 1.35;
color: hsl(var(--foreground));
letter-spacing: 0.06em;
}
.brief-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
align-items: center;
}
.brief-item {
display: inline-flex;
gap: 6px;
align-items: center;
min-width: 0;
font-size: 13px;
line-height: 1.4;
}
.brief-label {
flex-shrink: 0;
color: hsl(var(--muted-foreground));
}
.brief-value {
color: hsl(var(--foreground));
word-break: break-all;
}
.brief-name {
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,171 @@
<script lang="ts" setup>
/**
* 查看患者信息抽屉:完整患者字段 + 健康问诊信息(不进顶栏)
* 特色方/转诊等业务入口由父级通过 slot 注入,避免抽屉耦合开方逻辑
*/
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Descriptions, Empty, Tag } from 'ant-design-vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
defineOptions({ name: 'PatientInfoDrawer' });
type DrawerData = {
patient?: Record<string, any> | null;
register?: Record<string, any> | null;
healthInquiry?: Record<string, any> | null;
patientName?: string;
};
const meta = ref<DrawerData>({});
const sexText = computed(() => {
const sex = Number(meta.value.patient?.sex || 0);
if (sex === 1) return '男';
if (sex === 2) return '女';
return '未填写';
});
/** 逗号分隔标签拆分(与接诊页 splitString 一致) */
function splitString(str: unknown): string[] {
if (!str || typeof str !== 'string') return [];
return str.split(/[,]/).map((s) => s.trim()).filter(Boolean);
}
const [Drawer, drawerApi] = useVbenDrawer({
class: 'w-[720px]',
title: '患者信息',
cancelText: '关闭',
showConfirmButton: false,
onOpenChange(isOpen: boolean) {
if (!isOpen) {
meta.value = {};
return;
}
const data = drawerApi.getData<DrawerData>() || {};
meta.value = data;
const name = data.patientName || data.patient?.name || '';
drawerApi.setState({
title: name ? `患者信息 - ${name}` : '患者信息',
});
},
});
defineExpose({ drawerApi });
</script>
<template>
<Drawer>
<Descriptions
v-if="meta.patient"
:column="2"
bordered
size="small"
title="基本信息"
class="mb-4"
>
<Descriptions.Item label="姓名">
{{ meta.patient.name || '—' }}
</Descriptions.Item>
<Descriptions.Item label="性别">
{{ sexText }}
</Descriptions.Item>
<Descriptions.Item label="年龄">
{{ meta.patient.age ?? '—' }}
</Descriptions.Item>
<Descriptions.Item label="手机">
<SensitiveText
:record="meta.patient"
field="mobile"
:show-eye="false"
/>
</Descriptions.Item>
<Descriptions.Item label="门诊号">
{{ meta.register?.order_no || '—' }}
</Descriptions.Item>
<Descriptions.Item label="就诊时间">
{{ meta.register?.created_at || '—' }}
</Descriptions.Item>
</Descriptions>
<Empty v-else description="暂无患者信息" class="py-6" />
<Descriptions
v-if="meta.healthInquiry"
:column="2"
bordered
size="small"
title="健康信息"
class="mb-4"
>
<Descriptions.Item label="肝功能">
<span>{{
meta.healthInquiry.liver_function === 0 ? '正常' : '异常'
}}</span>
<div v-if="meta.healthInquiry.liver_function === 1" class="mt-2">
<Tag
v-for="(item, idx) in splitString(meta.healthInquiry.liver_index)"
:key="`liver-${idx}`"
color="#455cda"
>
{{ item }}
</Tag>
</div>
</Descriptions.Item>
<Descriptions.Item label="肾功能">
<span>{{
meta.healthInquiry.renal_function === 0 ? '正常' : '异常'
}}</span>
<div v-if="meta.healthInquiry.renal_function === 1" class="mt-2">
<Tag
v-for="(item, idx) in splitString(meta.healthInquiry.renal_index)"
:key="`renal-${idx}`"
color="#455cda"
>
{{ item }}
</Tag>
</div>
</Descriptions.Item>
<Descriptions.Item label="既往史" :span="2">
<span>{{
meta.healthInquiry.person_status === 0 ? '无' : '有'
}}</span>
<p v-if="meta.healthInquiry.person_status === 1" class="mt-2 mb-0">
{{ meta.healthInquiry.person_history || '' }}
</p>
</Descriptions.Item>
<Descriptions.Item label="过敏史" :span="2">
<span>{{
meta.healthInquiry.allergic_status === 0 ? '无' : '有'
}}</span>
<div v-if="meta.healthInquiry.allergic_status === 1" class="mt-2">
<Tag
v-for="(item, idx) in splitString(
meta.healthInquiry.allergic_history,
)"
:key="`allergy-${idx}`"
color="orange"
>
{{ item }}
</Tag>
</div>
</Descriptions.Item>
<Descriptions.Item label="家庭遗传史" :span="2">
<span>{{
meta.healthInquiry.family_status === 0 ? '无' : '有'
}}</span>
<p v-if="meta.healthInquiry.family_status === 1" class="mt-2 mb-0">
{{ meta.healthInquiry.family_history || '—' }}
</p>
</Descriptions.Item>
</Descriptions>
<Empty
v-else
description="暂无健康问诊信息"
class="py-4"
/>
<slot name="extra" />
</Drawer>
</template>

View File

@@ -0,0 +1,326 @@
<script lang="ts" setup>
/**
* 患者处方记录抽屉:本次 | 历史,支持按订单号/订单ID搜索、展示支付态、复用/撤回
*/
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import {
Button,
Empty,
Input,
Spin,
Tabs,
Tag,
message,
} from 'ant-design-vue';
import {
getPatientPrescriptionHistoryApi,
getRegisterPrescriptionListApi,
withdrawPrescriptionApi,
} from '../api';
defineOptions({ name: 'PatientPrescriptionHistoryDrawer' });
const TabPane = Tabs.TabPane;
type DrawerData = {
registerId?: number;
userPatientId?: number;
patientName?: string;
/** 复用回调:传入处方 id */
onReuse?: (prescriptionId: number) => void | Promise<void>;
/** 点击处方号打开详情 */
onDetail?: (prescriptionId: number) => void;
/** 是否允许撤回(线下接诊) */
allowWithdraw?: boolean;
/** 撤回成功后刷新 */
onWithdrawn?: () => void;
};
const meta = ref<DrawerData>({});
const loading = ref(false);
const withdrawingId = ref(0);
const listTab = ref<'current' | 'history'>('current');
const keyword = ref('');
const list = ref<Record<string, any>[]>([]);
const page = ref(1);
const hasMore = ref(false);
const pageSize = 20;
const categoryLabel = (c: number) => (Number(c) === 2 ? '医保' : '自费');
const typeLabel = (t: number) => {
const map: Record<number, string> = {
1: '中药',
2: '西药',
3: '简单产品',
5: '简单产品',
6: '简单产品',
7: '简单产品',
};
return map[Number(t)] || `类型${t}`;
};
const payColor = (row: Record<string, any>) => {
const text = String(row.order_pay_text || '');
if (text.includes('待支付')) return 'orange';
if (text.includes('已支付')) return 'green';
if (text.includes('取消')) return 'default';
return 'blue';
};
async function loadList(reset = true) {
if (reset) {
page.value = 1;
list.value = [];
}
loading.value = true;
try {
const kw = keyword.value.trim();
let res: any;
if (listTab.value === 'current') {
const rid = Number(meta.value.registerId || 0);
if (!rid) {
list.value = [];
return;
}
res = await getRegisterPrescriptionListApi({
register_id: rid,
page: page.value,
page_size: pageSize,
keyword: kw || undefined,
});
} else {
const pid = Number(meta.value.userPatientId || 0);
if (!pid) {
list.value = [];
return;
}
res = await getPatientPrescriptionHistoryApi({
patient_id: pid,
page: page.value,
page_size: pageSize,
exclude_register_id: Number(meta.value.registerId || 0) || undefined,
keyword: kw || undefined,
});
}
const rows = Array.isArray(res?.list) ? res.list : [];
list.value = reset ? rows : [...list.value, ...rows];
hasMore.value = !!res?.has_more;
} catch (e: any) {
if (reset) list.value = [];
message.error(e?.message || '加载处方失败');
} finally {
loading.value = false;
}
}
function onSearch() {
loadList(true);
}
function onTabChange(key: string | number) {
listTab.value = key === 'history' ? 'history' : 'current';
loadList(true);
}
function loadMore() {
if (!hasMore.value || loading.value) return;
page.value += 1;
loadList(false);
}
async function handleReuse(row: Record<string, any>) {
const id = Number(row.id || 0);
if (!id) return;
try {
await meta.value.onReuse?.(id);
} catch (e: any) {
message.error(e?.message || '复用失败');
}
}
/** 点击处方号打开处方详情弹窗 */
function handleOpenDetail(row: Record<string, any>) {
const id = Number(row.id || 0);
if (!id) return;
if (typeof meta.value.onDetail === 'function') {
meta.value.onDetail(id);
return;
}
message.warning('暂无法打开处方详情');
}
async function handleWithdraw(row: Record<string, any>) {
const prescriptionId = Number(row.id || 0);
const registerId = Number(row.register_id || meta.value.registerId || 0);
if (!prescriptionId || !registerId) return;
withdrawingId.value = prescriptionId;
try {
await withdrawPrescriptionApi({
prescription_id: prescriptionId,
register_id: registerId,
});
message.success('撤回成功');
meta.value.onWithdrawn?.();
await loadList(true);
} catch (e: any) {
message.error(e?.message || '撤回失败');
} finally {
withdrawingId.value = 0;
}
}
const titleText = computed(() => {
const name = meta.value.patientName ? ` - ${meta.value.patientName}` : '';
return `患者处方记录${name}`;
});
const [Drawer, drawerApi] = useVbenDrawer({
class: 'w-[820px]',
title: '患者处方记录',
cancelText: '关闭',
showConfirmButton: false,
onOpenChange(isOpen: boolean) {
if (!isOpen) {
meta.value = {};
list.value = [];
keyword.value = '';
listTab.value = 'current';
page.value = 1;
return;
}
const data = drawerApi.getData<DrawerData>() || {};
meta.value = data;
drawerApi.setState({ title: titleText.value });
loadList(true);
},
});
defineExpose({ drawerApi });
</script>
<template>
<Drawer>
<div class="mb-3 flex flex-wrap items-center gap-2">
<Input.Search
v-model:value="keyword"
allow-clear
placeholder="按订单号或订单ID搜索"
style="max-width: 280px"
@search="onSearch"
/>
<Button @click="onSearch">查询</Button>
</div>
<Tabs :active-key="listTab" @change="onTabChange">
<TabPane key="current" tab="本次" />
<TabPane key="history" tab="历史" />
</Tabs>
<Spin :spinning="loading">
<div v-if="list.length === 0 && !loading" class="py-10">
<Empty description="暂无处方记录" />
</div>
<div v-else class="rx-history-list">
<div
v-for="row in list"
:key="row.id"
class="rx-history-item"
>
<div class="rx-history-main">
<div class="rx-history-title">
<a
class="cursor-pointer font-medium text-primary hover:underline"
title="查看处方详情"
@click.prevent="handleOpenDetail(row)"
>
{{ row.prescription_no || `#${row.id}` }}
</a>
<Tag class="ml-2">{{ typeLabel(row.prescription_type) }}</Tag>
<Tag>{{ categoryLabel(row.category) }}</Tag>
<Tag :color="payColor(row)">{{ row.order_pay_text || '—' }}</Tag>
</div>
<div class="rx-history-meta text-muted-foreground">
<span>开方时间{{ row.created_at || '—' }}</span>
<span class="ml-3">金额¥{{ row.total_pay_price ?? '—' }}</span>
<span v-if="row.order_no" class="ml-3">订单号{{ row.order_no }}</span>
<span v-if="row.order_id" class="ml-3">订单ID{{ row.order_id }}</span>
<span
v-if="listTab === 'history' && row.register?.order_no"
class="ml-3"
>
挂号{{ row.register.order_no }}
</span>
</div>
</div>
<div class="rx-history-actions">
<Button
type="link"
size="small"
@click="handleReuse(row)"
>
复用
</Button>
<Button
v-if="meta.allowWithdraw && row.can_withdraw && listTab === 'current'"
type="link"
size="small"
danger
:loading="withdrawingId === row.id"
@click="handleWithdraw(row)"
>
撤回
</Button>
</div>
</div>
<div v-if="hasMore" class="mt-3 text-center">
<Button type="link" :loading="loading" @click="loadMore">加载更多</Button>
</div>
</div>
</Spin>
</Drawer>
</template>
<style scoped>
.rx-history-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: calc(100vh - 260px);
overflow: auto;
}
.rx-history-item {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
padding: 12px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.rx-history-main {
min-width: 0;
flex: 1;
}
.rx-history-title {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
margin-bottom: 6px;
}
.rx-history-meta {
font-size: 12px;
line-height: 1.6;
}
.rx-history-actions {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.text-muted-foreground {
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -322,7 +322,21 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
alt="签名"
style="width: 120px; height: 50px; display: inline-block"
/>
<span v-else>{{ item.content?.doctor?.name }}</span>
<!-- 二次签名:相冲继续开方时展示第二张医生签名 -->
<img
v-if="
item.doctor_second_sign == 1 &&
item.doctor_info?.identity_info?.sign_image
"
:src="
getImageSource(item.doctor_info.identity_info.sign_image)
"
alt="二次签名"
style="width: 120px; height: 50px; display: inline-block"
/>
<span v-else-if="!item.doctor_info?.identity_info?.sign_image">{{
item.content?.doctor?.name
}}</span>
</div>
<div class="signature">
<label>审核药师:</label>

Some files were not shown because too many files have changed in this diff Show More