28 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
李琦
b1df6fb4e8 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
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-23 16:55:26 +08:00
李琦
5fcb65aa0d feat:优化了门店搜索组件、优化了模态框默认的内容 2026-07-23 16:37:55 +08:00
李琦
bbb133476a feat:优化了门店搜索组件、优化了模态框默认的内容 2026-07-23 14:23:32 +08:00
李琦
700db76f16 feat:优化了订单、诊所搜索组件、药品这些交互 2026-07-23 09:24:06 +08:00
李琦
ee4490a91c feat:优化了订单、药品这些交互 2026-07-21 07:36:52 +08:00
李琦
7656c3ac49 feat:优化在线问诊的患者选药显示信息,并且优化了交互,患者挂号卡片可以直接把药品导入至处方 2026-07-19 16:10:55 +08:00
李琦
c1b71142df fix:
1. 优化了审方pc的效果
2. 就诊人信息优化
2026-07-18 17:08:37 +08:00
李琦
92149f43a6 fix:
1. 优化了审方pc的效果
2. 就诊人信息优化
2026-07-18 14:16:25 +08:00
李琦
47a829c1b1 1. 配送仓库 2026-07-18 10:22:36 +08:00
李琦
5ff6f0d837 1. 修复了接诊页面的剂量、天数输入框过窄 2026-07-15 08:46:37 +08:00
李琦
8eca0bd84d 1. 修复了接诊页面的剂量、天数输入框过窄 2026-07-15 08:46:31 +08:00
404 changed files with 38693 additions and 3553 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

@@ -145,6 +145,7 @@ alwaysApply: true
- 表单必须用 `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` 注册
@@ -152,82 +153,3 @@ alwaysApply: true
- 密码/敏感字段用 `VbenInputPassword` 组件,禁止用 `VbenInput` 配 `type="password"`
- 单选/多选选项来自接口时,必须在弹窗/页面 `onMounted` 或 `onOpenChange` 里拉取后通过 `updateSchema` 注入,禁止写死常量
# 专项模块规范
## OA 通知模块前端规则
### 组件与封装复用
- 所有 OA 配置入口(系统配置 tab、CRUD 页面、员工 OA 账号弹窗)必须复用 `Tabs.TabPane`、`useVbenVxeGrid`、`useVbenModal`、`useVbenForm`,禁止用原生 antd 表格/表单
### 平台动态化(禁止写死)
- **平台列表必须从接口 `oa-platform/list` 动态拉取**,禁止在前端写死 `['work_wechat', 'dingtalk', 'feishu']` 常量数组
- 系统配置 tab 中的平台开关行、机器人表单中的平台下拉、场景表单中的机器人分组,都要走接口动态渲染
- 平台编码统一为字符串 snake_case如 `work_wechat`、`dingtalk`、`feishu`),与后端 `xk_oa_platform.platform_code` 字段对齐
### 系统配置 tab 交互
- 系统配置 tab 中的「总开关关闭」必须禁用下方所有平台子开关(视觉上置灰),禁止总开关关闭时仍能切换子开关
- 平台子开关切换必须立即调 `oa-platform/update-enabled` 接口,**禁止依赖保存按钮批量提交**(避免总开关与子开关状态不一致)
- 系统配置页的总开关走现有 `getSystemConfigList` / `saveSystemConfig` 接口config_key = `oa_notify_enabled`**禁止新建专用接口**
### 表单字段约定
- @ 人配置必须按平台分组(用 Tabs 或 Collapse 分组),员工必须从 `xk_admin` 表选择(复用员工 picker 组件)
- OA 机器人表单中的 webhook、secret 字段必须用密码型输入框(`InputPassword`),禁止明文展示
- OA 场景表单中的机器人多选必须按平台分组展示(用 `Tabs` 或 `Collapse` 分组)
### 消息类型全量支持(数据库驱动 schema
- 场景弹窗每个平台 Tab 内的消息类型选项**必须**通过 `GET /oa-scene/message-types` 接口动态拉取,
按 `platform_code` 过滤当前平台支持的所有 `message_type`
- 每个消息类型对应的 payload 表单**必须**按后端返回的 `payload_schema`JSON 字符串)动态渲染,
禁止前端写死 schema数据库可启停类型前端硬编码会不同步
- payload 字段渲染规则(与后端 `payload_schema` 约定):
- `Textarea`:多行文本
- `Input` / `InputNumber`:单行文本/数字
- `RadioGroup` / `Select`:选项组
- `GalleryPickLink`:素材库选择器,透传 `props.acceptTypes: number[]` 过滤文件类型
- `TemplateCardEditor`:企微模板卡片专用编辑器(用 `views/system/oa-scene/components/template-card-form.vue`
- 提交结构:`message_config: { platform_code: { message_type, payload } }`
### image-gallery-picker 文件类型过滤
- `image-gallery-picker.vue` 和 `gallery-pick-link.vue` 都支持 `acceptTypes?: number[]` prop
- 取值对应 `xk_file_type.value`1=图片、2=视频、3=音频、6=文件 等
- 不传 `acceptTypes` 时显示全部类型(默认行为,保持向后兼容)
- 传数组时只展示匹配类型 tab且自动锁定到第一个匹配类型
- OA 场景中图片字段必须传 `[1]`、语音字段传 `[3]`、文件字段传 `[6]`,避免用户选错类型
### 通用拖拽可视化编辑器(`components/oa-template-card-editor/`
- 任何「左侧组件库 → 右侧画布拖拽组装 → 自动生成 JSON」的需求**必须复用此通用组件**,禁止重复造轮子
- schema 驱动:新增块类型只需扩展 `BlockSchema[]` 配置,禁止改组件内部
- SortableJS 用法参照 `views/system/role/components/quick-nav-transfer.vue`(动态 import `sortablejs/modular/sortable.complete.esm.js`
- 双向绑定走 `v-model``modelValue` + `update:modelValue`),禁止内部直接修改 props
- OA 场景中的企微模板卡片必须用 `views/system/oa-scene/components/template-card-form.vue`(在通用编辑器之上做 OA 封装)
### 测试发送(全类型循环)
- OA 机器人 modal 的「测试发送」必须循环跑该平台支持的所有消息类型(单平台最多 8 种)
- 后端返回 `results` 数组,前端必须遍历分类型展示成功/失败(不要只展示第一条)
- 测试按钮 loading 用独立状态(不要复用 modal 的 `confirmLoading`
- 测试发送结果以列表形式展示在表单下方,每种类型显示状态/耗时/错误信息
- 测试发送支持两套入口(共用 `testSendOaRobot` API
- **表单弹窗内测试**modal.vue用于「保存前验证密钥」直接传 webhook_url/secret不带 id
- **行级测试**(独立组件 `test-send-modal.vue`):用于列表中已有机器人,仅传 `id`,后端自动取已加密密钥
- 行级测试弹窗入参:`test_content` + `test_at_all` 开关 + `test_mobiles` 手机号多选
- 飞书 webhook 不支持手机号 @,前端必须在飞书平台下隐藏手机号字段并提示用户(仅 @all 有效)
### 群聊绑定(按平台过滤的 N:N 多选)
- 「OA群聊管理」是独立菜单pid=232 OA 通知父级下,菜单 id=233路由 `/system/oa-chat`
- 群聊管理页结构必须与 `oa-robot` 一致:`index.vue + api/index.ts + config/{table,search,form}.ts + components/modal.vue`
- 机器人表单中的「绑定群聊」字段必须用 `Checkbox.Group`
- 群聊列表通过 `getOaChatListByPlatform()` 一次性拉取全量(按平台分组的对象)
- 前端按当前 `platform_code` 过滤展示(平台切换时自动过滤掉不属于当前平台的勾选)
- 平台切换通过 `componentProps.onChange` 监听vben form 字段值变化无现成 watch必须通过 schema 注入 onChange 回调)
- 编辑场景下,机器人详情接口返回 `chat_ids: number[]`,前端 `selectedChatIds` 直接回显
- 提交时把 `chat_ids` 数组合并到 payload后端 `OaRobotController::notRequest` 已包含该字段)
- 列表展示用后端拼接好的 `chat_names` 字符串(按「、」拼接),详情接口才返回 `chat_ids` 数组

View File

@@ -21,3 +21,4 @@ VITE_ARCHIVER=true
# WebSocket 连接地址
VITE_WS_URL=wss://api.ws.g.xiaokang88.com/ws
# VITE_WS_URL=wss://xk.ws.nailaoyun.cn/ws

View File

@@ -82,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"
@click="openWindow"
/>
<div class="doctor-transfer-header-entry">
<Tooltip title="门店传方">
<Badge :count="pendingCount" :overflow-count="99" size="small">
<Button
type="text"
class="transfer-header-btn"
@click="openWindow"
>
<FileTextOutlined />
<span class="transfer-header-btn__text">传方</span>
</Button>
</Badge>
</Tooltip>
</div>
<SubWindow
v-model:open="windowOpen"
title="门店传方"
@@ -848,4 +859,25 @@ defineExpose({ loadList });
margin: 0 auto;
user-select: none;
}
.doctor-transfer-header-entry {
display: flex;
align-items: center;
margin-right: 4px;
}
.transfer-header-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 32px;
padding: 0 8px;
color: hsl(var(--foreground));
}
.transfer-header-btn__text {
font-size: 13px;
}
@media (max-width: 640px) {
.transfer-header-btn__text {
display: none;
}
}
</style>

View File

@@ -6,19 +6,20 @@
* - 支持中药/西药类型切换
* - 下拉选项展示:药品图片、名称、供应商、规格、价格
* - 选择时返回完整药品数据(包含默认用法字段)
* - isCard单选确认场景如仓绑药选中后以内嵌卡片展示已选药品
* @author 系统
* @date 2024
*/
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { SearchOutlined, LoadingOutlined } from '@ant-design/icons-vue';
import { Input, Spin } from 'ant-design-vue';
import { Button, Input, Spin } from 'ant-design-vue';
import { debounce } from 'lodash-es';
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
/** 中药无图时与仓库列表一致的占位图 */
const TCM_PLACEHOLDER_IMAGE =
/** 无图时占位(中药/西药等统一兜底,避免仓绑药下拉只剩文字) */
const DRUG_PLACEHOLDER_IMAGE =
'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk_upload_20250510/cd470ebf97e31c4048ba502ba71b66a3.jpg';
// ==================== Props 定义 ====================
@@ -29,12 +30,17 @@ interface Props {
* 1-中药2-西药
*/
type?: number;
/**
* 多药品类型(优先于 type有值时支持空关键词拉全量
* 例如 [2,4] 表示西药+中成药
*/
types?: number[];
/**
* 占位提示文本
*/
placeholder?: string;
/**
* 诊所ID
* 诊所ID(接诊/开方必传;仓药绑定等平台场景可省略,默认 0 走主库 types 检索)
*/
storeId?: number;
/**
@@ -45,14 +51,21 @@ interface Props {
* 是否禁用
*/
disabled?: boolean;
/**
* 是否以卡片展示已选药品(仓绑药等单选确认场景;开方默认 false
*/
isCard?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
type: 1,
types: () => [],
placeholder: '输入药品名称搜索',
storeId: 2,
// 默认 0平台无门店诊所场景由调用方传 :store-id
storeId: 0,
registerId: undefined,
disabled: false,
isCard: false,
});
// ==================== Emits 定义 ====================
@@ -97,10 +110,21 @@ const containerRef = ref<HTMLElement | null>(null);
*/
const highlightIndex = ref(-1);
/**
* isCard 模式下已选药品(用于输入框下方卡片)
*/
const selectedDrug = ref<any | null>(null);
/**
* 搜索框引用,便于「重新选择」时聚焦
*/
const inputRef = ref<{ focus?: () => void } | null>(null);
/**
* 解析下拉展示图:优先真图,无图用统一占位
*/
function resolveDropdownImage(item: any): string {
if (item._image) return item._image;
if (props.type === 1) return TCM_PLACEHOLDER_IMAGE;
return '';
return item._image || DRUG_PLACEHOLDER_IMAGE;
}
// ==================== 方法定义 ====================
@@ -108,10 +132,12 @@ function resolveDropdownImage(item: any): string {
/**
* 搜索药品
* @param keyword 搜索关键词
* @description 调用后端API搜索药品支持按名称和拼音搜索
* @description 调用后端API搜索药品支持按名称和拼音搜索
* 传入 types 时允许空关键词拉取该类型全量列表(用于下拉展开)
*/
const searchDrugs = debounce(async (keyword: string) => {
if (!keyword || keyword.length < 1) {
// 单类型模式:空关键词清空;多类型模式:空关键词仍请求后端拉全量
if ((!keyword || keyword.length < 1) && props.types.length === 0) {
searchResults.value = [];
showDropdown.value = false;
return;
@@ -121,37 +147,43 @@ const searchDrugs = debounce(async (keyword: string) => {
showDropdown.value = true;
try {
// types 优先:有多类型时只传 types不传 type避免后端被单 type 覆盖
const typeParams =
props.types.length > 0
? { types: props.types }
: { type: props.type };
const res = await getProductListDoctorReception({
name: keyword,
type: props.type,
name: keyword || '',
...typeParams,
store_id: props.storeId,
...(props.registerId ? { register_id: props.registerId } : {}),
});
// 处理返回数据
// 处理返回数据(兼容门店 relation 与平台主库打平结构)
if (Array.isArray(res)) {
searchResults.value = res.map((item: any) => ({
// 保留原始数据
...item,
// 提取常用字段便于访问
_id: item.id,
_drugId: item.drug_id || item.drug?.id,
_drugName: item.drug?.drug_name || item.drug_name || '',
_image: item.drug?.image || '',
_specification: item.drug?.specification || '',
_supplier: item.drug?.supplier?.name || '',
_price: item.price || 0,
// 默认用法字段
_timeId: item.drug?.time_id || 0,
_typeId: item.drug?.type_id || 0,
_frequencyId: item.drug?.frequency_id || 0,
_unitId: item.drug?.unit_id || 0,
_number: item.drug?.number || 1,
// 用法名称
_useNum: item.drug?.useNum,
_useType: item.drug?.useType,
_useFrequency: item.drug?.useFrequency,
}));
searchResults.value = res.map((item: any) => {
const drug = item.drug || {};
return {
...item,
_id: item.id,
_drugId: item.drug_id || drug.id || item.id,
_drugName: drug.drug_name || item.drug_name || '',
_image: drug.image || item.image || '',
_specification: drug.specification || item.specification || '',
_supplier: drug.supplier?.name || item.supplier?.name || '',
_price: Number(item.price ?? 0),
// 已绑定配送仓:列表展示「多仓」标识
_hasDeliveryWarehouse: Number(item.has_delivery_warehouse ?? 0) === 1,
_timeId: drug.time_id || 0,
_typeId: drug.type_id || 0,
_frequencyId: drug.frequency_id || 0,
_unitId: drug.unit_id || 0,
_number: drug.number || 1,
_useNum: drug.useNum,
_useType: drug.useType,
_useFrequency: drug.useFrequency,
};
});
} else {
searchResults.value = [];
}
@@ -176,8 +208,14 @@ function handleInput(e: Event) {
/**
* 处理输入框获得焦点
* @description 多类型模式聚焦即拉取列表并展开;单类型模式仅在已有结果时展示下拉
*/
function handleFocus() {
if (props.types.length > 0) {
searchDrugs('');
showDropdown.value = true;
return;
}
if (searchResults.value.length > 0) {
showDropdown.value = true;
}
@@ -186,15 +224,36 @@ function handleFocus() {
/**
* 处理选中药品
* @param drug 选中的药品数据
* @description isCard 时保留 selectedDrug 用于卡片展示;否则清空选中态
*/
function handleSelectDrug(drug: any) {
emit('select', drug);
// 清空搜索状态
searchKeyword.value = '';
searchResults.value = [];
showDropdown.value = false;
highlightIndex.value = -1;
if (props.isCard) {
selectedDrug.value = drug;
} else {
selectedDrug.value = null;
}
}
/**
* 清空已选卡片并重新搜索(仅 isCard
* 同步 emit null便于父级清空 drug_id
*/
function handleReselect() {
selectedDrug.value = null;
searchKeyword.value = '';
searchResults.value = [];
showDropdown.value = false;
highlightIndex.value = -1;
emit('select', null);
// 下一帧聚焦搜索框,方便重新选药
setTimeout(() => {
inputRef.value?.focus?.();
}, 0);
}
/**
@@ -261,6 +320,7 @@ watch(
searchResults.value = [];
showDropdown.value = false;
searchKeyword.value = '';
selectedDrug.value = null;
},
);
</script>
@@ -269,6 +329,7 @@ watch(
<div ref="containerRef" class="drug-search-select">
<!-- 搜索输入框 -->
<Input
ref="inputRef"
v-model:value="searchKeyword"
:placeholder="placeholder"
:disabled="disabled"
@@ -283,6 +344,49 @@ watch(
</template>
</Input>
<!-- isCard已选药品卡片与下拉项同结构带边框区分 -->
<div
v-if="isCard && selectedDrug"
class="drug-item drug-item--selected-card"
>
<div class="drug-item__image">
<img
:src="resolveDropdownImage(selectedDrug)"
alt=""
class="drug-item__img"
@error="
(e) => ((e.target as HTMLImageElement).src = DRUG_PLACEHOLDER_IMAGE)
"
/>
</div>
<div class="drug-item__info">
<div class="drug-item__name">{{ selectedDrug._drugName }}</div>
<div class="drug-item__meta">
<span class="drug-item__id">ID{{ selectedDrug._drugId }}</span>
<span v-if="selectedDrug._specification" class="drug-item__spec">
规格{{ selectedDrug._specification }}
</span>
<span v-if="selectedDrug._supplier" class="drug-item__supplier">
供应商{{ selectedDrug._supplier }}
</span>
</div>
</div>
<div class="drug-item__price">
<template v-if="Number(selectedDrug._price) > 0">
¥{{ Number(selectedDrug._price).toFixed(2) }}
</template>
<span v-else class="drug-item__price--empty">暂无</span>
</div>
<Button
type="link"
size="small"
class="drug-item__reselect"
@click="handleReselect"
>
重新选择
</Button>
</div>
<!-- 下拉列表 -->
<div v-if="showDropdown" class="drug-dropdown">
<!-- 加载中 -->
@@ -321,12 +425,14 @@ watch(
<div v-else class="drug-item__img-placeholder">无图</div>
</div>
<!-- 药品信息 -->
<!-- 药品信息药名 / 多仓 / ID / 规格 / 供应商 -->
<div class="drug-item__info">
<!-- 药品名称 -->
<div class="drug-item__name">{{ item._drugName }}</div>
<!-- 规格和供应商 -->
<div class="drug-item__name-row">
<div class="drug-item__name">{{ item._drugName }}</div>
<span v-if="item._hasDeliveryWarehouse" class="drug-item__multi-wh">多仓</span>
</div>
<div class="drug-item__meta">
<span class="drug-item__id">ID{{ item._drugId }}</span>
<span v-if="item._specification" class="drug-item__spec">
规格{{ item._specification }}
</span>
@@ -336,9 +442,12 @@ watch(
</div>
</div>
<!-- 价格 -->
<!-- 价格无价平台主库常见展示暂无 -->
<div class="drug-item__price">
¥{{ Number(item._price).toFixed(2) }}
<template v-if="Number(item._price) > 0">
¥{{ Number(item._price).toFixed(2) }}
</template>
<span v-else class="drug-item__price--empty">暂无</span>
</div>
</div>
</div>
@@ -347,6 +456,7 @@ watch(
</template>
<style lang="scss" scoped>
/* 只用框架主题变量,随亮暗/主题色自动适配,不写 .dark 硬编码覆盖 */
.drug-search-select {
position: relative;
width: 100%;
@@ -359,8 +469,8 @@ watch(
right: 0;
z-index: 1050;
margin-top: 4px;
background: #fff;
border: 1px solid #e5e7eb;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
max-height: 400px;
@@ -373,7 +483,7 @@ watch(
justify-content: center;
gap: 8px;
padding: 24px;
color: #999;
color: hsl(var(--muted-foreground));
font-size: 14px;
}
@@ -387,7 +497,7 @@ watch(
}
&::-webkit-scrollbar-thumb {
background: #d9d9d9;
background: hsl(var(--border));
border-radius: 3px;
}
@@ -407,7 +517,25 @@ watch(
&:hover,
&--active {
background-color: #f5f7fa;
background-color: hsl(var(--accent-hover));
}
/* 已选确认卡片:固定展示,非下拉浮层 */
&--selected-card {
margin-top: 8px;
cursor: default;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 8px;
&:hover {
background: hsl(var(--muted) / 0.3);
}
}
&__reselect {
flex-shrink: 0;
padding: 0 4px;
}
&__image {
@@ -421,7 +549,7 @@ watch(
height: 48px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #f0f0f0;
border: 1px solid hsl(var(--border));
}
&__img-placeholder {
@@ -430,10 +558,10 @@ watch(
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
background: hsl(var(--accent));
border-radius: 6px;
font-size: 12px;
color: #bbb;
color: hsl(var(--muted-foreground));
}
&__info {
@@ -442,14 +570,35 @@ watch(
overflow: hidden;
}
&__name-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
min-width: 0;
}
&__name {
font-size: 14px;
font-weight: 500;
color: #333;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
min-width: 0;
}
/* 配送仓绑定药品标识:小号胶囊,不抢主信息 */
&__multi-wh {
flex-shrink: 0;
padding: 0 6px;
height: 18px;
line-height: 16px;
font-size: 11px;
color: #2b6de5;
background: rgba(43, 109, 229, 0.08);
border: 1px solid rgba(43, 109, 229, 0.45);
border-radius: 999px;
}
&__meta {
@@ -457,57 +606,31 @@ watch(
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #999;
color: hsl(var(--muted-foreground));
}
&__id {
color: hsl(var(--muted-foreground));
}
&__spec {
color: #666;
color: hsl(var(--muted-foreground));
}
&__supplier {
color: #1890ff;
color: hsl(var(--primary));
}
&__price {
flex-shrink: 0;
font-size: 16px;
font-weight: 600;
color: #ff4d4f;
}
}
color: hsl(var(--destructive));
/* 暗色模式适配 */
.dark {
.drug-dropdown {
background: #1f2937;
border-color: #374151;
}
.drug-item {
&:hover,
&--active {
background-color: #374151;
}
&__img-placeholder {
background: #374151;
color: #6b7280;
}
&__name {
color: #f3f4f6;
}
&__meta {
color: #9ca3af;
}
&__spec {
color: #d1d5db;
}
&__supplier {
color: #60a5fa;
&--empty {
font-size: 13px;
font-weight: 400;
color: hsl(var(--muted-foreground));
}
}
}

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

@@ -4,6 +4,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { Empty, Input, Popover, Spin } from 'ant-design-vue';
import { matchExpressByTrackingNo } from '#/utils/matchExpressByTrackingNo';
import { getExpressCompaniesOption } from '#/views/business/express/express-company/api';
defineOptions({
@@ -12,9 +13,11 @@ defineOptions({
});
const props = defineProps<{
value?: string;
disabled?: boolean;
placeholder?: string;
/** 运单号:变化时按前缀自动匹配快递公司(用户手动改选后不再覆盖) */
trackingNo?: string;
value?: string;
}>();
const emits = defineEmits<{
@@ -24,9 +27,9 @@ const emits = defineEmits<{
const mValue = useVModel(props, 'value', emits, { passive: true });
type ExpressCompanyOption = {
code: string;
id: number;
name: string;
code: string;
};
const open = ref(false);
@@ -34,8 +37,13 @@ const loading = ref(false);
const searchKeyword = ref('');
const options = ref<ExpressCompanyOption[]>([]);
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
/** 用户是否已手动选择/清空;为 true 时不再按单号自动覆盖 */
const userPicked = ref(false);
function filterExpressCompanyOptions(keyword: string, list: ExpressCompanyOption[]) {
function filterExpressCompanyOptions(
keyword: string,
list: ExpressCompanyOption[],
) {
const q = keyword.trim().toLowerCase();
if (!q) return list;
return list.filter((item) => {
@@ -56,20 +64,39 @@ const selectedOption = computed(() =>
const displayText = computed(() => {
const item = selectedOption.value;
if (!item) return '';
return item.code ? `${item.name || '-'}${item.code}` : (item.name || '-');
return item.code ? `${item.name || '-'}${item.code}` : item.name || '-';
});
/**
* 根据 trackingNo 自动选中快递公司
* 规则:尚未手动改选,或当前选中仍与规则结果一致时才覆盖
*/
function tryAutoMatchByTrackingNo() {
if (options.value.length === 0) return;
const matched = matchExpressByTrackingNo(props.trackingNo, options.value);
if (!matched?.code) return;
const matchedCode = matched.code;
if (userPicked.value && mValue.value && mValue.value !== matchedCode) {
// 用户已选其他公司,不覆盖
return;
}
if (mValue.value === matchedCode) return;
mValue.value = matchedCode;
}
async function loadOptions() {
loading.value = true;
try {
const res = await getExpressCompaniesOption({});
options.value = Array.isArray(res) ? res : [];
tryAutoMatchByTrackingNo();
} finally {
loading.value = false;
}
}
function selectOption(item: ExpressCompanyOption) {
userPicked.value = true;
mValue.value = item.code;
open.value = false;
searchKeyword.value = '';
@@ -77,6 +104,7 @@ function selectOption(item: ExpressCompanyOption) {
function clearSelection(e: Event) {
e.stopPropagation();
userPicked.value = true;
mValue.value = undefined;
}
@@ -85,7 +113,7 @@ function onOpenChange(next: boolean) {
open.value = next;
if (next) {
searchKeyword.value = '';
if (!options.value.length) {
if (options.value.length === 0) {
loadOptions();
}
nextTick(() => searchInputRef.value?.focus?.());
@@ -99,6 +127,14 @@ watch(
},
);
watch(
() => props.trackingNo,
() => {
// 单号变化时允许再自动匹配(除非用户已改选为不一致公司)
tryAutoMatchByTrackingNo();
},
);
onMounted(() => {
loadOptions();
});
@@ -107,9 +143,9 @@ onMounted(() => {
<template>
<Popover
:open="open"
trigger="click"
placement="bottomLeft"
overlay-class-name="express-company-select-popover"
placement="bottomLeft"
trigger="click"
@open-change="onOpenChange"
>
<template #content>
@@ -118,17 +154,17 @@ onMounted(() => {
ref="searchInputRef"
v-model:value="searchKeyword"
allow-clear
placeholder="搜索公司名称或编码"
class="express-search"
placeholder="搜索公司名称或编码"
/>
<Spin :spinning="loading">
<div v-if="filteredOptions.length" class="express-list">
<div v-if="filteredOptions.length > 0" class="express-list">
<button
v-for="item in filteredOptions"
:key="item.id"
type="button"
class="express-item"
:class="{ active: item.code === mValue }"
class="express-item"
type="button"
@click="selectOption(item)"
>
<div class="express-meta">
@@ -137,20 +173,20 @@ onMounted(() => {
</div>
</button>
</div>
<Empty v-else description="无匹配快递公司" class="express-empty" />
<Empty v-else class="express-empty" description="无匹配快递公司" />
</Spin>
</div>
</template>
<div
:class="{ disabled, placeholder: !displayText }"
class="express-trigger"
:class="{ disabled: disabled, placeholder: !displayText }"
>
<Input
:value="displayText"
readonly
:disabled="disabled"
:placeholder="placeholder || '请选择快递公司'"
:value="displayText"
class="express-trigger-input"
readonly
>
<template v-if="displayText && !disabled" #suffix>
<span class="clear-btn" @click="clearSelection">×</span>
@@ -163,6 +199,8 @@ onMounted(() => {
<style scoped>
.express-trigger {
width: 100%;
/* min-width: fit-content; */
min-width: 250px;
cursor: pointer;
}
.express-trigger.disabled {
@@ -177,7 +215,7 @@ onMounted(() => {
.clear-btn {
pointer-events: auto;
cursor: pointer;
color: #86909c;
color: hsl(var(--muted-foreground));
font-size: 16px;
line-height: 1;
padding: 0 4px;
@@ -203,10 +241,11 @@ onMounted(() => {
text-align: left;
cursor: pointer;
border-radius: 6px;
color: hsl(var(--foreground));
}
.express-item:hover,
.express-item.active {
background: #f2f3f5;
background: hsl(var(--accent));
}
.express-meta {
min-width: 0;
@@ -214,11 +253,11 @@ onMounted(() => {
}
.express-name {
font-size: 14px;
color: #1d2129;
color: hsl(var(--foreground));
}
.express-sub {
font-size: 12px;
color: #86909c;
color: hsl(var(--muted-foreground));
}
.express-empty {
margin: 12px 0;
@@ -228,5 +267,8 @@ onMounted(() => {
<style>
.express-company-select-popover .ant-popover-inner {
padding: 12px;
background: hsl(var(--card));
color: hsl(var(--foreground));
border: 1px solid hsl(var(--border));
}
</style>

View File

@@ -9,8 +9,8 @@ const props = withDefaults(
maxCount?: number;
disabled?: boolean;
/**
* 限制可选择的文件类型(透传给 image-gallery-picker按 xk_file_type.value 过滤)
* 例如图片:[1];文件:[6];语音:[3]
* 限制可选择的文件类型(透传给 image-gallery-picker xk_file.type / xk_file_type.value 过滤)
* 例如图片:[0];视频:[1];文件:[6];语音:[3]
*/
acceptTypes?: number[];
}>(),

View File

@@ -18,7 +18,7 @@ const props = withDefaults(
* 限制可选择的文件类型(按 xk_file_type.value 过滤)
* - 不传:显示全部类型(默认行为,保持向后兼容)
* - 传数组:只展示指定类型 tab且自动锁定到第一个匹配的类型
* 例如图片:[1];图片+视频:[1, 2];文件:[6]
* 例如图片:[0];图片+视频:[0, 1];文件:[6]
*/
acceptTypes?: number[];
}>(),
@@ -217,9 +217,9 @@ function itemIcon(item: FileGalleryItem) {
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
}
/** 图片类型展示缩略图xk_file_type1=图片) */
/** 图片类型展示缩略图xk_file.type0=图片;兼容历史错误写入的 1 */
function isImageItem(item: FileGalleryItem) {
return Number(item.type) === 1 || Number(item.type) === 0;
return Number(item.type) === 0 || Number(item.type) === 1;
}
</script>

View File

@@ -0,0 +1,346 @@
<script setup lang="ts">
/**
* 门店多选搜索组件
* - 普通输入框输入,下方气泡卡片展示匹配结果(名称 / 拼音首拼)
* - 选中后在输入框下方用可关闭 Tag 展示「名称【id】」
* - 搜索结果默认高亮第一项Enter 可直接选中
* - 样式使用主题 CSS 变量,自动适配暗色
*/
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
import { useDebounceFn, useVModel } from '@vueuse/core';
import { Empty, Input, Spin, Tag } from 'ant-design-vue';
import { searchStoreOption } from '#/views/system/store/api';
defineOptions({
name: 'StoreMultiSearch',
inheritAttrs: false,
});
export type StoreSearchItem = {
id: number;
name: string;
type?: number;
shouzimu?: string;
};
const props = withDefaults(
defineProps<{
/** 已选门店 ID 列表 */
value?: number[];
/** 限定类型0诊所 1药店不传则诊所+药店都可搜 */
storeType?: 0 | 1;
placeholder?: string;
disabled?: boolean;
/**
* 外部已选门店详情(打开绑定弹窗时回填名称用)
* 仅用于 Tag 展示,不改变 value
*/
initialItems?: StoreSearchItem[];
}>(),
{
value: () => [],
storeType: undefined,
placeholder: '输入名称或拼音首拼搜索',
disabled: false,
initialItems: () => [],
},
);
const emits = defineEmits<{
'update:value': [value: number[]];
}>();
const mValue = useVModel(props, 'value', emits, {
passive: true,
defaultValue: [],
});
const containerRef = ref<HTMLElement | null>(null);
const searchKeyword = ref('');
const loading = ref(false);
const showDropdown = ref(false);
const highlightIndex = ref(-1);
const options = ref<StoreSearchItem[]>([]);
/** 已选门店详情(用于 Tag 展示名称) */
const selectedMap = ref<Record<number, StoreSearchItem>>({});
const selectedIds = computed(() =>
Array.isArray(mValue.value) ? mValue.value.map(Number) : [],
);
const selectedItems = computed(() =>
selectedIds.value.map((id) => {
const cached = selectedMap.value[id];
return cached || { id, name: `门店${id}` };
}),
);
/** 下拉中过滤掉已选 */
const visibleOptions = computed(() =>
options.value.filter((item) => !selectedIds.value.includes(item.id)),
);
/**
* 将初始/回填门店写入 selectedMap便于 Tag 显示真实名称
*/
function mergeInitialItems(items: StoreSearchItem[]) {
if (!items?.length) return;
const next = { ...selectedMap.value };
for (const item of items) {
if (item?.id) {
next[item.id] = item;
}
}
selectedMap.value = next;
}
/**
* 请求后端门店搜索;有结果时默认高亮第一项
*/
async function fetchOptions() {
const keyword = searchKeyword.value.trim();
if (!keyword) {
options.value = [];
showDropdown.value = false;
highlightIndex.value = -1;
return;
}
loading.value = true;
showDropdown.value = true;
try {
const res = await searchStoreOption({
keyword,
...(props.storeType === 0 || props.storeType === 1
? { type: props.storeType }
: {}),
limit: 20,
});
options.value = res?.items ?? [];
// 默认高亮第一项Enter 可直接选中
highlightIndex.value = options.value.length > 0 ? 0 : -1;
} catch {
options.value = [];
highlightIndex.value = -1;
} finally {
loading.value = false;
}
}
const debouncedFetch = useDebounceFn(fetchOptions, 300);
function handleInput() {
debouncedFetch();
}
function handleFocus() {
if (visibleOptions.value.length > 0) {
showDropdown.value = true;
if (highlightIndex.value < 0) {
highlightIndex.value = 0;
}
}
}
/**
* 选中一项:追加到 value缓存名称清空输入继续搜
*/
function selectOption(item: StoreSearchItem) {
if (selectedIds.value.includes(item.id)) return;
selectedMap.value = { ...selectedMap.value, [item.id]: item };
mValue.value = [...selectedIds.value, item.id];
searchKeyword.value = '';
options.value = [];
showDropdown.value = false;
highlightIndex.value = -1;
}
/**
* 取消某个已选门店
*/
function removeSelected(id: number) {
mValue.value = selectedIds.value.filter((x) => x !== id);
const next = { ...selectedMap.value };
delete next[id];
selectedMap.value = next;
}
function handleKeydown(e: KeyboardEvent) {
if (!showDropdown.value || visibleOptions.value.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
highlightIndex.value = Math.min(
Math.max(highlightIndex.value, 0) + 1,
visibleOptions.value.length - 1,
);
break;
case 'ArrowUp':
e.preventDefault();
highlightIndex.value = Math.max(highlightIndex.value - 1, 0);
break;
case 'Enter':
e.preventDefault();
{
const idx = highlightIndex.value >= 0 ? highlightIndex.value : 0;
const item = visibleOptions.value[idx];
if (item) selectOption(item);
}
break;
case 'Escape':
showDropdown.value = false;
highlightIndex.value = -1;
break;
}
}
function handleClickOutside(e: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
showDropdown.value = false;
highlightIndex.value = -1;
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside);
mergeInitialItems(props.initialItems || []);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
watch(
() => props.storeType,
() => {
options.value = [];
showDropdown.value = false;
searchKeyword.value = '';
highlightIndex.value = -1;
},
);
watch(
() => props.initialItems,
(items) => mergeInitialItems(items || []),
{ deep: true },
);
</script>
<template>
<div ref="containerRef" class="store-multi-search">
<Input
v-model:value="searchKeyword"
:placeholder="placeholder"
:disabled="disabled"
allow-clear
@input="handleInput"
@focus="handleFocus"
@keydown="handleKeydown"
>
<template #prefix>
<LoadingOutlined v-if="loading" class="text-muted-foreground" />
<SearchOutlined v-else class="text-muted-foreground" />
</template>
</Input>
<!-- 气泡建议列表 -->
<div v-if="showDropdown" class="store-multi-search__dropdown">
<Spin :spinning="loading">
<Empty
v-if="!loading && visibleOptions.length === 0"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无匹配门店"
class="py-3"
/>
<div
v-for="(item, index) in visibleOptions"
:key="item.id"
class="store-multi-search__option"
:class="{ 'is-active': index === highlightIndex }"
@mousedown.prevent="selectOption(item)"
>
<div class="store-multi-search__option-name">
{{ item.name }}{{ item.id }}
</div>
<div v-if="item.shouzimu" class="store-multi-search__option-sub">
首拼{{ item.shouzimu }}
</div>
</div>
</Spin>
</div>
<!-- 已选 Tag -->
<div v-if="selectedItems.length > 0" class="store-multi-search__tags">
<Tag
v-for="item in selectedItems"
:key="item.id"
closable
color="blue"
@close="() => removeSelected(item.id)"
>
{{ item.name }}{{ item.id }}
</Tag>
</div>
</div>
</template>
<style scoped lang="scss">
/* 只用框架主题变量,随亮暗/主题色自动适配 */
.store-multi-search {
position: relative;
width: 100%;
}
.store-multi-search__dropdown {
position: absolute;
z-index: 1050;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 280px;
overflow-y: auto;
padding: 6px;
border-radius: 8px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
.store-multi-search__option {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
&:hover,
&.is-active {
background: hsl(var(--accent-hover));
}
}
.store-multi-search__option-name {
font-size: 13px;
font-weight: 500;
color: hsl(var(--foreground));
line-height: 1.4;
}
.store-multi-search__option-sub {
margin-top: 2px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.store-multi-search__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
</style>

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;
@@ -25,7 +31,8 @@ interface Props {
multiple?: boolean;
maxCount?: number;
/**
* 素材库可选类型(透传 GalleryPickLink,默认仅图片
* 素材库可选类型(透传 GalleryPickLink
* xk_file.type0=图片 1=视频,默认仅图片
*/
acceptTypes?: number[];
}
@@ -34,13 +41,15 @@ const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: true,
maxCount: 9,
acceptTypes: () => [1],
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 = () => {
@@ -79,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);
@@ -160,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>
@@ -193,6 +261,25 @@ const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.val
@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

@@ -5,4 +5,5 @@ export type CustomComponentType =
| 'ApiSelect'
| 'ApiTreeSelect'
| 'IconPicker'
| 'StoreMultiSearch'
| 'WarehouseAdminDrugSearch';

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

@@ -45,7 +45,7 @@ export interface FormFieldSchema {
required?: boolean;
/** select / radio 类型的选项列表 */
options?: { label: string; value: any }[];
/** 图片字段:限定素材库文件类型(如 [1] 只显示图片) */
/** 图片字段:限定素材库文件类型(如 [0] 只显示图片) */
acceptTypes?: number[];
/** 最大长度 */
maxLength?: number;

View File

@@ -0,0 +1,276 @@
<script lang="ts" setup>
/**
* 处方溯源展示内容(对齐小程序药师溯源:就诊→开具→审核)
* 由审方/业务处方两个 Modal 共用,避免双份漂移
*/
import { computed } from 'vue';
import { Timeline, TimelineItem } from 'ant-design-vue';
import {
FileTextOutlined,
MedicineBoxOutlined,
ShopOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
const props = defineProps<{
data: Record<string, any> | null;
}>();
const formatTime = (timestamp: unknown) => {
if (!timestamp) return '--';
if (typeof timestamp === 'string' && timestamp.includes('-')) return timestamp;
const n = Number(timestamp);
if (!n) return '--';
return new Date(n * 1000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
const prescriptionTypeMap: Record<number, string> = {
1: '中药处方',
2: '西药处方',
3: '保健食品',
5: '产品服务包',
6: '非药品',
7: '医疗器械',
};
const statusMap: Record<number, string> = {
0: '待审核',
1: '已通过',
2: '已拒绝',
3: '已过期',
};
const prescriptionTypeText = computed(() => {
if (!props.data) return '--';
return prescriptionTypeMap[props.data.prescription_type] || '未知';
});
const statusText = computed(() => {
if (!props.data) return '--';
return statusMap[props.data.status] || '未知';
});
const statusColor = computed(() => {
if (!props.data) return 'gray';
const map: Record<number, string> = {
0: 'orange',
1: 'green',
2: 'red',
3: 'gray',
};
return map[props.data.status] || 'gray';
});
const expireTime = computed(() =>
props.data ? formatTime(props.data.auto_expire_time) : '--',
);
const patientSexText = computed(() => {
const sex = props.data?.user_patient?.sex;
if (sex === 1) return '男';
if (sex === 2) return '女';
return '--';
});
const doctorInfo = computed(
() => props.data?.doctor_info || props.data?.doctorInfo || null,
);
const pharmacistInfo = computed(
() => props.data?.pharmacist_info || props.data?.pharmacistInfo || null,
);
const userPatient = computed(
() => props.data?.user_patient || props.data?.userPatient || null,
);
const registerInfo = computed(
() => props.data?.register || null,
);
</script>
<template>
<div v-if="data" class="prescription-source-container">
<div class="mb-6 rounded-lg p-6 shadow-md">
<div class="mb-4 flex items-center">
<ShopOutlined class="mr-2 text-xl text-purple-500" />
<h2 class="text-xl font-bold">开具机构</h2>
</div>
<div v-if="data.store" class="grid grid-cols-1 gap-4">
<div class="info-item">
<span class="label">机构名称:</span>
<span class="value">{{ data.store.name }}</span>
</div>
</div>
<div v-else class="italic text-gray-500">暂无机构信息</div>
</div>
<div class="mb-6 rounded-lg p-6 shadow-md">
<div class="mb-4 flex items-center">
<FileTextOutlined class="mr-2 text-xl text-blue-500" />
<h2 class="text-xl font-bold">时间线</h2>
</div>
<Timeline>
<TimelineItem>
<div class="font-medium">就诊</div>
<div class="text-sm text-gray-500">
{{ formatTime(registerInfo?.created_at) }}
· {{ userPatient?.name || '--' }}
</div>
<div class="mt-1 text-sm">
性别{{ patientSexText }} · 年龄{{ userPatient?.age ?? '--' }}
</div>
<div v-if="data.clinical_diagnose" class="mt-1 text-sm">
诊断{{ data.clinical_diagnose }}
</div>
</TimelineItem>
<TimelineItem>
<div class="font-medium">开具</div>
<div class="text-sm text-gray-500">{{ data.created_at || '--' }}</div>
<div class="mt-1 text-sm">
医生{{ doctorInfo?.name || '--' }} · 科室{{
doctorInfo?.depart?.name || '--'
}}
</div>
<div class="mt-1 text-sm">
处方编号{{ data.prescription_no }} · 类型{{ prescriptionTypeText }}
</div>
</TimelineItem>
<TimelineItem>
<div class="font-medium">审核</div>
<div class="text-sm text-gray-500">
{{ data.pharmacist_view_time || '--' }}
</div>
<div class="mt-1 text-sm">
状态
<span :class="`text-${statusColor}-500`">{{ statusText }}</span>
· 药师{{ pharmacistInfo?.name || '--' }}
</div>
<div v-if="data.reject_reason" class="mt-1 text-sm text-red-500">
驳回原因{{ data.reject_reason }}
</div>
<div
v-else-if="data.cancel_remark && Number(data.status) === 2"
class="mt-1 text-sm text-red-500"
>
驳回原因{{ data.cancel_remark }}
</div>
</TimelineItem>
</Timeline>
</div>
<div class="mb-6 rounded-lg p-6 shadow-md">
<div class="mb-4 flex items-center">
<FileTextOutlined class="mr-2 text-xl text-blue-500" />
<h2 class="text-xl font-bold">处方基本信息</h2>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<div class="info-item">
<span class="label">处方编号:</span>
<span class="value">{{ data.prescription_no }}</span>
</div>
<div class="info-item">
<span class="label">处方类型:</span>
<span class="value">{{ prescriptionTypeText }}</span>
</div>
<div class="info-item">
<span class="label">处方状态:</span>
<span :class="`text-${statusColor}-500`" class="value">{{
statusText
}}</span>
</div>
<div class="info-item">
<span class="label">总金额:</span>
<span class="value font-bold text-red-500"
>¥{{ data.total_pay_price }}</span
>
</div>
<div class="info-item">
<span class="label">过期时间:</span>
<span class="value">{{ expireTime }}</span>
</div>
<div v-if="data.reject_reason" class="info-item">
<span class="label">驳回原因:</span>
<span class="value text-red-500">{{ data.reject_reason }}</span>
</div>
</div>
</div>
<div class="mb-6 rounded-lg p-6 shadow-md">
<div class="mb-4 flex items-center">
<MedicineBoxOutlined class="mr-2 text-xl text-green-500" />
<h2 class="text-xl font-bold">医生信息</h2>
</div>
<div v-if="doctorInfo" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="info-item">
<span class="label">开具医生:</span>
<span class="value">{{ doctorInfo.name }}</span>
</div>
<div class="info-item">
<span class="label">开具科室:</span>
<span class="value">{{ doctorInfo.depart?.name || '--' }}</span>
</div>
<div class="info-item">
<span class="label">开具机构:</span>
<span class="value">{{ data.store?.name || '--' }}</span>
</div>
<div class="info-item">
<span class="label">开具时间:</span>
<span class="value">{{ data.created_at || '--' }}</span>
</div>
</div>
<div v-else class="italic text-gray-500">暂无医生信息</div>
</div>
<div class="rounded-lg p-6 shadow-md">
<div class="mb-4 flex items-center">
<UserOutlined class="mr-2 text-xl text-amber-500" />
<h2 class="text-xl font-bold">患者信息</h2>
</div>
<div
v-if="userPatient"
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
>
<div class="info-item">
<span class="label">患者姓名:</span>
<span class="value">{{ userPatient.name }}</span>
</div>
<div class="info-item">
<span class="label">年龄:</span>
<span class="value">{{ userPatient.age }}</span>
</div>
<div class="info-item">
<span class="label">性别:</span>
<span class="value">{{ patientSexText }}</span>
</div>
</div>
<div v-else class="italic text-gray-500">暂无患者信息</div>
</div>
</div>
<div v-else class="flex h-64 items-center justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-b-2 border-t-2 border-blue-500"
></div>
</div>
</template>
<style lang="scss" scoped>
.prescription-source-container {
@apply max-h-[70vh] overflow-auto p-4;
}
.info-item {
@apply flex flex-col rounded-md p-3;
}
.label {
@apply mb-1 text-sm text-gray-500;
}
.value {
@apply font-medium;
}
</style>

View File

@@ -0,0 +1,547 @@
<script lang="ts" setup>
/**
* 门店详情弹窗(诊所/药店)
* 布局对齐医生档案:顶部统计 + 左侧竖向 Tabs
*/
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 {
Avatar,
Button,
DatePicker,
Descriptions,
Empty,
Image,
Select,
Spin,
Table,
Tabs,
Tag,
message,
} from 'ant-design-vue';
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
import { getStoreBankCardReportDetail } from '#/views/system/store-bank-card/api';
import {
getStoreCardApi,
getStoreCardStatsApi,
getStoreDrugsByTypeApi,
openQrCodeApi,
} from '#/views/system/store/api';
import { getSalespersonList } from '#/views/system/store/api/salesperson';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
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');
const cardData = ref<any>(null);
const searchTime = ref<[Dayjs, Dayjs]>([
dayjs().startOf('month'),
dayjs(),
]);
const bankLoading = ref(false);
const bankDetail = ref<any>(null);
const drugType = ref(2);
const drugLoading = ref(false);
const drugList = ref<any[]>([]);
const promoterLoading = ref(false);
const promoterList = ref<any[]>([]);
const generatingQr = ref(false);
const store = computed(() => cardData.value?.store ?? {});
const stats = computed(() => cardData.value?.stats_summary ?? {});
const doctorList = computed(() => cardData.value?.doctor_list ?? []);
const isClinic = computed(() => Number(store.value?.type) === 0);
const isPharmacy = computed(() => Number(store.value?.type) === 1);
const modalTitle = computed(() => {
const name = store.value?.name;
if (!name) return '门店详情';
return isPharmacy.value ? `药店详情 · ${name}` : `诊所详情 · ${name}`;
});
const drugTypeOptions = [
{ label: '中药', value: 1 },
{ label: '西药', value: 2 },
{ label: '保健食品', value: 3 },
{ label: '产品服务包', value: 5 },
{ label: '非药品', value: 6 },
{ label: '医疗器械', value: 7 },
];
const drugColumns = [
{ title: '药品名称', dataIndex: 'drug_name', key: 'drug_name', ellipsis: true },
{ title: '规格', dataIndex: 'specification', key: 'specification', width: 140 },
{ title: '售价', dataIndex: 'price', key: 'price', width: 100 },
{ title: '进价', dataIndex: 'buy_price', key: 'buy_price', width: 100 },
];
const promoterColumns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 140 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
];
const [Modal, modalApi] = useVbenModal({
class: 'w-[900px] xl:w-[1100px]',
fullscreenButton: true,
footer: false,
draggable: true,
closeOnClickModal: false,
onCancel() {
modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
storeId.value = Number(data.storeId || data.id || 0);
activeTab.value = 'basic';
searchTime.value = [dayjs().startOf('month'), dayjs()];
bankDetail.value = null;
drugList.value = [];
promoterList.value = [];
if (storeId.value) {
loadCard();
}
},
});
const [DoctorModal, DoctorModalApi] = useVbenModal({
connectedComponent: DoctorCardModal,
});
/** 添加医生弹窗:诊所详情医生团队 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'),
searchTime.value[1].format('YYYY-MM-DD 23:59:59'),
];
}
/** 加载门店卡片概览 */
async function loadCard() {
if (!storeId.value) return;
loading.value = true;
try {
const res = await getStoreCardApi({
id: storeId.value,
search_time: searchTimeParam(),
});
cardData.value = res;
if (isPharmacy.value) {
drugType.value = 2;
}
} catch (e: any) {
message.error(e?.message || '加载门店详情失败');
} finally {
loading.value = false;
}
}
/** 仅刷新统计 */
async function refreshStats() {
if (!storeId.value) return;
try {
const res = await getStoreCardStatsApi({
id: storeId.value,
search_time: searchTimeParam(),
});
if (cardData.value) {
cardData.value = {
...cardData.value,
stats_summary: res.stats_summary,
search_time: res.search_time,
};
}
} catch (e: any) {
message.error(e?.message || '刷新统计失败');
}
}
async function loadBank() {
if (!storeId.value || bankDetail.value) return;
bankLoading.value = true;
try {
bankDetail.value = await getStoreBankCardReportDetail(storeId.value);
} catch {
bankDetail.value = null;
} finally {
bankLoading.value = false;
}
}
async function loadDrugs() {
if (!storeId.value) return;
drugLoading.value = true;
try {
const res = await getStoreDrugsByTypeApi(storeId.value, drugType.value);
const list = Array.isArray(res) ? res : res?.items || res?.list || [];
drugList.value = (list || []).map((item: any) => {
const drug = item.drug || {};
return {
id: item.id,
drug_name: drug.drug_name || item.drug_name || '-',
specification: drug.specification || item.specification || '-',
price: item.price ?? '-',
buy_price: item.buy_price ?? '-',
};
});
} catch {
drugList.value = [];
} finally {
drugLoading.value = false;
}
}
async function loadPromoters() {
if (!storeId.value || promoterList.value.length > 0) return;
promoterLoading.value = true;
try {
const res = await getSalespersonList({
store_id: storeId.value,
page: 1,
pageSize: 100,
});
const list = res?.items || res?.list?.items || res || [];
promoterList.value = Array.isArray(list) ? list : [];
} catch {
promoterList.value = [];
} finally {
promoterLoading.value = false;
}
}
function onTabChange(key: string | number) {
activeTab.value = String(key);
if (key === 'bank') loadBank();
if (key === 'drugs') loadDrugs();
if (key === 'promoters') loadPromoters();
}
function openDoctor(suId: number) {
DoctorModalApi.setData({ su_id: suId, hideStoresTab: true, readonly: true });
DoctorModalApi.open();
}
async function ensureQrCode() {
if (cardData.value?.qr_code || store.value?.qr_code) return;
generatingQr.value = true;
try {
await openQrCodeApi(storeId.value);
await loadCard();
message.success('二维码已生成');
} catch (e: any) {
message.error(e?.message || '生成二维码失败');
} finally {
generatingQr.value = false;
}
}
function clinicTypeText(v: number) {
if (v === 1) return '西医诊所';
if (v === 2) return '中医诊所';
return '未设置';
}
</script>
<template>
<Modal :title="modalTitle">
<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>
<RangePicker
v-model:value="searchTime"
format="YYYY-MM-DD"
@change="refreshStats"
/>
</div>
<!-- 顶部统计 -->
<div
v-if="cardData"
class="mb-6 grid grid-cols-1 gap-4 px-1 md:grid-cols-3"
>
<div
class="relative flex flex-col overflow-hidden rounded-xl border border-blue-100 bg-blue-50 p-5 shadow-sm transition-all group hover:shadow-md dark:border-blue-800/50 dark:bg-blue-900/20"
>
<span class="text-sm font-semibold text-blue-600/80 dark:text-blue-300/80">
销售额
</span>
<div class="mt-2 text-2xl font-bold text-blue-900 dark:text-blue-100">
<span class="text-lg">¥</span>
{{ Number(stats.sales_amount ?? 0).toFixed(2) }}
</div>
</div>
<div
class="relative flex flex-col overflow-hidden rounded-xl border border-emerald-100 bg-emerald-50 p-5 shadow-sm transition-all group hover:shadow-md dark:border-emerald-800/50 dark:bg-emerald-900/20"
>
<span class="text-sm font-semibold text-emerald-600/80 dark:text-emerald-300/80">
利润
</span>
<div class="mt-2 text-2xl font-bold text-emerald-900 dark:text-emerald-100">
<span class="text-lg">¥</span>
{{ Number(stats.profit ?? 0).toFixed(2) }}
</div>
</div>
<div
class="relative flex flex-col overflow-hidden rounded-xl border border-purple-100 bg-purple-50 p-5 shadow-sm transition-all group hover:shadow-md dark:border-purple-800/50 dark:bg-purple-900/20"
>
<span class="text-sm font-semibold text-purple-600/80 dark:text-purple-300/80">
处方量
</span>
<div class="mt-2 text-2xl font-bold text-purple-900 dark:text-purple-100">
{{ stats.prescription_count ?? 0 }}
<span class="text-xs font-normal text-purple-700/60 dark:text-purple-300/60">
</span>
</div>
</div>
</div>
<Tabs
:active-key="activeTab"
tab-position="left"
class="custom-vertical-tabs min-h-[480px]"
@change="onTabChange"
>
<Tabs.TabPane key="basic" tab="门店基本信息">
<div class="pl-4">
<Descriptions bordered :column="2" size="small">
<Descriptions.Item label="名称">
{{ store.name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="ID">{{ store.id }}</Descriptions.Item>
<Descriptions.Item label="类型">
{{ isPharmacy ? '药店' : '诊所' }}
</Descriptions.Item>
<Descriptions.Item v-if="isClinic" label="诊所类型">
{{ clinicTypeText(Number(store.clinic_type)) }}
</Descriptions.Item>
<Descriptions.Item label="联系人">
{{ store.contact || '-' }}
</Descriptions.Item>
<Descriptions.Item label="手机号">
{{ store.mobile || '-' }}
</Descriptions.Item>
<Descriptions.Item label="地址" :span="2">
{{ store.position || store.address || '-' }}
</Descriptions.Item>
<Descriptions.Item label="ERP ID">
{{ store.erp_id || '-' }}
</Descriptions.Item>
<Descriptions.Item label="MES ID">
{{ store.mes_id || '-' }}
</Descriptions.Item>
<Descriptions.Item label="银行卡报备">
{{ store.bank_card_report_status_text || '-' }}
</Descriptions.Item>
<Descriptions.Item label="业务员">
{{ store.new_admin?.nick_name || '-' }}
</Descriptions.Item>
</Descriptions>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="bank" tab="银行卡">
<div class="pl-4">
<Spin :spinning="bankLoading">
<template v-if="bankDetail">
<Descriptions bordered :column="2" size="small">
<Descriptions.Item label="户名">
{{ bankDetail.bank_user_name || bankDetail.account_name || store.bank_user_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="卡号">
{{ bankDetail.bank_card || bankDetail.card_no || store.bank_card || '-' }}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{{ bankDetail.bank_name || store.bank_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="联行号">
{{ bankDetail.bank_no || store.bank_no || '-' }}
</Descriptions.Item>
<Descriptions.Item label="报备状态">
{{ bankDetail.report_status_text || store.bank_card_report_status_text || '-' }}
</Descriptions.Item>
<Descriptions.Item label="账户类型">
{{ bankDetail.bank_account_type ?? store.bank_account_type ?? '-' }}
</Descriptions.Item>
</Descriptions>
</template>
<Empty v-else description="暂无银行卡报备信息,展示门店预留信息">
<Descriptions bordered :column="2" size="small" class="mt-4 text-left">
<Descriptions.Item label="户名">
{{ store.bank_user_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="卡号">
{{ store.bank_card || '-' }}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{{ store.bank_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="联行号">
{{ store.bank_no || '-' }}
</Descriptions.Item>
</Descriptions>
</Empty>
</Spin>
</div>
</Tabs.TabPane>
<Tabs.TabPane v-if="isClinic" key="doctors" tab="诊所医生团队">
<div class="pl-4">
<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
v-for="item in doctorList"
:key="item.su_id"
class="flex items-center gap-3 rounded-lg border border-gray-100 p-3 dark:border-slate-700"
>
<Avatar :src="resolveAvatarUrl(item.doctor?.avatar)" :size="48" />
<div class="min-w-0 flex-1">
<Button type="link" class="!px-0" @click="openDoctor(item.doctor?.su_id || item.su_id)">
{{ item.doctor?.name || '未知医生' }}
</Button>
<div class="text-xs text-muted-foreground">
{{ item.doctor?.mobile || '-' }}
</div>
</div>
</div>
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane v-if="isPharmacy" key="drugs" tab="药店药品">
<div class="pl-4">
<div class="mb-3 flex items-center gap-2">
<span class="text-sm text-muted-foreground">药品类型</span>
<Select
v-model:value="drugType"
:options="drugTypeOptions"
style="width: 160px"
@change="loadDrugs"
/>
</div>
<Table
:columns="drugColumns"
:data-source="drugList"
:loading="drugLoading"
:pagination="false"
row-key="id"
size="small"
:scroll="{ y: 360 }"
/>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="qr" tab="门店二维码">
<div class="flex flex-col items-center gap-4 py-6 pl-4">
<template v-if="cardData?.qr_code || store.qr_code">
<Image
:src="cardData?.qr_code || store.qr_code"
:width="200"
:preview="true"
/>
<div class="text-sm text-muted-foreground">
{{ store.name }} 门店二维码
</div>
</template>
<template v-else>
<Empty description="尚未生成二维码" />
<Button type="primary" :loading="generatingQr" @click="ensureQrCode">
生成二维码
</Button>
</template>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="promoters" tab="门店推广员">
<div class="pl-4">
<Table
:columns="promoterColumns"
:data-source="promoterList"
:loading="promoterLoading"
:pagination="false"
row-key="id"
size="small"
:scroll="{ y: 360 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
{{ record.name || record.nick_name || '-' }}
</template>
<template v-if="column.key === 'status'">
<Tag>{{ record.status ?? '-' }}</Tag>
</template>
</template>
</Table>
</div>
</Tabs.TabPane>
</Tabs>
</Spin>
</Modal>
<DoctorModal />
</template>
<style scoped>
:deep(.custom-vertical-tabs .ant-tabs-nav) {
width: 140px;
}
:deep(.custom-vertical-tabs .ant-tabs-tab) {
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 6px;
transition: all 0.3s;
}
:deep(.custom-vertical-tabs .ant-tabs-tab-active) {
background-color: var(
--ant-primary-color-active-deprecated-f-12,
rgba(22, 119, 255, 0.08)
);
}
</style>

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

@@ -0,0 +1,112 @@
<script lang="ts" setup>
/**
* 处方失效时间展示
* - 剩余 ≤2 小时:标红
* - 剩余 ≤10 分钟:额外显示倒计时(每秒刷新)
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = defineProps<{
/** 失效时间戳(秒)或已格式化的字符串 */
autoExpireTime?: number | string | null;
}>();
const nowTs = ref(Math.floor(Date.now() / 1000));
let timer: ReturnType<typeof setInterval> | null = null;
/** 解析为秒级时间戳 */
function resolveExpireTs(raw: number | string | null | undefined): number {
if (raw == null || raw === '') return 0;
if (typeof raw === 'number') {
return raw > 1e12 ? Math.floor(raw / 1000) : raw;
}
const n = Number(raw);
if (Number.isFinite(n) && n > 0) {
return n > 1e12 ? Math.floor(n / 1000) : n;
}
const parsed = Date.parse(String(raw).replace(/-/g, '/'));
return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : 0;
}
const expireTs = computed(() => resolveExpireTs(props.autoExpireTime));
const remainSec = computed(() => {
if (!expireTs.value) return null;
return expireTs.value - nowTs.value;
});
const isUrgent = computed(() => {
const r = remainSec.value;
return r != null && r > 0 && r <= 2 * 3600;
});
const showCountdown = computed(() => {
const r = remainSec.value;
return r != null && r > 0 && r <= 10 * 60;
});
const displayText = computed(() => {
if (!expireTs.value) return '—';
const r = remainSec.value ?? 0;
if (r <= 0) return '已过期';
const d = new Date(expireTs.value * 1000);
const pad = (n: number) => String(n).padStart(2, '0');
const base = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
if (showCountdown.value) {
const m = Math.floor(r / 60);
const s = r % 60;
return `${base}(剩余 ${m}:${pad(s)}`;
}
return base;
});
function startTimer() {
stopTimer();
timer = setInterval(() => {
nowTs.value = Math.floor(Date.now() / 1000);
}, 1000);
}
function stopTimer() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
watch(
() => props.autoExpireTime,
() => {
nowTs.value = Math.floor(Date.now() / 1000);
},
);
onMounted(startTimer);
onBeforeUnmount(stopTimer);
</script>
<template>
<span
class="expire-time"
:class="{
'expire-time--urgent': isUrgent || (remainSec != null && remainSec <= 0),
'expire-time--countdown': showCountdown,
}"
>
{{ displayText }}
</span>
</template>
<style scoped>
.expire-time {
font-size: 12px;
color: inherit;
}
.expire-time--urgent {
color: #cf1322;
font-weight: 600;
}
.expire-time--countdown {
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -0,0 +1,25 @@
<script lang="ts" setup>
/**
* 就诊类型标签:线下就诊 / 在线问诊 / 在线复诊
* 对应处方、订单字段 is_online
*/
import { computed } from 'vue';
import { Tag } from 'ant-design-vue';
const props = defineProps<{
/** 就诊渠道0线下 1在线问诊 2/3在线复诊 */
isOnline?: number | null;
}>();
const label = computed(() => {
const v = Number(props.isOnline ?? 0);
if (v === 1) return { text: '在线问诊', color: 'blue' };
if (v === 2 || v === 3) return { text: '在线复诊', color: 'green' };
return { text: '线下就诊', color: 'purple' };
});
</script>
<template>
<Tag :color="label.color">{{ label.text }}</Tag>
</template>

View File

@@ -0,0 +1,103 @@
<script lang="ts" setup>
/**
* 列表/卡片内紧凑展示:小程序用户 + 就诊人
* 点击后由父级打开 WxUserPatientDrawer
*/
import { computed } from 'vue';
import { Avatar, Button } from 'ant-design-vue';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
const props = defineProps<{
/** 审方/订单行数据,需含 user、user_patient或 patient */
row: Record<string, any>;
}>();
const emit = defineEmits<{
open: [payload: { upId: number; patientName: string }];
}>();
const defaultAvatar = '/img/user-default-avatar.png';
const user = computed(() => props.row?.user ?? {});
const patient = computed(
() => props.row?.user_patient ?? props.row?.userPatient ?? {},
);
const upId = computed(() =>
Number(patient.value?.id ?? props.row?.up_id ?? 0),
);
const patientName = computed(
() => String(patient.value?.name ?? props.row?.patient ?? '') || '—',
);
function userAvatarSrc() {
const raw = String(user.value?.avatarurl ?? '').trim();
if (!raw) return defaultAvatar;
return resolveAvatarUrl(raw) || defaultAvatar;
}
/** 打开就诊人档案抽屉 */
function handleOpen() {
if (!upId.value) return;
emit('open', { upId: upId.value, patientName: patientName.value });
}
</script>
<template>
<div class="wx-user-patient-cell">
<div class="wx-user-patient-cell__row">
<Avatar :size="24" :src="userAvatarSrc()" class="shrink-0" />
<div class="wx-user-patient-cell__text min-w-0">
<span class="text-[11px] text-gray-400">微信用户</span>
<span class="truncate text-xs">{{ user?.nickname || '—' }}</span>
<div class="text-[11px] text-gray-500">ID{{ user?.id ?? '—' }}</div>
</div>
</div>
<div class="wx-user-patient-cell__row">
<Avatar :size="24" :src="defaultAvatar" class="shrink-0" />
<div class="wx-user-patient-cell__text min-w-0">
<span class="text-[11px] text-gray-400">就诊人</span>
<Button
v-if="upId"
class="!h-auto !px-0 !py-0"
type="link"
@click.stop="handleOpen"
>
{{ patientName }}
</Button>
<span v-else class="text-xs">{{ patientName }}</span>
<div
v-if="patient?.sex || patient?.age"
class="text-[11px] text-gray-500"
>
<template v-if="patient?.sex === 1"></template>
<template v-else-if="patient?.sex === 2"></template>
<template v-if="patient?.age"> · {{ patient.age }}</template>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.wx-user-patient-cell {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.wx-user-patient-cell__row {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.wx-user-patient-cell__text {
display: flex;
flex-direction: column;
line-height: 1.3;
}
</style>

View File

@@ -0,0 +1,451 @@
<script lang="ts" setup>
/**
* 就诊人详情 Modal患者视角
* 与 Drawer 内容一致:资料 + 挂号/处方/订单;会员管理、订单「就诊人」入口使用
*/
import { h, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Avatar,
Button,
Descriptions,
Empty,
Spin,
Table,
Tabs,
Tag,
message,
} from 'ant-design-vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import OrderDetail from '#/views/business/order/product-order/components/detail.vue';
import {
getUserPatientOrderListApi,
getUserPatientPrescriptionListApi,
getUserPatientProfileDetailApi,
getUserPatientRegisterListApi,
} from './api';
import VisitTypeTag from './VisitTypeTag.vue';
defineOptions({ name: 'WxUserPatientDetailModal' });
const defaultAvatar = '/img/user-default-avatar.png';
const upId = ref(0);
const loading = ref(false);
const activeTab = ref('info');
const profile = ref<Record<string, any> | null>(null);
const registerList = ref<any[]>([]);
const prescriptionList = ref<any[]>([]);
const orderList = ref<any[]>([]);
const registerLoading = ref(false);
const prescriptionLoading = ref(false);
const orderLoading = ref(false);
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
connectedComponent: PrescriptionDetail,
});
const [OrderDetailModal, OrderDetailModalApi] = useVbenModal({
connectedComponent: OrderDetail,
});
const [Modal, modalApi] = useVbenModal({
footer: false,
class: 'w-[760px]',
onOpenChange(isOpen: boolean) {
if (!isOpen) {
profile.value = null;
registerList.value = [];
prescriptionList.value = [];
orderList.value = [];
return;
}
const data = modalApi.getData<{ upId?: number; patientName?: string }>();
upId.value = Number(data?.upId || 0);
activeTab.value = 'info';
const name = String(data?.patientName || '').trim();
modalApi.setState({ title: name ? `就诊人:${name}` : '就诊人详情' });
if (upId.value > 0) {
void loadDetail();
}
},
});
/** 解析头像 URL */
function avatarSrc(raw?: string) {
const s = String(raw ?? '').trim();
if (!s) return defaultAvatar;
return resolveAvatarUrl(s) || defaultAvatar;
}
/** 既往/过敏/家族0 无,否则展示 history */
function historyText(status?: number, history?: string) {
if (Number(status) === 0) return '无';
const t = String(history || '').trim();
return t || '有';
}
/** 肝/肾功能0 正常,异常时附带指标文案 */
function functionText(flag?: number, indexText?: string) {
if (Number(flag) === 0) return '正常';
const t = String(indexText || '').trim();
return t ? `异常 · ${t}` : '异常';
}
async function loadDetail() {
loading.value = true;
try {
profile.value = await getUserPatientProfileDetailApi(upId.value);
} catch (e) {
console.error(e);
message.error('获取用户信息失败');
} finally {
loading.value = false;
}
}
async function loadRegisterList() {
registerLoading.value = true;
try {
const res = await getUserPatientRegisterListApi(upId.value);
registerList.value = res?.items ?? [];
} catch (e) {
console.error(e);
message.error('获取挂号记录失败');
} finally {
registerLoading.value = false;
}
}
async function loadPrescriptionList() {
prescriptionLoading.value = true;
try {
const res = await getUserPatientPrescriptionListApi(upId.value);
prescriptionList.value = res?.items ?? [];
} catch (e) {
console.error(e);
message.error('获取处方记录失败');
} finally {
prescriptionLoading.value = false;
}
}
async function loadOrderList() {
orderLoading.value = true;
try {
const res = await getUserPatientOrderListApi(upId.value);
orderList.value = res?.items ?? [];
} catch (e) {
console.error(e);
message.error('获取订单记录失败');
} finally {
orderLoading.value = false;
}
}
/** Tab 切换时懒加载对应列表 */
function onTabChange(key: string | number) {
const k = String(key);
activeTab.value = k;
if (k === 'register' && registerList.value.length === 0) {
void loadRegisterList();
} else if (k === 'prescription' && prescriptionList.value.length === 0) {
void loadPrescriptionList();
} else if (k === 'order' && orderList.value.length === 0) {
void loadOrderList();
}
}
function openPrescription(id: number) {
PrescriptionDetailModalApi.setData({ values: id });
PrescriptionDetailModalApi.open();
}
function openOrder(id: number) {
OrderDetailModalApi.setData({ id });
OrderDetailModalApi.open();
}
const registerColumns = [
{ title: '订单编号', dataIndex: 'order_no', key: 'order_no' },
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
{ title: '诊所', dataIndex: 'store', key: 'store' },
{
title: '费用',
dataIndex: 'price',
key: 'price',
customRender: ({ text }: { text: number }) =>
`¥${Number(text || 0).toFixed(2)}`,
},
{
title: '支付',
dataIndex: 'is_pay',
key: 'is_pay',
customRender: ({ text }: { text: number }) =>
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
text === 1 ? '已支付' : '未支付',
),
},
{ title: '状态', dataIndex: 'status_text', key: 'status_text' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
];
const prescriptionColumns = [
{
title: '处方编号',
dataIndex: 'prescription_no',
key: 'prescription_no',
customRender: ({ text, record }: { text: string; record: any }) =>
h(
Button,
{ type: 'link', onClick: () => openPrescription(record.id) },
() => text,
),
},
{
title: '就诊类型',
dataIndex: 'is_online',
key: 'is_online',
customRender: ({ text }: { text: number }) =>
h(VisitTypeTag, { isOnline: text }),
},
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
{ title: '诊所', dataIndex: 'store', key: 'store' },
{ title: '诊断', dataIndex: 'clinical_diagnose', key: 'clinical_diagnose' },
{
title: '状态',
dataIndex: 'status',
key: 'status',
customRender: ({ text }: { text: number }) => {
const map: Record<number, string> = {
0: '待审核',
1: '已通过',
2: '未通过',
3: '无需审核',
};
return map[text] || '未知';
},
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
];
const orderColumns = [
{
title: '订单号',
dataIndex: 'order_no',
key: 'order_no',
customRender: ({ text, record }: { text: string; record: any }) =>
h(Button, { type: 'link', onClick: () => openOrder(record.id) }, () => text),
},
{
title: '就诊类型',
dataIndex: 'is_online',
key: 'is_online',
customRender: ({ text }: { text: number }) =>
h(VisitTypeTag, { isOnline: text }),
},
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
{ title: '诊所', dataIndex: 'store', key: 'store' },
{
title: '实付',
dataIndex: 'total_pay_price',
key: 'total_pay_price',
customRender: ({ text }: { text: number }) =>
`¥${Number(text || 0).toFixed(2)}`,
},
{
title: '支付',
dataIndex: 'is_pay',
key: 'is_pay',
customRender: ({ text }: { text: number }) =>
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
text === 1 ? '已支付' : '未支付',
),
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
];
</script>
<template>
<Modal>
<PrescriptionDetailModal />
<OrderDetailModal />
<Spin :spinning="loading">
<template v-if="profile">
<div class="mb-4 flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-slate-800">
<Avatar :size="48" :src="avatarSrc(profile.user?.avatarurl)" />
<div class="min-w-0">
<div class="text-sm font-medium">
{{ profile.user?.nickname || '—' }}
</div>
<div class="text-xs text-gray-500">
用户 ID{{ profile.user?.id ?? '—' }}
</div>
</div>
</div>
<Tabs :active-key="activeTab" @change="onTabChange">
<Tabs.TabPane key="info" tab="就诊人信息">
<Descriptions :column="2" bordered size="small">
<Descriptions.Item label="姓名">
{{ profile.patient?.name || '—' }}
</Descriptions.Item>
<Descriptions.Item label="性别">
{{
profile.patient?.sex === 1
? '男'
: profile.patient?.sex === 2
? '女'
: '—'
}}
</Descriptions.Item>
<Descriptions.Item label="年龄">
{{ profile.patient?.age ? `${profile.patient.age}` : '—' }}
</Descriptions.Item>
<Descriptions.Item label="手机号">
<SensitiveText
v-if="profile.patient"
:record="profile.patient"
field="mobile"
/>
<span v-else></span>
</Descriptions.Item>
<Descriptions.Item
v-if="profile.patient?.id_card"
label="身份证"
:span="2"
>
<SensitiveText
:record="profile.patient"
field="id_card"
/>
</Descriptions.Item>
</Descriptions>
<!-- 健康问诊 / 功能异常与小程序资料 Tab 一致 -->
<div class="mt-4">
<div class="mb-2 text-sm font-medium text-gray-700">健康信息</div>
<Descriptions
v-if="profile.health_inquiry"
:column="2"
bordered
size="small"
>
<Descriptions.Item label="既往史">
{{
historyText(
profile.health_inquiry.person_status,
profile.health_inquiry.person_history,
)
}}
</Descriptions.Item>
<Descriptions.Item label="过敏史">
<span
:class="
Number(profile.health_inquiry.allergic_status) !== 0
? 'text-red-600 font-medium'
: ''
"
>
{{
historyText(
profile.health_inquiry.allergic_status,
profile.health_inquiry.allergic_history,
)
}}
</span>
</Descriptions.Item>
<Descriptions.Item label="家族遗传史">
{{
historyText(
profile.health_inquiry.family_status,
profile.health_inquiry.family_history,
)
}}
</Descriptions.Item>
<Descriptions.Item label="肝功能异常">
<span
:class="
Number(profile.health_inquiry.liver_function) !== 0
? 'text-red-600 font-medium'
: ''
"
>
{{
functionText(
profile.health_inquiry.liver_function,
profile.health_inquiry.liver_index,
)
}}
</span>
</Descriptions.Item>
<Descriptions.Item label="肾功能异常" :span="2">
<span
:class="
Number(profile.health_inquiry.renal_function) !== 0
? 'text-red-600 font-medium'
: ''
"
>
{{
functionText(
profile.health_inquiry.renal_function,
profile.health_inquiry.renal_index,
)
}}
</span>
</Descriptions.Item>
</Descriptions>
<Empty v-else description="暂无健康问诊记录" />
</div>
</Tabs.TabPane>
<Tabs.TabPane key="register" tab="挂号记录">
<Spin :spinning="registerLoading">
<Table
v-if="registerList.length"
:columns="registerColumns"
:data-source="registerList"
:pagination="false"
row-key="id"
size="small"
/>
<Empty v-else description="暂无挂号记录" />
</Spin>
</Tabs.TabPane>
<Tabs.TabPane key="prescription" tab="处方记录">
<Spin :spinning="prescriptionLoading">
<Table
v-if="prescriptionList.length"
:columns="prescriptionColumns"
:data-source="prescriptionList"
:pagination="false"
row-key="id"
size="small"
/>
<Empty v-else description="暂无处方记录" />
</Spin>
</Tabs.TabPane>
<Tabs.TabPane key="order" tab="订单记录">
<Spin :spinning="orderLoading">
<Table
v-if="orderList.length"
:columns="orderColumns"
:data-source="orderList"
:pagination="false"
row-key="id"
size="small"
/>
<Empty v-else description="暂无订单记录" />
</Spin>
</Tabs.TabPane>
</Tabs>
</template>
<Empty v-else-if="!loading" description="暂无数据" />
</Spin>
</Modal>
</template>

View File

@@ -0,0 +1,359 @@
<script lang="ts" setup>
/**
* 微信用户 + 就诊人档案抽屉
* 展示小程序用户基础信息、就诊人信息及挂号/处方/订单记录(跨医生)
*/
import { h, ref } from 'vue';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import {
Avatar,
Button,
Descriptions,
Empty,
Spin,
Table,
Tabs,
Tag,
message,
} from 'ant-design-vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import OrderDetail from '#/views/business/order/product-order/components/detail.vue';
import {
getUserPatientOrderListApi,
getUserPatientPrescriptionListApi,
getUserPatientProfileDetailApi,
getUserPatientRegisterListApi,
} from './api';
import VisitTypeTag from './VisitTypeTag.vue';
defineOptions({ name: 'WxUserPatientDrawer' });
const defaultAvatar = '/img/user-default-avatar.png';
const upId = ref(0);
const loading = ref(false);
const activeTab = ref('info');
const profile = ref<Record<string, any> | null>(null);
const registerList = ref<any[]>([]);
const prescriptionList = ref<any[]>([]);
const orderList = ref<any[]>([]);
const registerLoading = ref(false);
const prescriptionLoading = ref(false);
const orderLoading = ref(false);
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
connectedComponent: PrescriptionDetail,
});
const [OrderDetailModal, OrderDetailModalApi] = useVbenModal({
connectedComponent: OrderDetail,
});
const [Drawer, drawerApi] = useVbenDrawer({
footer: false,
onOpenChange(isOpen: boolean) {
if (!isOpen) {
profile.value = null;
registerList.value = [];
prescriptionList.value = [];
orderList.value = [];
return;
}
const data = drawerApi.getData<{ upId?: number; patientName?: string }>();
upId.value = Number(data?.upId || 0);
activeTab.value = 'info';
if (upId.value > 0) {
void loadDetail();
}
},
});
/** 解析头像 URL */
function avatarSrc(raw?: string) {
const s = String(raw ?? '').trim();
if (!s) return defaultAvatar;
return resolveAvatarUrl(s) || defaultAvatar;
}
async function loadDetail() {
loading.value = true;
try {
profile.value = await getUserPatientProfileDetailApi(upId.value);
} catch (e) {
console.error(e);
message.error('获取用户信息失败');
} finally {
loading.value = false;
}
}
async function loadRegisterList() {
registerLoading.value = true;
try {
const res = await getUserPatientRegisterListApi(upId.value);
registerList.value = res?.items ?? [];
} catch (e) {
console.error(e);
message.error('获取挂号记录失败');
} finally {
registerLoading.value = false;
}
}
async function loadPrescriptionList() {
prescriptionLoading.value = true;
try {
const res = await getUserPatientPrescriptionListApi(upId.value);
prescriptionList.value = res?.items ?? [];
} catch (e) {
console.error(e);
message.error('获取处方记录失败');
} finally {
prescriptionLoading.value = false;
}
}
async function loadOrderList() {
orderLoading.value = true;
try {
const res = await getUserPatientOrderListApi(upId.value);
orderList.value = res?.items ?? [];
} catch (e) {
console.error(e);
message.error('获取订单记录失败');
} finally {
orderLoading.value = false;
}
}
/** Tab 切换时懒加载对应列表 */
function onTabChange(key: string | number) {
const k = String(key);
activeTab.value = k;
if (k === 'register' && registerList.value.length === 0) {
void loadRegisterList();
} else if (k === 'prescription' && prescriptionList.value.length === 0) {
void loadPrescriptionList();
} else if (k === 'order' && orderList.value.length === 0) {
void loadOrderList();
}
}
function openPrescription(id: number) {
PrescriptionDetailModalApi.setData({ values: id });
PrescriptionDetailModalApi.open();
}
function openOrder(id: number) {
OrderDetailModalApi.setData({ id });
OrderDetailModalApi.open();
}
const registerColumns = [
{ title: '订单编号', dataIndex: 'order_no', key: 'order_no' },
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
{ title: '诊所', dataIndex: 'store', key: 'store' },
{
title: '费用',
dataIndex: 'price',
key: 'price',
customRender: ({ text }: { text: number }) =>
`¥${Number(text || 0).toFixed(2)}`,
},
{
title: '支付',
dataIndex: 'is_pay',
key: 'is_pay',
customRender: ({ text }: { text: number }) =>
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
text === 1 ? '已支付' : '未支付',
),
},
{ title: '状态', dataIndex: 'status_text', key: 'status_text' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
];
const prescriptionColumns = [
{
title: '处方编号',
dataIndex: 'prescription_no',
key: 'prescription_no',
customRender: ({ text, record }: { text: string; record: any }) =>
h(
Button,
{ type: 'link', onClick: () => openPrescription(record.id) },
() => text,
),
},
{
title: '就诊类型',
dataIndex: 'is_online',
key: 'is_online',
customRender: ({ text }: { text: number }) =>
h(VisitTypeTag, { isOnline: text }),
},
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
{ title: '诊所', dataIndex: 'store', key: 'store' },
{ title: '诊断', dataIndex: 'clinical_diagnose', key: 'clinical_diagnose' },
{
title: '状态',
dataIndex: 'status',
key: 'status',
customRender: ({ text }: { text: number }) => {
const map: Record<number, string> = {
0: '待审核',
1: '已通过',
2: '未通过',
3: '无需审核',
};
return map[text] || '未知';
},
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
];
const orderColumns = [
{
title: '订单号',
dataIndex: 'order_no',
key: 'order_no',
customRender: ({ text, record }: { text: string; record: any }) =>
h(Button, { type: 'link', onClick: () => openOrder(record.id) }, () => text),
},
{
title: '就诊类型',
dataIndex: 'is_online',
key: 'is_online',
customRender: ({ text }: { text: number }) =>
h(VisitTypeTag, { isOnline: text }),
},
{ title: '医生', dataIndex: 'doctor', key: 'doctor' },
{ title: '诊所', dataIndex: 'store', key: 'store' },
{
title: '实付',
dataIndex: 'total_pay_price',
key: 'total_pay_price',
customRender: ({ text }: { text: number }) =>
`¥${Number(text || 0).toFixed(2)}`,
},
{
title: '支付',
dataIndex: 'is_pay',
key: 'is_pay',
customRender: ({ text }: { text: number }) =>
h(Tag, { color: text === 1 ? 'green' : 'red' }, () =>
text === 1 ? '已支付' : '未支付',
),
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
];
</script>
<template>
<Drawer class="w-[720px]" title="微信用户 / 就诊人">
<PrescriptionDetailModal />
<OrderDetailModal />
<Spin :spinning="loading">
<template v-if="profile">
<!-- 小程序用户 -->
<div class="mb-4 flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-slate-800">
<Avatar :size="48" :src="avatarSrc(profile.user?.avatarurl)" />
<div class="min-w-0">
<div class="text-sm font-medium">
{{ profile.user?.nickname || '—' }}
</div>
<div class="text-xs text-gray-500">
用户 ID{{ profile.user?.id ?? '—' }}
</div>
</div>
</div>
<Tabs :active-key="activeTab" @change="onTabChange">
<Tabs.TabPane key="info" tab="就诊人信息">
<Descriptions :column="2" bordered size="small">
<Descriptions.Item label="姓名">
{{ profile.patient?.name || '—' }}
</Descriptions.Item>
<Descriptions.Item label="性别">
{{
profile.patient?.sex === 1
? '男'
: profile.patient?.sex === 2
? '女'
: '—'
}}
</Descriptions.Item>
<Descriptions.Item label="年龄">
{{ profile.patient?.age ? `${profile.patient.age}` : '—' }}
</Descriptions.Item>
<Descriptions.Item label="手机号">
<SensitiveText
v-if="profile.patient"
:record="profile.patient"
field="mobile"
/>
<span v-else></span>
</Descriptions.Item>
<Descriptions.Item
v-if="profile.patient?.id_card"
label="身份证"
:span="2"
>
<SensitiveText
:record="profile.patient"
field="id_card"
/>
</Descriptions.Item>
</Descriptions>
</Tabs.TabPane>
<Tabs.TabPane key="register" tab="挂号记录">
<Spin :spinning="registerLoading">
<Table
v-if="registerList.length"
:columns="registerColumns"
:data-source="registerList"
:pagination="false"
row-key="id"
size="small"
/>
<Empty v-else description="暂无挂号记录" />
</Spin>
</Tabs.TabPane>
<Tabs.TabPane key="prescription" tab="处方记录">
<Spin :spinning="prescriptionLoading">
<Table
v-if="prescriptionList.length"
:columns="prescriptionColumns"
:data-source="prescriptionList"
:pagination="false"
row-key="id"
size="small"
/>
<Empty v-else description="暂无处方记录" />
</Spin>
</Tabs.TabPane>
<Tabs.TabPane key="order" tab="订单记录">
<Spin :spinning="orderLoading">
<Table
v-if="orderList.length"
:columns="orderColumns"
:data-source="orderList"
:pagination="false"
row-key="id"
size="small"
/>
<Empty v-else description="暂无订单记录" />
</Spin>
</Tabs.TabPane>
</Tabs>
</template>
<Empty v-else-if="!loading" description="暂无数据" />
</Spin>
</Drawer>
</template>

View File

@@ -0,0 +1,147 @@
<script lang="ts" setup>
/**
* 某微信用户下的就诊人列表 Modal
* 点行再打开就诊人详情 Modal由父级或本组件内嵌 DetailModal 处理)
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Avatar, Button, Empty, Spin, Table, message } from 'ant-design-vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
import { getPatientListByUserApi } from './api';
import WxUserPatientDetailModal from './WxUserPatientDetailModal.vue';
defineOptions({ name: 'WxUserPatientsModal' });
const defaultAvatar = '/img/user-default-avatar.png';
const userId = ref(0);
const userNickname = ref('');
const userAvatar = ref('');
const loading = ref(false);
const list = ref<any[]>([]);
const [DetailModal, DetailModalApi] = useVbenModal({
connectedComponent: WxUserPatientDetailModal,
});
const [Modal, modalApi] = useVbenModal({
footer: false,
class: 'w-[640px]',
onOpenChange(isOpen: boolean) {
if (!isOpen) {
list.value = [];
return;
}
const data = modalApi.getData<{
userId?: number;
nickname?: string;
avatarurl?: string;
}>();
userId.value = Number(data?.userId || 0);
userNickname.value = String(data?.nickname || '');
userAvatar.value = String(data?.avatarurl || '');
modalApi.setState({
title: userNickname.value
? `就诊人列表 · ${userNickname.value}`
: '就诊人列表',
});
if (userId.value > 0) {
void loadList();
}
},
});
function avatarSrc(raw?: string) {
const s = String(raw ?? '').trim();
if (!s) return defaultAvatar;
return resolveAvatarUrl(s) || defaultAvatar;
}
async function loadList() {
loading.value = true;
try {
const res = await getPatientListByUserApi(userId.value);
list.value = res?.items ?? [];
} catch (e) {
console.error(e);
message.error('加载就诊人失败');
} finally {
loading.value = false;
}
}
/** 打开就诊人详情 Modal与微信用户列表分离 */
function openPatientDetail(row: Record<string, any>) {
const upId = Number(row.up_id || 0);
if (!upId) {
message.warning('缺少就诊人信息');
return;
}
DetailModalApi.setData({
upId,
patientName: row.name || '',
});
DetailModalApi.open();
}
const columns = [
{ title: '姓名', key: 'name' },
{ title: '性别', key: 'sex', width: 80 },
{ title: '手机', key: 'mobile' },
{ title: '操作', key: 'action', width: 100 },
];
</script>
<template>
<Modal>
<DetailModal />
<div class="mb-3 flex items-center gap-2">
<Avatar :size="36" :src="avatarSrc(userAvatar)" />
<div class="min-w-0 text-sm">
<div class="font-medium">{{ userNickname || '—' }}</div>
<div class="text-xs text-gray-500">用户 ID{{ userId || '—' }}</div>
</div>
</div>
<Spin :spinning="loading">
<Table
v-if="list.length"
:columns="columns"
:data-source="list"
:pagination="false"
row-key="up_id"
size="small"
:custom-row="
(record) => ({
onClick: () => openPatientDetail(record),
style: { cursor: 'pointer' },
})
"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
{{ record.name || '—' }}
</template>
<template v-else-if="column.key === 'sex'">
<template v-if="record.sex === 1">男</template>
<template v-else-if="record.sex === 2">女</template>
<template v-else>—</template>
</template>
<template v-else-if="column.key === 'mobile'">
<SensitiveText :record="record" field="mobile" />
</template>
<template v-else-if="column.key === 'action'">
<Button type="link" @click.stop="openPatientDetail(record)">
详情
</Button>
</template>
</template>
</Table>
<Empty v-else-if="!loading" description="暂无就诊人" />
</Spin>
</Modal>
</template>

View File

@@ -0,0 +1,68 @@
/**
* 就诊人档案 API跨医生视角供药师审方等场景
*/
import { requestClient } from '#/api/request';
const prefix = 'user-patient-profile/';
/** 用户管理列表(微信用户 + 就诊人,旧接口) */
export async function getUserPatientProfileListApi(params?: {
page?: number;
pageSize?: number;
keyword?: string;
}) {
return requestClient.get<any>(`${prefix}list`, { params });
}
/** 会员管理:微信用户分页 */
export async function getWxUserListApi(params?: {
page?: number;
pageSize?: number;
keyword?: string;
}) {
return requestClient.get<any>(`${prefix}user-list`, { params });
}
/** 某微信用户下的就诊人列表 */
export async function getPatientListByUserApi(userId: number) {
return requestClient.get<any>(`${prefix}patient-list-by-user`, {
params: { user_id: userId },
});
}
/** 小程序用户 + 就诊人基础信息 */
export async function getUserPatientProfileDetailApi(upId: number) {
return requestClient.get<any>(`${prefix}detail`, {
params: { up_id: upId },
});
}
/** 就诊人挂号记录 */
export async function getUserPatientRegisterListApi(
upId: number,
params?: { page?: number; pageSize?: number },
) {
return requestClient.get<any>(`${prefix}register-list`, {
params: { up_id: upId, ...params },
});
}
/** 就诊人处方记录 */
export async function getUserPatientPrescriptionListApi(
upId: number,
params?: { page?: number; pageSize?: number },
) {
return requestClient.get<any>(`${prefix}prescription-list`, {
params: { up_id: upId, ...params },
});
}
/** 就诊人商品订单记录 */
export async function getUserPatientOrderListApi(
upId: number,
params?: { page?: number; pageSize?: number },
) {
return requestClient.get<any>(`${prefix}order-list`, {
params: { up_id: upId, ...params },
});
}

View File

@@ -0,0 +1,10 @@
/**
* 微信用户/就诊人相关组件导出
*/
export { default as WxUserPatientCell } from './WxUserPatientCell.vue';
export { default as WxUserPatientDrawer } from './WxUserPatientDrawer.vue';
export { default as WxUserPatientDetailModal } from './WxUserPatientDetailModal.vue';
export { default as WxUserPatientsModal } from './WxUserPatientsModal.vue';
export { default as VisitTypeTag } from './VisitTypeTag.vue';
export { default as PrescriptionExpireTime } from './PrescriptionExpireTime.vue';
export * from './api';

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,15 +242,22 @@ watch(
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<!-- index=55紧挨全局搜索(50)右侧 -->
<template #header-right-55>
<DoctorTransferFloat v-if="showDoctorTransfer" />
</template>
<template #user-dropdown>
<UserDropdown
:avatar
:description="userStore.userInfo?.email"
:menus
:tag-text="userStore.userInfo?.roles?.name"
:text="userStore.userInfo?.nick_name"
@logout="handleLogout"
/>
<div class="flex items-center">
<HeaderVipBadge />
<UserDropdown
:avatar
:description="userStore.userInfo?.email"
:menus
:tag-text="userStore.userInfo?.roles?.name"
:text="userStore.userInfo?.nick_name"
@logout="handleLogout"
/>
</div>
</template>
<template #notification>
<Notification

View File

@@ -11,6 +11,7 @@ import {
addWestPrescription,
checkChineseMedicineConflictApi,
getCurrentStoreTypeApi,
getPrescriptionTypeOptionsApi,
getDrugUseList,
getMyStoreListApi,
getPatientItem,
@@ -54,6 +55,21 @@ export const usePrescriptionStore = defineStore('prescription', () => {
// 处方状态
const myStoreId = ref(0);
const activeCategory = ref(2);
/** 后端下发的处方类型列表(含可选 icon / icon_text */
const categories = ref([
{ label: '中药', value: 1, icon: '', icon_text: '' },
{ label: '西(中成)药', value: 2, icon: '', icon_text: '' },
{ label: '保健食品', value: 3, icon: '', icon_text: '' },
{ label: '产品服务包', value: 5, icon: '', icon_text: '' },
{ label: '非药品', value: 6, icon: '', icon_text: '' },
{ label: '医疗器械', value: 7, icon: '', icon_text: '' },
]);
const prescriptionTypeDefault = ref(2);
/** 开方种类权限校验中(进入开方页先 loading */
const prescriptionTypeLoading = ref(false);
/** 当前门店是否有可开方种类 */
const prescriptionTypeAllowed = ref(true);
const prescriptionTypeDenyMessage = ref('');
const diagnosis = ref('');
const medicalAdvice = ref('');
const treatmentPrice = ref(0);
@@ -192,6 +208,49 @@ export const usePrescriptionStore = defineStore('prescription', () => {
return calcItemMarginPercent(drug.price, drug.buy_price);
};
/**
* 拉取后端处方类型列表与默认选中;无绑定时清空 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);
if (rid) params.register_id = rid;
if (myStoreId.value) params.store_id = myStoreId.value;
const res = await getPrescriptionTypeOptionsApi(params);
const list = Array.isArray(res?.list) ? res.list : [];
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;
}
return allowed;
} catch (error) {
console.error('加载处方类型失败:', error);
categories.value = [];
prescriptionTypeAllowed.value = false;
prescriptionTypeDenyMessage.value = '处方类型加载失败,请稍后重试';
message.warning('处方类型加载失败,请稍后重试');
return false;
} finally {
prescriptionTypeLoading.value = false;
}
};
// localStorage同步方法
const syncToLocalStorage = () => {
try {
@@ -260,6 +319,12 @@ export const usePrescriptionStore = defineStore('prescription', () => {
// 设置当前注册ID
currentRegisterId.value = registerId;
// 先拉类型选项,再决定默认 Tab无本地记忆时用后端 default
if (!isInitialized.value) {
await initializeBasicData();
}
await loadPrescriptionTypeOptions(registerId);
// 恢复之前保存的 activeCategory
const savedCategory = localStorage.getItem(
`${storagePrefix.value}activeCategory${registerId}`
@@ -278,17 +343,14 @@ export const usePrescriptionStore = defineStore('prescription', () => {
activeCategory.value = 1;
} else if (westData && JSON.parse(westData).length > 0) {
activeCategory.value = 2;
} else if (prescriptionTypeDefault.value) {
activeCategory.value = prescriptionTypeDefault.value;
}
}
// 加载localStorage数据
loadFromLocalStorage();
// 如果基础数据未初始化,先初始化基础数据
if (!isInitialized.value) {
await initializeBasicData();
}
await fetchStoreSeeRate();
// 获取患者信息(只在需要时调用,如 PrescriptionModal
@@ -541,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,
);
@@ -561,7 +640,9 @@ export const usePrescriptionStore = defineStore('prescription', () => {
use_frequency: drugUseFrequency.value.find(
(item) => item.id === data.drug.frequency_id,
),
unit: drugUnit.value.find((item) => item.id === data.drug.unit_id),
unit:
data.drug?.unit ||
drugUnit.value.find((item) => item.id === data.drug.unit_id),
price: data.price,
buy_price: data.buy_price,
way_id: data.drug?.way_id,
@@ -571,6 +652,8 @@ export const usePrescriptionStore = defineStore('prescription', () => {
frequency_id: data.drug.frequency_id,
unit_id: data.drug.unit_id,
image: data.drug.image,
// 规格(选仓弹窗等处展示)
specification: data.drug?.specification || data.specification || '',
instruction: data.drug.instruction,
type: data.drug.type,
select_number: (() => {
@@ -656,6 +739,11 @@ export const usePrescriptionStore = defineStore('prescription', () => {
if (data) {
newDrugInfo.value.price = data.price;
newDrugInfo.value.name = data.drug.drug_name;
// 选药后立刻带上真实单位,供新行数量后缀展示
newDrugInfo.value.unit =
data.drug?.unit ||
drugUnit.value.find((item) => item.id === data.drug?.unit_id);
newDrugInfo.value.unit_id = data.drug?.unit_id;
nextTick(() => {
const input = document.querySelector(
'.new-number-input input',
@@ -691,7 +779,9 @@ export const usePrescriptionStore = defineStore('prescription', () => {
use_frequency: drugUseFrequency.value.find(
(item) => item.id === data.drug.frequency_id,
),
unit: drugUnit.value.find((item) => item.id === data.drug.unit_id),
unit:
data.drug?.unit ||
drugUnit.value.find((item) => item.id === data.drug.unit_id),
price: data.price,
buy_price: data.buy_price,
way_id: data.drug?.way_id,
@@ -701,6 +791,8 @@ export const usePrescriptionStore = defineStore('prescription', () => {
frequency_id: data.drug.frequency_id,
unit_id: data.drug.unit_id,
image: data.drug.image,
// 规格(选仓弹窗等处展示)
specification: data.drug?.specification || data.specification || '',
instruction: data.drug.instruction,
type: data.drug.type,
};
@@ -912,6 +1004,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
doctorSecondSignValue = 0,
customSendMode: number = 0,
customStoreId: number | null = null,
warehouseMap: Record<number, number> | null = null,
) => {
if (currentDrugs.value.length === 0) {
message.error('请选择药品');
@@ -986,6 +1079,13 @@ export const usePrescriptionStore = defineStore('prescription', () => {
// 诊所选择参数转诊挂号时自动使用承接方诊所ID
send_mode: finalSendMode,
custom_store_id: finalSendMode === 1 ? finalStoreId : null,
// 在线复诊手选仓:列表格式避免数字键对象序列化丢失
warehouse_map: warehouseMap
? Object.entries(warehouseMap).map(([drug_id, warehouse_id]) => ({
drug_id: Number(drug_id),
warehouse_id: Number(warehouse_id),
}))
: undefined,
});
const res = await response;
@@ -1015,8 +1115,42 @@ 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();
// 返回包含转诊信息的响应数据
return {
success: true,
@@ -1031,6 +1165,33 @@ export const usePrescriptionStore = defineStore('prescription', () => {
}
};
/**
* 清除当前挂号会话的处方 localStorage 草稿
* 原因resetForm 只会把当前分类写成空数组,另一分类与 activeCategory 会残留,再次打开仍会回填
*/
const clearLocalPrescriptionCache = () => {
const registerId = currentRegisterId.value;
if (!registerId) return;
try {
// 与小程序 PrescriptionStorage.clearAllPrescriptionData 对齐的分类集合
const categories = [1, 2, 3, 5, 6, 7];
categories.forEach((cat) => {
localStorage.removeItem(
`${storagePrefix.value}prescriptionData_${cat}_${registerId}`,
);
// 清理历史双横线孤儿 key旧版弹窗 `${prefix}-prescriptionData_`
localStorage.removeItem(
`${storagePrefix.value}-prescriptionData_${cat}_${registerId}`,
);
});
localStorage.removeItem(
`${storagePrefix.value}activeCategory${registerId}`,
);
} catch (error) {
console.error('清除处方本地缓存失败:', error);
}
};
const resetForm = () => {
updateCurrentDrugs([]);
diagnosis.value = '';
@@ -1063,6 +1224,11 @@ export const usePrescriptionStore = defineStore('prescription', () => {
} else {
currentDrugs.value.splice(0, currentDrugs.value.length);
}
// 切换后聚焦 Tab 栏当前项
nextTick(() => {
const el = document.getElementById(`rx-modal-tab-${categoryValue}`);
el?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
});
};
// 工具函数
@@ -1102,6 +1268,11 @@ export const usePrescriptionStore = defineStore('prescription', () => {
myStoreList,
myStoreId,
activeCategory,
categories,
prescriptionTypeDefault,
prescriptionTypeLoading,
prescriptionTypeAllowed,
prescriptionTypeDenyMessage,
diagnosis,
medicalAdvice,
treatmentPrice,
@@ -1138,9 +1309,11 @@ export const usePrescriptionStore = defineStore('prescription', () => {
initializePrescription,
initializeForModal,
initializeBasicData,
loadPrescriptionTypeOptions,
resetInitializationState,
getDrugList,
addProducts,
resolveDrugCategory,
removeDrug,
updateDrugQuantity,
increment,
@@ -1165,6 +1338,7 @@ export const usePrescriptionStore = defineStore('prescription', () => {
checkChineseMedicineConflict,
sendPrescription,
resetForm,
clearLocalPrescriptionCache,
changeCategory,
getProcessRuleListData,
splitString,

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

@@ -0,0 +1,15 @@
/**
* 在线处方诊所名追加「is_online 为 1/2/3问诊/复诊等),线下 0 不加
*/
export function formatStoreNameWithHu(
storeName: string | null | undefined,
isOnline: number | string | null | undefined,
): string {
const name = String(storeName ?? '').trim();
if (!name) return '';
const online = Number(isOnline);
if (online === 1 || online === 2 || online === 3) {
return name.endsWith('(互)') ? name : `${name}(互)`;
}
return name;
}

View File

@@ -0,0 +1,66 @@
/**
* 根据运单号前缀推断快递公司(用于发货表单自动选中)
* 匹配关键字同时支持公司名与 code如 shunfeng
*/
export type ExpressMatchOption = {
id?: number;
name?: string;
code?: string;
};
/** 常见单号前缀 → 公司名 / code 关键字(按前缀长度降序匹配,避免短前缀误伤) */
const PREFIX_RULES: Array<{ prefixes: string[]; keywords: string[] }> = [
{ prefixes: ['SF'], keywords: ['顺丰', 'shunfeng', 'sf'] },
{ prefixes: ['ZTO', 'ZT'], keywords: ['中通', 'zhongtong', 'zto'] },
{ prefixes: ['STO'], keywords: ['申通', 'shentong', 'sto'] },
{ prefixes: ['YTO', 'YT'], keywords: ['圆通', 'yuantong', 'yto'] },
{ prefixes: ['YD'], keywords: ['韵达', 'yunda', 'yd'] },
{ prefixes: ['JD'], keywords: ['京东', 'jd', 'jingdong'] },
{ prefixes: ['JT'], keywords: ['极兔', 'jitu', 'jt'] },
{ prefixes: ['EMS', 'E'], keywords: ['ems', '邮政'] },
{ prefixes: ['HHTT', 'HT'], keywords: ['百世', 'baishi', 'huitong'] },
{ prefixes: ['UC'], keywords: ['优速', 'uc'] },
{ prefixes: ['DBL'], keywords: ['德邦', 'debang', 'dbl'] },
];
/**
* 按运单号前缀在 options 中找最可能的快递公司
* @returns 匹配到的 option未匹配返回 null
*/
export function matchExpressByTrackingNo(
trackingNo: string | undefined | null,
options: ExpressMatchOption[],
): ExpressMatchOption | null {
const no = String(trackingNo || '')
.trim()
.toUpperCase()
.replace(/\s+/g, '');
if (!no || !Array.isArray(options) || !options.length) {
return null;
}
const sortedRules = [...PREFIX_RULES].sort(
(a, b) =>
Math.max(...b.prefixes.map((p) => p.length)) -
Math.max(...a.prefixes.map((p) => p.length)),
);
let matchedKeywords: string[] | null = null;
for (const rule of sortedRules) {
if (rule.prefixes.some((p) => no.startsWith(p))) {
matchedKeywords = rule.keywords;
break;
}
}
if (!matchedKeywords) {
return null;
}
const keywords = matchedKeywords.map((k) => k.toLowerCase());
for (const opt of options) {
const name = String(opt.name || '').toLowerCase();
const code = String(opt.code || '').toLowerCase();
if (keywords.some((k) => name.includes(k) || code === k || code.includes(k))) {
return opt;
}
}
return null;
}

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上传签名信息
*
@@ -252,6 +269,12 @@ export async function uploadToOss(options: UploadOptions): Promise<UploadResult>
// 这与后端上传保持一致后端上传后会调用putObjectAcl设置公共读
// 注意此字段必须与后端Policy中的条件一致否则会上传失败
formData.append('x-oss-object-acl', 'public-read');
// Content-Type按文件 MIME 写入,避免 PDF 被当成 octet-stream 导致浏览器直接下载
const contentType = resolveUploadContentType(file);
formData.append('Content-Type', contentType);
// Content-Disposition: inline —— 浏览器倾向预览而非附件下载
formData.append('Content-Disposition', 'inline');
// file: 要上传的文件
// 必须是最后一个字段OSS要求file字段在FormData的最后

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

@@ -1,14 +1,19 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { Avatar, Button, Image } from 'ant-design-vue';
// 勿用 message 作 toast 名:会覆盖 props.message导致模板 message_type 全失效
import { Avatar, Button, Image, message as antdMessage } from 'ant-design-vue';
import { usePrescriptionStore } from '#/store/prescription';
import { getChatMessageRegisterInfoApi } from '#/views/business/chat/api';
import { useChatStore } from '#/views/business/chat/stores/chat';
import { sendMessage } from '#/views/business/chat/utils/request';
import { getDrugsByRegisterId as getDrugsByRegisterIdPharmacy } from '#/views/doctor/online-consultation/api/index';
import { getDrugsByRegisterId as getDrugsByRegisterIdClinic } from '#/views/doctor/online-consultation-clinic/api/index';
// 辅助函数:按需解析消息内容
const getParsedContent = (messageType, messageContent) => {
@@ -81,10 +86,22 @@ const chatUserStore = chatUseUserStore();
const userStore = useUserStore();
const chatStore = useChatStore();
const themeStore = useThemeStore();
const route = useRoute();
const prescriptionStore = usePrescriptionStore();
/** 诊所接诊模块用 clinic API / 存储前缀,药店用 pharmacy */
const isClinicModule = computed(() => {
return route.path.includes('online-consultation-clinic');
});
const getDrugsByRegisterId = computed(() => {
return isClinicModule.value ? getDrugsByRegisterIdClinic : getDrugsByRegisterIdPharmacy;
});
// 转接方查看抽屉相关
const showTransferDrawer = ref(false);
const transferRegisterId = ref(null);
/** 挂号卡「添加到处方单」请求中,防重复点击 */
const registerAddToRxLoading = ref(false);
const [RefusalOfTreatmentModals, RefusalOfTreatmentModalApi] = useVbenModal({
connectedComponent: RefusalOfTreatmentModal,
@@ -182,23 +199,136 @@ const registerCardDisplay = computed(() => {
return base;
});
const registerDrugNamesLine = computed(() => {
/**
* 挂号卡片药品行:药名 + 规格 + 数量 + 缩略图(对齐小程序,去掉底部「各 X 盒」)
*/
const registerCardDrugRows = computed(() => {
const pc = registerCardDisplay.value;
if (!pc || typeof pc !== 'object') return '';
if (!pc || typeof pc !== 'object') return [];
const fallbackQty =
pc.number != null && pc.number !== '' ? Number(pc.number) : 1;
const arr = pc.selected_western_drugs;
if (Array.isArray(arr) && arr.length) {
return arr.map((d) => d?.name).filter(Boolean).join('、');
return arr.map((d, idx) => {
const q = d?.quantity != null ? Number(d.quantity) : fallbackQty;
return {
key: String(d?.drug_id || d?.id || idx),
name: d?.name || '',
specification: d?.specification || d?.spec || d?.drug_spec || '',
image: d?.image || d?.drug_image || '',
quantity: q >= 1 ? q : 1,
};
});
}
if (pc.drug?.name) return pc.drug.name;
return '';
if (pc.drug?.name) {
return [
{
key: String(pc.drug.drug_id || pc.drug.id || 0),
name: pc.drug.name,
specification: pc.drug.specification || pc.drug.spec || '',
image: pc.drug.image || pc.drug.drug_image || '',
quantity: fallbackQty >= 1 ? fallbackQty : 1,
},
];
}
return [];
});
/**
* 是否展示「添加到处方单」:有选药、接诊中、患者侧卡片(对齐小程序 canAddExperienceToRx && !isMine
*/
const canAddRegisterDrugsToRx = computed(() => {
return (
registerCardDrugRows.value.length > 0 &&
Number(registerCardDisplay.value?.status) === 2 &&
!props.isSent
);
});
/**
* 将本条挂号卡对应选药写入处方单(复用 getDrugsByRegisterId + prescriptionStore
*/
const handleAddRegisterDrugsToPrescription = async () => {
if (registerAddToRxLoading.value) return;
const cardRegisterId = registerCardDisplay.value?.id;
const followUpRegisterId =
isFollowUpDrugAssistant.value && parsedContent.value?.register_id
? parsedContent.value.register_id
: '';
const registerId =
prescriptionStore.currentRegisterId ||
chatStore.currentFriend?.register_id ||
cardRegisterId ||
followUpRegisterId;
if (!registerId) {
antdMessage.warning('请先接诊患者');
return;
}
if (!prescriptionStore.currentRegisterId) {
const storagePrefix = isClinicModule.value
? 'onlineConsultationClinic-'
: 'onlineConsultation-';
prescriptionStore.initializePrescription(registerId, false, storagePrefix);
}
let storeId = prescriptionStore.myStoreId;
if (!storeId || storeId === 0) {
if (prescriptionStore.myStoreList && prescriptionStore.myStoreList.length > 0) {
storeId = prescriptionStore.myStoreList[0].id;
} else {
antdMessage.warning('请先选择诊所');
return;
}
}
registerAddToRxLoading.value = true;
try {
const res = await getDrugsByRegisterId.value(registerId, storeId);
const drugList = res?.result || res?.data || res || [];
if (!drugList || drugList.length === 0) {
antdMessage.warning('未找到药品信息');
return;
}
let addedCount = 0;
for (const drugData of drugList) {
if (prescriptionStore.addProducts(drugData)) {
addedCount++;
}
}
if (addedCount > 0) {
antdMessage.success(`已成功添加 ${addedCount} 个药品到处方单`);
emit('open-prescription', registerId);
} else {
antdMessage.warning('所有药品已在处方中');
}
} catch (error) {
console.error('添加药品失败:', error);
antdMessage.error('添加药品失败,请重试');
} finally {
registerAddToRxLoading.value = false;
}
};
const isFollowUpDrugAssistant = computed(() => {
if (props.message.message_type !== 11) return false;
const pc = parsedContent.value;
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(
@@ -617,9 +747,13 @@ const getSexText = (sex) => {
</div>
</div>
<!-- 主诉 / 所选药品 / 数量小程序挂号卡对齐 -->
<!-- 主诉 / 适用症 / 所选药品逐药缩略图 + 规格 + 数量对齐小程序 -->
<div
v-if="registerCardDisplay.chief_complaint || registerDrugNamesLine || (registerCardDisplay.number != null && registerCardDisplay.number !== '')"
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">
@@ -628,19 +762,61 @@ const getSexText = (sex) => {
registerCardDisplay.chief_complaint
}}</span>
</div>
<div v-if="registerDrugNamesLine">
<span class="font-medium text-gray-600 dark:text-gray-300">所选药品</span>
<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">{{
registerDrugNamesLine
registerCardDisplay.illnessInfo
}}</span>
</div>
<div
v-if="registerCardDisplay.number != null && registerCardDisplay.number !== ''"
v-if="registerCardDisplay.illnessInfo"
class="flex flex-wrap gap-x-4 gap-y-1 text-gray-700 dark:text-gray-200"
>
<span class="font-medium text-gray-600 dark:text-gray-300">数量</span>
<span class="text-gray-800 dark:text-gray-100"
> {{ registerCardDisplay.number }} </span
<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
v-for="row in registerCardDrugRows"
:key="row.key"
class="flex items-start gap-2"
>
<Image
v-if="row.image"
:src="row.image"
:width="36"
:height="36"
class="register-drug-thumb flex-shrink-0 overflow-hidden rounded"
:preview="{ src: row.image }"
/>
<div class="min-w-0 flex-1">
<div class="text-gray-800 dark:text-gray-100">{{ row.name || '药品' }}</div>
<div
v-if="row.specification"
class="text-xs text-gray-400 dark:text-gray-500"
>
{{ row.specification }}
</div>
</div>
<span class="flex-shrink-0 font-medium text-gray-700 dark:text-gray-200"
>×{{ row.quantity }}</span
>
</div>
</div>
</div>
@@ -680,14 +856,20 @@ const getSexText = (sex) => {
拒诊
</Button>
</div>
<!-- 查看详情按钮 -->
<!-- <div class="flex items-center justify-center mt-2">-->
<!-- <div class="flex items-center text-blue-500 dark:text-blue-300 hover:text-blue-600 dark:hover:text-blue-200 transition-colors cursor-pointer">-->
<!-- <span class="text-sm font-medium">查看详情</span>-->
<!-- <i class="fas fa-chevron-right ml-1 text-xs"></i>-->
<!-- </div>-->
<!-- </div>-->
<!-- 接诊中且有选药:导入到处方单(对齐小程序挂号卡) -->
<div v-if="canAddRegisterDrugsToRx" class="mt-2">
<Button
:loading="registerAddToRxLoading"
block
size="small"
type="primary"
ghost
@click.stop="handleAddRegisterDrugsToPrescription"
>
<i class="fas fa-plus-circle mr-1"></i>
添加到处方单
</Button>
</div>
</div>
<!-- 时间信息 -->
@@ -914,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

@@ -0,0 +1,210 @@
<script lang="ts" setup>
/**
* 在线复诊开方:按药品分步卡片选配送仓库
* 默认选中 options[0](与后端 auto 一致quote ASC 最低价)
* 样式使用主题 token适配亮/暗色
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Image, message } from 'ant-design-vue';
const emit = defineEmits<{
(e: 'confirm', map: Record<number, number>): void;
}>();
type WarehouseOption = {
warehouse_id: number;
warehouse_name: string;
quote: string;
available_stock: number;
/** 接口可能在 option 上带药品展示字段,供 row 回退 */
drug_name?: string;
image?: string;
specification?: string;
};
type DrugRow = {
drug_id: number;
drug_name: string;
image?: string;
specification?: string;
options: WarehouseOption[];
warehouse_id?: number;
};
/**
* 从 row 或 options[0] 取药品展示字段(接口规格在 option 上)
*/
function resolveDrugMeta(row: DrugRow) {
const first = row.options?.[0];
return {
drug_name: row.drug_name || first?.drug_name || '',
image: row.image || first?.image || '',
specification: row.specification || first?.specification || '',
};
}
const drugRows = ref<DrugRow[]>([]);
/** 当前步骤下标0-based */
const stepIndex = ref(0);
const totalSteps = computed(() => drugRows.value.length);
const currentRow = computed(() => drugRows.value[stepIndex.value] || null);
/** 当前步药品展示:名/图/规格,含 option 回退 */
const currentDrugMeta = computed(() =>
currentRow.value
? resolveDrugMeta(currentRow.value)
: { drug_name: '', image: '', specification: '' },
);
const isFirstStep = computed(() => stepIndex.value <= 0);
const isLastStep = computed(
() => stepIndex.value >= Math.max(totalSteps.value - 1, 0),
);
const canGoNext = computed(() => !!currentRow.value?.warehouse_id);
/** 点击卡片选中当前步骤药品的配送仓 */
function selectWarehouse(warehouseId: number) {
const row = currentRow.value;
if (!row) return;
row.warehouse_id = warehouseId;
}
function goPrev() {
if (!isFirstStep.value) {
stepIndex.value -= 1;
}
}
function goNext() {
if (!canGoNext.value) {
message.error('请先选择配送仓库');
return;
}
if (!isLastStep.value) {
stepIndex.value += 1;
}
}
/** 汇总 warehouse_map 并确认 */
function confirmAll() {
if (!canGoNext.value) {
message.error('请先选择配送仓库');
return;
}
const map: Record<number, number> = {};
for (const row of drugRows.value) {
if (!row.warehouse_id) {
message.error(`请为【${row.drug_name}】选择配送仓库`);
return;
}
map[row.drug_id] = row.warehouse_id;
}
emit('confirm', map);
modalApi.close();
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
footer: false,
onCancel() {
modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{ rows: DrugRow[] }>();
// 默认选中每药 options[0](最低 quote规格/图/名可从 option 回退
drugRows.value = (data?.rows || []).map((row) => {
const meta = resolveDrugMeta(row);
return {
...row,
warehouse_id: row.options?.[0]?.warehouse_id,
drug_name: meta.drug_name || `药品#${row.drug_id}`,
image: meta.image,
specification: meta.specification,
};
});
stepIndex.value = 0;
}
},
});
</script>
<template>
<Modal title="选择配送仓库" class="w-[640px]">
<div v-if="currentRow" class="space-y-4 py-2">
<div class="text-muted-foreground text-sm">
{{ stepIndex + 1 }} / {{ totalSteps }} 个药品
</div>
<!-- 当前药品信息 / / 规格 -->
<div
class="border-border bg-muted/30 flex items-start gap-3 rounded-lg border p-3"
>
<Image
v-if="currentDrugMeta.image"
:src="currentDrugMeta.image"
:width="64"
:height="64"
class="shrink-0 rounded object-cover"
/>
<div
v-else
class="bg-accent text-muted-foreground flex h-16 w-16 shrink-0 items-center justify-center rounded text-xs"
>
暂无图
</div>
<div class="min-w-0 flex-1">
<div
class="text-foreground truncate text-base font-medium"
:title="currentDrugMeta.drug_name"
>
{{ currentDrugMeta.drug_name }}
</div>
<div class="text-muted-foreground mt-1 text-sm">
规格{{ currentDrugMeta.specification || '--' }}
</div>
</div>
</div>
<!-- 仓卡片 -->
<div class="flex flex-wrap gap-2">
<button
v-for="opt in currentRow.options"
:key="opt.warehouse_id"
type="button"
class="min-w-[140px] rounded-lg border px-3 py-2 text-left transition-colors"
:class="
currentRow.warehouse_id === opt.warehouse_id
? 'border-primary bg-primary/10 ring-primary ring-1'
: 'border-border hover:border-primary/50'
"
@click="selectWarehouse(opt.warehouse_id)"
>
<div class="text-foreground text-sm font-medium">
{{ opt.warehouse_name }}
</div>
<div class="text-muted-foreground mt-1 text-xs">
供货价 {{ opt.quote }} · 库存 {{ opt.available_stock }}
</div>
</button>
</div>
</div>
<div v-else class="text-muted-foreground py-6">当前药品无需选择配送仓库</div>
<!-- 自定义底栏上一步 / 下一步 / 确认 -->
<div class="border-border mt-4 flex justify-end gap-2 border-t pt-3">
<Button v-if="!isFirstStep" @click="goPrev">上一步</Button>
<Button
v-if="!isLastStep"
type="primary"
:disabled="!canGoNext"
@click="goNext"
>
下一步
</Button>
<Button v-else type="primary" :disabled="!canGoNext" @click="confirmAll">
确认
</Button>
</div>
</Modal>
</template>

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

@@ -57,7 +57,7 @@ const [Modal, modalApi] = useVbenModal({
});
</script>
<template>
<Modal :title="`${isUpdate === true ? '编辑' : '新增'}快递公司`" class="w-[30%]">
<Modal :title="`${isUpdate === true ? '编辑' : '新增'}快递公司`" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
<Form />
</Modal>
</template>

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';
@@ -19,7 +33,8 @@ export async function getOrderTraceApi(params: {
export async function getOrderLedgerDetailApi(params: {
order_id: number;
order_type: number;
scope?: 'all' | 'platform' | 'store';
/** 平台端可按受益方类型筛选:全部/门店/平台/供应商/配送仓库 */
scope?: 'all' | 'platform' | 'store' | 'supplier' | 'warehouse';
}) {
return requestClient.get<any>(`${prefix}ledger-detail`, { params });
}

View File

@@ -29,7 +29,8 @@ import ReconciliationDetailTable from './reconciliation-detail-table.vue';
defineOptions({ name: 'OrderTraceDrawer' });
type LedgerScope = 'all' | 'platform' | 'store';
/** 分账明细 scope与后端 allowed_scopes 对齐 */
type LedgerScope = 'all' | 'platform' | 'store' | 'supplier' | 'warehouse';
const router = useRouter();
const userStore = useUserStore();
@@ -57,7 +58,8 @@ const ledgerColumns = [
{ title: '药品编号', dataIndex: 'drug_number', key: 'drug_number' },
{ title: '药品规格', dataIndex: 'specification', key: 'specification' },
{ title: '分账对象', dataIndex: 'user_type_txt', key: 'user_type_txt' },
{ title: '门店', dataIndex: ['store', 'name'], key: 'store_name' },
// 受益方名称:门店/供应商/配送仓库实体名,平台为「平台」
{ title: '受益方名称', dataIndex: 'party_name', key: 'party_name' },
{ title: '分账金额', dataIndex: 'money', key: 'money' },
{ title: '结算状态', dataIndex: 'status_txt', key: 'status_txt' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
@@ -360,6 +362,8 @@ watch(isPlatformAdmin, (val) => {
<Tabs.TabPane key="all" tab="全部" />
<Tabs.TabPane key="store" tab="门店" />
<Tabs.TabPane key="platform" tab="平台" />
<Tabs.TabPane key="supplier" tab="供应商" />
<Tabs.TabPane key="warehouse" tab="配送仓库" />
</Tabs>
<Tabs v-model:active-key="ledgerSubTab" type="card">
@@ -389,7 +393,8 @@ watch(isPlatformAdmin, (val) => {
v-else-if="
column.key === 'drug_name' ||
column.key === 'drug_number' ||
column.key === 'specification'
column.key === 'specification' ||
column.key === 'party_name'
"
>
{{ formatDrugCell(text) }}
@@ -429,7 +434,8 @@ watch(isPlatformAdmin, (val) => {
v-else-if="
column.key === 'drug_name' ||
column.key === 'drug_number' ||
column.key === 'specification'
column.key === 'specification' ||
column.key === 'party_name'
"
>
{{ formatDrugCell(text) }}

View File

@@ -1,81 +1,18 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
/**
* 业务处方页处方溯源弹窗
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Timeline, TimelineItem } from 'ant-design-vue';
import {
FileTextOutlined,
MedicineBoxOutlined,
ShopOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import PrescriptionSourceContent from '#/components/prescription-source/PrescriptionSourceContent.vue';
import { getPrescriptionSourceApi } from '../api';
defineOptions({ name: 'PrescriptionSource' });
// 处方溯源信息
const data = ref();
// 格式化时间戳
const formatTime = (timestamp) => {
if (!timestamp) return '--';
return new Date(timestamp * 1000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
// 处方类型
const prescriptionTypeMap = {
1: '西药处方',
2: '中成药处方',
3: '中药处方',
};
// 处方状态
const statusMap = {
0: '待审核',
1: '已审核',
2: '已驳回',
3: '已过期',
};
// 计算属性:处方类型文本
const prescriptionTypeText = computed(() => {
return data.value
? prescriptionTypeMap[data.value.prescription_type] || '未知'
: '--';
});
// 计算属性:处方状态文本
const statusText = computed(() => {
return data.value ? statusMap[data.value.status] || '未知' : '--';
});
// 计算属性:状态颜色
const statusColor = computed(() => {
if (!data.value) return 'gray';
const statusColors = {
0: 'orange',
1: 'green',
2: 'red',
3: 'gray',
};
return statusColors[data.value.status] || 'gray';
});
// 计算属性:过期时间
const expireTime = computed(() => {
return data.value ? formatTime(data.value.auto_expire_time) : '--';
});
const data = ref<Record<string, any> | null>(null);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
@@ -87,226 +24,20 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onOpenChange(isOpen: boolean) {
const { id } = modalApi.getData<Record<string, any>>();
if (isOpen && id) {
getPrescriptionSourceApi({ id }).then((res) => {
const payload = modalApi.getData<Record<string, any>>();
if (isOpen && payload?.id) {
getPrescriptionSourceApi({ id: payload.id }).then((res) => {
data.value = res;
});
} else {
data.value = null; // Reset data when modal is closed or id is missing
data.value = null;
}
},
});
</script>
<template>
<Modal class="w-[80%]" title="处方溯源">
<div v-if="data" class="prescription-source-container">
<!-- 药店信息 -->
<div
class="mb-6 transform rounded-lg p-6 shadow-md transition-all duration-300 hover:shadow-lg"
>
<div class="mb-4 flex items-center">
<ShopOutlined class="mr-2 text-xl text-purple-500" />
<h2 class="text-xl font-bold">药店信息</h2>
</div>
<div v-if="data.store" class="grid grid-cols-1 gap-4">
<div class="info-item">
<span class="label">药店名称:</span>
<span class="value">{{ data.store.name }}</span>
</div>
</div>
<div v-else class="italic text-gray-500">暂无药店信息</div>
</div>
<!-- 时间线 -->
<div
class="mb-6 transform rounded-lg p-6 shadow-md transition-all duration-300 hover:shadow-lg"
>
<div class="mb-4 flex items-center">
<FileTextOutlined class="mr-2 text-xl text-blue-500" />
<h2 class="text-xl font-bold">时间线</h2>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 mt-5">
<Timeline>
<TimelineItem v-if="data.pharmacist_view_time">
{{ data.pharmacist_view_time }}
<template v-if="data.pharmacist_info">
{{ data.pharmacist_info.name }} 审核
</template>
<template v-else> -- </template>
</TimelineItem>
<TimelineItem v-else>
{{ statusText }}
{{ data.cancel_remark }}
</TimelineItem>
<TimelineItem>
{{ data.created_at }}
<template v-if="data.doctor_info">
{{ data.doctor_info.name }} 开方诊断{{
data.clinical_diagnose
}}
</template>
<template v-else> -- </template>
</TimelineItem>
<TimelineItem>
{{ data.register.created_at }}
<template v-if="data.doctor_info">
{{ data.user_patient.name }} 挂号
</template>
<template v-else> -- </template>
</TimelineItem>
</Timeline>
</div>
</div>
<!-- 处方基本信息 -->
<div
class="mb-6 transform rounded-lg p-6 shadow-md transition-all duration-300 hover:shadow-lg"
>
<div class="mb-4 flex items-center">
<FileTextOutlined class="mr-2 text-xl text-blue-500" />
<h2 class="text-xl font-bold">处方基本信息</h2>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<div class="info-item">
<span class="label">处方编号:</span>
<span class="value">{{ data.prescription_no }}</span>
</div>
<div class="info-item">
<span class="label">处方类型:</span>
<span class="value">{{ prescriptionTypeText }}</span>
</div>
<div class="info-item">
<span class="label">处方状态:</span>
<span :class="`text-${statusColor}-500`" class="value">{{
statusText
}}</span>
</div>
<div class="info-item">
<span class="label">总金额:</span>
<span class="value font-bold text-red-500">¥{{ data.total_pay_price }}</span>
</div>
<div class="info-item">
<span class="label">过期时间:</span>
<span class="value">{{ expireTime }}</span>
</div>
</div>
</div>
<!-- 医生信息 -->
<div
class="mb-6 transform rounded-lg p-6 shadow-md transition-all duration-300 hover:shadow-lg"
>
<div class="mb-4 flex items-center">
<MedicineBoxOutlined class="mr-2 text-xl text-green-500" />
<h2 class="text-xl font-bold">医生信息</h2>
</div>
<div
v-if="data.doctor_info"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div class="info-item">
<span class="label">医生姓名:</span>
<span class="value">{{ data.doctor_info.name }}</span>
</div>
<div class="info-item">
<span class="label">所属科室:</span>
<span class="value">{{
data.doctor_info.depart?.name || '--'
}}</span>
</div>
</div>
<div v-else class="italic text-gray-500">暂无医生信息</div>
</div>
<!-- 患者信息 -->
<div
class="transform rounded-lg p-6 shadow-md transition-all duration-300 hover:shadow-lg"
>
<div class="mb-4 flex items-center">
<UserOutlined class="mr-2 text-xl text-amber-500" />
<h2 class="text-xl font-bold">患者信息</h2>
</div>
<div
v-if="data.user_patient"
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
>
<div class="info-item">
<span class="label">患者姓名:</span>
<span class="value">{{ data.user_patient.name }}</span>
</div>
<div class="info-item">
<span class="label">年龄:</span>
<span class="value">{{ data.user_patient.age }}</span>
</div>
<div class="info-item">
<span class="label">性别:</span>
<span class="value">{{
data.user_patient.sex === 1 ? '男' : '女'
}}</span>
</div>
</div>
<div v-else class="italic text-gray-500">暂无患者信息</div>
</div>
</div>
<!-- 加载状态 -->
<div v-else class="flex h-64 items-center justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-b-2 border-t-2 border-blue-500"
></div>
</div>
<Modal class="w-[80%] h-[80%]" title="处方溯源">
<PrescriptionSourceContent :data="data" />
</Modal>
</template>
<style lang="scss" scoped>
.prescription-source-container {
@apply max-h-[70vh] overflow-auto p-4;
}
.info-item {
@apply flex flex-col rounded-md p-3 transition-all duration-300;
}
.label {
@apply mb-1 text-sm text-gray-500;
}
.value {
@apply font-medium;
}
/* 添加动感效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.prescription-source-container > div {
animation: fadeIn 0.5s ease-out forwards;
}
.prescription-source-container > div:nth-child(1) {
animation-delay: 0.1s;
}
.prescription-source-container > div:nth-child(2) {
animation-delay: 0.2s;
}
.prescription-source-container > div:nth-child(3) {
animation-delay: 0.3s;
}
.prescription-source-container > div:nth-child(4) {
animation-delay: 0.4s;
}
</style>

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,
},
{
label: '西药',
value: 2,
},
{
label: '中成药',
value: 3,
},
{
label: '产品服务包',
value: 5,
},
],
afterFetch: (data: { id: number; name: string }[]) => {
return data.map((item) => ({
label: item.name,
value: item.id,
}));
},
api: getOrderPrescriptionTypeOption,
placeholder: '请选择',
},
fieldName: 'prescription_type',
@@ -47,9 +38,14 @@ export const formOptions: VbenFormProps = {
{
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD', // 确保格式与你需要的一致
format: 'YYYY-MM-DD',
// 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59
valueFormat: 'YYYY-MM-DD',
},
defaultValue: [dayjs().startOf('month'), dayjs()],
defaultValue: [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
fieldName: 'search_time',
label: '时间范围',
},

View File

@@ -21,7 +21,7 @@ export const gridOptions: VxeGridProps<RowType> = {
{ field: 'prescription_no', align: 'left', title: '处方订单号' },
{ field: 'doctor_info.name', align: 'left', title: '开方医生' },
{ field: 'user_patient.name', align: 'left', title: '就诊人名称' },
{ field: 'store.name', align: 'left', title: '开方诊所' },
{ field: 'store.name', align: 'left', title: '开方诊所', slots: { default: 'store-name' } },
{ field: 'status', title: '状态', slots: { default: 'status' } },
{
field: 'is_online',

View File

@@ -1,44 +1,75 @@
<script lang="ts" setup>
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { ref, watch } from 'vue';
import {
Page,
useVbenModal,
} from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Tag } from 'ant-design-vue';
import { Button, Tabs, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import PrescriptionSource from './components/source.vue';
import { getPrescriptionListApi } from './api';
import PrescriptionSource from './components/source.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
import { gridOptions as baseGridOptions } from './config/table';
const hasTopTableDropDownActions = ref(false);
/** 审核状态 Tab0待审核 / 1已通过 / 2已拒绝 */
const statusTab = ref('0');
const gridEvents: VxeGridListeners<any> = {
checkboxChange() {
// eslint-disable-next-line no-use-before-define
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
checkboxAll() {
// eslint-disable-next-line no-use-before-define
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
};
const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
connectedComponent: StoreCardModal,
});
function openStoreCard(storeId?: number) {
if (!storeId) return;
StoreCardModalApi.setData({ storeId: Number(storeId) });
StoreCardModalApi.open();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridOptions: {
...baseGridOptions,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPrescriptionListApi({
page: page.currentPage,
pageSize: page.pageSize,
status: Number(statusTab.value),
...formValues,
});
},
},
},
},
gridEvents,
});
watch(statusTab, () => {
gridApi.query();
});
function onStatusTabChange(key: string | number) {
statusTab.value = String(key);
}
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
connectedComponent: PrescriptionDetail,
});
@@ -46,40 +77,52 @@ const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
const [PrescriptionSourceModal, PrescriptionSourceModalApi] = useVbenModal({
connectedComponent: PrescriptionSource,
});
const openPrescriptionDetail = (values) => {
// 打开西药处方模态框逻辑
PrescriptionDetailModalApi.setData({
values,
});
const openPrescriptionDetail = (values: number) => {
PrescriptionDetailModalApi.setData({ values });
PrescriptionDetailModalApi.open();
};
const openPrescriptionSourceModal = (id) => {
// 打开西药处方模态框逻辑
PrescriptionSourceModalApi.setData({
id,
});
const openPrescriptionSourceModal = (id: number) => {
PrescriptionSourceModalApi.setData({ id });
PrescriptionSourceModalApi.open();
};
</script>
<template>
<Page auto-content-height title="订单管理">
<Page auto-content-height title="处方管理">
<PrescriptionDetailModal />
<PrescriptionSourceModal />
<StoreCardModalComp />
<div class="mb-3">
<Tabs :active-key="statusTab" @change="onStatusTabChange">
<Tabs.TabPane key="0" tab="待审核" />
<Tabs.TabPane key="1" tab="已通过" />
<Tabs.TabPane key="2" tab="已拒绝" />
</Tabs>
</div>
<Grid>
<template #toolbar-buttons>
<TableAction
:actions="[]"
:drop-down-actions="[]"
>
<TableAction :actions="[]" :drop-down-actions="[]">
<template #more>
<Button style="margin-left: 16px">
批量操作
<Icon icon="ant-design:down-outlined" />
</Button>
</template>
</TableAction>
</template>
<template #store-name="{ row }">
<Button
v-if="row.store?.id || row.store_id"
type="link"
size="small"
class="!px-0"
@click="openStoreCard(row.store?.id || row.store_id)"
>
{{ row.store?.name || '—' }}
</Button>
<span v-else>{{ row.store?.name || '' }}</span>
</template>
<template #status="{ row }">
<div>
<Tag v-if="row.prescription_type === 1" color="orange">中药</Tag>
@@ -104,11 +147,12 @@ const openPrescriptionSourceModal = (id) => {
</div>
<div class="mt-3">
<Tag v-if="row.status === 0" color="red">待审核</Tag>
<Tag v-else-if="row.status === 1" color="green">通过审核</Tag>
<Tag v-else-if="row.status === 2" color="red">拒绝审核{{ row.reject_reason }}</Tag>
<Tag v-else-if="row.status === 1" color="green">通过</Tag>
<Tag v-else-if="row.status === 2" color="red"
>已拒绝{{ row.reject_reason }}</Tag
>
<Tag v-else-if="row.status === 3" color="purple">无需审核</Tag>
<Tag v-else-if="row.status === 4" color="green">无需审核</Tag>
<!-- <p>{{ row.created_at }}</p>-->
</div>
</template>
<template #is-online="{ row }">
@@ -124,19 +168,16 @@ const openPrescriptionSourceModal = (id) => {
label: '查看处方',
type: 'link',
icon: 'marketeq:eye',
// auth: ['超级订单', 'sys:user:save'],
onClick: openPrescriptionDetail.bind(null, row.id),
},
{
label: '处方溯源',
type: 'link',
icon: 'marketeq:eye',
// auth: ['超级订单', 'sys:user:save'],
onClick: openPrescriptionSourceModal.bind(null, row.id),
},
]"
:drop-down-actions="[
]"
:drop-down-actions="[]"
/>
</template>
</Grid>
@@ -147,28 +188,4 @@ const openPrescriptionSourceModal = (id) => {
list-style-type: none;
padding-left: 0;
}
.custom-list-item {
background-color: rgba(64, 158, 255, 0.04);
border-radius: 4px;
margin-bottom: 8px;
padding: 8px 12px;
font-size: 14px;
transition: background-color 0.3s;
&:hover {
background-color: rgba(64, 158, 255, 0.1);
}
&::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
background-color: #409eff;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
}
}
</style>

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
@@ -79,6 +89,39 @@ export async function expressDetailByOrderId(data: Record<string, any>) {
return requestClient.post<any>(`express-detail/detail-by-order`, data);
}
/**
* 修改已发货运单:快递单号 / 查件手机号 / 快递公司
*/
export async function updateExpressNosApi(data: {
express_no_id: number;
express_no?: string;
mobile?: string;
express_company_code?: string;
}) {
return requestClient.post<any>(`express-detail/update-express-nos`, data);
}
/**
* 超管:将历史订单级运单同步到分包裹 shipment
*/
export async function syncLegacyShipmentApi(orderId: number) {
return requestClient.post<any>(`${prefix}sync-legacy-shipment`, {
order_id: orderId,
});
}
/**
* 订单页改仓:将 from 仓名下未出库明细切到 to 仓0=萧康医药本仓库)
*/
export async function changeDeliveryWarehouseApi(data: {
order_id: number;
from_warehouse_id?: number;
to_warehouse_id?: number;
items?: Array<{ order_item_id: number; to_warehouse_id: number }>;
}) {
return requestClient.post<any>(`${prefix}change-delivery-warehouse`, data);
}
/**
* 导出订单数据(后端 Excel旧接口保留
*/

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

@@ -1,14 +1,24 @@
<script lang="ts" setup>
/**
* 订单列表:下单用户 / 医生 / 就诊人信息单元
* 下单用户 → 该用户就诊人列表 Modal就诊人 → 详情 Modal
*/
import { Avatar, Button } from 'ant-design-vue';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
defineProps<{
const props = defineProps<{
row: Record<string, any>;
}>();
const emit = defineEmits<{
openDoctor: [row: Record<string, any>];
/** 点下单微信用户 → 打开就诊人列表 */
openUserPatients: [
payload: { userId: number; nickname: string; avatarurl: string },
];
/** 点就诊人 → 打开详情 */
openPatient: [payload: { upId: number; patientName: string }];
}>();
/** 默认头像路径 */
@@ -36,26 +46,74 @@ function getDoctorAvatarSrc(row: Record<string, any>) {
* 格式化就诊人年龄副文本
*/
function formatPatientAge(row: Record<string, any>) {
const age = row.patient_age;
const age = row.patient_age ?? row.user_patient?.age ?? row.userPatient?.age;
if (age == null || age === '') return '';
return `${age}`;
}
/** 解析就诊人 ID */
function resolveUpId(row: Record<string, any>) {
return Number(
row.up_id ||
row.user_patient?.id ||
row.userPatient?.id ||
0,
);
}
/** 解析微信用户 ID */
function resolveUserId(row: Record<string, any>) {
return Number(row.user?.id || row.user_id || 0);
}
function patientName(row: Record<string, any>) {
return (
row.user_patient?.name ||
row.userPatient?.name ||
row.patient ||
'—'
);
}
/** 打开该微信用户下的就诊人列表 */
function handleOpenUser() {
const userId = resolveUserId(props.row);
if (!userId) return;
emit('openUserPatients', {
userId,
nickname: String(props.row.user?.nickname || ''),
avatarurl: String(props.row.user?.avatarurl || ''),
});
}
/** 打开就诊人详情 Modal */
function handleOpenPatient() {
const upId = resolveUpId(props.row);
if (!upId) return;
emit('openPatient', {
upId,
patientName: String(patientName(props.row)),
});
}
</script>
<template>
<div class="order-user-info">
<!-- 行1下单微信用户 -->
<div class="order-user-info__row">
<Avatar
:size="24"
:src="getUserAvatarSrc(row)"
class="shrink-0"
/>
<Avatar :size="24" :src="getUserAvatarSrc(row)" class="shrink-0" />
<div class="order-user-info__text min-w-0">
<span class="text-[11px] leading-tight text-gray-400 dark:text-slate-500">
下单用户
</span>
<span class="truncate text-xs text-gray-700 dark:text-slate-200">
<Button
v-if="resolveUserId(row)"
class="order-user-info__link-btn !h-auto !px-0 !py-0"
type="link"
@click.stop="handleOpenUser"
>
{{ row.user?.nickname || '—' }}
</Button>
<span v-else class="truncate text-xs text-gray-700 dark:text-slate-200">
{{ row.user?.nickname || '—' }}
</span>
<div class="text-[11px] text-gray-500 dark:text-slate-400">
@@ -63,14 +121,9 @@ function formatPatientAge(row: Record<string, any>) {
</div>
</div>
</div>
<!-- 行2开方医生可点击查看档案 -->
<div class="order-user-info__row">
<Avatar
:size="24"
:src="getDoctorAvatarSrc(row)"
class="shrink-0"
/>
<div class="order-user-info__text min-w-0" >
<Avatar :size="24" :src="getDoctorAvatarSrc(row)" class="shrink-0" />
<div class="order-user-info__text min-w-0">
<span class="text-[11px] leading-tight text-gray-400 dark:text-slate-500">
医生
</span>
@@ -82,28 +135,25 @@ function formatPatientAge(row: Record<string, any>) {
>
{{ row.doctor.name }}
</Button>
<!-- <template v-if="row.doctor?.name">-->
<!-- <Button-->
<!-- v-if="row.doctor?.name"-->
<!-- class="order-user-info__doctor-btn !h-auto !px-0 !py-0 dark:!text-blue-400"-->
<!-- type="link"-->
<!-- @click="emit('openDoctor', row)"-->
<!-- >-->
<!-- {{ row.doctor.name }}-->
<!-- </Button>-->
<!-- </template>-->
<div v-else class="text-xs text-gray-700 dark:text-slate-200"></div>
</div>
</div>
<!-- 行3就诊人与上两行保持 Avatar + 文本区结构 -->
<div class="order-user-info__row">
<Avatar :size="24" :src="defaultAvatar" class="shrink-0" />
<div class="order-user-info__text min-w-0">
<span class="text-[11px] leading-tight text-gray-400 dark:text-slate-500">
就诊人
</span>
<span class="truncate text-xs text-gray-700 dark:text-slate-200">
{{ row.patient || '—' }}
<Button
v-if="resolveUpId(row)"
class="order-user-info__link-btn !h-auto !px-0 !py-0"
type="link"
@click.stop="handleOpenPatient"
>
{{ patientName(row) }}
</Button>
<span v-else class="truncate text-xs text-gray-700 dark:text-slate-200">
{{ patientName(row) }}
</span>
<div
v-if="formatPatientAge(row)"
@@ -123,26 +173,23 @@ function formatPatientAge(row: Record<string, any>) {
gap: 6px;
line-height: 1.4;
}
.order-user-info__row {
display: flex;
align-items: flex-start;
gap: 6px;
}
.order-user-info__text {
flex: 1;
min-width: 0;
text-align: left;
}
.order-user-info__doctor-btn {
.order-user-info__doctor-btn,
.order-user-info__link-btn {
font-size: 12px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
//display: block;
text-align: left;
}
</style>

View File

@@ -2,24 +2,27 @@
import { computed, ref } from 'vue';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { Button, Card, Descriptions, Image, Space, Tag, Timeline } from 'ant-design-vue';
import { Button, Card, Descriptions, Image, 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';
import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
import { formatPriceDiscountLabel, normalizeQuickOptions } from '#/utils/pricePercentAdjust';
import { expressDetailByOrderId, getOrderInfo } from '../api';
import { expressDetailByOrderId, getOrderInfo, syncLegacyShipmentApi } from '../api';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
import LogisticsModal from './logistics-modal.vue';
defineOptions({
name: 'DetailModal',
});
const userStore = useUserStore();
const gridApi = ref();
// 订单信息
const data = ref();
@@ -27,6 +30,40 @@ const data = ref();
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
* 与后端一致:仅 role_id=1SUPPER_ADMIN
*/
const canSyncLegacyShipment = computed(() => {
const roleId = Number(
userStore.userInfo?.role_id ?? userStore.userInfo?.roles?.id,
);
if (roleId !== 1) return false;
if (Number(data.value?.delivery_method) !== 0) return false;
return Number(data.value?.express_no_id) > 0;
});
const [TraceDrawer, traceDrawerApi] = useVbenDrawer({
connectedComponent: OrderTraceDrawer,
@@ -36,6 +73,11 @@ const [PercentAdjustDrawer, percentAdjustDrawerApi] = useVbenDrawer({
connectedComponent: OrderPricePercentAdjustDrawer,
});
/** 详情内「查看物流动态」:独立弹窗只拉物流接口 */
const [LogisticsModalComp, logisticsModalApi] = useVbenModal({
connectedComponent: LogisticsModal,
});
const priceAdjustMeta = ref({
scope: 'sale_only' as 'both' | 'sale_only',
quickOptions: [] as QuickDiscountOption[],
@@ -102,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,
@@ -114,7 +181,10 @@ const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values, id } = modalApi.getData<Record<string, any>>();
const modalData = modalApi.getData<Record<string, any>>() || {};
const { values, id, onShipPackage: shipFn } = modalData;
// 列表传入:按 warehouse_id 打开发货弹窗(平台包=0
onShipPackage.value = typeof shipFn === 'function' ? shipFn : null;
if (id) {
getOrderInfo(id).then((res) => {
data.value = res;
@@ -127,6 +197,8 @@ const [Modal, modalApi] = useVbenModal({
deliveryMethod.value = data.value.delivery_method;
getExpressDetail();
}
} else {
onShipPackage.value = null;
}
},
});
@@ -138,26 +210,67 @@ async function getExpressDetail() {
}
/**
* 获取物流信息标签颜色
* @param status
* 多包裹:优先物流接口 packages门店角色已按开关抹仓名
* 避免订单详情未抹名数据盖过物流结果。
*/
function getColor(status: any) {
switch (status) {
case '在途': {
return '';
}
case '揽收': {
return 'orange';
}
case '派件': {
return 'blue';
}
case '签收': {
return 'green';
}
default: {
return '';
}
const packageTabs = computed(() => {
const fromExpress = Array.isArray((expressDetail.value as any)?.packages)
? (expressDetail.value as any).packages
: [];
if (fromExpress.length) return fromExpress;
const fromOrder = Array.isArray(data.value?.packages) ? data.value.packages : [];
return fromOrder;
});
const activePkg = ref('0');
/** 详情内「发本包」回调(由列表传入时仅平台包裹可发) */
const onShipPackage = ref<null | ((warehouseId: number) => void)>(null);
/**
* Tab 文案:有仓名才拼括号;空串不兜底「平台/仓」
*/
function packageTabLabel(pkg: Record<string, any>, idx: number): string {
const no = pkg.package_no || idx + 1;
const name = String(pkg.warehouse_name || '').trim();
return name ? `包裹${no}${name}` : `包裹${no}`;
}
/**
* 收件人姓名脱敏:保留首字,其余用 *(与物流动态弹窗一致)
*/
function maskExpressName(name: unknown): string {
const str = String(name || '').trim();
if (!str) {
return '-';
}
if (str.length === 1) {
return `${str}*`;
}
return str.slice(0, 1) + '*'.repeat(str.length - 1);
}
/**
* 打开独立物流动态弹窗(完整轨迹)
*/
function openLogisticsModal() {
if (!data.value?.id) return;
logisticsModalApi.setData({ order_id: data.value.id });
logisticsModalApi.open();
}
/**
* 超管手动同步历史运单到分包裹表,成功后刷新详情与物流
*/
async function syncLegacyShipment() {
if (!data.value?.id || syncingLegacy.value) return;
syncingLegacy.value = true;
try {
const res = await syncLegacyShipmentApi(Number(data.value.id));
message.success(res?.message || '同步成功');
await reloadOrder();
deliveryMethod.value = data.value?.delivery_method;
await getExpressDetail();
} finally {
syncingLegacy.value = false;
}
}
@@ -196,12 +309,20 @@ function prescriptionStatusColor() {
}
</script>
<template>
<Modal class="w-[80%]" 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
@@ -249,7 +370,7 @@ function prescriptionStatusColor() {
{{ data.trans_expenses }}
</Descriptions.Item>
<Descriptions.Item label="是否免邮">
{{ data.free_ship === 1 ? '是' : '否' }}
{{ data.free_ship === 0 ? '是' : '否' }}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag v-if="data.status === 0" color="red">待支付</Tag>
@@ -350,14 +471,14 @@ function prescriptionStatusColor() {
<div class="mt-1 text-gray-600 dark:text-gray-400">
供货价{{
item.buy_price != null && item.buy_price !== ''
? `${item.buy_price} 元/g`
? `${item.buy_price} 元/${item.drug?.unit?.name || item.unit?.name || 'g'}`
: '—'
}}
</div>
<div class="mt-1 text-gray-600 dark:text-gray-400">
售价{{
item.buy_price != null && item.buy_price !== ''
? `${item.price} 元/g`
? `${item.price} 元/${item.drug?.unit?.name || item.unit?.name || 'g'}`
: '—'
}}
</div>
@@ -383,7 +504,7 @@ function prescriptionStatusColor() {
</div>
<div>
<h4>{{ item.drug_name }}</h4>
<p>规格: {{ item.drug.specification || 'g' }}</p>
<p>规格: {{ item.drug.specification || item.drug?.unit?.name || 'g' }}</p>
<p>数量: {{ item.number }}</p>
<p>单价: {{ item.price }} </p>
<p>
@@ -428,45 +549,114 @@ function prescriptionStatusColor() {
</Descriptions.Item>
</Descriptions>
</div>
<div v-if="deliveryMethod === 0">
<div class="mt-4">
<h3>物流信息</h3>
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
class="mt-4"
>
<Descriptions.Item label="物流公司">
{{ expressDetail.express_company_name }}
</Descriptions.Item>
<Descriptions.Item label="运单号">
{{ expressDetail.express_no }}
</Descriptions.Item>
<Descriptions.Item label="最新状态">
{{ expressDetail.state_txt }}
</Descriptions.Item>
</Descriptions>
</div>
<div class="mt-4">
<h3>物流追踪</h3>
<Timeline class="mt-4">
<Timeline.Item
v-for="(detail, index) in expressDetail.detail"
:key="index"
<div v-if="deliveryMethod === 0" class="mt-4">
<div class="flex items-center justify-between">
<h3 class="mb-0">物流信息</h3>
<Space>
<Button
v-if="canSyncLegacyShipment"
type="link"
size="small"
:loading="syncingLegacy"
@click="syncLegacyShipment"
>
<Tag :color="getColor(detail.status)">
{{ detail.status }}
</Tag>
<p>{{ detail.detail_at }}</p>
<p>{{ detail.detail }}</p>
</Timeline.Item>
</Timeline>
同步物流包裹
</Button>
<Button type="link" size="small" @click="openLogisticsModal">
查看物流动态
</Button>
</Space>
</div>
<!-- 详情内仅保留包裹发货状态完整轨迹进独立物流动态弹窗 -->
<Tabs
v-if="packageTabs.length"
v-model:active-key="activePkg"
type="card"
class="mt-4"
>
<Tabs.TabPane
v-for="(pkg, idx) in packageTabs"
:key="String(idx)"
:tab="packageTabLabel(pkg, idx)"
>
<div
class="mb-3 flex justify-end"
v-if="Number(pkg.is_send) === 0 && Number(pkg.warehouse_id) === 0 && onShipPackage"
>
<Button
type="primary"
size="small"
@click="onShipPackage(Number(pkg.warehouse_id) || 0)"
>
发本包裹
</Button>
</div>
<template v-if="Number(pkg.is_send) === 1 && pkg.express">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
>
<Descriptions.Item label="收件人">
{{
maskExpressName(
(expressDetail as any).express_name || data?.express_name,
)
}}
</Descriptions.Item>
<Descriptions.Item label="手机号">
<SensitiveText :record="pkg.express" field="mobile" />
</Descriptions.Item>
<Descriptions.Item label="物流公司">
{{ pkg.express.express_company_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="运单号">
{{ pkg.express.express_no || '-' }}
</Descriptions.Item>
<Descriptions.Item label="最新状态">
{{ pkg.express.state_txt || '-' }}
</Descriptions.Item>
</Descriptions>
</template>
<div v-else class="text-gray-400 py-2">
本包裹尚未发货
<span v-if="Array.isArray(pkg.items) && pkg.items.length">
{{ pkg.items.map((p: any) => p.drug_name || p.name).filter(Boolean).join('、') }}
</span>
</div>
</Tabs.TabPane>
</Tabs>
<Descriptions
v-else
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
class="mt-4"
>
<Descriptions.Item label="收件人">
{{
maskExpressName(
(expressDetail as any).express_name || data?.express_name,
)
}}
</Descriptions.Item>
<Descriptions.Item label="手机号">
<SensitiveText :record="expressDetail" field="mobile" />
</Descriptions.Item>
<Descriptions.Item label="物流公司">
{{ expressDetail.express_company_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="运单号">
{{ expressDetail.express_no || '-' }}
</Descriptions.Item>
<Descriptions.Item label="最新状态">
{{ expressDetail.state_txt || '-' }}
</Descriptions.Item>
</Descriptions>
</div>
</div>
</Modal>
<TraceDrawer />
<PercentAdjustDrawer />
<LogisticsModalComp />
</template>
<style scoped>
@@ -494,6 +684,14 @@ function prescriptionStatusColor() {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.mb-0 {
margin-bottom: 0;
}
.space-x-4 > * + * {
margin-left: 1rem;
}

View File

@@ -0,0 +1,434 @@
<script lang="ts" setup>
/**
* 独立「物流动态」弹窗:只请求 express-detail不展示整单其它信息。
* 仓名为空时 Tab 仅显示「包裹N」与后端诊所开关抹名约定一致。
* 已发货包裹展示脱敏收件人/查件手机号,并支持修改手机号与快递单号。
*/
import { computed, reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Button,
Descriptions,
Form,
FormItem,
Input,
Modal as AModal,
Spin,
Tabs,
Tag,
Timeline,
message,
} from 'ant-design-vue';
import ExpressCompanySelect from '#/components/form/components/express-company-select.vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { expressDetailByOrderId, updateExpressNosApi } from '../api';
defineOptions({
name: 'ProductOrderLogisticsModal',
});
const loading = ref(false);
const expressDetail = ref<Record<string, any>>({});
const activePkg = ref('0');
const orderId = ref<number | null>(null);
/** 改运单弹窗 */
const editVisible = ref(false);
const editSubmitting = ref(false);
const editForm = reactive({
express_no_id: 0,
express_no: '',
mobile: '',
express_company_code: '' as string | undefined,
});
/**
* 多包裹列表:以物流接口 packages 为准(门店角色已按开关抹仓名)
*/
const packageTabs = computed(() => {
const pkgs = expressDetail.value?.packages;
return Array.isArray(pkgs) ? pkgs : [];
});
/**
* Tab 文案:有仓名才拼括号,空串不兜底「平台/仓」
*/
function packageTabLabel(pkg: Record<string, any>, idx: number): string {
const no = pkg.package_no || idx + 1;
const name = String(pkg.warehouse_name || '').trim();
return name ? `包裹${no}${name}` : `包裹${no}`;
}
/**
* 收件人姓名脱敏:保留首字,其余用 *
*/
function maskExpressName(name: unknown): string {
const str = String(name || '').trim();
if (!str) {
return '-';
}
if (str.length === 1) {
return `${str}*`;
}
return str.slice(0, 1) + '*'.repeat(str.length - 1);
}
/**
* 解析运单 ID优先 express_no_id其次 express.id
*/
function resolveExpressNoId(
express: Record<string, any> | null | undefined,
fallbackId?: number,
): number {
const fromExpress = Number(express?.id || 0);
const fromFallback = Number(fallbackId || 0);
return fromExpress > 0 ? fromExpress : fromFallback;
}
/**
* 物流状态 Tag 颜色
*/
function getColor(status: any) {
switch (status) {
case '在途': {
return '';
}
case '揽收': {
return 'orange';
}
case '派件': {
return 'blue';
}
case '签收': {
return 'green';
}
default: {
return '';
}
}
}
async function loadExpress(id: number, keepActive = false) {
loading.value = true;
try {
expressDetail.value = (await expressDetailByOrderId({ order_id: id })) || {};
if (!keepActive) {
activePkg.value = '0';
}
} finally {
loading.value = false;
}
}
/**
* 打开改运单表单(包裹或旧单运单共用)
*/
function openEditExpress(payload: {
express_no_id: number;
express_no?: string;
mobile?: string;
express_company_code?: string;
}) {
const id = Number(payload.express_no_id || 0);
if (id <= 0) {
message.warning('缺少运单信息,无法修改');
return;
}
editForm.express_no_id = id;
editForm.express_no = String(payload.express_no || '');
editForm.mobile = String(payload.mobile || '');
editForm.express_company_code = payload.express_company_code || undefined;
editVisible.value = true;
}
/**
* 提交修改运单号 / 查件手机号 / 快递公司
*/
async function submitEditExpress() {
if (!editForm.express_no?.trim()) {
message.warning('请输入快递单号');
return;
}
if (!editForm.express_company_code) {
message.warning('请选择快递公司');
return;
}
if (!editForm.mobile?.trim()) {
message.warning('请输入手机号');
return;
}
editSubmitting.value = true;
try {
await updateExpressNosApi({
express_no_id: editForm.express_no_id,
express_no: editForm.express_no.trim(),
mobile: editForm.mobile.trim(),
express_company_code: editForm.express_company_code,
});
message.success('修改成功');
editVisible.value = false;
if (orderId.value) {
await loadExpress(orderId.value, true);
}
} finally {
editSubmitting.value = false;
}
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
showConfirmButton: false,
cancelText: '关闭',
onCancel() {
modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const payload = modalApi.getData<Record<string, any>>() || {};
const id = Number(payload.order_id || payload.id || 0);
orderId.value = id || null;
expressDetail.value = {};
editVisible.value = false;
if (id) {
loadExpress(id);
}
} else {
orderId.value = null;
expressDetail.value = {};
activePkg.value = '0';
editVisible.value = false;
}
},
});
</script>
<template>
<Modal class="w-[640px]" title="物流动态">
<Spin :spinning="loading">
<div v-if="!orderId" class="text-gray-400 py-6 text-center">
缺少订单信息
</div>
<template v-else>
<Tabs
v-if="packageTabs.length"
v-model:active-key="activePkg"
type="card"
>
<Tabs.TabPane
v-for="(pkg, idx) in packageTabs"
:key="String(idx)"
:tab="packageTabLabel(pkg, idx)"
>
<template v-if="Number(pkg.is_send) === 1 && pkg.express">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
>
<Descriptions.Item label="收件人">
{{ maskExpressName(expressDetail.express_name) }}
</Descriptions.Item>
<Descriptions.Item label="手机号">
<span class="inline-flex items-center gap-2">
<SensitiveText :record="pkg.express" field="mobile" />
<Button
type="link"
size="small"
class="!px-0"
@click="
openEditExpress({
express_no_id: resolveExpressNoId(
pkg.express,
pkg.express_no_id,
),
express_no: pkg.express.express_no,
mobile: pkg.express.mobile,
express_company_code: pkg.express.express_company_code,
})
"
>
修改
</Button>
</span>
</Descriptions.Item>
<Descriptions.Item label="物流公司">
{{ pkg.express.express_company_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="运单号">
<span class="inline-flex items-center gap-2">
<span>{{ pkg.express.express_no || '-' }}</span>
<Button
type="link"
size="small"
class="!px-0"
@click="
openEditExpress({
express_no_id: resolveExpressNoId(
pkg.express,
pkg.express_no_id,
),
express_no: pkg.express.express_no,
mobile: pkg.express.mobile,
express_company_code: pkg.express.express_company_code,
})
"
>
修改
</Button>
</span>
</Descriptions.Item>
<Descriptions.Item label="最新状态">
{{ pkg.express.state_txt || '-' }}
</Descriptions.Item>
</Descriptions>
<div class="mt-4">
<h4 class="mb-2">物流追踪</h4>
<Timeline
v-if="Array.isArray(pkg.express.detail) && pkg.express.detail.length"
>
<Timeline.Item
v-for="(detail, dIdx) in pkg.express.detail"
:key="dIdx"
>
<Tag :color="getColor(detail.status)">{{ detail.status }}</Tag>
<p>{{ detail.detail_at }}</p>
<p>{{ detail.detail }}</p>
</Timeline.Item>
</Timeline>
<div v-else class="text-gray-400">暂无物流轨迹</div>
</div>
</template>
<div v-else class="text-gray-400 py-2">本包裹尚未发货</div>
</Tabs.TabPane>
</Tabs>
<template v-else>
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
>
<Descriptions.Item label="收件人">
{{ maskExpressName(expressDetail.express_name) }}
</Descriptions.Item>
<Descriptions.Item label="手机号">
<span class="inline-flex items-center gap-2">
<SensitiveText :record="expressDetail" field="mobile" />
<Button
v-if="resolveExpressNoId(expressDetail, expressDetail.express_no_id)"
type="link"
size="small"
class="!px-0"
@click="
openEditExpress({
express_no_id: resolveExpressNoId(
expressDetail,
expressDetail.express_no_id,
),
express_no: expressDetail.express_no,
mobile: expressDetail.mobile,
express_company_code: expressDetail.express_company_code,
})
"
>
修改
</Button>
</span>
</Descriptions.Item>
<Descriptions.Item label="物流公司">
{{ expressDetail.express_company_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="运单号">
<span class="inline-flex items-center gap-2">
<span>{{ expressDetail.express_no || '-' }}</span>
<Button
v-if="resolveExpressNoId(expressDetail, expressDetail.express_no_id)"
type="link"
size="small"
class="!px-0"
@click="
openEditExpress({
express_no_id: resolveExpressNoId(
expressDetail,
expressDetail.express_no_id,
),
express_no: expressDetail.express_no,
mobile: expressDetail.mobile,
express_company_code: expressDetail.express_company_code,
})
"
>
修改
</Button>
</span>
</Descriptions.Item>
<Descriptions.Item label="最新状态">
{{ expressDetail.state_txt || '-' }}
</Descriptions.Item>
</Descriptions>
<div class="mt-4">
<h4 class="mb-2">物流追踪</h4>
<Timeline
v-if="Array.isArray(expressDetail.detail) && expressDetail.detail.length"
>
<Timeline.Item
v-for="(detail, index) in expressDetail.detail"
:key="index"
>
<Tag :color="getColor(detail.status)">{{ detail.status }}</Tag>
<p>{{ detail.detail_at }}</p>
<p>{{ detail.detail }}</p>
</Timeline.Item>
</Timeline>
<div v-else class="text-gray-400">暂无物流轨迹</div>
</div>
</template>
</template>
</Spin>
</Modal>
<!-- 修改运单号 / 查件手机号与发货表单字段对齐改单号时可重匹配公司 -->
<AModal
v-model:open="editVisible"
title="修改运单信息"
:confirm-loading="editSubmitting"
destroy-on-close
@ok="submitEditExpress"
>
<Form layout="vertical" class="mt-2">
<FormItem label="快递单号" required>
<Input
v-model:value="editForm.express_no"
placeholder="请输入快递单号"
allow-clear
/>
</FormItem>
<FormItem label="快递公司" required>
<ExpressCompanySelect
v-model:value="editForm.express_company_code"
placeholder="请选择快递公司"
:tracking-no="editForm.express_no"
/>
</FormItem>
<FormItem label="手机号" required>
<Input
v-model:value="editForm.mobile"
placeholder="请输入查件手机号"
allow-clear
/>
</FormItem>
</Form>
</AModal>
</template>
<style scoped>
.mt-4 {
margin-top: 1rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
</style>

View File

@@ -27,9 +27,16 @@ const [Modal, modalApi] = useVbenModal({
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
// 仅提交合法非负整数 warehouse_id脏值不传由后端按角色默认平台包 0
const wid = Number(values.warehouse_id);
const payload: Record<string, any> = { ...values };
if (Number.isFinite(wid) && wid >= 0 && String(values.warehouse_id) !== '[object Object]') {
payload.warehouse_id = wid;
} else {
delete payload.warehouse_id;
}
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = sendOrder;
submitApi(values)
sendOrder(payload)
.then(() => {
message.success('发货成功');
gridApi.value?.reload();
@@ -45,7 +52,9 @@ const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values, update } = modalApi.getData<Record<string, any>>();
const { values, update } = modalApi.getData<Record<string, any>>() || {};
// 先重置,避免上次发货残留的 warehouse_id
formApi.resetForm();
if (values) {
orderNo.value = values.order_no;
isUpdate.value = update;
@@ -56,7 +65,7 @@ const [Modal, modalApi] = useVbenModal({
});
</script>
<template>
<Modal :title="`订单:【 ${orderNo} 】发货`" class="w-[30%]">
<Modal :title="`订单:【 ${orderNo} 】发货`" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
<Form />
</Modal>
</template>

View File

@@ -54,7 +54,7 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal
title="退款"
class="w-[30%]"
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
>
<Form />
</Modal>

View File

@@ -22,6 +22,17 @@ export const modalFormProps: VbenFormProps = {
triggerFields: ['oreder_id'],
},
},
{
// 分包裹发货0=平台包;>0=配送仓(默认 0
component: 'VbenInput',
fieldName: 'warehouse_id',
label: '发货方',
defaultValue: 0,
dependencies: {
show: false,
triggerFields: ['warehouse_id'],
},
},
{
component: 'VbenInput',
componentProps: {
@@ -40,6 +51,16 @@ export const modalFormProps: VbenFormProps = {
formItemClass: 'col-span-6',
label: '快递公司',
rules: 'required',
// 单号变化时传入 trackingNo组件按前缀自动匹配快递公司
dependencies: {
triggerFields: ['express_no'],
componentProps(values) {
return {
placeholder: '请选择快递公司',
trackingNo: values.express_no,
};
},
},
},
{
component: 'Textarea',

View File

@@ -1,8 +1,10 @@
import type { VbenFormProps } from '#/adapter/form';
import dayjs from 'dayjs';
import {getOrderStatusOption} from "#/views/business/order/product-order/api";
import {getStoreOption} from "#/views/system/store/api";
import {
getOrderPrescriptionTypeOption,
getOrderStatusOption,
} from '#/views/business/order/product-order/api';
export const formOptions: VbenFormProps = {
// 默认展开
@@ -18,22 +20,13 @@ export const formOptions: VbenFormProps = {
label: '订单号',
},
{
component: 'ApiSelect',
// 门店多选:名称/拼音首拼气泡选择,诊所+药店
component: 'StoreMultiSearch',
componentProps: {
allowClear: true,
filterOption: true,
// showSearch: true,
// 菜单接口转options格式
afterFetch: (data: { id: number; name: string }[]) => {
return data.map((item: any) => ({
label: `${item.name}${item.id}`,
value: item.id,
}));
},
api: getStoreOption,
placeholder: '请选择',
placeholder: '输入名称或拼音首拼',
},
fieldName: 'store_id',
defaultValue: [],
fieldName: 'store_ids',
label: '门店',
},
{
@@ -41,8 +34,6 @@ export const formOptions: VbenFormProps = {
componentProps: {
allowClear: true,
filterOption: true,
// showSearch: true,
// 菜单接口转options格式
afterFetch: (data: { id: number; name: string }[]) => {
return data.map((item: any) => ({
label: item.name,
@@ -77,29 +68,19 @@ export const formOptions: VbenFormProps = {
label: '发货方式',
},
{
component: 'VbenSelect',
// 类型从后端枚举拉取,避免前端写死漏掉非药品/医疗器械等
component: 'ApiSelect',
componentProps: {
allowClear: true,
filterOption: true,
showSearch: true,
options: [
{
label: '中药',
value: 1,
},
{
label: '西药',
value: 2,
},
{
label: '中成药',
value: 3,
},
{
label: '产品服务包',
value: 5,
},
],
afterFetch: (data: { id: number; name: string }[]) => {
return data.map((item) => ({
label: item.name,
value: item.id,
}));
},
api: getOrderPrescriptionTypeOption,
placeholder: '请选择',
},
fieldName: 'prescription_type',
@@ -117,21 +98,21 @@ 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: '时间范围',
},
],
// 控制表单是否显示折叠按钮
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
// 是否在字段值改变时提交表单
// submitOnChange: true,
// submitOnChange: true,
// 按下回车时是否提交表单
submitOnEnter: false,
};

View File

@@ -17,7 +17,7 @@ export const gridOptions: VxeGridProps<RowType> = {
},
columns: [
{ type: 'expand', width: 80, slots: { content: 'expand-content' } },
{ field: 'id', align: 'left', title: 'ID', width: 100 },
{ field: 'id', align: 'left', title: 'ID', width: 50 },
{
field: 'order_no',
align: 'left',
@@ -37,6 +37,12 @@ export const gridOptions: VxeGridProps<RowType> = {
width: 200,
slots: { default: 'express-info' },
},
{
field: 'delivery_warehouses',
title: '配送仓库',
width: 160,
slots: { default: 'delivery-warehouses' },
},
{
field: 'delivery_method',
title: '订单类型&邮寄方式&订单状态',

View File

@@ -2,7 +2,7 @@
import type { VbenFormProps } from '#/adapter/form';
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
@@ -15,7 +15,18 @@ import {
import { useUserStore } from '@vben/stores';
import { SvgCakeIcon } from '@vben/icons';
import { Button, Image, message, Modal as AntdModal, Popconfirm, Popover, Space, Switch, Table, Tag } from 'ant-design-vue';
import {
Button,
Image,
message,
Modal as AntdModal,
Popconfirm,
Popover,
Space,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import dayjs from 'dayjs';
@@ -24,13 +35,20 @@ import { TableAction } from '#/components/table-action';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import {
cancelOrderApi,
changeDeliveryWarehouseApi,
getOrderInfo,
getOrderList,
getVerifyRecentOrderAmounts,
saleAmountApi,
updateFreeShipping,
} from '#/views/business/order/product-order/api';
import { simulatePayApi, accrueSalespersonCommissionApi, reverseSalespersonCommissionApi } from '#/views/business/order/api/order-ops';
import { getDeliveryWarehouseOptionsByDrugs } from '#/views/doctor/doctor-reception/api';
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';
@@ -39,9 +57,15 @@ import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
import { normalizeQuickOptions } from '#/utils/pricePercentAdjust';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import {
WxUserPatientDetailModal,
WxUserPatientsModal,
} from '#/components/wx-user-patient';
import DetailModal from './components/detail.vue';
import OrderUserInfoCell from './components/cells/OrderUserInfoCell.vue';
import LogisticsModal from './components/logistics-modal.vue';
import FormModalDemo from './components/modal.vue';
import ProductOrderExportModal from './components/ProductOrderExportModal.vue';
import Refund from './components/refund.vue';
@@ -120,8 +144,8 @@ function buildFormOptionsFromRoute(): VbenFormProps {
const rawTimeScope = route.query.time_scope;
const timeScope = Array.isArray(rawTimeScope) ? rawTimeScope[0] : rawTimeScope;
const schema = (formOptions.schema ?? []).map((item) => {
if (item.fieldName === 'store_id') {
return { ...item, defaultValue: Number(storeId) };
if (item.fieldName === 'store_ids') {
return { ...item, defaultValue: [Number(storeId)] };
}
if (item.fieldName === 'search_time') {
if (timeScope === 'month') {
@@ -167,6 +191,10 @@ const [FormModal, formModalApi] = useVbenModal({
const [Modal, modalApi] = useVbenModal({
connectedComponent: DetailModal,
});
/** 独立物流动态弹窗:只拉 express-detail与订单详情解耦 */
const [LogisticsModalComp, logisticsModalApi] = useVbenModal({
connectedComponent: LogisticsModal,
});
const [RefundModal, RefundModalApi] = useVbenModal({
connectedComponent: Refund,
});
@@ -175,22 +203,51 @@ const [ExportModal, exportModalApi] = useVbenModal({
connectedComponent: ProductOrderExportModal,
});
const infoModal = (data = {}) => {
const infoModal = (data: Record<string, any> = {}) => {
modalApi.setData({
// 表单值
// 带 id 走详情接口拿到 packagesvalues 作首屏兜底
id: data?.id,
values: data,
gridApi,
// 详情内「发本包裹」:关详情后打开发货弹窗(仅平台包会传 0
onShipPackage: (warehouseId: number) => {
modalApi.close();
wareSend(data, warehouseId);
},
});
modalApi.open();
};
const wareSend = (data = {}) => {
/**
* 打开物流动态:仅传 order_id弹窗内单独请求物流接口
*/
const openLogisticsModal = (data: Record<string, any> = {}) => {
logisticsModalApi.setData({
order_id: data?.id,
});
logisticsModalApi.open();
};
/**
* 打开发货弹窗。
* 列表不传 warehouse_id后端平台默认 0详情按包发时仅传合法非负整数。
* 注意:勿用 bind(null,row)table-action 的 click 会把事件当成第 2 参污染 warehouse_id。
*/
const wareSend = (data: Record<string, any> = {}, warehouseId?: number) => {
const values: Record<string, any> = {
order_id: data?.id,
order_no: data?.order_no,
};
if (
warehouseId !== undefined &&
warehouseId !== null &&
Number.isFinite(Number(warehouseId)) &&
Number(warehouseId) >= 0
) {
values.warehouse_id = Number(warehouseId);
}
formModalApi.setData({
// 表单值
values: {
order_id: data?.id,
order_no: data?.order_no,
},
values,
gridApi,
});
formModalApi.open();
@@ -205,12 +262,15 @@ const collapseAll = () => {
const overviewItems = ref<AnalysisOverviewItem[]>([]);
const income = ref(0);
const total = ref(0);
/** 按当前列表筛选条件刷新顶部销售金额/收益(门店参数后端字段名为 id */
/** 按当前列表筛选条件刷新顶部销售金额/收益(支持门店多选 store_ids */
const saleAmount = (formValues?: Record<string, any>) => {
const values = formValues ?? gridApi.formApi.latestSubmissionValues ?? {};
saleAmountApi({
search_time: values.search_time,
id: values.store_id,
store_ids: values.store_ids,
id: Array.isArray(values.store_ids) && values.store_ids.length === 1
? values.store_ids[0]
: undefined,
}).then((res) => {
income.value = res.income;
total.value = res.total;
@@ -241,6 +301,24 @@ const [DoctorCardModals, DoctorCardModalApi] = useVbenModal({
connectedComponent: DoctorCardModal,
});
const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
connectedComponent: StoreCardModal,
});
function openStoreCard(storeId?: number) {
if (!storeId) return;
StoreCardModalApi.setData({ storeId: Number(storeId) });
StoreCardModalApi.open();
}
const [PatientsModal, PatientsModalApi] = useVbenModal({
connectedComponent: WxUserPatientsModal,
});
const [PatientDetailModal, PatientDetailModalApi] = useVbenModal({
connectedComponent: WxUserPatientDetailModal,
});
/** 从订单列表以只读模式打开医生档案 */
function showOrderDoctorCard(row: Record<string, any>) {
const doctor = row.doctor;
@@ -256,6 +334,37 @@ function showOrderDoctorCard(row: Record<string, any>) {
DoctorCardModalApi.open();
}
/** 点下单用户 → 该用户下就诊人列表 Modal */
function openOrderUserPatients(payload: {
userId: number;
nickname: string;
avatarurl: string;
}) {
if (!payload?.userId) {
message.warning('缺少用户信息');
return;
}
PatientsModalApi.setData({
userId: payload.userId,
nickname: payload.nickname,
avatarurl: payload.avatarurl,
});
PatientsModalApi.open();
}
/** 点就诊人 → 详情 Modal */
function openOrderPatient(payload: { upId: number; patientName: string }) {
if (!payload?.upId) {
message.warning('缺少就诊人信息');
return;
}
PatientDetailModalApi.setData({
upId: payload.upId,
patientName: payload.patientName,
});
PatientDetailModalApi.open();
}
const [TraceDrawer, traceDrawerApi] = useVbenDrawer({
connectedComponent: OrderTraceDrawer,
});
@@ -342,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[]>([]);
@@ -450,6 +582,173 @@ async function handleCancelOrder(row: Record<string, any>) {
}
}
/** 订单页改仓弹窗状态(按药分别选仓) */
const changeWhOpen = ref(false);
const changeWhSubmitting = ref(false);
const changeWhLoading = ref(false);
const changeWhOrderId = ref(0);
const changeWhFromId = ref(0);
const changeWhFromName = ref('');
/** 弹窗内药品行:每药独立选 to_warehouse_id */
const changeWhDrugRows = ref<
Array<{
order_item_id: number;
drug_id: number;
drug_name: string;
image: string;
specification: string;
number: number;
from_warehouse_id: number;
to_warehouse_id: number;
options: Array<{
warehouse_id: number;
warehouse_name: string;
quote: string | null;
disabled?: boolean;
}>;
}>
>([]);
const changeWhPendingCount = computed(
() =>
changeWhDrugRows.value.filter(
(r) => Number(r.to_warehouse_id) !== Number(r.from_warehouse_id),
).length,
);
/**
* 为单药构造仓卡片:本仓库 + 该药 options排除不可用
*/
function buildDrugWarehouseCards(
drugOptions: any[],
_fromWarehouseId: number,
): Array<{
warehouse_id: number;
warehouse_name: string;
quote: string | null;
disabled?: boolean;
}> {
const local = {
warehouse_id: 0,
warehouse_name: '萧康医药本仓库',
quote: null as string | null,
};
const seen = new Set<number>([0]);
const cards = [local];
for (const opt of drugOptions || []) {
const id = Number(opt?.warehouse_id ?? 0);
if (id <= 0 || seen.has(id)) continue;
seen.add(id);
cards.push({
warehouse_id: id,
warehouse_name: String(opt?.warehouse_name ?? `仓库#${id}`),
quote: String(opt?.quote ?? '0'),
});
}
return cards;
}
/**
* 点击仓库 Tag打开按药改仓弹窗
*/
async function openChangeWarehouse(
row: Record<string, any>,
wh: { id: number; name: string },
) {
changeWhOrderId.value = Number(row.id ?? 0);
changeWhFromId.value = Number(wh?.id ?? 0);
changeWhFromName.value = String(wh?.name ?? '');
changeWhDrugRows.value = [];
changeWhOpen.value = true;
changeWhLoading.value = true;
try {
const items = Array.isArray(row.product_order_items)
? row.product_order_items
: [];
const fromId = changeWhFromId.value;
const scoped = items.filter((it: any) => {
if (
it?.delivery_warehouse_id === undefined ||
it?.delivery_warehouse_id === null
) {
return true;
}
const wid = Number(it.delivery_warehouse_id ?? 0);
return fromId > 0 ? wid === fromId : wid <= 0;
});
const list = scoped.length ? scoped : items;
const drugIds = [
...new Set(
list
.map((it: any) => Number(it?.drug_id ?? 0))
.filter((id: number) => id > 0),
),
];
if (list.length === 0) {
message.warning('该仓库下没有可改仓的药品');
return;
}
const res =
drugIds.length > 0
? await getDeliveryWarehouseOptionsByDrugs({ drug_ids: drugIds })
: {};
const map = (res || {}) as Record<string, any[]>;
changeWhDrugRows.value = list.map((it: any) => {
const drugId = Number(it?.drug_id ?? 0);
const itemId = Number(it?.id ?? 0);
const opts = map[String(drugId)] || map[drugId] || [];
const fromWid = Number(it?.delivery_warehouse_id ?? fromId ?? 0);
return {
order_item_id: itemId,
drug_id: drugId,
drug_name: String(it?.drug_name || it?.name || `药品#${drugId}`),
image: String(it?.image || it?.drug?.image || ''),
specification: String(
it?.specification || it?.drug?.specification || '',
),
number: Number(it?.number ?? it?.select_number ?? 1),
from_warehouse_id: fromWid,
// 默认仍为当前仓,用户需主动点选目标仓
to_warehouse_id: fromWid,
options: buildDrugWarehouseCards(opts, fromWid),
};
});
} catch (error: any) {
message.error(error?.message || '加载配送仓库失败');
} finally {
changeWhLoading.value = false;
}
}
/** 确认按药改仓 */
async function submitChangeWarehouse() {
const items = changeWhDrugRows.value
.filter((r) => Number(r.to_warehouse_id) !== Number(r.from_warehouse_id))
.map((r) => ({
order_item_id: r.order_item_id,
to_warehouse_id: Number(r.to_warehouse_id),
}));
if (items.length === 0) {
message.warning('请至少为一种药品选择新的配送仓库');
return;
}
changeWhSubmitting.value = true;
try {
await changeDeliveryWarehouseApi({
order_id: changeWhOrderId.value,
from_warehouse_id: changeWhFromId.value,
items,
});
message.success(`改仓成功(${items.length} 种药品)`);
changeWhOpen.value = false;
await gridApi.query();
} catch (error: any) {
message.error(error?.message || '改仓失败');
} finally {
changeWhSubmitting.value = false;
}
}
const toggleFreeShipping = async (row: any, checked: boolean) => {
try {
const res = await updateFreeShipping({ id: row.id, is_free_shipping: checked ? 1 : 0 });
@@ -511,6 +810,7 @@ const openOrderAmountVerify = () => {
<Page auto-content-height title="订单管理">
<FormModal />
<Modal />
<LogisticsModalComp />
<AntdModal
v-model:open="verifyOpen"
title="近10分钟订单金额校验"
@@ -560,6 +860,9 @@ const openOrderAmountVerify = () => {
</AntdModal>
<RefundModal />
<DoctorCardModals />
<StoreCardModalComp />
<PatientsModal />
<PatientDetailModal />
<ExportModal />
<PrescriptionDetailModal />
<TraceDrawer />
@@ -607,7 +910,12 @@ const openOrderAmountVerify = () => {
</TableAction>
</template>
<template #order-user-info="{ row }">
<OrderUserInfoCell :row="row" @open-doctor="showOrderDoctorCard" />
<OrderUserInfoCell
:row="row"
@open-doctor="showOrderDoctorCard"
@open-user-patients="openOrderUserPatients"
@open-patient="openOrderPatient"
/>
</template>
<template #order-store="{ row }">
<div class="leading-snug">
@@ -618,7 +926,18 @@ const openOrderAmountVerify = () => {
<Tag v-else color="default">线下就诊</Tag>
</div>
<div class="font-medium">{{ row.order_no }}</div>
<div class="text-xs text-gray-500">{{ row.store?.name || '—' }}</div>
<div class="text-xs text-gray-500">
<Button
v-if="row.store?.id"
class="!h-auto max-w-[xxx] whitespace-normal break-words !px-0 text-left"
size="small"
type="link"
@click="openStoreCard(row.store.id)"
>
{{ row.store?.name || '—' }}
</Button>
<span v-else>{{ row.store?.name || '' }}</span>
</div>
<div class="text-xs text-gray-600">下单时间 {{ row.created_at || '—' }}</div>
</div>
</template>
@@ -643,6 +962,24 @@ const openOrderAmountVerify = () => {
<div class="text-xs text-gray-500">{{ formatExpressAddress(row) }}</div>
</div>
</template>
<!-- 患者配送仓库点击 Tag 可改仓本仓库 / 配送仓 -->
<template #delivery-warehouses="{ row }">
<div
v-if="Array.isArray(row.delivery_warehouses) && row.delivery_warehouses.length"
class="flex flex-wrap gap-1"
>
<Tag
v-for="wh in row.delivery_warehouses"
:key="wh.id"
color="processing"
class="cursor-pointer"
@click="openChangeWarehouse(row, wh)"
>
{{ wh.name }}
</Tag>
</div>
<span v-else class="text-[hsl(var(--muted-foreground))]">-</span>
</template>
<template #price-info="{ row }">
<div class="space-y-0.5 text-sm leading-snug">
<div>
@@ -958,6 +1295,19 @@ const openOrderAmountVerify = () => {
// auth: ['order', 'sys:role:detail'],
onClick: infoModal.bind(null, row),
},
{
label: '物流动态',
type: 'link',
icon: 'mdi:truck-delivery-outline',
size: 'small',
// 上门快递且已支付、非取消/待支付:可单独查看物流
ifShow:
row.delivery_method === 0 &&
row.is_pay === 1 &&
row.status !== 0 &&
row.status !== 9,
onClick: openLogisticsModal.bind(null, row),
},
{
label: '溯源',
type: 'link',
@@ -981,16 +1331,25 @@ 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',
icon: 'ri:send-plane-fill',
auth: ['Super Admin','Admin'],
onClick: wareSend.bind(null, row),
// popConfirm: {
// title: '确定发货吗?',
// confirm: wareSend.bind(null, row),
// },
onClick: () => wareSend(row),
},
]"
:drop-down-actions="[
@@ -1102,14 +1461,14 @@ const openOrderAmountVerify = () => {
<div class="mt-1 text-gray-600 dark:text-gray-400">
供货价:{{
item.buy_price != null && item.buy_price !== ''
? `${item.buy_price} 元/g`
? `${item.buy_price} 元/${item.drug?.unit?.name || item.unit?.name || 'g'}`
: '—'
}}
</div>
<div class="mt-1 text-gray-600 dark:text-gray-400">
售价:{{
item.buy_price != null && item.buy_price !== ''
? `${item.price} 元/g`
? `${item.price} 元/${item.drug?.unit?.name || item.unit?.name || 'g'}`
: '—'
}}
</div>
@@ -1128,6 +1487,86 @@ const openOrderAmountVerify = () => {
</template>
</Grid>
<PercentAdjustDrawer />
<!-- 订单改仓:按药分别选仓 -->
<AntdModal
v-model:open="changeWhOpen"
title="修改配送仓库"
:confirm-loading="changeWhSubmitting"
:ok-button-props="{ disabled: changeWhPendingCount <= 0 }"
destroy-on-close
width="720px"
@ok="submitChangeWarehouse"
>
<div class="text-muted-foreground mb-3 text-sm">
当前仓库:{{ changeWhFromName || '—' }}
<span v-if="!changeWhLoading" class="ml-2">
· 将改仓 {{ changeWhPendingCount }} 种药品
</span>
</div>
<div v-if="changeWhLoading" class="text-muted-foreground py-4 text-center">
加载可选仓库…
</div>
<div v-else class="change-wh-drug-list space-y-4">
<div
v-for="row in changeWhDrugRows"
:key="row.order_item_id"
class="border-border rounded-lg border p-3"
>
<div class="mb-2 flex items-start gap-3">
<img
v-if="row.image"
:src="row.image"
alt=""
class="h-12 w-12 shrink-0 rounded object-cover"
/>
<div
v-else
class="bg-muted text-muted-foreground flex h-12 w-12 shrink-0 items-center justify-center rounded text-xs"
>
无图
</div>
<div class="min-w-0 flex-1">
<div class="text-foreground truncate font-medium">
{{ row.drug_name }}
</div>
<div class="text-muted-foreground mt-0.5 text-xs">
规格:{{ row.specification || '--' }} · 数量 {{ row.number }}
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="opt in row.options"
:key="`${row.order_item_id}-${opt.warehouse_id}`"
type="button"
class="min-w-[120px] rounded-lg border px-3 py-2 text-left transition-colors"
:class="
row.to_warehouse_id === opt.warehouse_id
? 'border-primary bg-primary/10 ring-primary ring-1'
: 'border-border hover:border-primary/50'
"
@click="row.to_warehouse_id = opt.warehouse_id"
>
<div class="text-foreground text-sm font-medium">
{{ opt.warehouse_name }}
</div>
<div
v-if="opt.quote != null"
class="text-muted-foreground mt-1 text-xs"
>
供货价 {{ opt.quote }}
</div>
</button>
</div>
</div>
<div
v-if="changeWhDrugRows.length === 0"
class="text-muted-foreground text-sm"
>
暂无可改仓药品
</div>
</div>
</AntdModal>
</Page>
</template>
<style scoped lang="scss">

View File

@@ -54,7 +54,7 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal
title="退款"
class="w-[30%]"
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
>
<Form />
</Modal>

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

@@ -15,7 +15,7 @@ export const gridOptions: VxeGridProps<RegisterOrderItem> = {
{ field: 'doctor_info.name', title: '开方医生' },
{ field: 'doctor_info.depart.name', title: '科室' },
{ field: 'user_patient.name', title: '就诊人名称' },
{ field: 'store.name', title: '开方诊所' },
{ field: 'store.name', title: '开方诊所', slots: { default: 'store-name' } },
{ field: 'salesperson', title: '推广员', slots: { default: 'salesperson' } },
{ field: 'type', title: '挂号类型', slots: { default: 'type' } },
{ field: 'prescription', title: '处方', width: 240, slots: { default: 'prescription'} },

View File

@@ -10,6 +10,7 @@ import { Button, Image, message, Modal as AntdModal, Tag } from "ant-design-vue"
import { useVbenVxeGrid } from "#/adapter/vxe-table";
import { TableAction } from "#/components/table-action";
import StoreCardModal from "#/components/store-card/StoreCardModal.vue";
import { simulatePayApi } from "#/views/business/order/api/order-ops";
import PrescriptionDetail from "#/views/doctor/doctor-reception/components/PrescriptionDetail.vue";
@@ -38,6 +39,16 @@ const [Grid, gridApi] = useVbenVxeGrid({
gridEvents
});
const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
connectedComponent: StoreCardModal,
});
function openStoreCard(storeId?: number) {
if (!storeId) return;
StoreCardModalApi.setData({ storeId: Number(storeId) });
StoreCardModalApi.open();
}
const route = useRoute();
onMounted(() => {
@@ -108,6 +119,7 @@ function formatRegisterPrice(price: unknown) {
<Page auto-content-height title="订单管理">
<PrescriptionDetailModal />
<RefundModal />
<StoreCardModalComp />
<Grid>
<template #toolbar-buttons>
<TableAction :actions="[]" :drop-down-actions="[]">
@@ -119,6 +131,18 @@ function formatRegisterPrice(price: unknown) {
</template>
</TableAction>
</template>
<template #store-name="{ row }">
<Button
v-if="row.store?.id || row.store_id"
type="link"
size="small"
class="!px-0"
@click="openStoreCard(row.store?.id || row.store_id)"
>
{{ row.store?.name || "—" }}
</Button>
<span v-else>{{ row.store?.name || "" }}</span>
</template>
<template #prescription="{ row }">
<div v-for="item in row.prescription" :key="item.id">
<Button type="link" @click="openPrescriptionDetail(item.id)">

View File

@@ -0,0 +1,34 @@
import { requestClient } from '#/api/request';
// 后端路由前缀patient-call-log-dictroutes/admin.php 中 cc_auto_route_register 注册)
const prefix = 'patient-call-log-dict/';
/** 列表(分页 + 搜索 name/type/status */
export async function getPatientCallLogDictList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/** 下拉:可选 type 过滤type=1 结果 / type=2 标签) */
export async function getPatientCallLogDictOption(data?: any) {
return requestClient.get<any>(`${prefix}option`, { params: data });
}
/** 详情 */
export async function getPatientCallLogDictInfo(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/** 新增 */
export async function createPatientCallLogDict(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
/** 更新 */
export async function updatePatientCallLogDict(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
/** 删除软删ids 数组) */
export async function deletePatientCallLogDict(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createPatientCallLogDict,
updatePatientCallLogDict,
} from '../api';
import { modalFormProps } from '../config/form';
const isUpdate = ref(false);
const gridApi = ref();
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
? updatePatientCallLogDict
: createPatientCallLogDict;
submitApi(values)
.then(() => {
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values, update } = modalApi.getData<Record<string, any>>();
if (values && update) {
// 编辑:回填后端返回的字段
isUpdate.value = true;
formApi.setValues({
id: values.id || '',
type: values.type ?? 1,
name: values.name || '',
value: values.value || '',
color: values.color || '#6acdbb',
sort: values.sort ?? 0,
status: values.status ?? 1,
});
} else {
// 新增:默认值
isUpdate.value = false;
formApi.resetForm();
formApi.setValues({
id: '',
type: 1,
name: '',
value: '',
color: '#6acdbb',
sort: 0,
status: 1,
});
}
} else {
formApi.resetForm();
}
},
});
</script>
<template>
<Modal :title="`${isUpdate === true ? '编辑' : '新增'}回访字典`" class="w-[50%]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,96 @@
import type { VbenFormProps } from '#/adapter/form';
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
formItemClass: 'col-span-6',
defaultValue: '',
dependencies: {
show: false,
triggerFields: ['id'],
},
},
{
// 1 回访结果 / 2 回访标签;区分后续列表渲染
component: 'VbenSelect',
componentProps: {
placeholder: '请选择类型',
options: [
{ label: '回访结果', value: 1 },
{ label: '回访标签', value: 2 },
],
},
fieldName: 'type',
label: '类型',
rules: 'required',
defaultValue: 1,
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入展示名(如 接通/关怀)',
},
fieldName: 'name',
label: '名称',
rules: 'required',
defaultValue: '',
},
{
// 小程序存储的英文/拼音 value与历史记录匹配
component: 'VbenInput',
componentProps: {
placeholder: '请输入存储值(如 connected/care',
},
fieldName: 'value',
label: '存储值',
rules: 'required',
defaultValue: '',
},
{
// hex 颜色,用于小程序彩色 tag
component: 'VbenInput',
componentProps: {
placeholder: '请输入 hex 颜色,如 #6acdbb',
},
fieldName: 'color',
label: '颜色',
defaultValue: '#6acdbb',
},
{
component: 'VbenInputNumber',
componentProps: {
min: 0,
precision: 0,
},
fieldName: 'sort',
label: '排序',
defaultValue: 0,
},
{
component: 'VbenSelect',
componentProps: {
placeholder: '请选择状态',
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
},
fieldName: 'status',
label: '状态',
formItemClass: 'col-span-6',
defaultValue: 1,
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,45 @@
import type { VbenFormProps } from '@vben/common-ui';
// 列表搜索:按名称模糊、类型精确、状态精确
export const formOptions: VbenFormProps = {
layout: 'inline',
showResetButton: true,
showSubmitButton: true,
schemas: [
{
fieldName: 'name',
component: 'Input',
label: '名称',
componentProps: {
placeholder: '请输入名称',
allowClear: true,
},
},
{
fieldName: 'type',
component: 'Select',
label: '类型',
componentProps: {
placeholder: '请选择类型',
allowClear: true,
options: [
{ label: '回访结果', value: 1 },
{ label: '回访标签', value: 2 },
],
},
},
{
fieldName: 'status',
component: 'Select',
label: '状态',
componentProps: {
placeholder: '请选择状态',
allowClear: true,
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
},
},
],
};

View File

@@ -0,0 +1,79 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getPatientCallLogDictList } from '../api';
interface RowType {
id: number;
type: number;
type_txt: string;
name: string;
value: string;
color: string;
sort: number;
status: number;
status_txt: string;
created_at: string;
updated_at: string;
}
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: '',
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
isHover: true,
isCurrent: true,
},
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'type_txt', align: 'left', title: '类型', width: 110 },
{ field: 'name', align: 'left', title: '名称', minWidth: 140 },
{ field: 'value', align: 'left', title: '存储值', minWidth: 140 },
{
field: 'color',
align: 'left',
title: '颜色',
width: 120,
// 用色块+hex 直观预览,避免放函数调用导致渲染异常
slots: { default: 'color' },
},
{ field: 'sort', align: 'left', title: '排序', width: 90 },
{ field: 'status_txt', align: 'left', title: '状态', width: 90 },
{ field: 'created_at', title: '创建时间', width: 180 },
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 160 },
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPatientCallLogDictList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-buttons',
},
custom: {
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};

View File

@@ -0,0 +1,124 @@
<script lang="ts" setup>
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { deletePatientCallLogDict } from './api';
import DictModal from './components/modal.vue';
import { formOptions as searchFormOptions } from './config/search';
import { gridOptions } from './config/table';
const hasTopTableDropDownActions = ref(false);
const gridEvents: VxeGridListeners<any> = {
checkboxChange() {
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
checkboxAll() {
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: searchFormOptions,
gridOptions,
gridEvents,
});
const [DictFormModal, dictFormModalApi] = useVbenModal({
connectedComponent: DictModal,
});
const showDictModal = (data = {}, isUpdate = false) => {
dictFormModalApi.setData({
values: data,
update: isUpdate,
gridApi,
});
dictFormModalApi.open();
};
const deleteDictApi = (row: any) => {
let ids: (string | number)[] = [];
if (row) {
ids.push(row);
} else {
ids = gridApi.grid.getCheckboxRecords().map((item: any) => item.id);
}
deletePatientCallLogDict({ ids }).then(() => {
message.success('删除成功!');
gridApi.query();
});
};
</script>
<template>
<Page auto-content-height title="回访字典管理">
<DictFormModal />
<div class="p-4">
<TableAction
:actions="[
{
label: '新增',
type: 'primary',
icon: 'ant-design:plus-outlined',
onClick: () => showDictModal({}, false),
},
]"
/>
<Grid>
<template #toolbar-buttons>
<Button
v-if="hasTopTableDropDownActions"
danger
type="primary"
@click="deleteDictApi()"
>
删除
</Button>
</template>
<!-- 颜色列色块 + hex 直观预览 -->
<template #color="{ row }">
<div class="flex items-center gap-2">
<span
class="inline-block h-4 w-4 rounded"
:style="{ background: row.color, border: '1px solid #e5e7eb' }"
/>
<span>{{ row.color }}</span>
</div>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '编辑',
onClick: () => showDictModal(row, true),
},
{
label: '删除',
color: 'error',
popConfirm: {
title: '确定要删除该项吗?',
onConfirm: () => deleteDictApi(row.id),
},
},
]"
/>
</template>
</Grid>
</div>
</Page>
</template>

View File

@@ -0,0 +1,110 @@
<script lang="ts" setup>
/**
* 分类仓储合并列:分区/分类 + 配送仓绑定 + 总仓是否入库
* Tags 打开绑定列表;「新增绑定」直接走新增表单
*/
import { Tag } from 'ant-design-vue';
defineProps<{
row: Record<string, any>;
}>();
const emit = defineEmits<{
category: [row: Record<string, any>];
bind: [row: Record<string, any>];
bindCreate: [row: Record<string, any>];
stockIn: [row: Record<string, any>];
}>();
</script>
<template>
<div class="category-warehouse-cell">
<div class="category-warehouse-cell__row">
<span class="category-warehouse-cell__label">分区</span>
<a
class="text-primary cursor-pointer hover:underline"
@click.stop="emit('category', row)"
>
{{ row.zone_name || '未设置' }}
</a>
</div>
<div class="category-warehouse-cell__row">
<span class="category-warehouse-cell__label">分类</span>
<a
class="text-primary cursor-pointer hover:underline"
@click.stop="emit('category', row)"
>
{{ row.category_name || '未设置' }}
</a>
</div>
<div class="category-warehouse-cell__row">
<span class="category-warehouse-cell__label">配送仓</span>
<div class="category-warehouse-cell__wh">
<a
class="text-primary cursor-pointer hover:underline"
@click.stop="emit('bind', row)"
>
<template
v-if="row.delivery_warehouses && row.delivery_warehouses.length"
>
<Tag
v-for="w in row.delivery_warehouses"
:key="w.id"
class="mb-1 mr-1"
color="blue"
>
{{ w.name }}
</Tag>
</template>
<span v-else class="category-warehouse-cell__muted">未绑定</span>
</a>
<a
class="text-primary cursor-pointer hover:underline category-warehouse-cell__add"
@click.stop="emit('bindCreate', row)"
>
新增绑定
</a>
</div>
</div>
<div class="category-warehouse-cell__row">
<span class="category-warehouse-cell__label">入库</span>
<Tag v-if="row.in_central_warehouse" color="green">已入库</Tag>
<a v-else @click.stop="emit('stockIn', row)">
<Tag color="orange" class="cursor-pointer">未入库</Tag>
</a>
</div>
</div>
</template>
<style scoped>
.category-warehouse-cell {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0;
font-size: 13px;
}
.category-warehouse-cell__row {
display: flex;
align-items: flex-start;
gap: 8px;
line-height: 1.4;
}
.category-warehouse-cell__label {
flex-shrink: 0;
width: 42px;
color: hsl(var(--muted-foreground));
}
.category-warehouse-cell__muted {
color: hsl(var(--muted-foreground));
}
.category-warehouse-cell__wh {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.category-warehouse-cell__add {
flex-shrink: 0;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
/**
* 总仓售价列:已入库可点开调价;未入库提示并可触发入库
*/
defineProps<{
row: Record<string, any>;
}>();
const emit = defineEmits<{
adjust: [row: Record<string, any>];
stockIn: [row: Record<string, any>];
}>();
function formatPrice(v: unknown) {
const n = Number(v);
if (!(n >= 0) || Number.isNaN(n)) return '-';
return `${n.toFixed(2)}`;
}
</script>
<template>
<div>
<a
v-if="row.in_central_warehouse"
class="text-primary cursor-pointer hover:underline"
@click.stop="emit('adjust', row)"
>
{{ formatPrice(row.central_price) }}
</a>
<a
v-else
class="cursor-pointer"
style="color: hsl(var(--muted-foreground))"
@click.stop="emit('stockIn', row)"
>
未入库
</a>
</div>
</template>

View File

@@ -0,0 +1,114 @@
<script lang="ts" setup>
/**
* 总仓入库弹窗(从药品列表「未入库」打开)
* 药品已预填,只填供货价/销售价后 createWarehouseDrugManagement
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createWarehouseDrugManagement } from '#/views/business/warehouse-drug-management/admin/api';
const drugId = ref(0);
const drugName = ref('');
const productType = ref(2);
const productGridApi = ref();
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: { class: 'w-full' },
},
layout: 'horizontal',
showDefaultActions: false,
schema: [
{
component: 'VbenInput',
fieldName: 'drug_id',
label: '药品ID',
componentProps: { disabled: true },
},
{
component: 'VbenInput',
fieldName: 'drug_name',
label: '药品名称',
componentProps: { disabled: true },
},
{
component: 'InputNumber',
fieldName: 'market_price',
label: '供货价格',
rules: 'required',
componentProps: {
min: 0,
precision: 4,
class: 'w-full',
placeholder: '请输入供货价格',
},
},
{
component: 'InputNumber',
fieldName: 'price',
label: '销售价格',
rules: 'required',
componentProps: {
min: 0,
precision: 4,
class: 'w-full',
placeholder: '请输入销售价格',
},
},
],
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const result = await formApi.validate();
if (!result.valid) return;
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
try {
// 总仓入库接口必填drug_id / market_price / pricetype 由药品本身决定)
await createWarehouseDrugManagement({
drug_id: drugId.value,
market_price: values.market_price,
price: values.price,
});
message.success('入库成功');
productGridApi.value?.query?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
drugId.value = Number(data.drug_id || data.values?.id || 0);
drugName.value =
data.drug_name || data.values?.drug_name || `药品#${drugId.value}`;
productType.value = Number(data.productType || data.type || 2);
productGridApi.value = data.gridApi;
formApi.setValues({
drug_id: drugId.value,
drug_name: drugName.value,
market_price: undefined,
price: undefined,
});
},
});
</script>
<template>
<Modal :title="`总仓入库 - ${drugName}`" class="w-[420px]">
<Form />
</Modal>
</template>

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

@@ -0,0 +1,116 @@
<script lang="ts" setup>
/**
* 商品信息合并列:图片 + 名称/ID/拼音/发布时间 + 说明书预览链接
* 五类商品列表复用,避免每页各写一套 slot
*/
import { computed, ref } from 'vue';
import { Image } from 'ant-design-vue';
const props = defineProps<{
row: Record<string, any>;
}>();
const previewVisible = ref(false);
const instructionUrl = computed(() => {
const url = props.row?.instruction;
return typeof url === 'string' && url.trim() ? url.trim() : '';
});
function openInstruction() {
if (!instructionUrl.value) return;
previewVisible.value = true;
}
</script>
<template>
<div class="product-info-cell">
<Image
v-if="row.image"
:src="row.image"
:width="40"
:height="40"
class="product-info-cell__img"
/>
<div v-else class="product-info-cell__img product-info-cell__img--empty">
无图
</div>
<div class="product-info-cell__body">
<div class="product-info-cell__name">{{ row.drug_name || '-' }}</div>
<div class="product-info-cell__meta">
<span>ID{{ row.id }}</span>
<span v-if="row.pinyin_simple">拼音{{ row.pinyin_simple }}</span>
<span v-if="row.created_at">发布时间{{ row.created_at }}</span>
</div>
<a
v-if="instructionUrl"
class="product-info-cell__link text-primary"
@click.stop="openInstruction"
>
说明书
</a>
<!-- 隐藏 Image 仅用于说明书预览 -->
<Image
v-if="instructionUrl"
:src="instructionUrl"
:style="{ display: 'none' }"
:preview="{
visible: previewVisible,
onVisibleChange: (v: boolean) => (previewVisible = v),
}"
/>
</div>
</div>
</template>
<style scoped>
.product-info-cell {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 4px 0;
}
.product-info-cell__img {
flex-shrink: 0;
border-radius: 4px;
object-fit: cover;
}
.product-info-cell__img--empty {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 4px;
}
.product-info-cell__body {
min-width: 0;
flex: 1;
}
.product-info-cell__name {
font-weight: 500;
color: hsl(var(--foreground));
line-height: 1.4;
word-break: break-all;
}
.product-info-cell__meta {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
margin-top: 4px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.product-info-cell__link {
display: inline-block;
margin-top: 4px;
font-size: 12px;
cursor: pointer;
}
.product-info-cell__link:hover {
text-decoration: underline;
}
</style>

View File

@@ -80,7 +80,7 @@ const handleChange = (info: { file: UploadFile }) => {
};
</script>
<template>
<Modal class="w-[30%]" 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

@@ -123,7 +123,7 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal
:title="`${isUpdate === true ? '编辑' : '新增'}中药`"
class="w-[30%]"
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
>
<Form />
</Modal>

View File

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

View File

@@ -142,7 +142,7 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal
:title="`${isUpdate === true ? '编辑' : '新增'}保健食品`"
class="w-[30%]"
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
>
<Form />
</Modal>

View File

@@ -8,114 +8,92 @@ import { getHealthFoodList } from '../api';
* 表格行数据类型定义
*/
interface RowType {
id: string; // 保健食品ID
name: string; // 保健食品名称
logo: string; // 保健食品Logo
introduce: string; // 保健食品介绍
created_at: string; // 创建时间
zone_id: number; // 分区ID
zone_name: string; // 分区名称
category_id: number; // 分类ID
category_name: string; // 分类名称
id: string;
name: string;
logo: string;
introduce: string;
created_at: string;
zone_id: number;
zone_name: string;
category_id: number;
category_name: string;
}
/**
* 保健食品管理表格配置
* 定义表格的列、分页、查询等配置
* 列结构对齐西药:商品信息/分类仓储/总仓售价合并列 + 页内特有列
*/
export const gridOptions: VxeGridProps<RowType> = {
// 复选框配置
checkboxConfig: {
highlight: true, // 高亮选中行
labelField: '', // 标签字段
highlight: true,
labelField: '',
},
// 列配置
columnConfig: {
useKey: true, // 使用key作为列的唯一标识
useKey: true,
},
// 行配置
rowConfig: {
useKey: true, // 使用key作为行的唯一标识
useKey: true,
},
// 表格列定义数组
columns: [
{ type: 'checkbox', width: 60 }, // 复选框列
{ field: 'id', align: 'left', title: 'ID', width: 100 }, // ID列
{ field: 'drug_name', align: 'left', title: '保健食品名称' }, // 保健食品名称列
{ field: 'pinyin_simple', title: '拼音' }, // 拼音列
{ type: 'checkbox', width: 60 },
{
field: 'drug_name',
align: 'left',
title: '商品信息',
minWidth: 260,
slots: { default: 'product_info' },
},
{
field: 'zone_name',
title: '分',
title: '分类仓储',
minWidth: 200,
slots: { default: 'category_warehouse' },
},
{
field: 'central_price',
title: '总仓售价',
width: 110,
slots: { default: 'central_price' },
},
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' }, width: 130 },
{ field: 'specification', title: '规格', width: 120 },
{
field: 'is_free_shipping',
title: '单品包邮',
width: 100,
slots: { default: 'zone' }, // 使用插槽显示可点击的分区链接
slots: { default: 'is_free_shipping' },
},
{
field: 'category_name',
title: '分类',
width: 120,
slots: { default: 'category' }, // 使用插槽显示可点击的分类链接
},
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' } }, // 供应商列,使用插槽自定义显示
{
field: 'image',
align: 'left',
title: '产品图片',
slots: { default: 'image' }, // 使用插槽显示图片
width: 130,
},
{
field: 'instruction',
align: 'left',
title: '说明书',
slots: { default: 'instruction' }, // 使用插槽显示说明书
width: 130,
},
// 注意已移除function列主要功能因为保健食品不需要功效字段
{ field: 'specification', title: '规格' }, // 规格列
{ field: 'status', title: '状态', slots: { default: 'status' } }, // 状态列,使用插槽显示标签
{ field: 'created_at', title: '发布时间' }, // 发布时间列
{ type: 'html', title: '操作', width: 230, slots: { default: 'action' } }, // 操作列,使用插槽显示操作按钮
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
],
// 保持数据源
keepSource: true,
// 分页配置
pagerConfig: {},
// 代理配置:用于数据请求
proxyConfig: {
ajax: {
// 请求后端接口方法
// 当表格需要加载数据时,会调用此方法
query: async ({ page }, formValues) => {
// 调用保健食品列表查询API
return await getHealthFoodList({
page: page.currentPage, // 当前页码
pageSize: page.pageSize, // 每页条数
...formValues, // 合并搜索表单的值
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
// 表格高度:自动适应
height: 'auto',
// 是否显示边框
border: false,
// 工具栏配置
toolbarConfig: {
// 是否显示搜索表单控制按钮
// @ts-ignore 正式环境时有完整的类型声明
search: true,
refresh: true, // 显示刷新按钮
print: false, // 不显示打印按钮
export: false, // 不显示导出按钮(使用自定义导出按钮)
zoom: true, // 显示最大化最小化按钮
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-buttons', // 自定义工具栏按钮插槽
buttons: 'toolbar-buttons',
},
custom: {
// 自定义列-图标
icon: 'vxe-icon-menu',
},
},
// 是否显示溢出内容
showOverflow: false,
};

View File

@@ -9,7 +9,7 @@ import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
// 导入Ant Design Vue组件
import { Button, Image, message, Tag } from 'ant-design-vue';
import { Button, message, Tag } from 'ant-design-vue';
// 导入Vben VxeGrid适配器
import { useVbenVxeGrid } from '#/adapter/vxe-table';
@@ -17,20 +17,28 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
// 导入文件下载工具
import { downloadByData } from '#/util/tool';
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
import { resolveCentralPrice } from '#/views/system/delivery-warehouse-drug/utils/resolve-central-price';
// 导入保健食品管理相关API
import { deleteHealthFood, exportHealthFoodApi } from './api';
// 导入表单弹窗组件
import FormModalDemo from './components/modal.vue';
// 导入Excel上传组件
import ExcelUpload from './components/ExcelUpload.vue';
// 导入分类设置弹窗组件
import CategoryModal from './components/CategoryModal.vue';
// 导入搜索表单配置
import { formOptions } from './config/search';
// 导入表格配置
import { gridOptions } from './config/table';
/** 保健食品产品类型 */
const PRODUCT_TYPE = 3;
// 控制顶部表格下拉操作按钮的显示状态
const hasTopTableDropDownActions = ref(false);
@@ -74,6 +82,78 @@ const [CategoryModalComp, categoryModalApi] = useVbenModal({
connectedComponent: CategoryModal,
});
const [BindListModalComp, bindListModalApi] = useVbenModal({
connectedComponent: BindListModal,
});
const [CentralStockInModalComp, centralStockInModalApi] = useVbenModal({
connectedComponent: CentralStockInModal,
});
const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
connectedComponent: WarehouseDrugPriceModal,
});
/**
* 打开某产品的配送仓绑定列表(带总仓售价供费用封顶/默认平台费)
* @param autoCreate 为 true 时打开后立刻弹出新增绑定表单
*/
function openBindList(row: any, autoCreate = false) {
const price = resolveCentralPrice(row);
bindListModalApi.setData({
drug_id: row.id,
drug_name: row.drug_name,
specification: row.specification,
image: row.image,
central_price: price,
values: row,
gridApi,
autoCreate,
});
bindListModalApi.open();
}
/** 列上「新增绑定」:打开列表并自动弹出新增表单 */
function openBindCreate(row: any) {
openBindList(row, true);
}
/** 未入总仓时打开入库弹窗 */
function openStockIn(row: any) {
if (row.in_central_warehouse) {
message.info('该药品已在总仓库');
return;
}
centralStockInModalApi.setData({
drug_id: row.id,
drug_name: row.drug_name,
productType: PRODUCT_TYPE,
values: row,
gridApi,
});
centralStockInModalApi.open();
}
/** 已入库:打开总仓调价弹窗 */
function openCentralPriceAdjust(row: any) {
const cwd = row.central_warehouse_drug;
if (!cwd?.id) {
message.warning('未找到总仓药品记录');
return;
}
warehousePriceModalApi.setData({
update: true,
listDrugType: PRODUCT_TYPE,
gridApi,
values: {
id: cwd.id,
price: cwd.price ?? row.central_price,
market_price: cwd.market_price ?? row.central_market_price,
},
});
warehousePriceModalApi.open();
}
/**
* 打开分类设置弹窗
* @param row - 当前行数据
@@ -155,6 +235,9 @@ const openExcelUploadModal = () => {
<ExcelUploadModal />
<FormModal />
<CategoryModalComp />
<BindListModalComp />
<CentralStockInModalComp />
<WarehousePriceModalComp />
<Grid>
<template #toolbar-buttons>
<TableAction
@@ -199,26 +282,30 @@ const openExcelUploadModal = () => {
</template>
</TableAction>
</template>
<template #image="{ row }">
<Image :src="row.image" height="30" width="30" />
<template #product_info="{ row }">
<ProductInfoCell :row="row" />
</template>
<template #category_warehouse="{ row }">
<CategoryWarehouseCell
:row="row"
@category="openCategoryModal"
@bind="openBindList"
@bind-create="openBindCreate"
@stock-in="openStockIn"
/>
</template>
<template #central_price="{ row }">
<CentralPriceCell
:row="row"
@adjust="openCentralPriceAdjust"
@stock-in="openStockIn"
/>
</template>
<template #supplier="{ row }">
{{ row.supplier?.name || row.source }}
</template>
<template #zone="{ row }">
<a class="text-primary cursor-pointer hover:underline" @click="openCategoryModal(row)">
{{ row.zone_name || '未设置' }}
</a>
</template>
<template #category="{ row }">
<a class="text-primary cursor-pointer hover:underline" @click="openCategoryModal(row)">
{{ row.category_name || '未设置' }}
</a>
</template>
<template #instruction="{ row }">
<div class="div" style="width: 120px; height: 120px; overflow: scroll">
<Image :src="row.instruction" height="30" width="30" />
</div>
<template #is_free_shipping="{ row }">
<DrugFreeShippingCell :row="row" />
</template>
<template #status="{ row }">
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
@@ -234,6 +321,13 @@ const openExcelUploadModal = () => {
size: 'small',
onClick: showModal.bind(null, row, true),
},
{
label: '绑定配送仓',
type: 'link',
icon: 'mdi:warehouse',
size: 'small',
onClick: openBindList.bind(null, row),
},
{
label: '删除',
type: 'link',

View File

@@ -73,7 +73,7 @@ const handleChange = (info: { file: UploadFile }) => {
};
</script>
<template>
<Modal class="w-[30%]" 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

@@ -96,7 +96,7 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal
:title="`${isUpdate === true ? '编辑' : '新增'}医疗器械`"
class="w-[30%]"
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
>
<Form />
</Modal>

View File

@@ -14,6 +14,7 @@ interface RowType {
category_name: string;
}
/** 医疗器械表格:合并商品信息/分类仓储/总仓售价列 */
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
@@ -27,34 +28,36 @@ export const gridOptions: VxeGridProps<RowType> = {
},
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', align: 'left', title: 'ID', width: 100 },
{ field: 'drug_name', align: 'left', title: '器械名称' },
{ field: 'pinyin_simple', title: '拼音' },
{
field: 'drug_name',
align: 'left',
title: '商品信息',
minWidth: 260,
slots: { default: 'product_info' },
},
{
field: 'zone_name',
title: '分',
title: '分类仓储',
minWidth: 200,
slots: { default: 'category_warehouse' },
},
{
field: 'central_price',
title: '总仓售价',
width: 110,
slots: { default: 'central_price' },
},
{ 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: 'zone' },
slots: { default: 'is_free_shipping' },
},
{
field: 'category_name',
title: '分类',
width: 120,
slots: { default: 'category' },
},
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' } },
{
field: 'image',
align: 'left',
title: '产品图片',
slots: { default: 'image' },
width: 130,
},
{ field: 'function', title: '器械功能' },
{ field: 'specification', title: '规格型号' },
{ field: 'status', title: '状态', slots: { default: 'status' } },
{ field: 'created_at', title: '发布时间' },
{ type: 'html', title: '操作', width: 230, slots: { default: 'action' } },
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
],
keepSource: true,
pagerConfig: {},
@@ -87,11 +90,3 @@ export const gridOptions: VxeGridProps<RowType> = {
},
showOverflow: false,
};

View File

@@ -5,11 +5,19 @@ import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Image, message, Tag } from 'ant-design-vue';
import { Button, message, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { downloadByData } from '#/util/tool';
import CategoryWarehouseCell from '#/views/business/product/_shared/category-warehouse-cell.vue';
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
import DrugFreeShippingCell from '#/views/business/product/_shared/drug-free-shipping-cell.vue';
import ProductInfoCell from '#/views/business/product/_shared/product-info-cell.vue';
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
import BindListModal from '#/views/system/delivery-warehouse-drug/components/bind-list-modal.vue';
import { resolveCentralPrice } from '#/views/system/delivery-warehouse-drug/utils/resolve-central-price';
import { deleteMedicalDevice, exportMedicalDeviceApi } from './api';
import FormModalDemo from './components/modal.vue';
@@ -18,6 +26,9 @@ import CategoryModal from './components/CategoryModal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
/** 医疗器械产品类型 */
const PRODUCT_TYPE = 7;
const hasTopTableDropDownActions = ref(false);
const gridEvents: VxeGridListeners<any> = {
@@ -49,6 +60,78 @@ const [CategoryModalComp, categoryModalApi] = useVbenModal({
connectedComponent: CategoryModal,
});
const [BindListModalComp, bindListModalApi] = useVbenModal({
connectedComponent: BindListModal,
});
const [CentralStockInModalComp, centralStockInModalApi] = useVbenModal({
connectedComponent: CentralStockInModal,
});
const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
connectedComponent: WarehouseDrugPriceModal,
});
/**
* 打开某产品的配送仓绑定列表(带总仓售价供费用封顶/默认平台费)
* @param autoCreate 为 true 时打开后立刻弹出新增绑定表单
*/
function openBindList(row: any, autoCreate = false) {
const price = resolveCentralPrice(row);
bindListModalApi.setData({
drug_id: row.id,
drug_name: row.drug_name,
specification: row.specification,
image: row.image,
central_price: price,
values: row,
gridApi,
autoCreate,
});
bindListModalApi.open();
}
/** 列上「新增绑定」:打开列表并自动弹出新增表单 */
function openBindCreate(row: any) {
openBindList(row, true);
}
/** 未入总仓时打开入库弹窗 */
function openStockIn(row: any) {
if (row.in_central_warehouse) {
message.info('该药品已在总仓库');
return;
}
centralStockInModalApi.setData({
drug_id: row.id,
drug_name: row.drug_name,
productType: PRODUCT_TYPE,
values: row,
gridApi,
});
centralStockInModalApi.open();
}
/** 已入库:打开总仓调价弹窗 */
function openCentralPriceAdjust(row: any) {
const cwd = row.central_warehouse_drug;
if (!cwd?.id) {
message.warning('未找到总仓药品记录');
return;
}
warehousePriceModalApi.setData({
update: true,
listDrugType: PRODUCT_TYPE,
gridApi,
values: {
id: cwd.id,
price: cwd.price ?? row.central_price,
market_price: cwd.market_price ?? row.central_market_price,
},
});
warehousePriceModalApi.open();
}
const openCategoryModal = (row: any) => {
categoryModalApi.setData({
row,
@@ -99,6 +182,9 @@ const openExcelUploadModal = () => {
<ExcelUploadModal />
<FormModal />
<CategoryModalComp />
<BindListModalComp />
<CentralStockInModalComp />
<WarehousePriceModalComp />
<Grid>
<template #toolbar-buttons>
<TableAction
@@ -143,21 +229,30 @@ const openExcelUploadModal = () => {
</template>
</TableAction>
</template>
<template #image="{ row }">
<Image :src="row.image" height="30" width="30" />
<template #product_info="{ row }">
<ProductInfoCell :row="row" />
</template>
<template #category_warehouse="{ row }">
<CategoryWarehouseCell
:row="row"
@category="openCategoryModal"
@bind="openBindList"
@bind-create="openBindCreate"
@stock-in="openStockIn"
/>
</template>
<template #central_price="{ row }">
<CentralPriceCell
:row="row"
@adjust="openCentralPriceAdjust"
@stock-in="openStockIn"
/>
</template>
<template #supplier="{ row }">
{{ row.supplier?.name || row.source }}
</template>
<template #zone="{ row }">
<a class="text-primary cursor-pointer hover:underline" @click="openCategoryModal(row)">
{{ row.zone_name || '未设置' }}
</a>
</template>
<template #category="{ row }">
<a class="text-primary cursor-pointer hover:underline" @click="openCategoryModal(row)">
{{ row.category_name || '未设置' }}
</a>
<template #is_free_shipping="{ row }">
<DrugFreeShippingCell :row="row" />
</template>
<template #status="{ row }">
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
@@ -173,6 +268,13 @@ const openExcelUploadModal = () => {
size: 'small',
onClick: showModal.bind(null, row, true),
},
{
label: '绑定配送仓',
type: 'link',
icon: 'mdi:warehouse',
size: 'small',
onClick: openBindList.bind(null, row),
},
{
label: '删除',
type: 'link',

View File

@@ -73,7 +73,7 @@ const handleChange = (info: { file: UploadFile }) => {
};
</script>
<template>
<Modal class="w-[30%]" 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

@@ -96,7 +96,7 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal
:title="`${isUpdate === true ? '编辑' : '新增'}非药品`"
class="w-[30%]"
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
>
<Form />
</Modal>

View File

@@ -14,6 +14,7 @@ interface RowType {
category_name: string;
}
/** 非药品表格:合并商品信息/分类仓储/总仓售价列 */
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
@@ -27,34 +28,36 @@ export const gridOptions: VxeGridProps<RowType> = {
},
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', align: 'left', title: 'ID', width: 100 },
{ field: 'drug_name', align: 'left', title: '产品名称' },
{ field: 'pinyin_simple', title: '拼音' },
{
field: 'drug_name',
align: 'left',
title: '商品信息',
minWidth: 260,
slots: { default: 'product_info' },
},
{
field: 'zone_name',
title: '分',
title: '分类仓储',
minWidth: 200,
slots: { default: 'category_warehouse' },
},
{
field: 'central_price',
title: '总仓售价',
width: 110,
slots: { default: 'central_price' },
},
{ 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: 'zone' },
slots: { default: 'is_free_shipping' },
},
{
field: 'category_name',
title: '分类',
width: 120,
slots: { default: 'category' },
},
{ field: 'supplier.name', title: '供应商', slots: { default: 'supplier' } },
{
field: 'image',
align: 'left',
title: '产品图片',
slots: { default: 'image' },
width: 130,
},
{ field: 'function', title: '产品功能' },
{ field: 'specification', title: '规格' },
{ field: 'status', title: '状态', slots: { default: 'status' } },
{ field: 'created_at', title: '发布时间' },
{ type: 'html', title: '操作', width: 230, slots: { default: 'action' } },
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
{ type: 'html', title: '操作', fixed: 'right', width: 320, slots: { default: 'action' } },
],
keepSource: true,
pagerConfig: {},
@@ -87,11 +90,3 @@ export const gridOptions: VxeGridProps<RowType> = {
},
showOverflow: false,
};

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