Compare commits
11 Commits
main
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
174693f6db | ||
|
|
c1bc234509 | ||
|
|
dc9f9c5c13 | ||
|
|
0331325929 | ||
|
|
9c629ff8ee | ||
|
|
bd5b0b2de1 | ||
|
|
029b45d0bb | ||
|
|
212b16a8ec | ||
|
|
feebf4b14e | ||
|
|
3bc22d02e6 | ||
|
|
371594bfda |
@@ -152,82 +152,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` 数组
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
}>(),
|
||||
|
||||
@@ -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_type:1=图片) */
|
||||
/** 图片类型展示缩略图(xk_file.type:0=图片;兼容历史错误写入的 1) */
|
||||
function isImageItem(item: FileGalleryItem) {
|
||||
return Number(item.type) === 1 || Number(item.type) === 0;
|
||||
return Number(item.type) === 0 || Number(item.type) === 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -24,12 +24,18 @@ interface Props {
|
||||
modelValue: string[];
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 素材库可选类型(透传 GalleryPickLink)
|
||||
* xk_file.type:0=图片 1=视频;默认仅图片。不传会展示全部类型
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
maxCount: 9,
|
||||
acceptTypes: () => [0],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -345,6 +351,7 @@ onMounted(() => {
|
||||
v-if="showUploadButton"
|
||||
:multiple="multiple"
|
||||
:max-count="galleryRemainCount"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onGallerySelect"
|
||||
/>
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ interface Props {
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 素材库可选类型(透传 GalleryPickLink),默认仅图片
|
||||
* 素材库可选类型(透传 GalleryPickLink)
|
||||
* xk_file.type:0=图片 1=视频,默认仅图片
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}
|
||||
@@ -40,7 +41,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
maxCount: 9,
|
||||
acceptTypes: () => [1],
|
||||
acceptTypes: () => [0],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
714
apps/web-antd/src/components/loading/PrescriptionLoading.vue
Normal file
714
apps/web-antd/src/components/loading/PrescriptionLoading.vue
Normal file
@@ -0,0 +1,714 @@
|
||||
<template>
|
||||
<!--
|
||||
AI 加载动画:处方=药丸;病历=病历本
|
||||
type + text 由调用方传入;嵌入抽屉时默认不显示主题切换
|
||||
-->
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="loading-wrapper"
|
||||
:class="{ 'loading-wrapper--dark': isDark }"
|
||||
:style="{ width: '100%', height: '100%' }"
|
||||
>
|
||||
<canvas ref="canvasRef" class="loading-canvas"></canvas>
|
||||
<button
|
||||
v-if="showToggle"
|
||||
class="theme-toggle-btn"
|
||||
:title="isDark ? '切换到亮色模式' : '切换到暗色模式'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<span class="theme-icon">
|
||||
<svg
|
||||
v-if="!isDark"
|
||||
class="sun"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="moon"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</span>
|
||||
<!-- <span class="theme-label">{{ isDark ? '亮色' : '暗色' }}</span>-->
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI 生成加载动画
|
||||
* - type=prescription:中心画胶囊药丸
|
||||
* - type=medical_record:中心画病历本
|
||||
* - text:底部文案(可动态传)
|
||||
*/
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
export type AiLoadingType = 'prescription' | 'medical_record';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 动画类型:处方 / 病历 */
|
||||
type?: AiLoadingType;
|
||||
/** 底部文案 */
|
||||
text?: string;
|
||||
/** 是否显示主题切换(嵌入业务抽屉建议关闭) */
|
||||
showToggle?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: 'prescription',
|
||||
text: '',
|
||||
showToggle: false,
|
||||
},
|
||||
);
|
||||
|
||||
/** 未传文案时按 type 给默认值 */
|
||||
const displayText = computed(() => {
|
||||
const t = String(props.text || '').trim();
|
||||
if (t) return t;
|
||||
return props.type === 'medical_record'
|
||||
? 'AI正在生成病历'
|
||||
: 'AI正在生成处方';
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const isDark = ref(false);
|
||||
/** 画布循环里读取最新文案/类型,避免闭包过期 */
|
||||
const liveText = ref(displayText.value);
|
||||
const liveType = ref<AiLoadingType>(props.type);
|
||||
|
||||
watch(
|
||||
displayText,
|
||||
(v) => {
|
||||
liveText.value = v;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(
|
||||
() => props.type,
|
||||
(v) => {
|
||||
liveType.value = v || 'prescription';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function detectAppDark(): boolean {
|
||||
return (
|
||||
document.documentElement.classList.contains('dark') ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
document.documentElement.getAttribute('class')?.includes('dark') === true
|
||||
);
|
||||
}
|
||||
|
||||
function syncThemeFromApp() {
|
||||
if (localStorage.getItem('prescription-loading-theme')) {
|
||||
isDark.value = localStorage.getItem('prescription-loading-theme') === 'dark';
|
||||
return;
|
||||
}
|
||||
isDark.value = detectAppDark();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value;
|
||||
localStorage.setItem(
|
||||
'prescription-loading-theme',
|
||||
isDark.value ? 'dark' : 'light',
|
||||
);
|
||||
resetParticles?.();
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
size: number;
|
||||
color: { r: number; g: number; b: number };
|
||||
type: 'circle' | 'cross';
|
||||
}
|
||||
|
||||
interface ThemeColors {
|
||||
textColor: string;
|
||||
ringColor: string;
|
||||
glowInner: string;
|
||||
glowMiddle: string;
|
||||
glowOuter: string;
|
||||
particleBright: number;
|
||||
iconShadow: string;
|
||||
}
|
||||
|
||||
let animationId = 0;
|
||||
let resetParticles: (() => void) | null = null;
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let removeWindowResize: (() => void) | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
syncThemeFromApp();
|
||||
if (!containerRef.value || !canvasRef.value) return;
|
||||
|
||||
const canvas = canvasRef.value;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const container = containerRef.value;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let cssWidth = 0;
|
||||
let cssHeight = 0;
|
||||
let prevW = 0;
|
||||
let prevH = 0;
|
||||
let startTime: number | null = null;
|
||||
let lastTime: number | null = null;
|
||||
let particles: Particle[] = [];
|
||||
const MAX_PARTICLES = 28;
|
||||
|
||||
resetParticles = () => {
|
||||
particles = [];
|
||||
};
|
||||
|
||||
function updateSize() {
|
||||
const rect = container.getBoundingClientRect();
|
||||
cssWidth = rect.width || 300;
|
||||
cssHeight = rect.height || 280;
|
||||
const pw = Math.round(cssWidth * dpr);
|
||||
const ph = Math.round(cssHeight * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
if (
|
||||
prevW &&
|
||||
(Math.abs(cssWidth - prevW) > 2 || Math.abs(cssHeight - prevH) > 2)
|
||||
) {
|
||||
particles = [];
|
||||
}
|
||||
prevW = cssWidth;
|
||||
prevH = cssHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function createParticle(cx: number, cy: number, s: number): Particle {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = s * (0.15 + Math.random() * 0.45);
|
||||
const speed = s * (0.03 + Math.random() * 0.08);
|
||||
const colors = [
|
||||
{ r: 61, g: 214, b: 208 },
|
||||
{ r: 74, g: 195, b: 190 },
|
||||
{ r: 93, g: 173, b: 226 },
|
||||
{ r: 46, g: 180, b: 170 },
|
||||
{ r: 100, g: 200, b: 215 },
|
||||
];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)]!;
|
||||
return {
|
||||
x: cx + Math.cos(angle) * distance,
|
||||
y: cy + Math.sin(angle) * distance,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
maxLife: 1.8 + Math.random() * 2.5,
|
||||
size: 1.2 + Math.random() * 2.8,
|
||||
color,
|
||||
type: Math.random() < 0.25 ? 'cross' : 'circle',
|
||||
};
|
||||
}
|
||||
|
||||
function updateParticles(dt: number, cx: number, cy: number, s: number) {
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const p = particles[i]!;
|
||||
p.life -= dt / p.maxLife;
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.vx *= 0.997;
|
||||
p.vy *= 0.997;
|
||||
if (p.life <= 0) particles.splice(i, 1);
|
||||
}
|
||||
while (particles.length < MAX_PARTICLES) {
|
||||
const p = createParticle(cx, cy, s);
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
p.x = cx + Math.cos(ang) * s * 0.12 * Math.random();
|
||||
p.y = cy + Math.sin(ang) * s * 0.12 * Math.random();
|
||||
particles.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
function drawParticle(
|
||||
c: CanvasRenderingContext2D,
|
||||
p: Particle,
|
||||
bright: number,
|
||||
) {
|
||||
const alpha = p.life;
|
||||
const { r, g, b } = p.color;
|
||||
const size = p.size * (0.7 + 0.3 * p.life);
|
||||
const br = Math.min(255, Math.round(r * bright));
|
||||
const bg = Math.min(255, Math.round(g * bright));
|
||||
const bb = Math.min(255, Math.round(b * bright));
|
||||
c.save();
|
||||
c.globalAlpha = alpha;
|
||||
if (p.type === 'cross') {
|
||||
const len = size * 1.8;
|
||||
const w = size * 0.55;
|
||||
c.strokeStyle = `rgba(${br},${bg},${bb},${alpha})`;
|
||||
c.lineWidth = w;
|
||||
c.lineCap = 'round';
|
||||
c.beginPath();
|
||||
c.moveTo(p.x - len, p.y);
|
||||
c.lineTo(p.x + len, p.y);
|
||||
c.stroke();
|
||||
c.beginPath();
|
||||
c.moveTo(p.x, p.y - len);
|
||||
c.lineTo(p.x, p.y + len);
|
||||
c.stroke();
|
||||
c.fillStyle = `rgba(255,255,255,${alpha * 0.9})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size * 0.35, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
} else {
|
||||
c.shadowColor = `rgba(${br},${bg},${bb},${alpha * 0.7})`;
|
||||
c.shadowBlur = size * 2.5;
|
||||
c.fillStyle = `rgba(${br},${bg},${bb},${alpha})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
c.shadowBlur = 0;
|
||||
c.fillStyle = `rgba(255,255,255,${alpha * 0.6})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size * 0.4, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** 处方药丸(处方) */
|
||||
function drawPill(
|
||||
c: CanvasRenderingContext2D,
|
||||
px: number,
|
||||
py: number,
|
||||
pw: number,
|
||||
ph: number,
|
||||
floatOff: number,
|
||||
pulse: number,
|
||||
shadowCol: string,
|
||||
) {
|
||||
c.save();
|
||||
const cx = px + pw / 2;
|
||||
const cy = py + ph / 2;
|
||||
const hw = (pw / 2) * pulse;
|
||||
const hh = (ph / 2) * pulse;
|
||||
const adjY = cy + floatOff;
|
||||
const dx = cx - hw;
|
||||
const dy = adjY - hh;
|
||||
const dw = hw * 2;
|
||||
const dh = hh * 2;
|
||||
const rad = hh;
|
||||
const path = new Path2D();
|
||||
path.roundRect(dx, dy, dw, dh, rad);
|
||||
|
||||
c.shadowColor = shadowCol;
|
||||
c.shadowBlur = hh * 0.7;
|
||||
c.shadowOffsetY = hh * 0.25;
|
||||
c.fillStyle = '#ffffff';
|
||||
c.fill(path);
|
||||
c.shadowColor = 'transparent';
|
||||
c.shadowBlur = 0;
|
||||
c.shadowOffsetY = 0;
|
||||
|
||||
c.save();
|
||||
c.clip(path);
|
||||
const leftGrad = c.createLinearGradient(dx, 0, dx + dw * 0.5, 0);
|
||||
leftGrad.addColorStop(0, '#FDFBF7');
|
||||
leftGrad.addColorStop(0.7, '#F5F1EA');
|
||||
leftGrad.addColorStop(1, '#EDE8DF');
|
||||
c.fillStyle = leftGrad;
|
||||
c.fillRect(dx, dy, dw * 0.5, dh);
|
||||
|
||||
const rightGrad = c.createLinearGradient(dx + dw * 0.5, 0, dx + dw, 0);
|
||||
rightGrad.addColorStop(0, '#5DD9D1');
|
||||
rightGrad.addColorStop(0.5, '#3DD6D0');
|
||||
rightGrad.addColorStop(1, '#2CB8B0');
|
||||
c.fillStyle = rightGrad;
|
||||
c.fillRect(dx + dw * 0.5, dy, dw * 0.5, dh);
|
||||
|
||||
c.strokeStyle = 'rgba(180,180,180,0.5)';
|
||||
c.lineWidth = Math.max(0.6, hh * 0.06);
|
||||
c.beginPath();
|
||||
c.moveTo(dx + dw * 0.5, dy + 1);
|
||||
c.lineTo(dx + dw * 0.5, dy + dh - 1);
|
||||
c.stroke();
|
||||
|
||||
const hy = dy + dh * 0.15;
|
||||
const hh2 = dh * 0.38;
|
||||
const hp = new Path2D();
|
||||
hp.roundRect(dx + dw * 0.06, hy, dw * 0.88, hh2, hh2 / 2);
|
||||
c.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
c.fill(hp);
|
||||
c.restore();
|
||||
|
||||
c.strokeStyle = 'rgba(200,200,200,0.5)';
|
||||
c.lineWidth = Math.max(0.8, hh * 0.08);
|
||||
c.stroke(path);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** 病历本(病历) */
|
||||
function drawMedicalRecord(
|
||||
c: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
scale: number,
|
||||
floatOff: number,
|
||||
pulse: number,
|
||||
shadowCol: string,
|
||||
) {
|
||||
c.save();
|
||||
const w = scale * 0.28 * pulse;
|
||||
const h = scale * 0.34 * pulse;
|
||||
const x = cx - w / 2;
|
||||
const y = cy - h / 2 + floatOff;
|
||||
const r = Math.max(4, scale * 0.018);
|
||||
const path = new Path2D();
|
||||
path.roundRect(x, y, w, h, r);
|
||||
|
||||
c.shadowColor = shadowCol;
|
||||
c.shadowBlur = scale * 0.035;
|
||||
c.shadowOffsetY = scale * 0.012;
|
||||
c.fillStyle = '#ffffff';
|
||||
c.fill(path);
|
||||
c.shadowColor = 'transparent';
|
||||
c.shadowBlur = 0;
|
||||
c.shadowOffsetY = 0;
|
||||
|
||||
// 左侧装订条
|
||||
c.fillStyle = '#3DD6D0';
|
||||
c.fillRect(x, y + r * 0.2, w * 0.12, h - r * 0.4);
|
||||
|
||||
// 横线(像病历行)
|
||||
c.strokeStyle = 'rgba(61,214,208,0.35)';
|
||||
c.lineWidth = Math.max(1, scale * 0.004);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const ly = y + h * (0.32 + i * 0.14);
|
||||
c.beginPath();
|
||||
c.moveTo(x + w * 0.22, ly);
|
||||
c.lineTo(x + w * 0.88, ly);
|
||||
c.stroke();
|
||||
}
|
||||
// 顶部标题条
|
||||
c.fillStyle = 'rgba(61,214,208,0.2)';
|
||||
c.beginPath();
|
||||
c.roundRect(x + w * 0.22, y + h * 0.12, w * 0.5, h * 0.08, 2);
|
||||
c.fill();
|
||||
|
||||
c.strokeStyle = 'rgba(180,200,200,0.55)';
|
||||
c.lineWidth = Math.max(0.8, scale * 0.005);
|
||||
c.stroke(path);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function drawCross(
|
||||
c: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
alpha: number,
|
||||
rot: number,
|
||||
) {
|
||||
c.save();
|
||||
c.translate(x, y);
|
||||
c.rotate(rot);
|
||||
c.globalAlpha = alpha;
|
||||
const len = size;
|
||||
const w = size * 0.3;
|
||||
c.strokeStyle = '#3DD6D0';
|
||||
c.lineWidth = w;
|
||||
c.lineCap = 'round';
|
||||
c.beginPath();
|
||||
c.moveTo(-len, 0);
|
||||
c.lineTo(len, 0);
|
||||
c.stroke();
|
||||
c.beginPath();
|
||||
c.moveTo(0, -len);
|
||||
c.lineTo(0, len);
|
||||
c.stroke();
|
||||
c.fillStyle = '#ffffff';
|
||||
c.beginPath();
|
||||
c.arc(0, 0, size * 0.25, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function getTheme(): ThemeColors {
|
||||
const dark = isDark.value;
|
||||
return {
|
||||
textColor: dark ? '#d1d5db' : '#4a5568',
|
||||
ringColor: dark ? 'rgba(255,255,255,0.25)' : 'rgba(180,195,200,0.45)',
|
||||
glowInner: dark ? 'rgba(61,214,208,0.15)' : 'rgba(61,214,208,0.13)',
|
||||
glowMiddle: dark ? 'rgba(61,214,208,0.05)' : 'rgba(61,214,208,0.04)',
|
||||
glowOuter: 'rgba(61,214,208,0)',
|
||||
particleBright: dark ? 1.4 : 1.0,
|
||||
iconShadow: dark ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.12)',
|
||||
};
|
||||
}
|
||||
|
||||
function draw(timestamp: number) {
|
||||
updateSize();
|
||||
if (cssWidth <= 0 || cssHeight <= 0) {
|
||||
animationId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
if (startTime === null) {
|
||||
startTime = timestamp;
|
||||
lastTime = timestamp;
|
||||
}
|
||||
const elapsed = (timestamp - startTime) / 1000;
|
||||
let dt = (timestamp - (lastTime ?? timestamp)) / 1000;
|
||||
if (dt <= 0) dt = 0.016;
|
||||
if (dt > 0.2) dt = 0.2;
|
||||
lastTime = timestamp;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const cx = cssWidth / 2;
|
||||
const cy = cssHeight / 2 - cssHeight * 0.04;
|
||||
const scale = Math.min(cssWidth, cssHeight);
|
||||
const orbitR = scale * 0.26;
|
||||
const fontSize = Math.max(12, scale * 0.055);
|
||||
const textY = cy + orbitR + fontSize * 2.4;
|
||||
const text = liveText.value;
|
||||
const animType = liveType.value;
|
||||
|
||||
const dashOff = elapsed * scale * 0.025;
|
||||
const arcRot = elapsed * 3.5;
|
||||
const arcLen = (100 * Math.PI) / 180;
|
||||
const floatOff = Math.sin(elapsed * 2.5) * scale * 0.025;
|
||||
const pulseS = 1 + Math.sin(elapsed * 3.7) * 0.04;
|
||||
const textAlpha = 0.55 + 0.45 * Math.sin(elapsed * 1.6);
|
||||
const theme = getTheme();
|
||||
|
||||
const grad = ctx.createRadialGradient(
|
||||
cx,
|
||||
cy,
|
||||
orbitR * 0.15,
|
||||
cx,
|
||||
cy,
|
||||
orbitR * 1.6,
|
||||
);
|
||||
grad.addColorStop(0, theme.glowInner);
|
||||
grad.addColorStop(0.5, theme.glowMiddle);
|
||||
grad.addColorStop(1, theme.glowOuter);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash([scale * 0.018, scale * 0.04]);
|
||||
ctx.lineDashOffset = -dashOff;
|
||||
ctx.strokeStyle = theme.ringColor;
|
||||
ctx.lineWidth = Math.max(1, scale * 0.006);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, orbitR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
|
||||
const arcS = arcRot;
|
||||
const arcE = arcRot + arcLen;
|
||||
const fx = cx + orbitR * Math.cos(arcE);
|
||||
const fy = cy + orbitR * Math.sin(arcE);
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(61,214,208,0.8)';
|
||||
ctx.shadowBlur = scale * 0.04;
|
||||
ctx.strokeStyle = '#3DD6D0';
|
||||
ctx.lineWidth = Math.max(2, scale * 0.014);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, orbitR, arcS, arcE);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(255,255,255,0.9)';
|
||||
ctx.shadowBlur = scale * 0.03;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(fx, fy, Math.max(2.5, scale * 0.014), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// 中心图标:按 type 切换
|
||||
if (animType === 'medical_record') {
|
||||
drawMedicalRecord(ctx, cx, cy, scale, floatOff, pulseS, theme.iconShadow);
|
||||
} else {
|
||||
const pillW = scale * 0.34;
|
||||
const pillH = scale * 0.1;
|
||||
drawPill(
|
||||
ctx,
|
||||
cx - pillW / 2,
|
||||
cy - pillH / 2,
|
||||
pillW,
|
||||
pillH,
|
||||
floatOff,
|
||||
pulseS,
|
||||
theme.iconShadow,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ang = elapsed * 1.8 + (i * Math.PI * 2) / 3;
|
||||
const crossOrbitR = orbitR * 1.02;
|
||||
const rx = cx + crossOrbitR * Math.cos(ang);
|
||||
const ry = cy + crossOrbitR * Math.sin(ang);
|
||||
const rs = Math.max(4, scale * 0.022);
|
||||
const ra = 0.4 + 0.35 * Math.sin(elapsed * 3.2 + i * 1.7);
|
||||
drawCross(ctx, rx, ry, rs, ra, elapsed * 0.9 + i);
|
||||
}
|
||||
|
||||
updateParticles(dt, cx, cy, scale);
|
||||
for (const p of particles) {
|
||||
drawParticle(ctx, p, theme.particleBright);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = textAlpha;
|
||||
ctx.fillStyle = theme.textColor;
|
||||
ctx.font = `${fontSize}px "PingFang SC","Microsoft YaHei","Noto Sans SC","Helvetica Neue",sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.shadowColor = 'rgba(61,214,208,0.25)';
|
||||
ctx.shadowBlur = fontSize * 0.6;
|
||||
ctx.fillText(text, cx, textY);
|
||||
ctx.restore();
|
||||
|
||||
const charW = fontSize * 0.55;
|
||||
const dotsStartX = cx + (text.length / 2) * charW + fontSize * 0.5;
|
||||
const dotR = Math.max(2, fontSize * 0.16);
|
||||
const dotSpacing = fontSize * 0.7;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const phase = (elapsed * 2.8 + i * 1.0) % 3;
|
||||
let brightness = phase < 1 ? phase : phase < 2 ? 2 - phase : 0;
|
||||
brightness = brightness * brightness * (3 - 2 * brightness);
|
||||
const alpha = 0.2 + brightness * 0.75;
|
||||
const dx = dotsStartX + i * dotSpacing * 2.2;
|
||||
ctx.save();
|
||||
ctx.shadowColor = `rgba(61,214,208,${alpha * 0.8})`;
|
||||
ctx.shadowBlur = dotR * 3;
|
||||
ctx.fillStyle = `rgba(61,214,208,${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, textY, dotR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
animationId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
updateSize();
|
||||
animationId = requestAnimationFrame(draw);
|
||||
|
||||
if (window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => updateSize());
|
||||
resizeObserver.observe(container);
|
||||
} else {
|
||||
const onResize = () => updateSize();
|
||||
window.addEventListener('resize', onResize);
|
||||
removeWindowResize = () => window.removeEventListener('resize', onResize);
|
||||
}
|
||||
|
||||
// 跟随应用 dark class(未手动锁定主题时)
|
||||
themeObserver = new MutationObserver(() => {
|
||||
if (!localStorage.getItem('prescription-loading-theme')) {
|
||||
syncThemeFromApp();
|
||||
}
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(animationId);
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
removeWindowResize?.();
|
||||
removeWindowResize = null;
|
||||
themeObserver?.disconnect();
|
||||
themeObserver = null;
|
||||
resetParticles = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
//background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.loading-wrapper--dark {
|
||||
//background: #1e2227;
|
||||
}
|
||||
.loading-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.theme-toggle-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(128, 128, 128, 0.3);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.25s;
|
||||
}
|
||||
.loading-wrapper--dark .theme-toggle-btn {
|
||||
background: rgba(30, 34, 39, 0.9);
|
||||
color: #d1d5db;
|
||||
}
|
||||
.theme-toggle-btn:hover {
|
||||
border-color: #3dd6d0;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
.theme-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -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">
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface FormFieldSchema {
|
||||
required?: boolean;
|
||||
/** select / radio 类型的选项列表 */
|
||||
options?: { label: string; value: any }[];
|
||||
/** 图片字段:限定素材库文件类型(如 [1] 只显示图片) */
|
||||
/** 图片字段:限定素材库文件类型(如 [0] 只显示图片) */
|
||||
acceptTypes?: number[];
|
||||
/** 最大长度 */
|
||||
maxLength?: number;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Dayjs } from 'dayjs';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
@@ -35,9 +36,15 @@ import {
|
||||
} from '#/views/system/store/api';
|
||||
import { getSalespersonList } from '#/views/system/store/api/salesperson';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
import AddDoctorFromStoreModal from '#/views/system/store/components/AddDoctorFromStoreModal.vue';
|
||||
import { canAddStoreDoctor } from '#/views/system/admin/_shared/platform-admin-role';
|
||||
|
||||
const RangePicker = DatePicker.RangePicker;
|
||||
|
||||
const userStore = useUserStore();
|
||||
// 是否允许「添加医生」:超管/管理员/省经理/市经理/业务员
|
||||
const canAddDoctor = computed(() => canAddStoreDoctor(userStore.userInfo));
|
||||
|
||||
const storeId = ref(0);
|
||||
const loading = ref(false);
|
||||
const activeTab = ref('basic');
|
||||
@@ -121,6 +128,25 @@ const [DoctorModal, DoctorModalApi] = useVbenModal({
|
||||
connectedComponent: DoctorCardModal,
|
||||
});
|
||||
|
||||
/** 添加医生弹窗:诊所详情医生团队 Tab 内入口 */
|
||||
const [AddDoctorModal, AddDoctorModalApi] = useVbenModal({
|
||||
connectedComponent: AddDoctorFromStoreModal,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开「添加医生」弹窗:默认绑定当前门店,提交成功后刷新医生团队列表
|
||||
*/
|
||||
const openAddDoctorModal = () => {
|
||||
AddDoctorModalApi.setData({
|
||||
storeId: storeId.value,
|
||||
storeName: (store.value as any)?.name || '',
|
||||
onSuccess: () => {
|
||||
loadCard();
|
||||
},
|
||||
});
|
||||
AddDoctorModalApi.open();
|
||||
};
|
||||
|
||||
function searchTimeParam(): [string, string] {
|
||||
return [
|
||||
searchTime.value[0].format('YYYY-MM-DD 00:00:00'),
|
||||
@@ -256,6 +282,7 @@ function clinicTypeText(v: number) {
|
||||
|
||||
<template>
|
||||
<Modal :title="modalTitle">
|
||||
<AddDoctorModal />
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="cardData" class="mb-4 flex flex-wrap items-center justify-between gap-3 px-1">
|
||||
<div class="text-sm text-muted-foreground">统计时间范围</div>
|
||||
@@ -399,6 +426,15 @@ function clinicTypeText(v: number) {
|
||||
|
||||
<Tabs.TabPane v-if="isClinic" key="doctors" tab="诊所医生团队">
|
||||
<div class="pl-4">
|
||||
<div v-if="canAddDoctor" class="mb-3">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openAddDoctorModal"
|
||||
>
|
||||
添加医生
|
||||
</Button>
|
||||
</div>
|
||||
<Empty v-if="doctorList.length === 0" description="暂无医生" />
|
||||
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
|
||||
30
apps/web-antd/src/components/vip/HeaderVipBadge.vue
Normal file
30
apps/web-antd/src/components/vip/HeaderVipBadge.vue
Normal 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>
|
||||
83
apps/web-antd/src/components/vip/StoreVipCell.vue
Normal file
83
apps/web-antd/src/components/vip/StoreVipCell.vue
Normal 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>
|
||||
107
apps/web-antd/src/components/vip/StoreVipMemberCard.vue
Normal file
107
apps/web-antd/src/components/vip/StoreVipMemberCard.vue
Normal 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>
|
||||
117
apps/web-antd/src/components/vip/VipBadgeCombo.vue
Normal file
117
apps/web-antd/src/components/vip/VipBadgeCombo.vue
Normal 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>
|
||||
41
apps/web-antd/src/components/vip/VipGate.vue
Normal file
41
apps/web-antd/src/components/vip/VipGate.vue
Normal 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>
|
||||
440
apps/web-antd/src/components/vip/VipUpgradeModal.vue
Normal file
440
apps/web-antd/src/components/vip/VipUpgradeModal.vue
Normal 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>
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,6 +65,11 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
{ label: '医疗器械', value: 7, icon: '', icon_text: '' },
|
||||
]);
|
||||
const prescriptionTypeDefault = ref(2);
|
||||
/** 开方种类权限校验中(进入开方页先 loading) */
|
||||
const prescriptionTypeLoading = ref(false);
|
||||
/** 当前门店是否有可开方种类 */
|
||||
const prescriptionTypeAllowed = ref(true);
|
||||
const prescriptionTypeDenyMessage = ref('');
|
||||
const diagnosis = ref('');
|
||||
const medicalAdvice = ref('');
|
||||
const treatmentPrice = ref(0);
|
||||
@@ -204,9 +209,10 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 拉取后端处方类型列表与默认选中
|
||||
* 拉取后端处方类型列表与默认选中;无绑定时清空 list 并返回 false
|
||||
*/
|
||||
const loadPrescriptionTypeOptions = async (registerId?: number | string) => {
|
||||
prescriptionTypeLoading.value = true;
|
||||
try {
|
||||
const params: { register_id?: number; store_id?: number } = {};
|
||||
const rid = Number(registerId || currentRegisterId.value);
|
||||
@@ -214,19 +220,34 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
if (myStoreId.value) params.store_id = myStoreId.value;
|
||||
const res = await getPrescriptionTypeOptionsApi(params);
|
||||
const list = Array.isArray(res?.list) ? res.list : [];
|
||||
if (list.length) {
|
||||
categories.value = list.map((item) => ({
|
||||
value: Number(item.value),
|
||||
label: item.label || '',
|
||||
icon: item.icon || '',
|
||||
icon_text: item.icon_text || '',
|
||||
}));
|
||||
categories.value = list.map((item) => ({
|
||||
value: Number(item.value),
|
||||
label: item.label || '',
|
||||
icon: item.icon || '',
|
||||
icon_text: item.icon_text || '',
|
||||
}));
|
||||
const def = Number(res?.default) || 0;
|
||||
prescriptionTypeDefault.value = def;
|
||||
const allowed =
|
||||
res?.allowed !== false && list.length > 0;
|
||||
prescriptionTypeAllowed.value = allowed;
|
||||
prescriptionTypeDenyMessage.value = allowed
|
||||
? ''
|
||||
: (res?.message || '当前门店未开通开方权限');
|
||||
// 当前 Tab 不在 allowed 内时强制切到 default
|
||||
if (allowed && def && !categories.value.some((c) => c.value === activeCategory.value)) {
|
||||
activeCategory.value = def;
|
||||
}
|
||||
const def = Number(res?.default);
|
||||
if (def) prescriptionTypeDefault.value = def;
|
||||
return allowed;
|
||||
} catch (error) {
|
||||
console.error('加载处方类型失败:', error);
|
||||
categories.value = [];
|
||||
prescriptionTypeAllowed.value = false;
|
||||
prescriptionTypeDenyMessage.value = '处方类型加载失败,请稍后重试';
|
||||
message.warning('处方类型加载失败,请稍后重试');
|
||||
return false;
|
||||
} finally {
|
||||
prescriptionTypeLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1249,6 +1270,9 @@ export const usePrescriptionStore = defineStore('prescription', () => {
|
||||
activeCategory,
|
||||
categories,
|
||||
prescriptionTypeDefault,
|
||||
prescriptionTypeLoading,
|
||||
prescriptionTypeAllowed,
|
||||
prescriptionTypeDenyMessage,
|
||||
diagnosis,
|
||||
medicalAdvice,
|
||||
treatmentPrice,
|
||||
|
||||
165
apps/web-antd/src/utils/dictSearchRank.ts
Normal file
165
apps/web-antd/src/utils/dictSearchRank.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
68
apps/web-antd/src/utils/vip.ts
Normal file
68
apps/web-antd/src/utils/vip.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,9 +4,10 @@ import { computed, ref } from 'vue';
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { Button, Card, Descriptions, Image, Space, Tabs, Tag, message } from 'ant-design-vue';
|
||||
import { Button, Card, Descriptions, Image, Modal as AntdModal, Space, Tabs, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
|
||||
import { retrySettleApi } from '#/views/business/order/api/order-ops';
|
||||
|
||||
import OrderPricePercentAdjustDrawer from '#/views/business/order/components/OrderPricePercentAdjustDrawer.vue';
|
||||
import { getOrderPriceAdjustConfig, adjustOrderPercent } from '#/api/order/priceAdjust';
|
||||
@@ -30,6 +31,26 @@ const expressDetail = ref({});
|
||||
// 订单发货方式
|
||||
const deliveryMethod = ref(-1);
|
||||
const syncingLegacy = ref(false);
|
||||
/** 重新结算请求中,防止重复点击 */
|
||||
const retrySettling = ref(false);
|
||||
|
||||
/**
|
||||
* 超管可见:已支付且未取消/未退款时可重新结算(补齐残缺分账)
|
||||
* 与后端一致:仅 role_id=1(SUPPER_ADMIN)
|
||||
*/
|
||||
const canRetrySettle = computed(() => {
|
||||
const roleId = Number(
|
||||
userStore.userInfo?.role_id ?? userStore.userInfo?.roles?.id,
|
||||
);
|
||||
if (roleId !== 1) return false;
|
||||
const row = data.value;
|
||||
if (!row?.id) return false;
|
||||
return (
|
||||
Number(row.is_pay) === 1 &&
|
||||
Number(row.cancel_status) === 0 &&
|
||||
Number(row.refund_status) === 0
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 超管可见:上门快递且订单有运单时,可手动把历史运单同步到 shipment
|
||||
@@ -123,6 +144,31 @@ function openOrderTrace() {
|
||||
traceDrawerApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认后触发后端重新结算:作废残缺分账并按规则重建入账,避免重复加钱
|
||||
*/
|
||||
function handleRetrySettle() {
|
||||
if (!data.value?.id || retrySettling.value) {
|
||||
return;
|
||||
}
|
||||
AntdModal.confirm({
|
||||
title: '重新结算',
|
||||
content: `确认为订单 ${data.value.order_no} 重新结算分账?将作废旧分账后按当前规则重建,已完整的订单不会重复加账。`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
retrySettling.value = true;
|
||||
try {
|
||||
const res = await retrySettleApi({ order_id: Number(data.value.id) });
|
||||
message.success(res?.message || '重新结算成功');
|
||||
await reloadOrder();
|
||||
} finally {
|
||||
retrySettling.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
@@ -269,6 +315,14 @@ function prescriptionStatusColor() {
|
||||
<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
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { getOrderStatusOption } from '#/views/business/order/product-order/api';
|
||||
import {
|
||||
getOrderPrescriptionTypeOption,
|
||||
getOrderStatusOption,
|
||||
} from '#/views/business/order/product-order/api';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
// 默认展开
|
||||
@@ -65,29 +68,19 @@ export const formOptions: VbenFormProps = {
|
||||
label: '发货方式',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
// 类型从后端枚举拉取,避免前端写死漏掉非药品/医疗器械等
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
filterOption: true,
|
||||
showSearch: true,
|
||||
options: [
|
||||
{
|
||||
label: '中药',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
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',
|
||||
|
||||
@@ -43,7 +43,12 @@ import {
|
||||
updateFreeShipping,
|
||||
} from '#/views/business/order/product-order/api';
|
||||
import { getDeliveryWarehouseOptionsByDrugs } from '#/views/doctor/doctor-reception/api';
|
||||
import { simulatePayApi, accrueSalespersonCommissionApi, reverseSalespersonCommissionApi } from '#/views/business/order/api/order-ops';
|
||||
import {
|
||||
accrueSalespersonCommissionApi,
|
||||
retryConfirmReceiveApi,
|
||||
reverseSalespersonCommissionApi,
|
||||
simulatePayApi,
|
||||
} from '#/views/business/order/api/order-ops';
|
||||
import ChinaErpSyncLogDrawer from '#/views/business/order/components/china-erp-sync-log-drawer.vue';
|
||||
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
|
||||
import OrderPricePercentAdjustDrawer from '#/views/business/order/components/OrderPricePercentAdjustDrawer.vue';
|
||||
@@ -446,6 +451,29 @@ function handleSimulatePay(row: Record<string, any>) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管手动确认收货 / 重试解冻(自动确认队列失败后的人工补偿)
|
||||
* 仅 Super Admin 可见;待收货走完整签收,已确认收货仅重试解冻
|
||||
*/
|
||||
function handleRetryConfirmReceive(row: Record<string, any>) {
|
||||
const isWaitReceive = Number(row.status) === 2;
|
||||
AntdModal.confirm({
|
||||
title: isWaitReceive ? '确认收货' : '重试解冻',
|
||||
content: isWaitReceive
|
||||
? `确认为订单 ${row.order_no} 手动确认收货?将更新为已确认收货并解冻供应商/配送仓分账冻结。`
|
||||
: `订单 ${row.order_no} 已确认收货,是否重试解冻分账冻结?(幂等,不会重复加账)`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const res = await retryConfirmReceiveApi({ order_id: Number(row.id) });
|
||||
message.success(
|
||||
res?.message || (isWaitReceive ? '确认收货成功' : '解冻重试成功'),
|
||||
);
|
||||
await gridApi.query();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const accruingOrderIds = ref<number[]>([]);
|
||||
const reversingOrderIds = ref<number[]>([]);
|
||||
|
||||
@@ -1303,6 +1331,19 @@ const openOrderAmountVerify = () => {
|
||||
ifShow: row.is_pay === 0,
|
||||
onClick: handleSimulatePay.bind(null, row),
|
||||
},
|
||||
{
|
||||
// 仅超管:待收货确认收货 / 已确认收货重试解冻
|
||||
label: Number(row.status) === 2 ? '确认收货' : '重试解冻',
|
||||
type: 'link',
|
||||
icon: 'mdi:package-check',
|
||||
auth: ['Super Admin'],
|
||||
ifShow:
|
||||
Number(row.is_pay) === 1 &&
|
||||
Number(row.cancel_status) === 0 &&
|
||||
Number(row.refund_status) === 0 &&
|
||||
(Number(row.status) === 2 || Number(row.status) === 7),
|
||||
onClick: handleRetryConfirmReceive.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '发货',
|
||||
type: 'link',
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
import DoctorQrCodePreview from '#/components/modal/DoctorQrCodePreview.vue';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import StoreVipMemberCard from '#/components/vip/StoreVipMemberCard.vue';
|
||||
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
|
||||
import {
|
||||
deleteNavApi,
|
||||
@@ -31,7 +33,6 @@ import {
|
||||
updateNavSortApi,
|
||||
updateClinicTypeApi,
|
||||
} from '#/views/business/store/settings/api';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
|
||||
import UploadModal from './components/uploadModal.vue';
|
||||
import SalespersonCard from "#/views/business/store/settings/components/SalespersonCard.vue";
|
||||
@@ -63,6 +64,8 @@ interface StoreInfo {
|
||||
qr_code: string;
|
||||
doctor_list: any[];
|
||||
nav: { id: number; pic: string }[];
|
||||
/** 门店 VIP:等级徽标 + 期限丝带 */
|
||||
vip?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
const [DoctorQrCodePreviewModal, DoctorQrCodePreviewModalApi] = useVbenModal({
|
||||
@@ -375,6 +378,8 @@ const handleSwitchClinicType = () => {
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<Card v-if="activeTabBar === 1" :title="isPharmacy ? '药店资料' : '诊所资料'">
|
||||
<!-- 门店 VIP 会员卡:大尺寸徽标 + 期限丝带 -->
|
||||
<StoreVipMemberCard :vip="data.vip" />
|
||||
<!-- 头部信息 -->
|
||||
<div class="mb-8 flex flex-col gap-6 md:flex-row md:gap-8">
|
||||
<div class="flex-1">
|
||||
@@ -576,16 +581,15 @@ const handleSwitchClinicType = () => {
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
专业:
|
||||
|
||||
<span
|
||||
:class="
|
||||
doctor.identity === 1
|
||||
doctor.doctor?.identity === 1
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
"
|
||||
class="rounded px-2 py-1"
|
||||
>
|
||||
{{ doctor.identity === 1 ? '中医' : '西医' }}
|
||||
{{ doctor.doctor?.identity === 1 ? '中医' : '西医' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 醒目提示条:病历/处方生成记录与模态框顶部展示
|
||||
* 样式仅用主题变量,禁止 :global(.dark) 以免污染全局配色
|
||||
*/
|
||||
defineProps<{
|
||||
/** 完整提示文案(可含换行) */
|
||||
text?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="text" class="ai-disclaimer" role="note">
|
||||
<div class="ai-disclaimer__title">重要提示</div>
|
||||
<div class="ai-disclaimer__body">{{ text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-disclaimer {
|
||||
border: 1px solid hsl(var(--warning) / 0.45);
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--warning) / 0.12);
|
||||
padding: 10px 12px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.ai-disclaimer__title {
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--warning));
|
||||
}
|
||||
.ai-disclaimer__body {
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -119,6 +119,8 @@ export async function getPrescriptionTypeOptionsApi(params?: {
|
||||
icon?: string;
|
||||
icon_text?: string;
|
||||
}>;
|
||||
allowed?: boolean;
|
||||
message?: string;
|
||||
}>(`${prefix}prescription-type-options`, {
|
||||
params,
|
||||
});
|
||||
@@ -142,6 +144,31 @@ export async function withdrawPrescriptionApi(data: {
|
||||
return requestClient.post<any>(`${prefix}withdraw-prescription`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本次挂号处方列表(分页,支持 keyword 按订单号/订单ID筛选)
|
||||
*/
|
||||
export async function getRegisterPrescriptionListApi(params: {
|
||||
register_id: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}register-prescription-list`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 患者历史处方(排除当前挂号),支持 keyword 按订单号/订单ID筛选
|
||||
*/
|
||||
export async function getPatientPrescriptionHistoryApi(params: {
|
||||
patient_id: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
exclude_register_id?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}patient-prescription-history`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 接诊
|
||||
*/
|
||||
@@ -401,3 +428,86 @@ export async function saveGranularCommonPrescriptionApi(data: {
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** AI 给处方 */
|
||||
export async function aiGeneratePrescriptionApi(data: {
|
||||
register_id: number;
|
||||
store_id?: number;
|
||||
prescription_type: number;
|
||||
/** 前端确认过的主诉(可编辑) */
|
||||
chief_complaint?: string;
|
||||
/** 当前病历表单快照(可未保存),供 AI 综合参考 */
|
||||
medical_record?: Record<string, any>;
|
||||
/** 中药:是否委托调剂 */
|
||||
use_entrusted_process?: 0 | 1 | boolean;
|
||||
/** 中药委托调剂:制剂要求 id(yii_process_rule pid=0) */
|
||||
process_rule_id?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-generate-prescription`, data);
|
||||
}
|
||||
|
||||
/** AI 功能门闸(醒目提示 + 是否已签署知情同意) */
|
||||
export async function aiFeatureGateApi(data: { feature_code: string }) {
|
||||
return requestClient.get<any>(`${prefix}ai-feature-gate`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 知情同意签署(按 VIP 功能码,签署一次即可) */
|
||||
export async function aiAgreeConsentApi(data: {
|
||||
feature_code: string;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-agree-consent`, data);
|
||||
}
|
||||
|
||||
/** 本挂号 AI 生成历史(轻量列表) */
|
||||
export async function aiListGenerationsApi(data: {
|
||||
register_id: number;
|
||||
scene?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}ai-list-generations`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 生成详情(点选历史) */
|
||||
export async function aiGenerationDetailApi(data: { id: number }) {
|
||||
return requestClient.get<any>(`${prefix}ai-generation-detail`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 处方药名模糊对照 */
|
||||
export async function aiMatchPrescriptionDrugsApi(data: {
|
||||
generation_id?: number;
|
||||
register_id?: number;
|
||||
store_id?: number;
|
||||
prescription_type?: number;
|
||||
items?: Array<Record<string, any>>;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-match-prescription-drugs`, data);
|
||||
}
|
||||
|
||||
/** 金方列表(VIP:golden_formula) */
|
||||
export async function goldenFormulaListApi(data: {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}golden-formula-list`, { params: data });
|
||||
}
|
||||
|
||||
/** 金方详情 */
|
||||
export async function goldenFormulaDetailApi(data: {
|
||||
id: number;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}golden-formula-detail`, { params: data });
|
||||
}
|
||||
|
||||
/** 金方导入药名对照 */
|
||||
export async function goldenFormulaApplyApi(data: {
|
||||
id: number;
|
||||
store_id?: number;
|
||||
register_id?: number;
|
||||
prescription_type?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}golden-formula-apply`, data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* PC 接诊:AI 辅助出方
|
||||
* 主抽屉:历史列表 + 详情对照 + 确认导入
|
||||
* 二级弹窗:紧凑可编辑病历预览 → 生成处方(编辑同步回病历面板)
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { Button, Checkbox, Descriptions, Input, Select, Spin, Switch, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
aiGeneratePrescriptionApi,
|
||||
aiListGenerationsApi,
|
||||
aiMatchPrescriptionDrugsApi,
|
||||
getProcessRuleList,
|
||||
} from '../api';
|
||||
import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue';
|
||||
import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate';
|
||||
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
|
||||
|
||||
/** 二级弹窗通栏字段(Descriptions span=2) */
|
||||
const FULL_SPAN_KEYS = new Set([
|
||||
'chief_complaint',
|
||||
'present_illness',
|
||||
'tcm_case',
|
||||
'treatment_advice',
|
||||
'doctor_order',
|
||||
'auxiliary_exam',
|
||||
'physical_exam',
|
||||
]);
|
||||
|
||||
/** 二级弹窗可编辑的病历字段(主诉单独置顶) */
|
||||
const MR_EDIT_FIELDS: Array<{ key: string; label: string }> = [
|
||||
{ key: 'present_illness', label: '现病史' },
|
||||
{ key: 'tongue', label: '舌象' },
|
||||
{ key: 'pulse', label: '脉象' },
|
||||
{ key: 'tcm_case', label: '中医病案' },
|
||||
{ key: 'tcm_disease', label: '中医疾病' },
|
||||
{ key: 'tcm_syndrome', label: '中医证候' },
|
||||
{ key: 'tcm_method', label: '中医治法' },
|
||||
{ key: 'diagnosis', label: '临床诊断' },
|
||||
{ key: 'treatment_advice', label: '治疗意见' },
|
||||
{ key: 'doctor_order', label: '医嘱' },
|
||||
{ key: 'allergy_history', label: '过敏史' },
|
||||
{ key: 'past_history', label: '既往史' },
|
||||
{ key: 'family_history', label: '家族史' },
|
||||
{ key: 'epidemic_history', label: '流行病学史' },
|
||||
{ key: 'personal_history', label: '个人史' },
|
||||
{ key: 'menstrual_history', label: '月经史' },
|
||||
{ key: 'marital_history', label: '婚育史' },
|
||||
{ key: 'physical_exam', label: '体征检查' },
|
||||
{ key: 'auxiliary_exam', label: '辅助检查' },
|
||||
];
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'import',
|
||||
payload: {
|
||||
rows: Array<Record<string, any>>;
|
||||
dosage?: number;
|
||||
day_dosage?: number;
|
||||
process_rule_id?: number;
|
||||
child_process_rule_id?: number;
|
||||
process_rule_note_id?: number;
|
||||
rule_type?: number;
|
||||
},
|
||||
): void;
|
||||
(e: 'search-drug', keyword: string): void;
|
||||
/** 预览里改病历字段时回写父页病历面板 */
|
||||
(e: 'sync-medical-record', payload: Record<string, any>): void;
|
||||
}>();
|
||||
|
||||
const registerId = ref(0);
|
||||
const storeId = ref(0);
|
||||
const prescriptionType = ref(0);
|
||||
const patientName = ref('');
|
||||
const patientSex = ref(0);
|
||||
const patientAge = ref(0);
|
||||
const chiefComplaint = ref('');
|
||||
const medicalRecord = ref<Record<string, any>>({});
|
||||
const histLoading = ref(false);
|
||||
const generating = ref(false);
|
||||
const matchLoading = ref(false);
|
||||
const historyList = ref<any[]>([]);
|
||||
const activeId = ref(0);
|
||||
const matchedList = ref<any[]>([]);
|
||||
const unmatchedList = ref<any[]>([]);
|
||||
const conflictMessages = ref<string[]>([]);
|
||||
const rxDosage = ref(0);
|
||||
const rxDayDosage = ref(0);
|
||||
const rxReason = ref('');
|
||||
const rxBasis = ref('');
|
||||
const rxPrescriptionName = ref('');
|
||||
/** 最近一次生成耗时(毫秒) */
|
||||
const lastDurationMs = ref(0);
|
||||
/** AI 返回的委托调剂制剂信息(导入时传给开方区) */
|
||||
const rxProcessRuleId = ref(0);
|
||||
const rxChildProcessRuleId = ref(0);
|
||||
const rxProcessRuleNoteId = ref(0);
|
||||
const rxProcessRuleName = ref('');
|
||||
const rxChildProcessRuleName = ref('');
|
||||
const rxProcessRuleNote = ref('');
|
||||
/** 生成弹窗:中药委托调剂选项 */
|
||||
const useEntrustedProcess = ref(false);
|
||||
const genProcessRuleId = ref<number | undefined>(undefined);
|
||||
const processRuleOptions = ref<Array<{ label: string; value: number }>>([]);
|
||||
const processRuleLoading = ref(false);
|
||||
/** 二级弹窗:当前正在编辑的字段 key,空表示浏览态 */
|
||||
const editingKey = ref('');
|
||||
/** 醒目提示文案(系统配置 / 门闸下发) */
|
||||
const disclaimerText = ref('');
|
||||
|
||||
const sexLabel = computed(() => {
|
||||
const s = Number(patientSex.value);
|
||||
if (s === 1) return '男';
|
||||
if (s === 2) return '女';
|
||||
return '未知';
|
||||
});
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
const t = Number(prescriptionType.value);
|
||||
if (t === 1) return '中药';
|
||||
if (t === 2) return '西(中成)药';
|
||||
return `类型 ${t || '—'}`;
|
||||
});
|
||||
|
||||
const isTcmRx = computed(() => Number(prescriptionType.value) === 1);
|
||||
|
||||
/** 二级弹窗紧凑展示:有内容的字段 + 主诉 */
|
||||
const genPreviewRows = computed(() => {
|
||||
const mr = medicalRecord.value || {};
|
||||
const rows: Array<{ key: string; label: string; value: string }> = [
|
||||
{
|
||||
key: 'chief_complaint',
|
||||
label: '主诉',
|
||||
value: String(chiefComplaint.value || mr.chief_complaint || '').trim(),
|
||||
},
|
||||
];
|
||||
for (const f of MR_EDIT_FIELDS) {
|
||||
const val = String(mr[f.key] ?? '').trim();
|
||||
if (val) rows.push({ key: f.key, label: f.label, value: val });
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: 'AI辅助出方',
|
||||
class: 'w-[920px]',
|
||||
// 按钮放内容区,关掉默认 footer,避免底部留白占高
|
||||
footer: false,
|
||||
showCancelButton: false,
|
||||
showConfirmButton: false,
|
||||
// 内容区改 flex 铺满,由内部滚动,避免双重裁切
|
||||
contentClass: '!flex !flex-col !overflow-hidden',
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
resetState();
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<{
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
prescriptionType: number;
|
||||
patientName?: string;
|
||||
patientSex?: number;
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
medicalRecord?: Record<string, any>;
|
||||
}>();
|
||||
registerId.value = Number(data?.registerId || 0);
|
||||
storeId.value = Number(data?.storeId || 0);
|
||||
prescriptionType.value = Number(data?.prescriptionType || 0);
|
||||
patientName.value = String(data?.patientName || '');
|
||||
patientSex.value = Number(data?.patientSex || 0);
|
||||
patientAge.value = Number(data?.patientAge || 0);
|
||||
chiefComplaint.value = String(data?.chiefComplaint || '');
|
||||
medicalRecord.value =
|
||||
data?.medicalRecord && typeof data.medicalRecord === 'object'
|
||||
? { ...data.medicalRecord }
|
||||
: {};
|
||||
resetPreview();
|
||||
// 首次使用对应 VIP 功能需签署知情同意;拒绝则关闭抽屉
|
||||
try {
|
||||
const gate = await ensureAiFeatureConsent({
|
||||
featureCode: 'ai_prescription',
|
||||
storeId: storeId.value || undefined,
|
||||
});
|
||||
if (!gate) {
|
||||
drawerApi.close();
|
||||
return;
|
||||
}
|
||||
disclaimerText.value = gate.disclaimer_text || '';
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || e?.msg || '无法校验 AI 使用规范');
|
||||
drawerApi.close();
|
||||
return;
|
||||
}
|
||||
void bootstrapHistory();
|
||||
},
|
||||
});
|
||||
|
||||
const [GenModal, genModalApi] = useVbenModal({
|
||||
title: '生成处方',
|
||||
class: 'w-[920px]',
|
||||
confirmText: '开始生成',
|
||||
cancelText: '取消',
|
||||
// 校验失败不关;通过后立刻关弹窗,生成在抽屉内进行
|
||||
onConfirm: async () => confirmGenerateAndClose(),
|
||||
});
|
||||
|
||||
function resetPreview() {
|
||||
matchedList.value = [];
|
||||
unmatchedList.value = [];
|
||||
conflictMessages.value = [];
|
||||
rxDosage.value = 0;
|
||||
rxDayDosage.value = 0;
|
||||
rxReason.value = '';
|
||||
rxBasis.value = '';
|
||||
rxPrescriptionName.value = '';
|
||||
lastDurationMs.value = 0;
|
||||
rxProcessRuleId.value = 0;
|
||||
rxChildProcessRuleId.value = 0;
|
||||
rxProcessRuleNoteId.value = 0;
|
||||
rxProcessRuleName.value = '';
|
||||
rxChildProcessRuleName.value = '';
|
||||
rxProcessRuleNote.value = '';
|
||||
activeId.value = 0;
|
||||
editingKey.value = '';
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
generating.value = false;
|
||||
matchLoading.value = false;
|
||||
histLoading.value = false;
|
||||
historyList.value = [];
|
||||
medicalRecord.value = {};
|
||||
chiefComplaint.value = '';
|
||||
disclaimerText.value = '';
|
||||
useEntrustedProcess.value = false;
|
||||
genProcessRuleId.value = undefined;
|
||||
processRuleOptions.value = [];
|
||||
resetPreview();
|
||||
genModalApi.close();
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const n = Number(ts || 0);
|
||||
if (!n) return '—';
|
||||
const d = new Date(n * 1000);
|
||||
const pad = (x: number) => (x < 10 ? `0${x}` : `${x}`);
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 毫秒转可读耗时(850ms / 1.2s) */
|
||||
function formatDurationMs(ms: number | undefined | null) {
|
||||
const n = Number(ms || 0);
|
||||
if (!n) return '';
|
||||
if (n < 1000) return `${Math.round(n)}ms`;
|
||||
const sec = n / 1000;
|
||||
return sec >= 10 ? `${Math.round(sec)}s` : `${sec.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function typeLabelOf(t: number | string) {
|
||||
const n = Number(t || 0);
|
||||
if (n === 1) return '中药';
|
||||
if (n === 2) return '西药(中成药)';
|
||||
return n ? `类型 ${n}` : '—';
|
||||
}
|
||||
|
||||
function applyRxMeta(data: any) {
|
||||
rxDosage.value = Number(data?.dosage || 0);
|
||||
rxDayDosage.value = Number(data?.day_dosage || 0);
|
||||
rxReason.value = String(data?.reason || '').trim();
|
||||
rxBasis.value = String(data?.basis || '').trim();
|
||||
rxPrescriptionName.value = String(data?.prescription_name || '').trim();
|
||||
if (data?.duration_ms != null) {
|
||||
lastDurationMs.value = Number(data.duration_ms || 0);
|
||||
}
|
||||
rxProcessRuleId.value = Number(data?.process_rule_id || 0);
|
||||
rxChildProcessRuleId.value = Number(data?.child_process_rule_id || 0);
|
||||
rxProcessRuleNoteId.value = Number(data?.process_rule_note_id || 0);
|
||||
rxProcessRuleName.value = String(data?.process_rule_name || '').trim();
|
||||
rxChildProcessRuleName.value = String(data?.child_process_rule_name || '').trim();
|
||||
rxProcessRuleNote.value = String(data?.process_rule_note || '').trim();
|
||||
}
|
||||
|
||||
/** 加载制剂要求列表(pid=0),供生成前委托调剂选择 */
|
||||
async function loadTopProcessRules() {
|
||||
if (!isTcmRx.value) return;
|
||||
processRuleLoading.value = true;
|
||||
try {
|
||||
const list = await getProcessRuleList({ pid: 0 });
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
processRuleOptions.value = arr.map((item: any) => ({
|
||||
label: String(item.name || item.title || `#${item.id}`),
|
||||
value: Number(item.id),
|
||||
}));
|
||||
} catch {
|
||||
processRuleOptions.value = [];
|
||||
} finally {
|
||||
processRuleLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onEntrustedToggle(checked: boolean) {
|
||||
if (checked && !processRuleOptions.value.length) {
|
||||
void loadTopProcessRules();
|
||||
}
|
||||
if (!checked) {
|
||||
genProcessRuleId.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function applyConflict(conflict: any) {
|
||||
const msgs = Array.isArray(conflict?.messages) ? conflict.messages : [];
|
||||
conflictMessages.value = msgs
|
||||
.map((m: any) => String(m || '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function applyMatch(match: any) {
|
||||
const matched = Array.isArray(match?.matched) ? match.matched : [];
|
||||
matchedList.value = matched.map((m: any) => ({
|
||||
...m,
|
||||
_import: true,
|
||||
}));
|
||||
unmatchedList.value = Array.isArray(match?.unmatched) ? match.unmatched : [];
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
histLoading.value = true;
|
||||
try {
|
||||
const list = await aiListGenerationsApi({
|
||||
register_id: registerId.value,
|
||||
scene: 'prescription',
|
||||
limit: 30,
|
||||
});
|
||||
historyList.value = Array.isArray(list) ? list : [];
|
||||
} catch (e: any) {
|
||||
historyList.value = [];
|
||||
message.error(e?.message || e?.msg || '加载历史失败');
|
||||
} finally {
|
||||
histLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开抽屉后加载历史;有则选最新,无则自动打开生成框 */
|
||||
async function bootstrapHistory() {
|
||||
if (!registerId.value) return;
|
||||
await loadHistory();
|
||||
if (historyList.value.length) {
|
||||
await onSelectHistory(historyList.value[0]);
|
||||
} else {
|
||||
openGenerateModal();
|
||||
}
|
||||
}
|
||||
|
||||
function openGenerateModal() {
|
||||
if (!prescriptionType.value) {
|
||||
message.warning('请先选择处方类型');
|
||||
return;
|
||||
}
|
||||
editingKey.value = '';
|
||||
if (isTcmRx.value && useEntrustedProcess.value && !processRuleOptions.value.length) {
|
||||
void loadTopProcessRules();
|
||||
}
|
||||
genModalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 点字段进入编辑;改完 blur 后同步病历
|
||||
*/
|
||||
function startEdit(key: string) {
|
||||
editingKey.value = key;
|
||||
}
|
||||
|
||||
function fieldValue(key: string) {
|
||||
if (key === 'chief_complaint') return chiefComplaint.value;
|
||||
return String(medicalRecord.value?.[key] ?? '');
|
||||
}
|
||||
|
||||
function onFieldInput(key: string, val: string) {
|
||||
if (key === 'chief_complaint') {
|
||||
chiefComplaint.value = val;
|
||||
medicalRecord.value = {
|
||||
...(medicalRecord.value || {}),
|
||||
chief_complaint: val,
|
||||
};
|
||||
} else {
|
||||
medicalRecord.value = {
|
||||
...(medicalRecord.value || {}),
|
||||
[key]: val,
|
||||
};
|
||||
}
|
||||
emit('sync-medical-record', { [key]: val });
|
||||
}
|
||||
|
||||
function endEdit() {
|
||||
editingKey.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级弹窗点「开始生成」:先关弹窗,回抽屉等待 AI
|
||||
*/
|
||||
async function confirmGenerateAndClose(): Promise<boolean> {
|
||||
if (generating.value) return false;
|
||||
const chief = String(chiefComplaint.value || '').trim();
|
||||
if (!chief) {
|
||||
message.warning('请先填写主诉后再生成');
|
||||
return false;
|
||||
}
|
||||
if (!prescriptionType.value) {
|
||||
message.warning('请先选择处方类型');
|
||||
return false;
|
||||
}
|
||||
if (!registerId.value) {
|
||||
message.warning('挂号无效');
|
||||
return false;
|
||||
}
|
||||
if (isTcmRx.value && useEntrustedProcess.value && !genProcessRuleId.value) {
|
||||
message.warning('请选择制剂要求');
|
||||
return false;
|
||||
}
|
||||
// 先关模态,在抽屉内展示「AI 处理中」
|
||||
genModalApi.close();
|
||||
void runGenerateInDrawer(chief);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runGenerateInDrawer(chief: string) {
|
||||
generating.value = true;
|
||||
try {
|
||||
const req: Record<string, any> = {
|
||||
register_id: registerId.value,
|
||||
store_id: storeId.value || undefined,
|
||||
prescription_type: prescriptionType.value,
|
||||
chief_complaint: chief,
|
||||
medical_record: {
|
||||
...(medicalRecord.value || {}),
|
||||
chief_complaint: chief,
|
||||
},
|
||||
};
|
||||
// 中药委托调剂:传制剂要求,后端会补全煎法/规格
|
||||
if (isTcmRx.value && useEntrustedProcess.value && genProcessRuleId.value) {
|
||||
req.use_entrusted_process = 1;
|
||||
req.process_rule_id = genProcessRuleId.value;
|
||||
}
|
||||
const data = await aiGeneratePrescriptionApi(req);
|
||||
const durationText = formatDurationMs(data?.duration_ms);
|
||||
// 业务软失败:HTTP 成功但 ok=false,警告提示而非 error
|
||||
if (data?.ok === false) {
|
||||
matchedList.value = [];
|
||||
unmatchedList.value = [];
|
||||
conflictMessages.value = [];
|
||||
applyRxMeta({
|
||||
duration_ms: data.duration_ms,
|
||||
});
|
||||
if (data?.generation_id) {
|
||||
activeId.value = Number(data.generation_id);
|
||||
await loadHistory();
|
||||
}
|
||||
message.warning(String(data?.message || '生成未成功'));
|
||||
return;
|
||||
}
|
||||
await loadHistory();
|
||||
activeId.value = Number(data?.generation_id || 0);
|
||||
applyMatch(data?.match || null);
|
||||
applyConflict(data?.conflict || null);
|
||||
applyRxMeta(data);
|
||||
message.success(
|
||||
durationText ? `已生成(耗时 ${durationText})` : '已生成,请确认导入',
|
||||
);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || e?.msg || '生成失败');
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectHistory(row: any) {
|
||||
if (!row?.id) return;
|
||||
activeId.value = Number(row.id);
|
||||
matchLoading.value = true;
|
||||
try {
|
||||
const data = await aiMatchPrescriptionDrugsApi({
|
||||
generation_id: Number(row.id),
|
||||
register_id: registerId.value,
|
||||
store_id: storeId.value || undefined,
|
||||
prescription_type: Number(
|
||||
prescriptionType.value || row.prescription_type || 0,
|
||||
),
|
||||
});
|
||||
applyMatch(data);
|
||||
applyConflict(data?.conflict || null);
|
||||
applyRxMeta({
|
||||
...data,
|
||||
duration_ms: row.duration_ms,
|
||||
prescription_name:
|
||||
data?.prescription_name || row?.name || row?.prescription_name || '',
|
||||
});
|
||||
} catch (e: any) {
|
||||
matchedList.value = [];
|
||||
unmatchedList.value = [];
|
||||
conflictMessages.value = [];
|
||||
message.error(e?.message || e?.msg || '对照失败');
|
||||
} finally {
|
||||
matchLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirmImport() {
|
||||
const drugs: any[] = [];
|
||||
for (const m of matchedList.value) {
|
||||
if (!m || m._import === false) continue;
|
||||
const sid = Number(m.selected_drug_id || 0);
|
||||
if (!sid) continue;
|
||||
const picked = (m.candidates || []).find(
|
||||
(c: any) => Number(c.drug_id) === sid,
|
||||
);
|
||||
if (!picked) continue;
|
||||
drugs.push({
|
||||
candidate: picked,
|
||||
dose: m.dose || '',
|
||||
unit: m.unit || '',
|
||||
usage: m.usage || '',
|
||||
ai_name: m.ai_name || '',
|
||||
});
|
||||
}
|
||||
if (!drugs.length) {
|
||||
message.warning('请先勾选已对照药品');
|
||||
return;
|
||||
}
|
||||
const importPayload: {
|
||||
rows: typeof drugs;
|
||||
dosage?: number;
|
||||
day_dosage?: number;
|
||||
rule_type?: number;
|
||||
process_rule_id?: number;
|
||||
child_process_rule_id?: number;
|
||||
process_rule_note_id?: number;
|
||||
} = {
|
||||
rows: drugs,
|
||||
dosage: rxDosage.value || undefined,
|
||||
day_dosage: rxDayDosage.value || undefined,
|
||||
};
|
||||
if (rxProcessRuleId.value > 0) {
|
||||
importPayload.rule_type = 2;
|
||||
importPayload.process_rule_id = rxProcessRuleId.value;
|
||||
if (rxChildProcessRuleId.value > 0) {
|
||||
importPayload.child_process_rule_id = rxChildProcessRuleId.value;
|
||||
}
|
||||
if (rxProcessRuleNoteId.value > 0) {
|
||||
importPayload.process_rule_note_id = rxProcessRuleNoteId.value;
|
||||
}
|
||||
}
|
||||
emit('import', importPayload);
|
||||
drawerApi.close();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open(payload: {
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
prescriptionType: number;
|
||||
patientName?: string;
|
||||
patientSex?: number;
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
medicalRecord?: Record<string, any>;
|
||||
}) {
|
||||
drawerApi.setData(payload);
|
||||
drawerApi.open();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<!-- 铺满抽屉内容区:头部/禁忌/按钮固定,中间历史+对照可滚动 -->
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden text-foreground">
|
||||
<div class="shrink-0 text-sm text-muted-foreground">
|
||||
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁 ·
|
||||
{{ typeLabel }}
|
||||
</div>
|
||||
<AiDisclaimerBanner class="shrink-0" :text="disclaimerText" />
|
||||
<div
|
||||
v-if="conflictMessages.length"
|
||||
class="shrink-0 rounded-lg border border-orange-500/35 bg-orange-500/10 px-3 py-2 text-xs"
|
||||
>
|
||||
<div class="mb-1 font-medium">配伍禁忌提示</div>
|
||||
<div v-for="(line, i) in conflictMessages" :key="i" class="text-muted-foreground">
|
||||
· {{ line }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 gap-3 overflow-hidden">
|
||||
<div class="w-44 shrink-0 overflow-y-auto border-r border-border pr-2">
|
||||
<div class="mb-2 text-sm font-medium">历史记录</div>
|
||||
<div v-if="histLoading" class="text-xs text-muted-foreground">加载中…</div>
|
||||
<div v-else-if="!historyList.length" class="text-xs text-muted-foreground">
|
||||
暂无记录,可点击下方「生成处方」
|
||||
</div>
|
||||
<div
|
||||
v-for="row in historyList"
|
||||
:key="row.id"
|
||||
class="mb-1 cursor-pointer rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-accent"
|
||||
:class="{
|
||||
'bg-primary/10 ring-1 ring-primary/20': activeId === Number(row.id),
|
||||
}"
|
||||
@click="onSelectHistory(row)"
|
||||
>
|
||||
<div class="truncate font-medium">
|
||||
{{ row.name || row.prescription_name || `方案 #${row.id}` }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-muted-foreground">
|
||||
{{ row.prescription_type_label || typeLabelOf(row.prescription_type) }}
|
||||
· {{ row.item_count || 0 }}味 · {{ formatTime(row.created_at) }}
|
||||
<span v-if="row.duration_ms"> · {{ formatDurationMs(row.duration_ms) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 min-w-0 flex-1 overflow-y-auto pl-1 pb-2">
|
||||
<!-- AI 专属动画:仅真正调用生成接口时展示;历史/对照加载用普通 Spin -->
|
||||
<div
|
||||
v-if="generating"
|
||||
class="flex h-full min-h-[260px] items-center justify-center"
|
||||
>
|
||||
<PrescriptionLoading
|
||||
type="prescription"
|
||||
text="AI正在生成处方"
|
||||
class="h-[280px] w-full max-w-[420px]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="matchLoading"
|
||||
class="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Spin />
|
||||
<span class="text-sm">加载历史处方…</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!matchedList.length && !unmatchedList.length && !lastDurationMs"
|
||||
class="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
请选择左侧历史,或点击「生成处方」
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!matchedList.length && !unmatchedList.length && lastDurationMs"
|
||||
class="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
本次生成未产出对照结果
|
||||
<span v-if="formatDurationMs(lastDurationMs)">
|
||||
(耗时 {{ formatDurationMs(lastDurationMs) }})
|
||||
</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="mb-1.5 text-xs font-medium text-muted-foreground">
|
||||
已对照({{ matchedList.length }})
|
||||
</div>
|
||||
<!-- 中药:一行多味网格;西药:仍用较完整卡片 -->
|
||||
<div
|
||||
v-if="isTcmRx"
|
||||
class="grid grid-cols-2 gap-1.5 xl:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="(m, mi) in matchedList"
|
||||
:key="mi"
|
||||
class="rounded border border-border bg-card px-1.5 py-1"
|
||||
>
|
||||
<div class="flex items-start gap-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs font-medium">{{ m.ai_name }}</div>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{{ m.dose }}{{ m.unit }}{{ m.usage ? `·${m.usage}` : '' }}
|
||||
</div>
|
||||
</div>
|
||||
<Switch v-model:checked="m._import" size="small" />
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-0.5">
|
||||
<Tag
|
||||
v-for="c in m.candidates || []"
|
||||
:key="c.drug_id"
|
||||
class="cursor-pointer !m-0 !px-1 !text-[10px] !leading-4"
|
||||
:color="
|
||||
Number(m.selected_drug_id) === Number(c.drug_id)
|
||||
? 'success'
|
||||
: undefined
|
||||
"
|
||||
@click="m.selected_drug_id = Number(c.drug_id)"
|
||||
>
|
||||
{{ c.drug_name }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(m, mi) in matchedList"
|
||||
:key="mi"
|
||||
class="rounded-lg border border-border bg-card p-2.5"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ m.ai_name }}</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ m.dose }}{{ m.unit }}{{ m.usage ? `·${m.usage}` : '' }}
|
||||
</span>
|
||||
<Switch v-model:checked="m._import" size="small" />
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<Tag
|
||||
v-for="c in m.candidates || []"
|
||||
:key="c.drug_id"
|
||||
class="cursor-pointer !m-0 !text-[11px]"
|
||||
:color="
|
||||
Number(m.selected_drug_id) === Number(c.drug_id)
|
||||
? 'success'
|
||||
: undefined
|
||||
"
|
||||
@click="m.selected_drug_id = Number(c.drug_id)"
|
||||
>
|
||||
{{ c.drug_name }} ¥{{ Number(c.price || 0).toFixed(2) }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="unmatchedList.length"
|
||||
class="mb-1.5 mt-3 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
未对照({{ unmatchedList.length }})
|
||||
</div>
|
||||
<div
|
||||
v-for="(u, ui) in unmatchedList"
|
||||
:key="ui"
|
||||
class="mb-1.5 rounded border border-orange-500/30 bg-orange-500/10 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<div class="font-medium">{{ u.ai_name }}</div>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
class="!h-auto !px-0"
|
||||
@click="
|
||||
emit('search-drug', u.ai_name || '');
|
||||
drawerApi.close();
|
||||
"
|
||||
>
|
||||
去手动搜索
|
||||
</Button>
|
||||
</div>
|
||||
<!-- 药方摘要:Descriptions 双列展示名称/剂数/出方理由 -->
|
||||
<Descriptions
|
||||
v-if="
|
||||
rxPrescriptionName ||
|
||||
rxReason ||
|
||||
rxBasis ||
|
||||
rxDosage ||
|
||||
rxDayDosage ||
|
||||
lastDurationMs ||
|
||||
rxProcessRuleName
|
||||
"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="ai-rx-meta-desc mt-3"
|
||||
title="出方说明"
|
||||
>
|
||||
<Descriptions.Item label="药方名" :span="2">
|
||||
{{ rxPrescriptionName || '未命名药方' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="推荐剂数">
|
||||
{{ rxDosage || '—' }}剂
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="每天几次">
|
||||
{{ rxDayDosage || '—' }}次
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item v-if="lastDurationMs" label="生成耗时">
|
||||
{{ formatDurationMs(lastDurationMs) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-if="rxProcessRuleName"
|
||||
label="制剂要求"
|
||||
:span="2"
|
||||
>
|
||||
{{ rxProcessRuleName }}
|
||||
<span v-if="rxChildProcessRuleName"> · {{ rxChildProcessRuleName }}</span>
|
||||
<span v-if="rxProcessRuleNote"> · {{ rxProcessRuleNote }}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-if="rxReason"
|
||||
label="为什么出这个方"
|
||||
:span="2"
|
||||
>
|
||||
<div class="max-h-28 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{{ rxReason }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-if="rxBasis"
|
||||
label="根据哪些"
|
||||
:span="2"
|
||||
>
|
||||
<div class="max-h-28 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{{ rxBasis }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 justify-end gap-2 border-t border-border pt-3">
|
||||
<Button @click="drawerApi.close()">关闭</Button>
|
||||
<Button type="default" @click="openGenerateModal">生成处方</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="generating || matchLoading || !matchedList.length"
|
||||
@click="onConfirmImport"
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<!-- 二级:病历预览(可点改)+ 生成;Descriptions 双列 -->
|
||||
<GenModal>
|
||||
<div class="space-y-2 text-sm text-foreground">
|
||||
<AiDisclaimerBanner :text="disclaimerText" />
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁 ·
|
||||
{{ typeLabel }} · 点击文字可编辑,将同步到病历
|
||||
</div>
|
||||
<div class="max-h-[58vh] overflow-y-auto">
|
||||
<Descriptions
|
||||
v-if="genPreviewRows.length"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="ai-rx-gen-desc"
|
||||
>
|
||||
<Descriptions.Item
|
||||
v-for="row in genPreviewRows"
|
||||
:key="row.key"
|
||||
:label="row.label"
|
||||
:span="FULL_SPAN_KEYS.has(row.key) ? 2 : 1"
|
||||
>
|
||||
<Input.TextArea
|
||||
v-if="editingKey === row.key"
|
||||
:value="fieldValue(row.key)"
|
||||
:rows="row.key === 'chief_complaint' ? 3 : 2"
|
||||
autofocus
|
||||
@update:value="(v) => onFieldInput(row.key, String(v ?? ''))"
|
||||
@blur="endEdit"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="max-h-24 cursor-text overflow-y-auto whitespace-pre-wrap leading-relaxed"
|
||||
title="点击编辑"
|
||||
@click="startEdit(row.key)"
|
||||
>
|
||||
{{ fieldValue(row.key) || '(空,点击填写)' }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div
|
||||
v-if="genPreviewRows.length <= 1"
|
||||
class="px-2 py-3 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
仅有主诉。建议先在病历区补充现病史、舌脉等,再生成。
|
||||
</div>
|
||||
</div>
|
||||
<!-- 中药:生成前可选委托调剂 + 制剂要求 -->
|
||||
<div
|
||||
v-if="isTcmRx"
|
||||
class="rounded-lg border border-border bg-muted/30 p-3"
|
||||
>
|
||||
<Checkbox
|
||||
v-model:checked="useEntrustedProcess"
|
||||
@update:checked="onEntrustedToggle"
|
||||
>
|
||||
委托调剂
|
||||
</Checkbox>
|
||||
<div v-if="useEntrustedProcess" class="mt-2">
|
||||
<div class="mb-1 text-xs text-muted-foreground">制剂要求</div>
|
||||
<Select
|
||||
v-model:value="genProcessRuleId"
|
||||
:loading="processRuleLoading"
|
||||
placeholder="请选择制剂要求"
|
||||
class="w-full"
|
||||
:options="processRuleOptions"
|
||||
allow-clear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GenModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-rx-gen-desc :deep(.ant-descriptions-item-label),
|
||||
.ai-rx-meta-desc :deep(.ant-descriptions-item-label) {
|
||||
width: 110px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-rx-gen-desc :deep(.ant-descriptions-item-content),
|
||||
.ai-rx-meta-desc :deep(.ant-descriptions-item-content) {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
addDoctorMyDiseaseApi,
|
||||
deleteDoctorMyDiseaseApi,
|
||||
} from "#/views/doctor/settings/api";
|
||||
import {
|
||||
applyDictRankIfNeeded,
|
||||
splitHighlight,
|
||||
} from '#/utils/dictSearchRank';
|
||||
|
||||
const searchKey = ref('');
|
||||
// 存储接口返回的所有诊断数据
|
||||
@@ -90,32 +94,37 @@ const [Modal, modalApi] = useVbenModal({
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取诊断列表
|
||||
* 获取诊断列表(按配置匹配度排序 + 关键字高亮片段)
|
||||
* @param searchKey
|
||||
*/
|
||||
function getDiagnosisList(searchKey = '') {
|
||||
getDiseaseList(searchKey).then((res) => {
|
||||
// 处理所有数据
|
||||
const processedList = res.map((item) => {
|
||||
const kw = String(searchKey || '').trim();
|
||||
getDiseaseList(kw).then((res) => {
|
||||
const rows = Array.isArray(res) ? res : [];
|
||||
const modeFromApi = rows[0]?.rank_mode;
|
||||
const ranked = applyDictRankIfNeeded(
|
||||
rows,
|
||||
kw,
|
||||
(item: any) => String(item?.name || ''),
|
||||
(item: any) => String(item?.pinyin || ''),
|
||||
modeFromApi,
|
||||
);
|
||||
const processedList = ranked.map((item: any) => {
|
||||
item.isSelect = 0;
|
||||
// 检查是否已在常用诊断中
|
||||
item.isInMyList = doctorMyDiseaseList.value.some(
|
||||
(myItem) => myItem.disease.id === item.id
|
||||
(myItem: any) => myItem.disease.id === item.id,
|
||||
);
|
||||
if (selectDiagnosisList.value) {
|
||||
// 把values的字符串转为数组根据,分解
|
||||
const valuesArr = selectDiagnosisList.value.split(',');
|
||||
// 判断valuesArr数组中是否有item.name
|
||||
valuesArr.forEach((value) => {
|
||||
if (value === item.name) {
|
||||
item.isSelect = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
item._hlParts = splitHighlight(item.name || '', kw);
|
||||
return item;
|
||||
});
|
||||
|
||||
// 更新总数据源并重置页码
|
||||
allDiagnosisList.value = processedList;
|
||||
currentPage.value = 1;
|
||||
});
|
||||
@@ -123,7 +132,7 @@ function getDiagnosisList(searchKey = '') {
|
||||
|
||||
const getDiagnosisListChage = debounce(async (searchText = '') => {
|
||||
getDiagnosisList(searchText);
|
||||
}, 300)
|
||||
}, 500)
|
||||
|
||||
/**
|
||||
* 选择诊断
|
||||
@@ -255,7 +264,12 @@ function handlePageChange(page: number, size: number) {
|
||||
:class="{ 'is-selected': item.isSelect === 1 }"
|
||||
@click="selectDiagnosis(item)"
|
||||
>
|
||||
<span class="tag-text">{{ item.name }}</span>
|
||||
<span class="tag-text">
|
||||
<template v-for="(p, pi) in (item._hlParts || [{ text: item.name, hit: false }])" :key="pi">
|
||||
<em v-if="p.hit" class="hit">{{ p.text }}</em>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<div class="tag-action-wrapper" @click.stop>
|
||||
<Tooltip v-if="!item.isInMyList" title="加入常用">
|
||||
@@ -500,4 +514,12 @@ function handlePageChange(page: number, size: number) {
|
||||
.dark .pagination-wrapper {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.hit {
|
||||
font-style: normal;
|
||||
color: #cf1322;
|
||||
background: rgba(255, 214, 102, 0.55);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 金方导入弹窗(PC 接诊 / 在线复诊共用)
|
||||
* - VIP:golden_formula
|
||||
* - 选方后调用药名对照,确认导入时抛出与 AI 出方相同结构的 rows
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Input,
|
||||
Spin,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
goldenFormulaApplyApi,
|
||||
goldenFormulaListApi,
|
||||
} from '../api';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'import',
|
||||
payload: {
|
||||
rows: Array<Record<string, any>>;
|
||||
formulaName?: string;
|
||||
},
|
||||
): void;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const applying = ref(false);
|
||||
const keyword = ref('');
|
||||
const list = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const storeId = ref(0);
|
||||
const registerId = ref(0);
|
||||
/** 处方类型:1中药 2西药(与 ProductTypeEnum 对齐;接诊 activeCategory 1=中 2=西) */
|
||||
const prescriptionType = ref(1);
|
||||
const selectedId = ref(0);
|
||||
const matchResult = ref<any | null>(null);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
footer: false,
|
||||
class: 'w-[880px]',
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
keyword.value = '';
|
||||
list.value = [];
|
||||
matchResult.value = null;
|
||||
selectedId.value = 0;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{
|
||||
storeId?: number;
|
||||
registerId?: number;
|
||||
/** 开方页 activeCategory:1中药 2西药 → ProductType 同值 */
|
||||
category?: number;
|
||||
}>() || {};
|
||||
storeId.value = Number(data.storeId || 0);
|
||||
registerId.value = Number(data.registerId || 0);
|
||||
// activeCategory 1=中药 2=西药,与 ProductTypeEnum 一致
|
||||
prescriptionType.value = Number(data.category || 1) === 2 ? 2 : 1;
|
||||
page.value = 1;
|
||||
loadList();
|
||||
},
|
||||
});
|
||||
|
||||
/** 加载金方列表 */
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
matchResult.value = null;
|
||||
selectedId.value = 0;
|
||||
try {
|
||||
const res = await goldenFormulaListApi({
|
||||
keyword: keyword.value,
|
||||
page: page.value,
|
||||
pageSize,
|
||||
store_id: storeId.value || undefined,
|
||||
});
|
||||
list.value = res?.items || [];
|
||||
total.value = Number(res?.total || 0);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载金方失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
loadList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中金方并做药名对照
|
||||
*/
|
||||
async function handleSelect(row: any) {
|
||||
selectedId.value = Number(row.id);
|
||||
applying.value = true;
|
||||
matchResult.value = null;
|
||||
try {
|
||||
const res = await goldenFormulaApplyApi({
|
||||
id: selectedId.value,
|
||||
store_id: storeId.value || undefined,
|
||||
register_id: registerId.value || undefined,
|
||||
prescription_type: prescriptionType.value,
|
||||
});
|
||||
matchResult.value = res;
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '对照失败');
|
||||
} finally {
|
||||
applying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认导入:把 matched 转成 AI 导入同构 rows(候选默认取第一项)
|
||||
*/
|
||||
function handleConfirmImport() {
|
||||
const matched = matchResult.value?.matched || [];
|
||||
if (!matched.length) {
|
||||
message.warning('没有可导入的已匹配药品');
|
||||
return;
|
||||
}
|
||||
const rows = matched.map((m: any) => {
|
||||
const candidates = m.candidates || [];
|
||||
const selectedId = Number(m.selected_drug_id || 0);
|
||||
const candidate =
|
||||
candidates.find((c: any) => Number(c.drug_id) === selectedId) ||
|
||||
candidates[0] ||
|
||||
{};
|
||||
return {
|
||||
ai_name: m.ai_name,
|
||||
dose: m.dose,
|
||||
unit: m.unit,
|
||||
usage: m.usage,
|
||||
frequency: m.frequency,
|
||||
candidate,
|
||||
};
|
||||
});
|
||||
emit('import', {
|
||||
rows,
|
||||
formulaName: matchResult.value?.formula?.name || '',
|
||||
});
|
||||
const unmatched = matchResult.value?.unmatched || [];
|
||||
if (unmatched.length) {
|
||||
message.warning(
|
||||
`已导入 ${rows.length} 味;未匹配 ${unmatched.length} 味请手动补录`,
|
||||
);
|
||||
} else {
|
||||
message.success(`已导入金方:${matchResult.value?.formula?.name || ''}`);
|
||||
}
|
||||
modalApi.close();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: (data: Record<string, any>) => {
|
||||
modalApi.setData(data);
|
||||
modalApi.open();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal title="导入金方">
|
||||
<div class="flex gap-3" style="min-height: 420px">
|
||||
<div class="w-[340px] shrink-0 border-r pr-3">
|
||||
<div class="mb-2 flex gap-2">
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
placeholder="名称/首拼/来源/速览"
|
||||
@press-enter="onSearch"
|
||||
/>
|
||||
<Button type="primary" @click="onSearch">搜索</Button>
|
||||
</div>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="!list.length" class="py-8">
|
||||
<Empty description="暂无金方" />
|
||||
</div>
|
||||
<div v-else class="max-h-[360px] space-y-2 overflow-y-auto">
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="cursor-pointer rounded border p-2 transition hover:border-primary"
|
||||
:class="selectedId === item.id ? 'border-primary bg-primary/5' : ''"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<div class="font-medium">{{ item.name }}</div>
|
||||
<div class="mt-1 text-xs text-gray-500 line-clamp-2">
|
||||
{{ item.herb_overview || '—' }}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<Tag v-if="item.source" color="gold">{{ item.source }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="total > pageSize" class="mt-2 text-center text-xs text-gray-400">
|
||||
共 {{ total }} 条(当前第 {{ page }} 页)
|
||||
<Button
|
||||
v-if="page * pageSize < total"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="page += 1; loadList()"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<Spin :spinning="applying">
|
||||
<div v-if="!matchResult" class="py-16">
|
||||
<Empty description="请选择左侧金方进行药名对照" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="mb-2 text-base font-medium">
|
||||
{{ matchResult.formula?.name }}
|
||||
<Tag v-if="matchResult.formula?.source" class="ml-2" color="gold">
|
||||
{{ matchResult.formula.source }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-if="matchResult.formula?.indication_translation || matchResult.formula?.indication_original"
|
||||
class="mb-2 text-xs text-gray-500"
|
||||
>
|
||||
主治:
|
||||
{{
|
||||
matchResult.formula.indication_translation ||
|
||||
matchResult.formula.indication_original
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="matchResult.formula?.original_formula"
|
||||
class="mb-2 max-h-24 overflow-y-auto rounded bg-amber-50 px-2 py-1 text-xs text-gray-700 whitespace-pre-wrap"
|
||||
>
|
||||
<span class="font-medium text-amber-700">原方:</span>
|
||||
{{ matchResult.formula.original_formula }}
|
||||
</div>
|
||||
<div class="mb-2 text-sm">
|
||||
已匹配
|
||||
<Tag color="success">{{ (matchResult.matched || []).length }}</Tag>
|
||||
未匹配
|
||||
<Tag color="warning">{{ (matchResult.unmatched || []).length }}</Tag>
|
||||
</div>
|
||||
<div class="max-h-[280px] space-y-1 overflow-y-auto text-sm">
|
||||
<div
|
||||
v-for="(m, idx) in matchResult.matched || []"
|
||||
:key="'m' + idx"
|
||||
class="rounded bg-green-50 px-2 py-1"
|
||||
>
|
||||
{{ m.show_dose || m.display_text || `${m.ai_name} ${m.dose || ''}${m.unit || ''}${m.usage ? `(${m.usage})` : ''}` }}
|
||||
→
|
||||
{{ (m.candidates && m.candidates[0] && m.candidates[0].drug_name) || '—' }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(u, idx) in matchResult.unmatched || []"
|
||||
:key="'u' + idx"
|
||||
class="rounded bg-orange-50 px-2 py-1"
|
||||
>
|
||||
{{ u.show_dose || u.display_text || `${u.ai_name} ${u.dose || ''}${u.unit || ''}` }}
|
||||
(未匹配)
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 text-right">
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="!(matchResult.matched || []).length"
|
||||
@click="handleConfirmImport"
|
||||
>
|
||||
确认导入到处方
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 接诊台顶部患者简要信息条
|
||||
* 上方居中展示诊所名 + 处方类型;下方展示门诊号/日期/费别/姓名等
|
||||
* 诊断只在处方/病历编辑区一处维护
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import { formatStoreNameWithHu } from '#/utils/formatStoreNameWithHu';
|
||||
|
||||
defineOptions({ name: 'PatientBriefBar' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 挂号订单(含 order_no、created_at) */
|
||||
register?: Record<string, any> | null;
|
||||
/** 就诊人 */
|
||||
patient?: Record<string, any> | null;
|
||||
/** 当前费别:1 自费 2 医保 */
|
||||
category?: number;
|
||||
/** 诊所名称(优先;也可从 register.store.name 兜底) */
|
||||
storeName?: string;
|
||||
/**
|
||||
* 是否在线复诊开方
|
||||
* false/0:门诊普通处方笺;true/1/2/3:在线处方
|
||||
*/
|
||||
isOnline?: boolean | number;
|
||||
}>(),
|
||||
{
|
||||
register: null,
|
||||
patient: null,
|
||||
category: 1,
|
||||
storeName: '',
|
||||
isOnline: false,
|
||||
},
|
||||
);
|
||||
|
||||
const onlineFlag = computed(() => {
|
||||
if (props.isOnline === true) return 1;
|
||||
if (props.isOnline === false) return 0;
|
||||
const fromProp = Number(props.isOnline);
|
||||
if (fromProp > 0) return fromProp;
|
||||
// 挂号单上可能带 is_online
|
||||
return Number(props.register?.is_online || 0);
|
||||
});
|
||||
|
||||
/** 居中诊所名:在线渠道按既有规则追加(互) */
|
||||
const displayStoreName = computed(() => {
|
||||
const raw =
|
||||
String(props.storeName || '').trim() ||
|
||||
String(props.register?.store?.name || props.register?.store_name || '').trim();
|
||||
return formatStoreNameWithHu(raw, onlineFlag.value) || '诊所';
|
||||
});
|
||||
|
||||
/** 处方类型标题:线下普通 / 线上复诊 */
|
||||
const prescriptionTypeTitle = computed(() => {
|
||||
const online = onlineFlag.value;
|
||||
if (online === 1 || online === 2 || online === 3) {
|
||||
return '在线处方';
|
||||
}
|
||||
return '门诊普通处方笺';
|
||||
});
|
||||
|
||||
const sexText = computed(() => {
|
||||
const sex = Number(props.patient?.sex || 0);
|
||||
if (sex === 1) return '男';
|
||||
if (sex === 2) return '女';
|
||||
return '未填';
|
||||
});
|
||||
|
||||
const categoryText = computed(() => {
|
||||
return Number(props.category) === 2 ? '医保' : '自费';
|
||||
});
|
||||
|
||||
const visitDate = computed(() => {
|
||||
const raw = props.register?.created_at;
|
||||
if (!raw) return '—';
|
||||
// 后端查询器已格式化为字符串时直接展示
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' && raw > 0) {
|
||||
const d = new Date(raw * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
return '—';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="patient-brief-bar">
|
||||
<!-- 居中抬头:诊所名 + 处方类型 -->
|
||||
<div class="brief-header">
|
||||
<div class="brief-clinic">{{ displayStoreName }}</div>
|
||||
<div class="brief-rx-type">{{ prescriptionTypeTitle }}</div>
|
||||
</div>
|
||||
<div class="brief-meta">
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">门诊号</span>
|
||||
<span class="brief-value">{{ register?.order_no || '—' }}</span>
|
||||
</div>
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">日期</span>
|
||||
<span class="brief-value">{{ visitDate }}</span>
|
||||
</div>
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">费别</span>
|
||||
<Tag :color="Number(category) === 2 ? 'blue' : 'default'" class="m-0">
|
||||
{{ categoryText }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">姓名</span>
|
||||
<span class="brief-value brief-name">{{ patient?.name || '—' }}</span>
|
||||
</div>
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">性别</span>
|
||||
<span class="brief-value">{{ sexText }}</span>
|
||||
</div>
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">年龄</span>
|
||||
<span class="brief-value">{{ patient?.age ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="brief-item">
|
||||
<span class="brief-label">手机</span>
|
||||
<span class="brief-value">
|
||||
<SensitiveText
|
||||
:record="patient"
|
||||
field="mobile"
|
||||
:show-eye="false"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.patient-brief-bar {
|
||||
padding: 10px 14px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
}
|
||||
.brief-header {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid hsl(var(--border) / 0.7);
|
||||
}
|
||||
.brief-clinic {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: hsl(var(--foreground));
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.brief-rx-type {
|
||||
margin-top: 2px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: hsl(var(--foreground));
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.brief-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.brief-item {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.brief-label {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.brief-value {
|
||||
color: hsl(var(--foreground));
|
||||
word-break: break-all;
|
||||
}
|
||||
.brief-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 查看患者信息抽屉:完整患者字段 + 健康问诊信息(不进顶栏)
|
||||
* 特色方/转诊等业务入口由父级通过 slot 注入,避免抽屉耦合开方逻辑
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { Descriptions, Empty, Tag } from 'ant-design-vue';
|
||||
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
|
||||
defineOptions({ name: 'PatientInfoDrawer' });
|
||||
|
||||
type DrawerData = {
|
||||
patient?: Record<string, any> | null;
|
||||
register?: Record<string, any> | null;
|
||||
healthInquiry?: Record<string, any> | null;
|
||||
patientName?: string;
|
||||
};
|
||||
|
||||
const meta = ref<DrawerData>({});
|
||||
|
||||
const sexText = computed(() => {
|
||||
const sex = Number(meta.value.patient?.sex || 0);
|
||||
if (sex === 1) return '男';
|
||||
if (sex === 2) return '女';
|
||||
return '未填写';
|
||||
});
|
||||
|
||||
/** 逗号分隔标签拆分(与接诊页 splitString 一致) */
|
||||
function splitString(str: unknown): string[] {
|
||||
if (!str || typeof str !== 'string') return [];
|
||||
return str.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
class: 'w-[720px]',
|
||||
title: '患者信息',
|
||||
cancelText: '关闭',
|
||||
showConfirmButton: false,
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
meta.value = {};
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<DrawerData>() || {};
|
||||
meta.value = data;
|
||||
const name = data.patientName || data.patient?.name || '';
|
||||
drawerApi.setState({
|
||||
title: name ? `患者信息 - ${name}` : '患者信息',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
defineExpose({ drawerApi });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<Descriptions
|
||||
v-if="meta.patient"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
title="基本信息"
|
||||
class="mb-4"
|
||||
>
|
||||
<Descriptions.Item label="姓名">
|
||||
{{ meta.patient.name || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="性别">
|
||||
{{ sexText }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="年龄">
|
||||
{{ meta.patient.age ?? '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">
|
||||
<SensitiveText
|
||||
:record="meta.patient"
|
||||
field="mobile"
|
||||
:show-eye="false"
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门诊号">
|
||||
{{ meta.register?.order_no || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="就诊时间">
|
||||
{{ meta.register?.created_at || '—' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Empty v-else description="暂无患者信息" class="py-6" />
|
||||
|
||||
<Descriptions
|
||||
v-if="meta.healthInquiry"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
title="健康信息"
|
||||
class="mb-4"
|
||||
>
|
||||
<Descriptions.Item label="肝功能">
|
||||
<span>{{
|
||||
meta.healthInquiry.liver_function === 0 ? '正常' : '异常'
|
||||
}}</span>
|
||||
<div v-if="meta.healthInquiry.liver_function === 1" class="mt-2">
|
||||
<Tag
|
||||
v-for="(item, idx) in splitString(meta.healthInquiry.liver_index)"
|
||||
:key="`liver-${idx}`"
|
||||
color="#455cda"
|
||||
>
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="肾功能">
|
||||
<span>{{
|
||||
meta.healthInquiry.renal_function === 0 ? '正常' : '异常'
|
||||
}}</span>
|
||||
<div v-if="meta.healthInquiry.renal_function === 1" class="mt-2">
|
||||
<Tag
|
||||
v-for="(item, idx) in splitString(meta.healthInquiry.renal_index)"
|
||||
:key="`renal-${idx}`"
|
||||
color="#455cda"
|
||||
>
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="既往史" :span="2">
|
||||
<span>{{
|
||||
meta.healthInquiry.person_status === 0 ? '无' : '有'
|
||||
}}</span>
|
||||
<p v-if="meta.healthInquiry.person_status === 1" class="mt-2 mb-0">
|
||||
{{ meta.healthInquiry.person_history || '—' }}
|
||||
</p>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="过敏史" :span="2">
|
||||
<span>{{
|
||||
meta.healthInquiry.allergic_status === 0 ? '无' : '有'
|
||||
}}</span>
|
||||
<div v-if="meta.healthInquiry.allergic_status === 1" class="mt-2">
|
||||
<Tag
|
||||
v-for="(item, idx) in splitString(
|
||||
meta.healthInquiry.allergic_history,
|
||||
)"
|
||||
:key="`allergy-${idx}`"
|
||||
color="orange"
|
||||
>
|
||||
{{ item }}
|
||||
</Tag>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="家庭遗传史" :span="2">
|
||||
<span>{{
|
||||
meta.healthInquiry.family_status === 0 ? '无' : '有'
|
||||
}}</span>
|
||||
<p v-if="meta.healthInquiry.family_status === 1" class="mt-2 mb-0">
|
||||
{{ meta.healthInquiry.family_history || '—' }}
|
||||
</p>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Empty
|
||||
v-else
|
||||
description="暂无健康问诊信息"
|
||||
class="py-4"
|
||||
/>
|
||||
|
||||
<slot name="extra" />
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,326 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 患者处方记录抽屉:本次 | 历史,支持按订单号/订单ID搜索、展示支付态、复用/撤回
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Input,
|
||||
Spin,
|
||||
Tabs,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
getPatientPrescriptionHistoryApi,
|
||||
getRegisterPrescriptionListApi,
|
||||
withdrawPrescriptionApi,
|
||||
} from '../api';
|
||||
|
||||
defineOptions({ name: 'PatientPrescriptionHistoryDrawer' });
|
||||
|
||||
const TabPane = Tabs.TabPane;
|
||||
|
||||
type DrawerData = {
|
||||
registerId?: number;
|
||||
userPatientId?: number;
|
||||
patientName?: string;
|
||||
/** 复用回调:传入处方 id */
|
||||
onReuse?: (prescriptionId: number) => void | Promise<void>;
|
||||
/** 点击处方号打开详情 */
|
||||
onDetail?: (prescriptionId: number) => void;
|
||||
/** 是否允许撤回(线下接诊) */
|
||||
allowWithdraw?: boolean;
|
||||
/** 撤回成功后刷新 */
|
||||
onWithdrawn?: () => void;
|
||||
};
|
||||
|
||||
const meta = ref<DrawerData>({});
|
||||
const loading = ref(false);
|
||||
const withdrawingId = ref(0);
|
||||
const listTab = ref<'current' | 'history'>('current');
|
||||
const keyword = ref('');
|
||||
const list = ref<Record<string, any>[]>([]);
|
||||
const page = ref(1);
|
||||
const hasMore = ref(false);
|
||||
const pageSize = 20;
|
||||
|
||||
const categoryLabel = (c: number) => (Number(c) === 2 ? '医保' : '自费');
|
||||
|
||||
const typeLabel = (t: number) => {
|
||||
const map: Record<number, string> = {
|
||||
1: '中药',
|
||||
2: '西药',
|
||||
3: '简单产品',
|
||||
5: '简单产品',
|
||||
6: '简单产品',
|
||||
7: '简单产品',
|
||||
};
|
||||
return map[Number(t)] || `类型${t}`;
|
||||
};
|
||||
|
||||
const payColor = (row: Record<string, any>) => {
|
||||
const text = String(row.order_pay_text || '');
|
||||
if (text.includes('待支付')) return 'orange';
|
||||
if (text.includes('已支付')) return 'green';
|
||||
if (text.includes('取消')) return 'default';
|
||||
return 'blue';
|
||||
};
|
||||
|
||||
async function loadList(reset = true) {
|
||||
if (reset) {
|
||||
page.value = 1;
|
||||
list.value = [];
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const kw = keyword.value.trim();
|
||||
let res: any;
|
||||
if (listTab.value === 'current') {
|
||||
const rid = Number(meta.value.registerId || 0);
|
||||
if (!rid) {
|
||||
list.value = [];
|
||||
return;
|
||||
}
|
||||
res = await getRegisterPrescriptionListApi({
|
||||
register_id: rid,
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
keyword: kw || undefined,
|
||||
});
|
||||
} else {
|
||||
const pid = Number(meta.value.userPatientId || 0);
|
||||
if (!pid) {
|
||||
list.value = [];
|
||||
return;
|
||||
}
|
||||
res = await getPatientPrescriptionHistoryApi({
|
||||
patient_id: pid,
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
exclude_register_id: Number(meta.value.registerId || 0) || undefined,
|
||||
keyword: kw || undefined,
|
||||
});
|
||||
}
|
||||
const rows = Array.isArray(res?.list) ? res.list : [];
|
||||
list.value = reset ? rows : [...list.value, ...rows];
|
||||
hasMore.value = !!res?.has_more;
|
||||
} catch (e: any) {
|
||||
if (reset) list.value = [];
|
||||
message.error(e?.message || '加载处方失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
loadList(true);
|
||||
}
|
||||
|
||||
function onTabChange(key: string | number) {
|
||||
listTab.value = key === 'history' ? 'history' : 'current';
|
||||
loadList(true);
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore.value || loading.value) return;
|
||||
page.value += 1;
|
||||
loadList(false);
|
||||
}
|
||||
|
||||
async function handleReuse(row: Record<string, any>) {
|
||||
const id = Number(row.id || 0);
|
||||
if (!id) return;
|
||||
try {
|
||||
await meta.value.onReuse?.(id);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '复用失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击处方号打开处方详情弹窗 */
|
||||
function handleOpenDetail(row: Record<string, any>) {
|
||||
const id = Number(row.id || 0);
|
||||
if (!id) return;
|
||||
if (typeof meta.value.onDetail === 'function') {
|
||||
meta.value.onDetail(id);
|
||||
return;
|
||||
}
|
||||
message.warning('暂无法打开处方详情');
|
||||
}
|
||||
|
||||
async function handleWithdraw(row: Record<string, any>) {
|
||||
const prescriptionId = Number(row.id || 0);
|
||||
const registerId = Number(row.register_id || meta.value.registerId || 0);
|
||||
if (!prescriptionId || !registerId) return;
|
||||
withdrawingId.value = prescriptionId;
|
||||
try {
|
||||
await withdrawPrescriptionApi({
|
||||
prescription_id: prescriptionId,
|
||||
register_id: registerId,
|
||||
});
|
||||
message.success('撤回成功');
|
||||
meta.value.onWithdrawn?.();
|
||||
await loadList(true);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '撤回失败');
|
||||
} finally {
|
||||
withdrawingId.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const titleText = computed(() => {
|
||||
const name = meta.value.patientName ? ` - ${meta.value.patientName}` : '';
|
||||
return `患者处方记录${name}`;
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
class: 'w-[820px]',
|
||||
title: '患者处方记录',
|
||||
cancelText: '关闭',
|
||||
showConfirmButton: false,
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
meta.value = {};
|
||||
list.value = [];
|
||||
keyword.value = '';
|
||||
listTab.value = 'current';
|
||||
page.value = 1;
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<DrawerData>() || {};
|
||||
meta.value = data;
|
||||
drawerApi.setState({ title: titleText.value });
|
||||
loadList(true);
|
||||
},
|
||||
});
|
||||
|
||||
defineExpose({ drawerApi });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Input.Search
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
placeholder="按订单号或订单ID搜索"
|
||||
style="max-width: 280px"
|
||||
@search="onSearch"
|
||||
/>
|
||||
<Button @click="onSearch">查询</Button>
|
||||
</div>
|
||||
<Tabs :active-key="listTab" @change="onTabChange">
|
||||
<TabPane key="current" tab="本次" />
|
||||
<TabPane key="history" tab="历史" />
|
||||
</Tabs>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="list.length === 0 && !loading" class="py-10">
|
||||
<Empty description="暂无处方记录" />
|
||||
</div>
|
||||
<div v-else class="rx-history-list">
|
||||
<div
|
||||
v-for="row in list"
|
||||
:key="row.id"
|
||||
class="rx-history-item"
|
||||
>
|
||||
<div class="rx-history-main">
|
||||
<div class="rx-history-title">
|
||||
<a
|
||||
class="cursor-pointer font-medium text-primary hover:underline"
|
||||
title="查看处方详情"
|
||||
@click.prevent="handleOpenDetail(row)"
|
||||
>
|
||||
{{ row.prescription_no || `#${row.id}` }}
|
||||
</a>
|
||||
<Tag class="ml-2">{{ typeLabel(row.prescription_type) }}</Tag>
|
||||
<Tag>{{ categoryLabel(row.category) }}</Tag>
|
||||
<Tag :color="payColor(row)">{{ row.order_pay_text || '—' }}</Tag>
|
||||
</div>
|
||||
<div class="rx-history-meta text-muted-foreground">
|
||||
<span>开方时间:{{ row.created_at || '—' }}</span>
|
||||
<span class="ml-3">金额:¥{{ row.total_pay_price ?? '—' }}</span>
|
||||
<span v-if="row.order_no" class="ml-3">订单号:{{ row.order_no }}</span>
|
||||
<span v-if="row.order_id" class="ml-3">订单ID:{{ row.order_id }}</span>
|
||||
<span
|
||||
v-if="listTab === 'history' && row.register?.order_no"
|
||||
class="ml-3"
|
||||
>
|
||||
挂号:{{ row.register.order_no }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-history-actions">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleReuse(row)"
|
||||
>
|
||||
复用
|
||||
</Button>
|
||||
<Button
|
||||
v-if="meta.allowWithdraw && row.can_withdraw && listTab === 'current'"
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
:loading="withdrawingId === row.id"
|
||||
@click="handleWithdraw(row)"
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasMore" class="mt-3 text-center">
|
||||
<Button type="link" :loading="loading" @click="loadMore">加载更多</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rx-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: calc(100vh - 260px);
|
||||
overflow: auto;
|
||||
}
|
||||
.rx-history-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
}
|
||||
.rx-history-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.rx-history-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.rx-history-meta {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.rx-history-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.text-muted-foreground {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -322,7 +322,21 @@ function parseRecipeContent(content: string | Record<string, unknown>) {
|
||||
alt="签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else>{{ item.content?.doctor?.name }}</span>
|
||||
<!-- 二次签名:相冲继续开方时展示第二张医生签名 -->
|
||||
<img
|
||||
v-if="
|
||||
item.doctor_second_sign == 1 &&
|
||||
item.doctor_info?.identity_info?.sign_image
|
||||
"
|
||||
:src="
|
||||
getImageSource(item.doctor_info.identity_info.sign_image)
|
||||
"
|
||||
alt="二次签名"
|
||||
style="width: 120px; height: 50px; display: inline-block"
|
||||
/>
|
||||
<span v-else-if="!item.doctor_info?.identity_info?.sign_image">{{
|
||||
item.content?.doctor?.name
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<label>审核药师:</label>
|
||||
|
||||
@@ -141,8 +141,10 @@ const getDrugListByWesternModal = debounce(async (searchText = '') => {
|
||||
select_number: matched?.select_number || 0,
|
||||
drug: {
|
||||
...item.drug,
|
||||
// 如果已选,使用已选的数量,否则默认为1
|
||||
number: matched?.number || 1,
|
||||
// 中药克数不默认 1,需医生手动填写;西药仍默认 1
|
||||
number:
|
||||
matched?.number ??
|
||||
(Number(type.value) === 1 ? undefined : 1),
|
||||
// 如果已选,同步已选的用法
|
||||
frequency_id: matched?.frequency_id || item.drug.frequency_id,
|
||||
type_id: matched?.type_id || item.drug.type_id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1362
apps/web-antd/src/views/doctor/medical-record/MedicalRecordPanel.vue
Normal file
1362
apps/web-antd/src/views/doctor/medical-record/MedicalRecordPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
128
apps/web-antd/src/views/doctor/medical-record/api/index.ts
Normal file
128
apps/web-antd/src/views/doctor/medical-record/api/index.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** 病历与词条 API(admin medical-record/) */
|
||||
const prefix = 'medical-record/';
|
||||
|
||||
export async function getMedicalRecordEntryList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function createMedicalRecordEntry(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateMedicalRecordEntry(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteMedicalRecordEntry(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
export async function getMedicalRecordFieldOptions() {
|
||||
return requestClient.get<any>(`${prefix}field-options`);
|
||||
}
|
||||
|
||||
export async function searchMedicalRecordEntry(data: {
|
||||
field_code: string;
|
||||
keyword?: string;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}search-entry`, { params: data });
|
||||
}
|
||||
|
||||
/** 我的常用词条(医生维度) */
|
||||
export async function getMyMedicalRecordEntryList(data: {
|
||||
field_code?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}my-entry-list`, { params: data });
|
||||
}
|
||||
|
||||
/** 加入常用词条 */
|
||||
export async function addMyMedicalRecordEntry(entry_id: number) {
|
||||
return requestClient.post<any>(`${prefix}add-my-entry`, { entry_id });
|
||||
}
|
||||
|
||||
/** 删除常用词条(常用记录 id) */
|
||||
export async function deleteMyMedicalRecordEntry(id: number) {
|
||||
return requestClient.post<any>(`${prefix}delete-my-entry`, { id });
|
||||
}
|
||||
|
||||
export async function getMedicalRecord(data: {
|
||||
register_id: number;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}get-record`, { params: data });
|
||||
}
|
||||
|
||||
export async function saveMedicalRecord(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}save-record`, data);
|
||||
}
|
||||
|
||||
export async function clearMedicalRecord(data: {
|
||||
register_id: number;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}clear-record`, data);
|
||||
}
|
||||
|
||||
export async function getLatestMedicalRecord(data: {
|
||||
user_patient_id: number;
|
||||
register_id?: number;
|
||||
store_id?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}latest-by-patient`, { params: data });
|
||||
}
|
||||
|
||||
/** 按就诊人拉取病历列表(查看历史) */
|
||||
export async function getMedicalRecordListByPatient(data: {
|
||||
user_patient_id: number;
|
||||
store_id?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}list-by-patient`, { params: data });
|
||||
}
|
||||
|
||||
/** AI 写病历(需先填主诉;可选传 medical_record 上下文字段) */
|
||||
export async function aiGenerateMedicalRecord(data: {
|
||||
register_id: number;
|
||||
store_id?: number;
|
||||
chief_complaint?: string;
|
||||
medical_record?: Record<string, any>;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}ai-generate-medical-record`, data);
|
||||
}
|
||||
|
||||
/** 本挂号 AI 生成历史(与接诊共用 doctor-reception 接口) */
|
||||
export async function aiListGenerations(data: {
|
||||
register_id: number;
|
||||
scene?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
return requestClient.get<any>('doctor-reception/ai-list-generations', {
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
/** AI 生成详情(点选历史) */
|
||||
export async function aiGenerationDetail(data: { id: number }) {
|
||||
return requestClient.get<any>('doctor-reception/ai-generation-detail', {
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
/** Excel 解析后批量导入词条 */
|
||||
export async function importMedicalRecordEntry(data: {
|
||||
scope: 'public' | 'store';
|
||||
rows: Array<Record<string, any>>;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}import-entry`, data);
|
||||
}
|
||||
|
||||
/** 手动同步空拼音首拼 */
|
||||
export async function syncMedicalRecordEntryPinyin(data?: {
|
||||
scope?: 'public' | 'store' | '';
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, data || {});
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* PC:AI 写病历
|
||||
* 主抽屉:生成历史 + 预览 + 确认导入
|
||||
* 二级弹窗:确认主诉后点生成 → 关弹窗,回抽屉等待结果
|
||||
* 无历史时打开抽屉后自动弹出生成框
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { Button, Descriptions, Input, Spin, message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
aiGenerateMedicalRecord,
|
||||
aiGenerationDetail,
|
||||
aiListGenerations,
|
||||
} from '../api';
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS } from '../config/constants';
|
||||
import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue';
|
||||
import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate';
|
||||
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
|
||||
|
||||
/** Descriptions 通栏字段 */
|
||||
const FULL_SPAN_KEYS = new Set([
|
||||
'present_illness',
|
||||
'tcm_case',
|
||||
'treatment_advice',
|
||||
'auxiliary_exam',
|
||||
'physical_exam',
|
||||
'chief_complaint',
|
||||
]);
|
||||
/** 预览字段顺序(不含主诉;与后端 MR_OUTPUT_FIELDS 对齐,含医嘱) */
|
||||
const PREVIEW_FIELDS = [
|
||||
{ label: '现病史', value: 'present_illness' },
|
||||
{ label: '舌象', value: 'tongue' },
|
||||
{ label: '脉象', value: 'pulse' },
|
||||
{ label: '中医病案', value: 'tcm_case' },
|
||||
{ label: '中医证候', value: 'tcm_syndrome' },
|
||||
{ label: '中医疾病', value: 'tcm_disease' },
|
||||
{ label: '中医治法', value: 'tcm_method' },
|
||||
{ label: '临床诊断', value: 'diagnosis' },
|
||||
{ label: '治疗意见', value: 'treatment_advice' },
|
||||
{ label: '医嘱', value: 'doctor_order' },
|
||||
...MEDICAL_RECORD_FIELD_OPTIONS.filter((f) =>
|
||||
[
|
||||
'family_history',
|
||||
'epidemic_history',
|
||||
'past_history',
|
||||
'menstrual_history',
|
||||
'marital_history',
|
||||
'personal_history',
|
||||
'allergy_history',
|
||||
'physical_exam',
|
||||
'auxiliary_exam',
|
||||
].includes(f.value),
|
||||
),
|
||||
];
|
||||
|
||||
/** 生成前可选填(非必填),传给 AI 作上下文;有值则优先保留 */
|
||||
const GEN_OPTIONAL_FIELDS = [
|
||||
{ label: '现病史', value: 'present_illness' },
|
||||
{ label: '既往史', value: 'past_history' },
|
||||
{ label: '过敏史', value: 'allergy_history' },
|
||||
{ label: '月经史', value: 'menstrual_history' },
|
||||
{ label: '家族史', value: 'family_history' },
|
||||
{ label: '婚育史', value: 'marital_history' },
|
||||
{ label: '个人史', value: 'personal_history' },
|
||||
{ label: '体征检查', value: 'physical_exam' },
|
||||
{ label: '辅助检查', value: 'auxiliary_exam' },
|
||||
{ label: '舌象', value: 'tongue' },
|
||||
{ label: '脉象', value: 'pulse' },
|
||||
];
|
||||
|
||||
function emptyGenContext(): Record<string, string> {
|
||||
const o: Record<string, string> = {};
|
||||
for (const f of GEN_OPTIONAL_FIELDS) o[f.value] = '';
|
||||
return o;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'import',
|
||||
payload: { chief_complaint: string; fields: Record<string, string> },
|
||||
): void;
|
||||
}>();
|
||||
|
||||
const registerId = ref(0);
|
||||
const storeId = ref(0);
|
||||
const patientName = ref('');
|
||||
const patientSex = ref(0);
|
||||
const patientAge = ref(0);
|
||||
const chiefComplaint = ref('');
|
||||
/** 生成弹窗可选上下文字段 */
|
||||
const genContext = ref<Record<string, string>>(emptyGenContext());
|
||||
const histLoading = ref(false);
|
||||
const generating = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const historyList = ref<any[]>([]);
|
||||
const activeId = ref(0);
|
||||
const previewFields = ref<Record<string, string>>({});
|
||||
const previewChief = ref('');
|
||||
/** 最近一次生成耗时(毫秒) */
|
||||
const lastDurationMs = ref(0);
|
||||
/** 醒目提示文案(系统配置 / 门闸下发) */
|
||||
const disclaimerText = ref('');
|
||||
|
||||
const sexLabel = computed(() => {
|
||||
const s = Number(patientSex.value);
|
||||
if (s === 1) return '男';
|
||||
if (s === 2) return '女';
|
||||
return '未知';
|
||||
});
|
||||
|
||||
const previewRows = computed(() =>
|
||||
PREVIEW_FIELDS.map((f) => ({
|
||||
label: f.label,
|
||||
value: String(previewFields.value[f.value] || '').trim(),
|
||||
code: f.value,
|
||||
})).filter((r) => r.value !== ''),
|
||||
);
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: 'AI写病历',
|
||||
class: 'w-[860px]',
|
||||
footer: false,
|
||||
showCancelButton: false,
|
||||
showConfirmButton: false,
|
||||
contentClass: '!flex !flex-col !overflow-hidden',
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
resetState();
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<{
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
patientName?: string;
|
||||
patientSex?: number;
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
medicalRecord?: Record<string, any>;
|
||||
}>();
|
||||
registerId.value = Number(data?.registerId || 0);
|
||||
storeId.value = Number(data?.storeId || 0);
|
||||
patientName.value = String(data?.patientName || '');
|
||||
patientSex.value = Number(data?.patientSex || 0);
|
||||
patientAge.value = Number(data?.patientAge || 0);
|
||||
chiefComplaint.value = String(data?.chiefComplaint || '');
|
||||
// 从当前病历表单预填可选字段
|
||||
const nextCtx = emptyGenContext();
|
||||
const snap = data?.medicalRecord || {};
|
||||
for (const f of GEN_OPTIONAL_FIELDS) {
|
||||
nextCtx[f.value] = String(snap[f.value] ?? '').trim();
|
||||
}
|
||||
genContext.value = nextCtx;
|
||||
previewFields.value = {};
|
||||
previewChief.value = '';
|
||||
activeId.value = 0;
|
||||
try {
|
||||
const gate = await ensureAiFeatureConsent({
|
||||
featureCode: 'ai_medical_record',
|
||||
storeId: storeId.value || undefined,
|
||||
});
|
||||
if (!gate) {
|
||||
drawerApi.close();
|
||||
return;
|
||||
}
|
||||
disclaimerText.value = gate.disclaimer_text || '';
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || e?.msg || '无法校验 AI 使用规范');
|
||||
drawerApi.close();
|
||||
return;
|
||||
}
|
||||
void bootstrapHistory();
|
||||
},
|
||||
});
|
||||
|
||||
const [GenModal, genModalApi] = useVbenModal({
|
||||
title: '生成病历',
|
||||
// 与 AI 出方生成弹窗同宽,便于选填字段双列展示
|
||||
class: 'w-[920px]',
|
||||
confirmText: '开始生成',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => confirmGenerateAndClose(),
|
||||
});
|
||||
|
||||
function resetState() {
|
||||
histLoading.value = false;
|
||||
generating.value = false;
|
||||
detailLoading.value = false;
|
||||
historyList.value = [];
|
||||
activeId.value = 0;
|
||||
previewFields.value = {};
|
||||
previewChief.value = '';
|
||||
lastDurationMs.value = 0;
|
||||
chiefComplaint.value = '';
|
||||
genContext.value = emptyGenContext();
|
||||
disclaimerText.value = '';
|
||||
genModalApi.close();
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const n = Number(ts || 0);
|
||||
if (!n) return '—';
|
||||
const d = new Date(n * 1000);
|
||||
const pad = (x: number) => (x < 10 ? `0${x}` : `${x}`);
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 毫秒转可读耗时(850ms / 1.2s) */
|
||||
function formatDurationMs(ms: number | undefined | null) {
|
||||
const n = Number(ms || 0);
|
||||
if (!n) return '';
|
||||
if (n < 1000) return `${Math.round(n)}ms`;
|
||||
const sec = n / 1000;
|
||||
return sec >= 10 ? `${Math.round(sec)}s` : `${sec.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
histLoading.value = true;
|
||||
try {
|
||||
const list = await aiListGenerations({
|
||||
register_id: registerId.value,
|
||||
scene: 'medical_record',
|
||||
limit: 30,
|
||||
});
|
||||
historyList.value = Array.isArray(list) ? list : [];
|
||||
} catch (e: any) {
|
||||
historyList.value = [];
|
||||
message.error(e?.message || e?.msg || '加载历史失败');
|
||||
} finally {
|
||||
histLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 有历史选最新;无历史自动打开生成框 */
|
||||
async function bootstrapHistory() {
|
||||
if (!registerId.value) return;
|
||||
await loadHistory();
|
||||
if (historyList.value.length) {
|
||||
await onSelectHistory(historyList.value[0]);
|
||||
} else {
|
||||
openGenerateModal();
|
||||
}
|
||||
}
|
||||
|
||||
function openGenerateModal() {
|
||||
genModalApi.open();
|
||||
}
|
||||
|
||||
async function onSelectHistory(row: any) {
|
||||
if (!row?.id) return;
|
||||
activeId.value = Number(row.id);
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
const data = await aiGenerationDetail({ id: Number(row.id) });
|
||||
const fields = (data?.fields || {}) as Record<string, string>;
|
||||
previewFields.value = { ...fields };
|
||||
const snap = data?.input_snapshot || {};
|
||||
previewChief.value = String(
|
||||
snap.chief_complaint || chiefComplaint.value || '',
|
||||
).trim();
|
||||
lastDurationMs.value = Number(row.duration_ms || 0);
|
||||
} catch (e: any) {
|
||||
previewFields.value = {};
|
||||
previewChief.value = '';
|
||||
message.error(e?.message || e?.msg || '加载详情失败');
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 先关弹窗,回抽屉等待生成 */
|
||||
async function confirmGenerateAndClose(): Promise<boolean> {
|
||||
if (generating.value) return false;
|
||||
const chief = chiefComplaint.value.trim();
|
||||
if (!chief) {
|
||||
message.warning('请先填写主诉后再生成');
|
||||
return false;
|
||||
}
|
||||
if (!registerId.value) {
|
||||
message.warning('挂号无效');
|
||||
return false;
|
||||
}
|
||||
genModalApi.close();
|
||||
void runGenerateInDrawer(chief);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runGenerateInDrawer(chief: string) {
|
||||
generating.value = true;
|
||||
previewFields.value = {};
|
||||
previewChief.value = chief;
|
||||
lastDurationMs.value = 0;
|
||||
try {
|
||||
const data = await aiGenerateMedicalRecord({
|
||||
register_id: registerId.value,
|
||||
store_id: storeId.value || undefined,
|
||||
chief_complaint: chief,
|
||||
medical_record: { ...genContext.value },
|
||||
});
|
||||
const durationText = formatDurationMs(data?.duration_ms);
|
||||
lastDurationMs.value = Number(data?.duration_ms || 0);
|
||||
// 业务软失败:HTTP 成功但 ok=false,警告提示而非 error
|
||||
if (data?.ok === false) {
|
||||
if (data?.generation_id) {
|
||||
activeId.value = Number(data.generation_id);
|
||||
await loadHistory();
|
||||
}
|
||||
message.warning(String(data?.message || '生成未成功'));
|
||||
return;
|
||||
}
|
||||
previewFields.value = (data?.fields || {}) as Record<string, string>;
|
||||
await loadHistory();
|
||||
activeId.value = Number(data?.generation_id || 0);
|
||||
message.success(
|
||||
durationText ? `已生成(耗时 ${durationText})` : '已生成,请预览后确认导入',
|
||||
);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || e?.msg || 'AI写病历失败');
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirmImport() {
|
||||
if (!Object.keys(previewFields.value || {}).length && !previewChief.value) {
|
||||
message.warning('暂无可导入内容');
|
||||
return;
|
||||
}
|
||||
emit('import', {
|
||||
chief_complaint: String(previewChief.value || chiefComplaint.value || '').trim(),
|
||||
fields: { ...previewFields.value },
|
||||
});
|
||||
drawerApi.close();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open(payload: {
|
||||
registerId: number;
|
||||
storeId?: number;
|
||||
patientName?: string;
|
||||
patientSex?: number;
|
||||
patientAge?: number;
|
||||
chiefComplaint?: string;
|
||||
medicalRecord?: Record<string, any>;
|
||||
}) {
|
||||
drawerApi.setData(payload);
|
||||
drawerApi.open();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden text-foreground">
|
||||
<div class="shrink-0 text-sm text-muted-foreground">
|
||||
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁
|
||||
</div>
|
||||
<AiDisclaimerBanner class="shrink-0" :text="disclaimerText" />
|
||||
<div class="flex min-h-0 flex-1 gap-3 overflow-hidden">
|
||||
<div class="w-44 shrink-0 overflow-y-auto border-r border-border pr-2">
|
||||
<div class="mb-2 text-sm font-medium">历史记录</div>
|
||||
<div v-if="histLoading" class="text-xs text-muted-foreground">加载中…</div>
|
||||
<div v-else-if="!historyList.length" class="text-xs text-muted-foreground">
|
||||
暂无记录,可点击下方「生成病历」
|
||||
</div>
|
||||
<div
|
||||
v-for="row in historyList"
|
||||
:key="row.id"
|
||||
class="mb-1 cursor-pointer rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-accent"
|
||||
:class="{
|
||||
'bg-primary/10 ring-1 ring-primary/20': activeId === Number(row.id),
|
||||
}"
|
||||
@click="onSelectHistory(row)"
|
||||
>
|
||||
<div class="truncate font-medium">
|
||||
{{ row.name || `病历 #${row.id}` }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-muted-foreground">
|
||||
{{ formatTime(row.created_at) }}
|
||||
<span v-if="row.duration_ms"> · {{ formatDurationMs(row.duration_ms) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 min-w-0 flex-1 overflow-y-auto pl-1 pb-2">
|
||||
<!-- AI 专属动画:仅真正调用生成接口时展示,历史详情加载用普通 Spin -->
|
||||
<div
|
||||
v-if="generating"
|
||||
class="flex h-full min-h-[260px] items-center justify-center"
|
||||
>
|
||||
<PrescriptionLoading
|
||||
type="medical_record"
|
||||
text="AI正在生成病历"
|
||||
class="h-[280px] w-full max-w-[420px]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="detailLoading"
|
||||
class="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Spin />
|
||||
<span class="text-sm">加载历史病历…</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!previewRows.length && !previewChief && !lastDurationMs"
|
||||
class="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
请选择左侧历史,或点击「生成病历」
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!previewRows.length && !previewChief && lastDurationMs"
|
||||
class="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
本次生成未产出病历内容
|
||||
<span v-if="formatDurationMs(lastDurationMs)">
|
||||
(耗时 {{ formatDurationMs(lastDurationMs) }})
|
||||
</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<Descriptions
|
||||
v-if="previewChief || previewRows.length"
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="ai-mr-preview-desc"
|
||||
>
|
||||
<Descriptions.Item
|
||||
v-if="previewChief"
|
||||
label="主诉"
|
||||
:span="2"
|
||||
>
|
||||
<div class="max-h-20 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{{ previewChief }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-if="lastDurationMs"
|
||||
label="生成耗时"
|
||||
>
|
||||
{{ formatDurationMs(lastDurationMs) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-for="row in previewRows"
|
||||
:key="row.code"
|
||||
:label="row.label"
|
||||
:span="FULL_SPAN_KEYS.has(row.code) ? 2 : 1"
|
||||
>
|
||||
<div class="max-h-20 overflow-y-auto whitespace-pre-wrap leading-relaxed">
|
||||
{{ row.value }}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div
|
||||
v-else
|
||||
class="py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无生成字段
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 justify-end gap-2 border-t border-border pt-3">
|
||||
<Button @click="drawerApi.close()">关闭</Button>
|
||||
<Button type="default" @click="openGenerateModal">生成病历</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="generating || detailLoading || (!previewRows.length && !previewChief)"
|
||||
@click="onConfirmImport"
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<!-- 二级:与 AI 出方一致 — Descriptions 双列紧凑表单 -->
|
||||
<GenModal>
|
||||
<div class="space-y-2 text-sm text-foreground">
|
||||
<AiDisclaimerBanner :text="disclaimerText" />
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ patientName || '—' }} · {{ sexLabel }} · {{ patientAge || '—' }}岁 ·
|
||||
主诉必填;下方选填有内容会作为 AI 参考(若该字段在「病历配置」中需生成则保留原文)
|
||||
</div>
|
||||
<div class="max-h-[58vh] overflow-y-auto">
|
||||
<Descriptions
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="ai-mr-gen-desc"
|
||||
>
|
||||
<Descriptions.Item label="主诉" :span="2">
|
||||
<Input.TextArea
|
||||
v-model:value="chiefComplaint"
|
||||
:rows="3"
|
||||
placeholder="请确认或补充主诉后再生成"
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item
|
||||
v-for="f in GEN_OPTIONAL_FIELDS"
|
||||
:key="f.value"
|
||||
:label="f.label"
|
||||
:span="1"
|
||||
>
|
||||
<Input.TextArea
|
||||
v-model:value="genContext[f.value]"
|
||||
:rows="2"
|
||||
:placeholder="`选填${f.label}`"
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
点击「开始生成」后将关闭本弹窗,请在抽屉中等待结果。
|
||||
</div>
|
||||
</div>
|
||||
</GenModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-mr-preview-desc :deep(.ant-descriptions-item-label),
|
||||
.ai-mr-gen-desc :deep(.ant-descriptions-item-label) {
|
||||
width: 88px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-mr-preview-desc :deep(.ant-descriptions-item-content),
|
||||
.ai-mr-gen-desc :deep(.ant-descriptions-item-content) {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 常用快捷标签(诊断 / 医嘱 / 病历词条)
|
||||
* - 正文已包含该文案时高亮(is-active)
|
||||
* - 点击:已在正文则移除,否则追加(由父级 onEntryPick 统一 toggle)
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
fetchDoctorOrderListCached,
|
||||
fetchMyDiseaseListCached,
|
||||
fetchMyEntriesByField,
|
||||
invalidateCommonChipsCache,
|
||||
} from '../utils/commonChipsCache';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** diagnosis=常用诊断;doctor_order=常用医嘱;entry=常用词条 */
|
||||
source: 'diagnosis' | 'doctor_order' | 'entry';
|
||||
/** entry 时必填 */
|
||||
fieldCode?: string;
|
||||
/** 当前字段正文,用于高亮已选 */
|
||||
fieldValue?: string;
|
||||
limit?: number;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
fieldCode: '',
|
||||
fieldValue: '',
|
||||
limit: 12,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [text: string];
|
||||
}>();
|
||||
|
||||
type ChipItem = { id: string | number; text: string };
|
||||
|
||||
const chips = ref<ChipItem[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
/** 文案是否已出现在字段值中(逗号分段或整行/包含) */
|
||||
function textInField(fieldValue: string, text: string) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return false;
|
||||
const v = String(fieldValue || '');
|
||||
if (!v) return false;
|
||||
if (v.split(',').map((s) => s.trim()).includes(t)) return true;
|
||||
if (v.split('\n').map((s) => s.trim()).includes(t)) return true;
|
||||
return v.includes(t);
|
||||
}
|
||||
|
||||
const activeSet = computed(() => {
|
||||
const set = new Set<string>();
|
||||
const v = props.fieldValue || '';
|
||||
for (const c of chips.value) {
|
||||
if (textInField(v, c.text)) set.add(c.text);
|
||||
}
|
||||
return set;
|
||||
});
|
||||
|
||||
/**
|
||||
* 拉取常用项(诊断/医嘱/词条均走模块缓存,进页多实例只打一次网)
|
||||
*/
|
||||
async function loadChips() {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (props.source === 'diagnosis') {
|
||||
const list = await fetchMyDiseaseListCached();
|
||||
chips.value = (list || [])
|
||||
.map((item: any) => {
|
||||
const d = item?.disease || item;
|
||||
const text = String(d?.name || '').trim();
|
||||
return text ? { id: d?.id ?? item?.id, text } : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, props.limit) as ChipItem[];
|
||||
return;
|
||||
}
|
||||
if (props.source === 'entry') {
|
||||
if (!props.fieldCode) {
|
||||
chips.value = [];
|
||||
return;
|
||||
}
|
||||
// 全量 my-entry-list 一次,再按 field_code 本地切片
|
||||
const list = await fetchMyEntriesByField(props.fieldCode, props.limit);
|
||||
chips.value = (list || [])
|
||||
.map((item: any) => {
|
||||
const text = String(item?.content || item?.title || '').trim();
|
||||
return text
|
||||
? { id: item?.id ?? item?.entry_id ?? text, text }
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, props.limit) as ChipItem[];
|
||||
return;
|
||||
}
|
||||
const res = await fetchDoctorOrderListCached();
|
||||
const my = (res?.my || [])
|
||||
.map((item: any) => {
|
||||
const text = String(item?.content || '').trim();
|
||||
return text ? { id: item?.id ?? text, text } : null;
|
||||
})
|
||||
.filter(Boolean) as ChipItem[];
|
||||
if (my.length > 0) {
|
||||
chips.value = my.slice(0, props.limit);
|
||||
return;
|
||||
}
|
||||
chips.value = (res?.common || [])
|
||||
.map((item: any) => {
|
||||
const text = String(item?.name || item?.content || '').trim();
|
||||
return text ? { id: item?.id ?? text, text } : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, props.limit) as ChipItem[];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 强制刷新:清缓存后重拉(新增/删除常用后用) */
|
||||
async function reloadChips() {
|
||||
invalidateCommonChipsCache(props.source);
|
||||
await loadChips();
|
||||
}
|
||||
|
||||
function onPick(item: ChipItem) {
|
||||
if (props.disabled || !item?.text) return;
|
||||
emit('select', item.text);
|
||||
}
|
||||
|
||||
const labelText = computed(() => {
|
||||
if (props.source === 'diagnosis') return '常用诊断';
|
||||
if (props.source === 'doctor_order') return '常用医嘱';
|
||||
return '常用词条';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.source, props.fieldCode] as const,
|
||||
() => {
|
||||
loadChips();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadChips();
|
||||
});
|
||||
|
||||
defineExpose({ reload: reloadChips });
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="chips.length > 0" class="dx-order-chips">
|
||||
<span class="dx-order-chips__label">{{ labelText }}</span>
|
||||
<div class="dx-order-chips__list">
|
||||
<Tag
|
||||
v-for="item in chips"
|
||||
:key="`${item.id}_${item.text}`"
|
||||
class="dx-order-chips__tag"
|
||||
:class="{
|
||||
'is-disabled': disabled,
|
||||
'is-active': activeSet.has(item.text),
|
||||
}"
|
||||
:color="activeSet.has(item.text) ? 'success' : 'processing'"
|
||||
@click="onPick(item)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.dx-order-chips {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dx-order-chips__label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.dx-order-chips__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.dx-order-chips__tag {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dx-order-chips__tag.is-active {
|
||||
font-weight: 600;
|
||||
}
|
||||
.dx-order-chips__tag.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 病历词条列表(公共/门店共用)
|
||||
* - scope=public → store_id=0;scope=store → 当前登录门店
|
||||
* - fixedFieldCode:按字段拆分菜单时锁定字段,隐藏字段筛选
|
||||
* - 支持 Excel 导入 + 手动同步空拼音首拼
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Modal as AntModal, Tag, Upload } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { downloadByData } from '#/util/tool';
|
||||
|
||||
import {
|
||||
deleteMedicalRecordEntry,
|
||||
getMedicalRecordEntryList,
|
||||
importMedicalRecordEntry,
|
||||
syncMedicalRecordEntryPinyin,
|
||||
} from '../api';
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS, STATUS_OPTIONS } from '../config/constants';
|
||||
import { exportMedicalRecordEntryTemplate } from '../utils/exportMedicalRecordEntryExcel';
|
||||
import { parseMedicalRecordEntryExcelBuffer } from '../utils/parseMedicalRecordEntryExcel';
|
||||
import EntryModal from './EntryModal.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
scope: 'public' | 'store';
|
||||
pageTitle: string;
|
||||
pageName: string;
|
||||
/** 锁定字段编码(按类型拆菜单时传入) */
|
||||
fixedFieldCode?: string;
|
||||
}>();
|
||||
|
||||
defineOptions({ name: 'MedicalRecordEntryList' });
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const importing = ref(false);
|
||||
const syncing = ref(false);
|
||||
|
||||
const formOptions = computed(() => {
|
||||
const schema: any[] = [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'title',
|
||||
label: '词条名称',
|
||||
componentProps: { placeholder: '名称/首拼模糊搜索' },
|
||||
},
|
||||
];
|
||||
if (!props.fixedFieldCode) {
|
||||
schema.push({
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'field_code',
|
||||
label: '所属字段',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: MEDICAL_RECORD_FIELD_OPTIONS,
|
||||
},
|
||||
});
|
||||
}
|
||||
schema.push({
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
componentProps: { allowClear: true, options: STATUS_OPTIONS },
|
||||
});
|
||||
return { schema };
|
||||
});
|
||||
|
||||
const gridOptions = computed(() => ({
|
||||
checkboxConfig: { highlight: true, labelField: '' },
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
...(props.fixedFieldCode
|
||||
? []
|
||||
: [{ field: 'field_code_txt', title: '字段', width: 120 }]),
|
||||
{ field: 'title', title: '词条名称', minWidth: 140 },
|
||||
{ field: 'pinyin_initials', title: '首拼(全拼)', minWidth: 160 },
|
||||
{ field: 'content', title: '正文', minWidth: 200 },
|
||||
{ field: 'remark', title: '临床意义', width: 140 },
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{ field: 'sort', title: '排序', width: 80 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||
{ title: '操作', slots: { default: 'action' }, width: 160, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any, formValues: any) => {
|
||||
return await getMedicalRecordEntryList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
scope: props.scope,
|
||||
field_code: props.fixedFieldCode || undefined,
|
||||
...formValues,
|
||||
...(props.fixedFieldCode ? { field_code: props.fixedFieldCode } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
slots: { buttons: 'toolbar-buttons' },
|
||||
},
|
||||
}));
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: formOptions.value as any,
|
||||
gridOptions: gridOptions.value as any,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.fixedFieldCode,
|
||||
() => {
|
||||
gridApi.reload?.();
|
||||
},
|
||||
);
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: EntryModal,
|
||||
});
|
||||
|
||||
const showModal = (data: any = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
values: {
|
||||
...data,
|
||||
...(props.fixedFieldCode && !isUpdate
|
||||
? { field_code: props.fixedFieldCode }
|
||||
: {}),
|
||||
},
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
scope: props.scope,
|
||||
fixedFieldCode: props.fixedFieldCode || '',
|
||||
});
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
const handleDelete = async (ids: number[]) => {
|
||||
await deleteMedicalRecordEntry({ ids });
|
||||
message.success('删除成功');
|
||||
gridApi.query();
|
||||
};
|
||||
|
||||
async function handleDownloadTemplate() {
|
||||
const buffer = await exportMedicalRecordEntryTemplate();
|
||||
downloadByData(
|
||||
buffer,
|
||||
'病历词条导入模板.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
}
|
||||
|
||||
async function handleImportFile(file: File) {
|
||||
importing.value = true;
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
// 按字段拆分页传入 defaultFieldCode:允许 Excel 字段编码留空 / 填中文名
|
||||
const parsed = await parseMedicalRecordEntryExcelBuffer(buffer, {
|
||||
defaultFieldCode: props.fixedFieldCode || '',
|
||||
});
|
||||
let rows = parsed.items;
|
||||
if (props.fixedFieldCode) {
|
||||
rows = rows
|
||||
.map((r) => ({ ...r, field_code: props.fixedFieldCode as string }))
|
||||
.filter((r) => r.title);
|
||||
}
|
||||
if (!rows.length) {
|
||||
const detail = parsed.firstInvalidReason
|
||||
? `:${parsed.firstInvalidReason}`
|
||||
: '';
|
||||
message.warning(
|
||||
parsed.invalidCount
|
||||
? `没有可导入的有效行(无效 ${parsed.invalidCount} 行)${detail}`
|
||||
: '文件无有效数据,请确认填写的是「词条导入」工作表',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const res = await importMedicalRecordEntry({
|
||||
scope: props.scope,
|
||||
rows,
|
||||
});
|
||||
const success = Number(res?.success ?? 0);
|
||||
const fail = Number(res?.fail ?? 0);
|
||||
const errors = Array.isArray(res?.errors) ? res.errors : [];
|
||||
if (fail > 0) {
|
||||
message.warning(
|
||||
`导入完成:成功 ${success},失败 ${fail}${
|
||||
errors.length ? `;${errors.slice(0, 3).join(';')}` : ''
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
message.success(`成功导入 ${success} 条`);
|
||||
}
|
||||
gridApi.reload();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '导入失败');
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 手动同步当前 scope 下空拼音首拼 */
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音首拼',
|
||||
content: '将扫描拼音为空的词条并自动生成,已有值不会覆盖。确认执行?',
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncMedicalRecordEntryPinyin({ scope: props.scope });
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height :title="pageTitle">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增词条',
|
||||
type: 'primary',
|
||||
onClick: () => showModal({}, false),
|
||||
},
|
||||
{
|
||||
label: '下载模板',
|
||||
onClick: () => handleDownloadTemplate(),
|
||||
},
|
||||
{
|
||||
label: '批量删除',
|
||||
danger: true,
|
||||
ifShow: () => hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确认删除所选词条?',
|
||||
confirm: () => {
|
||||
const ids = gridApi.grid
|
||||
.getCheckboxRecords()
|
||||
.map((r: any) => r.id);
|
||||
handleDelete(ids);
|
||||
},
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<Upload
|
||||
:show-upload-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
:before-upload="handleImportFile"
|
||||
class="ml-2 inline-block"
|
||||
>
|
||||
<Button :loading="importing">导入 Excel</Button>
|
||||
</Upload>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音首拼
|
||||
</Button>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status === 1 ? 'success' : 'default'">
|
||||
{{ row.status_txt || (row.status === 1 ? '启用' : '禁用') }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{ label: '编辑', onClick: () => showModal(row, true) },
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确认删除?',
|
||||
confirm: () => handleDelete([row.id]),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 病历词条新增/编辑弹窗
|
||||
* scope 从 modalApi.getData() 读取:public | store
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import {
|
||||
createMedicalRecordEntry,
|
||||
updateMedicalRecordEntry,
|
||||
} from '../api';
|
||||
import { buildEntryFormProps } from '../config/entry-form';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
const entryScope = ref<'public' | 'store'>('public');
|
||||
const fixedFieldCode = ref('');
|
||||
const [Form, formApi] = useVbenForm(buildEntryFormProps());
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
scope: entryScope.value,
|
||||
...(fixedFieldCode.value ? { field_code: fixedFieldCode.value } : {}),
|
||||
};
|
||||
if (isUpdate.value) {
|
||||
await updateMedicalRecordEntry(payload);
|
||||
} else {
|
||||
await createMedicalRecordEntry(payload);
|
||||
}
|
||||
message.success('保存成功');
|
||||
gridApi.value?.query();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
const data = isOpen ? modalApi.getData<Record<string, any>>() : null;
|
||||
gridApi.value = data?.gridApi || null;
|
||||
entryScope.value = (data?.scope as 'public' | 'store') || 'public';
|
||||
fixedFieldCode.value = String(data?.fixedFieldCode || '');
|
||||
if (!isOpen) {
|
||||
formApi.resetForm();
|
||||
return;
|
||||
}
|
||||
// 按字段拆页时隐藏「所属字段」,并去掉必填校验(提交时由 fixedFieldCode 注入)
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'field_code',
|
||||
rules: fixedFieldCode.value ? '' : 'selectRequired',
|
||||
dependencies: {
|
||||
show: () => !fixedFieldCode.value,
|
||||
triggerFields: ['title'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const { values, update } = data || {};
|
||||
isUpdate.value = !!update;
|
||||
if (values && update) {
|
||||
formApi.setValues({ ...values });
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
formApi.setValues({
|
||||
sort: 0,
|
||||
status: 1,
|
||||
content: '',
|
||||
remark: '',
|
||||
field_code: fixedFieldCode.value || undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}词条`" class="w-[640px]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,319 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 患者历史病历抽屉
|
||||
* - view:只读查看
|
||||
* - cite:选择一条后点「引用此病历」回填到当前接诊病历
|
||||
* 右侧预览用 Descriptions 双列,跳过空字段
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { Descriptions, Empty, Spin, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { getMedicalRecordListByPatient } from '../api';
|
||||
import { MEDICAL_RECORD_VIEW_FIELDS } from '../config/constants';
|
||||
|
||||
defineOptions({ name: 'PatientMedicalRecordDrawer' });
|
||||
|
||||
type DrawerData = {
|
||||
userPatientId?: number;
|
||||
storeId?: number;
|
||||
patientName?: string;
|
||||
mode?: 'view' | 'cite';
|
||||
excludeRegisterId?: number;
|
||||
onCite?: (record: Record<string, any>) => void;
|
||||
};
|
||||
|
||||
/** Descriptions 通栏字段 */
|
||||
const FULL_SPAN_KEYS = new Set([
|
||||
'chief_complaint',
|
||||
'present_illness',
|
||||
'tcm_case',
|
||||
'treatment_advice',
|
||||
'doctor_order',
|
||||
'auxiliary_exam',
|
||||
'physical_exam',
|
||||
]);
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<Record<string, any>[]>([]);
|
||||
const activeId = ref(0);
|
||||
const meta = ref<DrawerData>({});
|
||||
|
||||
const isCiteMode = computed(() => meta.value.mode === 'cite');
|
||||
|
||||
const activeRecord = computed(() => {
|
||||
return list.value.find((r) => Number(r.id) === activeId.value) || null;
|
||||
});
|
||||
|
||||
function vitalsText(row: Record<string, any>) {
|
||||
const parts: string[] = [];
|
||||
if (row.temperature) parts.push(`体温 ${row.temperature}℃`);
|
||||
if (row.height) parts.push(`身高 ${row.height}cm`);
|
||||
if (row.weight) parts.push(`体重 ${row.weight}KG`);
|
||||
if (row.respiratory_rate) parts.push(`呼吸 ${row.respiratory_rate}次/分`);
|
||||
if (row.bp_systolic || row.bp_diastolic) {
|
||||
parts.push(`血压 ${row.bp_systolic || '-'}/${row.bp_diastolic || '-'}mmHg`);
|
||||
}
|
||||
return parts.join(';') || '';
|
||||
}
|
||||
|
||||
function fieldDisplay(row: Record<string, any>, key: string) {
|
||||
if (key === 'physical_exam') {
|
||||
const body = String(row.physical_exam || '').trim();
|
||||
const vitals = vitalsText(row);
|
||||
if (body && vitals) return `${vitals}\n${body}`;
|
||||
return body || vitals || '';
|
||||
}
|
||||
const v = row[key];
|
||||
if (v === null || v === undefined || v === '' || v === 0) return '';
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
/** 仅有内容的字段,供 Descriptions 渲染 */
|
||||
const filledViewFields = computed(() => {
|
||||
const row = activeRecord.value;
|
||||
if (!row) return [];
|
||||
return MEDICAL_RECORD_VIEW_FIELDS.map((f) => ({
|
||||
label: f.label,
|
||||
value: f.value,
|
||||
text: fieldDisplay(row, f.value),
|
||||
span: FULL_SPAN_KEYS.has(f.value) ? 2 : 1,
|
||||
})).filter((f) => !!f.text);
|
||||
});
|
||||
|
||||
function listTitle(row: Record<string, any>) {
|
||||
const dx = String(row.diagnosis || '').trim();
|
||||
if (dx) return dx.length > 28 ? `${dx.slice(0, 28)}…` : dx;
|
||||
return `挂号 #${row.register_id || '-'}`;
|
||||
}
|
||||
|
||||
function isCurrentRegister(row: Record<string, any>) {
|
||||
const exclude = Number(meta.value.excludeRegisterId || 0);
|
||||
return exclude > 0 && Number(row.register_id) === exclude;
|
||||
}
|
||||
|
||||
function pickDefaultActiveId(rows: Record<string, any>[]) {
|
||||
if (!rows.length) return 0;
|
||||
const exclude = Number(meta.value.excludeRegisterId || 0);
|
||||
if (exclude > 0) {
|
||||
const other = rows.find((r) => Number(r.register_id) !== exclude);
|
||||
if (other) return Number(other.id);
|
||||
}
|
||||
return Number(rows[0].id);
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
const uid = Number(meta.value.userPatientId || 0);
|
||||
if (!uid) {
|
||||
list.value = [];
|
||||
activeId.value = 0;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getMedicalRecordListByPatient({
|
||||
user_patient_id: uid,
|
||||
store_id: meta.value.storeId || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
list.value = Array.isArray(res) ? res : res?.items || res?.list || [];
|
||||
activeId.value = pickDefaultActiveId(list.value);
|
||||
} catch (e: any) {
|
||||
list.value = [];
|
||||
activeId.value = 0;
|
||||
message.error(e?.message || '加载病历失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
class: 'w-[900px]',
|
||||
title: '查看患者病历',
|
||||
cancelText: '关闭',
|
||||
confirmText: '引用此病历',
|
||||
showConfirmButton: false,
|
||||
onConfirm() {
|
||||
if (!isCiteMode.value) {
|
||||
drawerApi.close();
|
||||
return;
|
||||
}
|
||||
const row = activeRecord.value;
|
||||
if (!row) {
|
||||
message.warning('请先选择要引用的病历');
|
||||
return;
|
||||
}
|
||||
meta.value.onCite?.(row);
|
||||
drawerApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
list.value = [];
|
||||
activeId.value = 0;
|
||||
meta.value = {};
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<DrawerData>() || {};
|
||||
meta.value = data;
|
||||
const cite = data.mode === 'cite';
|
||||
const name = data.patientName ? ` - ${data.patientName}` : '';
|
||||
drawerApi.setState({
|
||||
title: cite ? `引用历史病历${name}` : `查看患者病历${name}`,
|
||||
showConfirmButton: cite,
|
||||
confirmText: '引用此病历',
|
||||
cancelText: cite ? '取消' : '关闭',
|
||||
});
|
||||
loadList();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Drawer>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="list.length === 0 && !loading" class="py-10">
|
||||
<Empty description="暂无病历记录" />
|
||||
</div>
|
||||
<div v-else class="mr-view-layout">
|
||||
<div class="mr-view-list">
|
||||
<div
|
||||
v-for="row in list"
|
||||
:key="row.id"
|
||||
class="mr-view-item"
|
||||
:class="{ 'is-active': Number(row.id) === activeId }"
|
||||
@click="activeId = Number(row.id)"
|
||||
>
|
||||
<div class="mr-view-item__title">{{ listTitle(row) }}</div>
|
||||
<div class="mr-view-item__meta">
|
||||
<Tag>挂号 {{ row.register_id || '-' }}</Tag>
|
||||
<Tag v-if="isCurrentRegister(row)" color="blue">本次</Tag>
|
||||
<span>{{ row.created_at || row.updated_at || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mr-view-detail">
|
||||
<template v-if="activeRecord">
|
||||
<Descriptions
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
class="mb-3 mr-view-meta-desc"
|
||||
>
|
||||
<Descriptions.Item label="挂号 ID">
|
||||
{{ activeRecord.register_id || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建">
|
||||
{{ activeRecord.created_at || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新" :span="2">
|
||||
{{ activeRecord.updated_at || '—' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div
|
||||
v-if="isCiteMode && isCurrentRegister(activeRecord)"
|
||||
class="mr-view-tip"
|
||||
>
|
||||
当前选中的是本次挂号病历,引用后会覆盖编辑区内容(仍可继续改)。
|
||||
</div>
|
||||
<Empty
|
||||
v-if="!filledViewFields.length"
|
||||
description="该病历暂无填写内容"
|
||||
class="py-6"
|
||||
/>
|
||||
<Descriptions
|
||||
v-else
|
||||
:column="2"
|
||||
bordered
|
||||
size="small"
|
||||
title="病历内容"
|
||||
class="mr-view-content-desc"
|
||||
>
|
||||
<Descriptions.Item
|
||||
v-for="f in filledViewFields"
|
||||
:key="f.value"
|
||||
:label="f.label"
|
||||
:span="f.span"
|
||||
>
|
||||
<div class="mr-view-content-text">{{ f.text }}</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</template>
|
||||
<Empty v-else description="请选择左侧病历" />
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
<style scoped>
|
||||
.mr-view-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 12px;
|
||||
min-height: 480px;
|
||||
}
|
||||
.mr-view-list {
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
padding-right: 10px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mr-view-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
margin-bottom: 6px;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.mr-view-item:hover {
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
}
|
||||
.mr-view-item.is-active {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
.mr-view-item__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
}
|
||||
.mr-view-item__meta {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.mr-view-detail {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.mr-view-tip {
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: #ad6800;
|
||||
background: #fff7e6;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.mr-view-meta-desc :deep(.ant-descriptions-item-label),
|
||||
.mr-view-content-desc :deep(.ant-descriptions-item-label) {
|
||||
width: 88px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mr-view-content-desc :deep(.ant-descriptions-item-content) {
|
||||
font-size: 12px;
|
||||
}
|
||||
.mr-view-content-text {
|
||||
max-height: 88px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 病历文本域双击放大编辑弹窗
|
||||
* - 内嵌关键字搜索气泡 + 常用快捷(诊断/医嘱)
|
||||
* - 确认或弹窗内文本域再双击:写回并关闭
|
||||
* - 禁止点遮罩关闭,避免误丢编辑内容
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { Textarea } from 'ant-design-vue';
|
||||
|
||||
import CommonDxOrderChips from './CommonDxOrderChips.vue';
|
||||
import EntryKeywordBubble, {
|
||||
type MedicalRecordEntryItem,
|
||||
} from './EntryKeywordBubble.vue';
|
||||
|
||||
const draft = ref('');
|
||||
const fieldLabel = ref('编辑内容');
|
||||
const onConfirmCb = ref<((v: string) => void) | null>(null);
|
||||
type ExpandBubbleSource =
|
||||
| 'entry'
|
||||
| 'diagnosis'
|
||||
| 'doctor_order'
|
||||
| 'tcm_disease'
|
||||
| 'tcm_syndrome'
|
||||
| 'tcm_method';
|
||||
const source = ref<ExpandBubbleSource>('entry');
|
||||
const fieldCode = ref('');
|
||||
const storeId = ref(0);
|
||||
const showChips = ref(false);
|
||||
/** 气泡内展示常用|我的(与系统配置 mr_common_display_mode 对齐) */
|
||||
const showCommonInBubble = ref(false);
|
||||
const joinMode = ref<'comma' | 'newline' | 'replace'>('newline');
|
||||
const readonly = ref(false);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '编辑',
|
||||
class: 'w-[780px]',
|
||||
draggable: true,
|
||||
closeOnClickModal: false,
|
||||
onConfirm() {
|
||||
confirmAndClose();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
draft.value = '';
|
||||
onConfirmCb.value = null;
|
||||
source.value = 'entry';
|
||||
fieldCode.value = '';
|
||||
showChips.value = false;
|
||||
showCommonInBubble.value = false;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{
|
||||
title?: string;
|
||||
value?: string;
|
||||
onConfirm?: (v: string) => void;
|
||||
source?: ExpandBubbleSource;
|
||||
fieldCode?: string;
|
||||
storeId?: number;
|
||||
showChips?: boolean;
|
||||
showCommonInBubble?: boolean;
|
||||
joinMode?: 'comma' | 'newline' | 'replace';
|
||||
readonly?: boolean;
|
||||
}>();
|
||||
fieldLabel.value = data?.title || '编辑内容';
|
||||
draft.value = data?.value || '';
|
||||
onConfirmCb.value = data?.onConfirm || null;
|
||||
source.value = data?.source || 'entry';
|
||||
fieldCode.value = data?.fieldCode || '';
|
||||
storeId.value = Number(data?.storeId || 0);
|
||||
showChips.value = !!data?.showChips;
|
||||
showCommonInBubble.value = !!data?.showCommonInBubble;
|
||||
joinMode.value = data?.joinMode || 'newline';
|
||||
readonly.value = !!data?.readonly;
|
||||
modalApi.setState({ title: fieldLabel.value });
|
||||
},
|
||||
});
|
||||
|
||||
const showBubble = computed(() => {
|
||||
if (readonly.value) return false;
|
||||
if (source.value === 'entry') return !!fieldCode.value;
|
||||
return (
|
||||
source.value === 'diagnosis' ||
|
||||
source.value === 'doctor_order' ||
|
||||
source.value === 'tcm_disease' ||
|
||||
source.value === 'tcm_syndrome' ||
|
||||
source.value === 'tcm_method'
|
||||
);
|
||||
});
|
||||
|
||||
/** 中文逗号拼接,去重 */
|
||||
function appendCommaText(base: string, add: string) {
|
||||
if (!add) return base || '';
|
||||
const parts = (base || '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.includes(add)) return parts.join(',');
|
||||
parts.push(add);
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
function removeCommaText(base: string, remove: string) {
|
||||
if (!base || !remove) return base || '';
|
||||
return (base || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && s !== remove)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function appendNewline(base: string, add: string) {
|
||||
if (!add) return base || '';
|
||||
if (!base) return add;
|
||||
return `${base}${base.endsWith('\n') ? '' : '\n'}${add}`;
|
||||
}
|
||||
|
||||
function removeNewline(base: string, remove: string) {
|
||||
if (!base || !remove) return base || '';
|
||||
const lines = base.split('\n');
|
||||
const idx = lines.findIndex((l) => l.trim() === remove.trim());
|
||||
if (idx >= 0) {
|
||||
lines.splice(idx, 1);
|
||||
return lines.join('\n');
|
||||
}
|
||||
const i = base.indexOf(remove);
|
||||
if (i < 0) return base;
|
||||
return `${base.slice(0, i)}${base.slice(i + remove.length)}`
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/^\n+|\n+$/g, '');
|
||||
}
|
||||
|
||||
function containsText(base: string, text: string) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return false;
|
||||
const v = String(base || '');
|
||||
if (!v) return false;
|
||||
if (v.split(',').map((s) => s.trim()).includes(t)) return true;
|
||||
if (v.split('\n').map((s) => s.trim()).includes(t)) return true;
|
||||
return v.includes(t);
|
||||
}
|
||||
|
||||
/** 气泡/快捷写入草稿(已有则删除,实现选择/取消) */
|
||||
function applyPick(text: string) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t || readonly.value) return;
|
||||
if (joinMode.value === 'replace') {
|
||||
draft.value = String(draft.value || '').trim() === t ? '' : t;
|
||||
return;
|
||||
}
|
||||
if (joinMode.value === 'comma') {
|
||||
draft.value = containsText(draft.value, t)
|
||||
? removeCommaText(draft.value, t)
|
||||
: appendCommaText(draft.value, t);
|
||||
return;
|
||||
}
|
||||
draft.value = containsText(draft.value, t)
|
||||
? removeNewline(draft.value, t)
|
||||
: appendNewline(draft.value, t);
|
||||
}
|
||||
|
||||
function onBubbleSelect(item: MedicalRecordEntryItem) {
|
||||
applyPick(item.content || item.title || '');
|
||||
}
|
||||
|
||||
function confirmAndClose() {
|
||||
onConfirmCb.value?.(draft.value);
|
||||
modalApi.close();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Modal>
|
||||
<div class="py-2">
|
||||
<div v-if="showBubble" class="mb-2">
|
||||
<EntryKeywordBubble
|
||||
:source="source"
|
||||
:field-code="fieldCode"
|
||||
:store-id="storeId"
|
||||
:placeholder="
|
||||
source === 'diagnosis'
|
||||
? '诊断关键字/首拼'
|
||||
: source === 'doctor_order'
|
||||
? '医嘱关键字/首拼'
|
||||
: source === 'tcm_disease' ||
|
||||
source === 'tcm_syndrome' ||
|
||||
source === 'tcm_method'
|
||||
? `${fieldLabel}关键字/首拼/全拼`
|
||||
: '词条关键字/首拼'
|
||||
"
|
||||
:disabled="readonly"
|
||||
:show-common-in-bubble="showCommonInBubble"
|
||||
@select="onBubbleSelect"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
v-model:value="draft"
|
||||
:rows="12"
|
||||
:placeholder="fieldLabel"
|
||||
:disabled="readonly"
|
||||
class="w-full"
|
||||
allow-clear
|
||||
@dblclick="confirmAndClose"
|
||||
/>
|
||||
<CommonDxOrderChips
|
||||
v-if="
|
||||
showChips &&
|
||||
(source === 'diagnosis' ||
|
||||
source === 'doctor_order' ||
|
||||
source === 'entry')
|
||||
"
|
||||
:source="
|
||||
source === 'diagnosis'
|
||||
? 'diagnosis'
|
||||
: source === 'doctor_order'
|
||||
? 'doctor_order'
|
||||
: 'entry'
|
||||
"
|
||||
:field-code="fieldCode"
|
||||
:field-value="draft"
|
||||
:disabled="readonly"
|
||||
@select="applyPick"
|
||||
/>
|
||||
<div class="mt-2 text-xs text-muted-foreground">
|
||||
可先搜索/点常用写入;双击文本域或点确定写回关闭;Ctrl+Enter 在主面板切换模块
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
/** 病历词条字段选项(与后端 MedicalRecordFieldCodeEnum 对齐) */
|
||||
export const MEDICAL_RECORD_FIELD_OPTIONS = [
|
||||
{ label: '主诉', value: 'chief_complaint' },
|
||||
{ label: '现病史', value: 'present_illness' },
|
||||
{ label: '舌象', value: 'tongue' },
|
||||
{ label: '脉象', value: 'pulse' },
|
||||
{ label: '中医病案', value: 'tcm_case' },
|
||||
{ label: '治疗意见', value: 'treatment_advice' },
|
||||
// 诊断/医嘱与处方「常用诊断」「常用医嘱」同源,不在此表维护
|
||||
{ label: '家族史', value: 'family_history' },
|
||||
{ label: '流行病学史', value: 'epidemic_history' },
|
||||
{ label: '既往史', value: 'past_history' },
|
||||
{ label: '月经史', value: 'menstrual_history' },
|
||||
{ label: '婚育史', value: 'marital_history' },
|
||||
{ label: '个人史', value: 'personal_history' },
|
||||
{ label: '过敏史', value: 'allergy_history' },
|
||||
{ label: '体征检查', value: 'physical_exam' },
|
||||
{ label: '辅助检查', value: 'auxiliary_exam' },
|
||||
];
|
||||
|
||||
/** 病历只读展示字段(含诊断/医嘱正文) */
|
||||
export const MEDICAL_RECORD_VIEW_FIELDS = [
|
||||
{ label: '临床诊断', value: 'diagnosis' },
|
||||
{ label: '主诉', value: 'chief_complaint' },
|
||||
{ label: '现病史', value: 'present_illness' },
|
||||
{ label: '舌象', value: 'tongue' },
|
||||
{ label: '脉象', value: 'pulse' },
|
||||
{ label: '中医病案', value: 'tcm_case' },
|
||||
{ label: '中医证候', value: 'tcm_syndrome' },
|
||||
{ label: '中医疾病', value: 'tcm_disease' },
|
||||
{ label: '中医治法', value: 'tcm_method' },
|
||||
{ label: '治疗意见', value: 'treatment_advice' },
|
||||
{ label: '医嘱', value: 'doctor_order' },
|
||||
{ label: '家族史', value: 'family_history' },
|
||||
{ label: '流行病学史', value: 'epidemic_history' },
|
||||
{ label: '既往史', value: 'past_history' },
|
||||
{ label: '月经史', value: 'menstrual_history' },
|
||||
{ label: '婚育史', value: 'marital_history' },
|
||||
{ label: '个人史', value: 'personal_history' },
|
||||
{ label: '过敏史', value: 'allergy_history' },
|
||||
{ label: '体征检查', value: 'physical_exam' },
|
||||
{ label: '辅助检查', value: 'auxiliary_exam' },
|
||||
];
|
||||
|
||||
export const STATUS_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 按性别/年龄判断病史字段是否展示
|
||||
* 月经史:仅女;婚育史:男>22 或 女>20;个人史:始终
|
||||
*/
|
||||
export function isHistoryFieldVisible(
|
||||
fieldCode: string,
|
||||
sex: number,
|
||||
age: number,
|
||||
): boolean {
|
||||
if (fieldCode === 'menstrual_history') {
|
||||
return Number(sex) === 2;
|
||||
}
|
||||
if (fieldCode === 'marital_history') {
|
||||
const s = Number(sex);
|
||||
const a = Number(age) || 0;
|
||||
return (s === 1 && a > 22) || (s === 2 && a > 20);
|
||||
}
|
||||
if (fieldCode === 'personal_history') {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 空病历结构 */
|
||||
export function emptyMedicalRecord(registerId = 0, storeId = 0) {
|
||||
return {
|
||||
id: 0,
|
||||
store_id: storeId,
|
||||
register_id: registerId,
|
||||
user_patient_id: 0,
|
||||
doctor_id: 0,
|
||||
chief_complaint: '',
|
||||
present_illness: '',
|
||||
tcm_disease: '',
|
||||
tcm_syndrome: '',
|
||||
tcm_method: '',
|
||||
tongue: '',
|
||||
pulse: '',
|
||||
tcm_case: '',
|
||||
treatment_advice: '',
|
||||
doctor_order: '',
|
||||
diagnosis: '',
|
||||
family_history: '',
|
||||
epidemic_history: '',
|
||||
past_history: '',
|
||||
menstrual_history: '',
|
||||
marital_history: '',
|
||||
personal_history: '',
|
||||
allergy_history: '',
|
||||
physical_exam: '',
|
||||
auxiliary_exam: '',
|
||||
temperature: 0,
|
||||
height: 0,
|
||||
weight: 0,
|
||||
respiratory_rate: 0,
|
||||
bp_systolic: 0,
|
||||
bp_diastolic: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS, STATUS_OPTIONS } from './constants';
|
||||
|
||||
/** 词条表单 */
|
||||
export function buildEntryFormProps(): VbenFormProps {
|
||||
return {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'field_code',
|
||||
label: '所属字段',
|
||||
rules: 'selectRequired',
|
||||
componentProps: { options: MEDICAL_RECORD_FIELD_OPTIONS },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'title',
|
||||
label: '词条名称',
|
||||
rules: 'required',
|
||||
componentProps: { placeholder: '列表展示名称' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'content',
|
||||
label: '词条正文',
|
||||
componentProps: { type: 'textarea', rows: 4, placeholder: '选中后写入文本区' },
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'remark',
|
||||
label: '临床意义',
|
||||
componentProps: { placeholder: '可选' },
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { min: 0 },
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { options: STATUS_OPTIONS },
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 接诊/病历常用标签请求缓存
|
||||
* 进页时多个 CommonDxOrderChips 会同时挂载,合并为单次请求再本地按字段切片
|
||||
*/
|
||||
import { getMyMedicalRecordEntryList } from '../api';
|
||||
import { getDoctorOrderList } from '#/views/doctor/doctor-reception/api';
|
||||
import { getDoctorMyDiseaseListApi } from '#/views/doctor/settings/api';
|
||||
|
||||
type OrderListResult = { my?: any[]; common?: any[] };
|
||||
|
||||
let entryAllPromise: Promise<any[]> | null = null;
|
||||
let diseasePromise: Promise<any[]> | null = null;
|
||||
let orderPromise: Promise<OrderListResult> | null = null;
|
||||
|
||||
/** 使缓存失效(新增/删除常用后调用) */
|
||||
export function invalidateCommonChipsCache(
|
||||
kind?: 'entry' | 'diagnosis' | 'doctor_order' | 'all',
|
||||
) {
|
||||
const k = kind || 'all';
|
||||
if (k === 'all' || k === 'entry') entryAllPromise = null;
|
||||
if (k === 'all' || k === 'diagnosis') diseasePromise = null;
|
||||
if (k === 'all' || k === 'doctor_order') orderPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次拉全量我的常用词条(不传 field_code)
|
||||
* 后端空 field 时上限 500,覆盖多字段常用标签
|
||||
*/
|
||||
export function fetchMyEntryListAll(limit = 500): Promise<any[]> {
|
||||
if (!entryAllPromise) {
|
||||
entryAllPromise = getMyMedicalRecordEntryList({ limit })
|
||||
.then((list) => (Array.isArray(list) ? list : []))
|
||||
.catch(() => []);
|
||||
}
|
||||
return entryAllPromise;
|
||||
}
|
||||
|
||||
/** 按 field_code 从全量缓存中切出该字段常用(最多 limit 条) */
|
||||
export async function fetchMyEntriesByField(
|
||||
fieldCode: string,
|
||||
limit = 12,
|
||||
): Promise<any[]> {
|
||||
const all = await fetchMyEntryListAll();
|
||||
const code = String(fieldCode || '').trim();
|
||||
if (!code) return [];
|
||||
return all
|
||||
.filter((item) => String(item?.field_code || '') === code)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export function fetchMyDiseaseListCached(): Promise<any[]> {
|
||||
if (!diseasePromise) {
|
||||
diseasePromise = getDoctorMyDiseaseListApi()
|
||||
.then((list) => (Array.isArray(list) ? list : []))
|
||||
.catch(() => []);
|
||||
}
|
||||
return diseasePromise;
|
||||
}
|
||||
|
||||
export function fetchDoctorOrderListCached(): Promise<OrderListResult> {
|
||||
if (!orderPromise) {
|
||||
orderPromise = getDoctorOrderList()
|
||||
.then((res) => res || { my: [], common: [] })
|
||||
.catch(() => ({ my: [], common: [] }));
|
||||
}
|
||||
return orderPromise;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS } from '../config/constants';
|
||||
|
||||
const THIN_BORDER: Partial<ExcelJS.Borders> = {
|
||||
top: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
left: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
bottom: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
right: { style: 'thin', color: { argb: 'FF000000' } },
|
||||
};
|
||||
|
||||
const HEADERS = [
|
||||
'字段编码',
|
||||
'词条名称',
|
||||
'正文',
|
||||
'临床意义',
|
||||
'排序',
|
||||
'状态',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 下载病历词条导入模板(含字段编码说明 sheet)
|
||||
*/
|
||||
export async function exportMedicalRecordEntryTemplate(): Promise<ArrayBuffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('词条导入');
|
||||
sheet.columns = [
|
||||
{ width: 18 },
|
||||
{ width: 20 },
|
||||
{ width: 36 },
|
||||
{ width: 24 },
|
||||
{ width: 10 },
|
||||
{ width: 10 },
|
||||
];
|
||||
const headerRow = sheet.addRow([...HEADERS]);
|
||||
headerRow.height = 28;
|
||||
headerRow.eachCell((cell) => {
|
||||
cell.font = { bold: true };
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFFFFF00' },
|
||||
};
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
cell.border = THIN_BORDER;
|
||||
});
|
||||
// 示例行:方便运营对照填写
|
||||
const sample = sheet.addRow([
|
||||
'chief_complaint',
|
||||
'头痛三天',
|
||||
'头痛三天,加重一天',
|
||||
'常见主诉示例',
|
||||
100,
|
||||
1,
|
||||
]);
|
||||
sample.eachCell((cell) => {
|
||||
cell.border = THIN_BORDER;
|
||||
cell.alignment = { vertical: 'middle' };
|
||||
});
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
|
||||
const tip = workbook.addWorksheet('字段编码说明');
|
||||
tip.columns = [{ width: 20 }, { width: 16 }];
|
||||
const tipHead = tip.addRow(['字段名称', '字段编码']);
|
||||
tipHead.eachCell((cell) => {
|
||||
cell.font = { bold: true };
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFD9EAD3' },
|
||||
};
|
||||
cell.border = THIN_BORDER;
|
||||
});
|
||||
MEDICAL_RECORD_FIELD_OPTIONS.forEach((opt) => {
|
||||
const row = tip.addRow([opt.label, opt.value]);
|
||||
row.eachCell((cell) => {
|
||||
cell.border = THIN_BORDER;
|
||||
});
|
||||
});
|
||||
tip.addRow([]);
|
||||
tip.addRow([
|
||||
'填写说明',
|
||||
'「字段编码」可填右侧英文编码,也可直接填左侧中文名(如:主诉)',
|
||||
]);
|
||||
tip.addRow(['状态说明', '1=启用,0=禁用;空默认启用']);
|
||||
tip.addRow(['拼音首拼', '由系统根据词条名称自动生成,无需填写']);
|
||||
tip.addRow([
|
||||
'按字段菜单导入',
|
||||
'在「主诉/既往史」等拆分页导入时,字段编码列可留空,系统自动归属当前字段',
|
||||
]);
|
||||
|
||||
// 给「字段编码」列加下拉,减少填错中文/编码混用导致整表无效
|
||||
const codeList = MEDICAL_RECORD_FIELD_OPTIONS.map((o) => o.value).join(',');
|
||||
// Excel 数据验证公式长度有限;字段不多时可直接列枚举
|
||||
sheet.dataValidations.add('A2:A2001', {
|
||||
type: 'list',
|
||||
allowBlank: true,
|
||||
formulae: [`"${codeList}"`],
|
||||
showErrorMessage: true,
|
||||
errorTitle: '字段编码',
|
||||
error: '请从下拉选择英文编码,或手动填写中文名(如:主诉)',
|
||||
});
|
||||
|
||||
return workbook.xlsx.writeBuffer() as Promise<ArrayBuffer>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 病历本地草稿(按挂号 ID)
|
||||
* 刷新页面后优先用草稿覆盖接口空/旧内容,避免未点「保存病历」就丢失
|
||||
*/
|
||||
const PREFIX = 'medicalRecordDraft_';
|
||||
const TAB_PREFIX = 'rxMrTab_';
|
||||
|
||||
export function medicalRecordDraftKey(registerId: number | string) {
|
||||
return `${PREFIX}${registerId}`;
|
||||
}
|
||||
|
||||
/** 处方|病历 Tab 本地键;prefix 用于 chat-shop 等场景隔离 */
|
||||
export function rxMrTabKey(registerId: number | string, prefix = '') {
|
||||
return `${prefix}${TAB_PREFIX}${registerId}`;
|
||||
}
|
||||
|
||||
export function saveMedicalRecordDraft(
|
||||
registerId: number | string,
|
||||
data: Record<string, any>,
|
||||
) {
|
||||
if (!registerId) return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
medicalRecordDraftKey(registerId),
|
||||
JSON.stringify({ ...data, _savedAt: Date.now() }),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('保存病历草稿失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadMedicalRecordDraft(
|
||||
registerId: number | string,
|
||||
): Record<string, any> | null {
|
||||
if (!registerId) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(medicalRecordDraftKey(registerId));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMedicalRecordDraft(registerId?: number | string) {
|
||||
if (!registerId) return;
|
||||
localStorage.removeItem(medicalRecordDraftKey(registerId));
|
||||
}
|
||||
|
||||
export function saveRxMrTab(
|
||||
registerId: number | string,
|
||||
tab: 'rx' | 'mr',
|
||||
prefix = '',
|
||||
) {
|
||||
if (!registerId) return;
|
||||
localStorage.setItem(rxMrTabKey(registerId, prefix), tab);
|
||||
}
|
||||
|
||||
export function loadRxMrTab(
|
||||
registerId: number | string,
|
||||
prefix = '',
|
||||
): 'rx' | 'mr' | null {
|
||||
if (!registerId) return null;
|
||||
const v = localStorage.getItem(rxMrTabKey(registerId, prefix));
|
||||
if (v === 'rx' || v === 'mr') return v;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS } from '../config/constants';
|
||||
|
||||
const MAX_ROWS = 2000;
|
||||
|
||||
export type ParsedMedicalRecordEntryRow = {
|
||||
field_code: string;
|
||||
title: string;
|
||||
content: string;
|
||||
remark: string;
|
||||
sort: number;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type ParseMedicalRecordEntryResult = {
|
||||
items: ParsedMedicalRecordEntryRow[];
|
||||
invalidCount: number;
|
||||
rowCount: number;
|
||||
/** 首条无效原因,便于前端提示(如填了中文名未识别等) */
|
||||
firstInvalidReason?: string;
|
||||
};
|
||||
|
||||
export type ParseMedicalRecordEntryOptions = {
|
||||
/**
|
||||
* 按字段拆分菜单导入时传入:字段编码列可留空,自动落到当前字段
|
||||
* 为什么需要:运营在「主诉词条」页导入时常不填字段编码,旧逻辑会整行判无效
|
||||
*/
|
||||
defaultFieldCode?: string;
|
||||
};
|
||||
|
||||
function normalizeCell(value: ExcelJS.CellValue): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// 富文本:Excel/WPS 局部加粗后常见 { richText: [...] }
|
||||
if ('richText' in value && Array.isArray((value as any).richText)) {
|
||||
return (value as any).richText
|
||||
.map((p: { text?: string }) => p?.text ?? '')
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
// 超链接 / 带 text 的复合值
|
||||
if ('text' in value) {
|
||||
return String((value as any).text ?? '').trim();
|
||||
}
|
||||
// 公式结果
|
||||
if ('result' in value) {
|
||||
return normalizeCell((value as any).result as ExcelJS.CellValue);
|
||||
}
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? String(value) : String(value).trim();
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '1' : '0';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string): string {
|
||||
return value.replace(/\s+/g, '').replace(/\u3000/g, '');
|
||||
}
|
||||
|
||||
const FIELD_ALIASES = ['字段编码', 'field_code', '字段'];
|
||||
const TITLE_ALIASES = ['词条名称', '名称', 'title'];
|
||||
const CONTENT_ALIASES = ['正文', '内容', 'content'];
|
||||
const REMARK_ALIASES = ['临床意义', '备注', 'remark'];
|
||||
const SORT_ALIASES = ['排序', 'sort'];
|
||||
const STATUS_ALIASES = ['状态', 'status'];
|
||||
|
||||
const VALID_FIELDS = new Set(MEDICAL_RECORD_FIELD_OPTIONS.map((o) => o.value));
|
||||
|
||||
/** 中文名 / 英文编码 → 标准 field_code(运营常按「字段编码说明」里的中文名填写) */
|
||||
const FIELD_LABEL_TO_CODE = new Map<string, string>(
|
||||
MEDICAL_RECORD_FIELD_OPTIONS.flatMap((o) => [
|
||||
[normalizeHeader(o.value), o.value],
|
||||
[normalizeHeader(o.label), o.value],
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* 把单元格里的字段编码或中文名解析成标准 code;无法识别则返回空串
|
||||
*/
|
||||
function resolveFieldCode(raw: string): string {
|
||||
const key = normalizeHeader(raw);
|
||||
if (!key) return '';
|
||||
return FIELD_LABEL_TO_CODE.get(key) || (VALID_FIELDS.has(raw) ? raw : '');
|
||||
}
|
||||
|
||||
function buildHeaderIndexMap(headerRow: ExcelJS.Row): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
headerRow.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
||||
const key = normalizeHeader(normalizeCell(cell.value));
|
||||
if (key) {
|
||||
map[key] = colNumber;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function pickColumn(
|
||||
headerMap: Record<string, number>,
|
||||
aliases: string[],
|
||||
): number | undefined {
|
||||
for (const alias of aliases) {
|
||||
const col = headerMap[normalizeHeader(alias)];
|
||||
if (col) return col;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getCellText(row: ExcelJS.Row, col?: number): string {
|
||||
if (!col) return '';
|
||||
return normalizeCell(row.getCell(col).value);
|
||||
}
|
||||
|
||||
function getCellInt(row: ExcelJS.Row, col?: number, fallback = 0): number {
|
||||
if (!col) return fallback;
|
||||
const raw = normalizeCell(row.getCell(col).value);
|
||||
if (raw === '') return fallback;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
||||
}
|
||||
|
||||
function rowIsEmpty(row: ExcelJS.Row): boolean {
|
||||
let hasValue = false;
|
||||
row.eachCell({ includeEmpty: false }, (cell) => {
|
||||
if (normalizeCell(cell.value) !== '') {
|
||||
hasValue = true;
|
||||
}
|
||||
});
|
||||
return !hasValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先取「词条导入」sheet;避免用户改完后激活了「字段编码说明」导致读错表
|
||||
*/
|
||||
function pickImportSheet(workbook: ExcelJS.Workbook): ExcelJS.Worksheet | undefined {
|
||||
const byName = workbook.worksheets.find((s) => s.name === '词条导入');
|
||||
return byName || workbook.worksheets[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析词条导入 Excel(首行为表头)
|
||||
* 字段编码列支持英文 code 或中文名;按字段页可传 defaultFieldCode 允许留空
|
||||
*/
|
||||
export async function parseMedicalRecordEntryExcelBuffer(
|
||||
buffer: ArrayBuffer,
|
||||
options: ParseMedicalRecordEntryOptions = {},
|
||||
): Promise<ParseMedicalRecordEntryResult> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
const sheet = pickImportSheet(workbook);
|
||||
if (!sheet) {
|
||||
return { items: [], invalidCount: 0, rowCount: 0 };
|
||||
}
|
||||
const defaultFieldCode = resolveFieldCode(options.defaultFieldCode || '');
|
||||
const headerRow = sheet.getRow(1);
|
||||
const headerMap = buildHeaderIndexMap(headerRow);
|
||||
const fieldCol = pickColumn(headerMap, FIELD_ALIASES);
|
||||
const titleCol = pickColumn(headerMap, TITLE_ALIASES);
|
||||
const contentCol = pickColumn(headerMap, CONTENT_ALIASES);
|
||||
const remarkCol = pickColumn(headerMap, REMARK_ALIASES);
|
||||
const sortCol = pickColumn(headerMap, SORT_ALIASES);
|
||||
const statusCol = pickColumn(headerMap, STATUS_ALIASES);
|
||||
// 无默认字段时必须有「字段编码」列;有默认字段时仅要求「词条名称」
|
||||
if (!titleCol) {
|
||||
throw new Error('模板表头缺少「词条名称」列,请重新下载模板');
|
||||
}
|
||||
if (!fieldCol && !defaultFieldCode) {
|
||||
throw new Error('模板表头缺少「字段编码」列,请重新下载模板');
|
||||
}
|
||||
const items: ParsedMedicalRecordEntryRow[] = [];
|
||||
let invalidCount = 0;
|
||||
let rowCount = 0;
|
||||
let firstInvalidReason: string | undefined;
|
||||
const maxRow = Math.min(sheet.rowCount || 0, MAX_ROWS + 1);
|
||||
for (let rowNumber = 2; rowNumber <= maxRow; rowNumber++) {
|
||||
const row = sheet.getRow(rowNumber);
|
||||
if (rowIsEmpty(row)) continue;
|
||||
rowCount++;
|
||||
if (items.length >= MAX_ROWS) break;
|
||||
const rawField = getCellText(row, fieldCol);
|
||||
const title = getCellText(row, titleCol);
|
||||
let field_code = resolveFieldCode(rawField);
|
||||
if (!field_code && defaultFieldCode) {
|
||||
field_code = defaultFieldCode;
|
||||
}
|
||||
if (!title) {
|
||||
invalidCount++;
|
||||
if (!firstInvalidReason) {
|
||||
firstInvalidReason = `第${rowNumber}行:词条名称为空`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!field_code) {
|
||||
invalidCount++;
|
||||
if (!firstInvalidReason) {
|
||||
firstInvalidReason = rawField
|
||||
? `第${rowNumber}行:字段「${rawField}」无法识别(请填英文编码如 chief_complaint,或中文名如「主诉」)`
|
||||
: `第${rowNumber}行:字段编码为空(请对照「字段编码说明」填写,或从对应字段菜单页导入)`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const statusRaw = getCellInt(row, statusCol, 1);
|
||||
items.push({
|
||||
field_code,
|
||||
title,
|
||||
content: getCellText(row, contentCol) || title,
|
||||
remark: getCellText(row, remarkCol),
|
||||
sort: getCellInt(row, sortCol, 0),
|
||||
status: statusRaw === 0 ? 0 : 1,
|
||||
});
|
||||
}
|
||||
return { items, invalidCount, rowCount, firstInvalidReason };
|
||||
}
|
||||
@@ -96,6 +96,8 @@ const openPrescriptionModule = (id) => {
|
||||
prescriptionModalApi.setData({
|
||||
registerId: targetRegisterId,
|
||||
onPrescriptionSent: handlePrescriptionSent,
|
||||
storagePrefix: 'onlineConsultation-',
|
||||
enableMedicalRecord: true,
|
||||
});
|
||||
prescriptionModalApi.open();
|
||||
};
|
||||
@@ -335,6 +337,8 @@ const handleAddPatientProductsToPrescription = async () => {
|
||||
prescriptionModalApi.setData({
|
||||
registerId: prescriptionStore.currentRegisterId,
|
||||
onPrescriptionSent: handlePrescriptionSent,
|
||||
storagePrefix: 'onlineConsultation-',
|
||||
enableMedicalRecord: true,
|
||||
});
|
||||
prescriptionModalApi.open();
|
||||
} else {
|
||||
|
||||
@@ -45,6 +45,11 @@ import {
|
||||
saveServicePriceApi,
|
||||
} from '#/views/doctor/settings/api';
|
||||
import type { CommonPrescriptionResponse } from '#/views/doctor/settings/api';
|
||||
import {
|
||||
deleteMyMedicalRecordEntry,
|
||||
getMyMedicalRecordEntryList,
|
||||
} from '#/views/doctor/medical-record/api';
|
||||
import { MEDICAL_RECORD_FIELD_OPTIONS } from '#/views/doctor/medical-record/config/constants';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
|
||||
const userInfoStore = useUserStore();
|
||||
@@ -63,6 +68,7 @@ const activeTabBar = ref(savedTab ? Number(savedTab) : 1);
|
||||
* - 3: 常用方(新增)
|
||||
* - 4: 常用医嘱
|
||||
* - 5: 常用诊断
|
||||
* - 6: 常用词条
|
||||
*/
|
||||
const tabBar = [
|
||||
{
|
||||
@@ -81,6 +87,10 @@ const tabBar = [
|
||||
value: 5,
|
||||
label: '常用诊断',
|
||||
},
|
||||
{
|
||||
value: 6,
|
||||
label: '常用词条',
|
||||
},
|
||||
];
|
||||
|
||||
// 格式化日期
|
||||
@@ -127,6 +137,28 @@ const getDoctorMyDiseaseList = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/** 常用词条(医生维度) */
|
||||
const myEntryCommonList = ref<any[]>([]);
|
||||
const getMyEntryCommonList = () => {
|
||||
getMyMedicalRecordEntryList({ limit: 200 })
|
||||
.then((res) => {
|
||||
myEntryCommonList.value = Array.isArray(res) ? res : [];
|
||||
})
|
||||
.catch(() => {
|
||||
myEntryCommonList.value = [];
|
||||
});
|
||||
};
|
||||
const fieldCodeLabel = (code: string) => {
|
||||
const hit = MEDICAL_RECORD_FIELD_OPTIONS.find((o) => o.value === code);
|
||||
return hit?.label || code || '-';
|
||||
};
|
||||
const removeMyEntryCommon = (id: number) => {
|
||||
deleteMyMedicalRecordEntry(id).then(() => {
|
||||
notification.success({ message: '已移除常用词条' });
|
||||
getMyEntryCommonList();
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 常用方相关 ====================
|
||||
|
||||
/**
|
||||
@@ -282,6 +314,11 @@ const tabBarChange = () => {
|
||||
getDoctorMyDiseaseList();
|
||||
break;
|
||||
}
|
||||
case 6: {
|
||||
// 常用词条
|
||||
getMyEntryCommonList();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
// 初始化时加载基础数据和当前tab对应的数据
|
||||
@@ -807,6 +844,29 @@ const handleViewSignature = () => {
|
||||
{{ item.disease.name }}
|
||||
</Button>
|
||||
</Card>
|
||||
<Card v-else-if="data && activeTabBar === 6" title="常用词条">
|
||||
<div class="mb-3 text-sm text-gray-500">
|
||||
在病历接诊气泡中点「加常用」可收藏;此处可移除。点击标签本身无操作。
|
||||
</div>
|
||||
<Empty v-if="!myEntryCommonList.length" description="暂无常用词条" />
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<Tag
|
||||
v-for="item in myEntryCommonList"
|
||||
:key="item.id"
|
||||
color="processing"
|
||||
class="mb-2"
|
||||
>
|
||||
<span class="mr-1 text-xs opacity-70">{{ fieldCodeLabel(item.field_code) }}</span>
|
||||
{{ item.title || item.content }}
|
||||
<Popconfirm
|
||||
title="确定从常用词条中移除吗?"
|
||||
@confirm="removeMyEntryCommon(item.id)"
|
||||
>
|
||||
<a class="ml-2 text-red-500" @click.stop>移除</a>
|
||||
</Popconfirm>
|
||||
</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 常用方编辑弹窗 -->
|
||||
<EditCommonPrescriptionModals />
|
||||
|
||||
101
apps/web-antd/src/views/doctor/utils/aiFeatureGate.ts
Normal file
101
apps/web-antd/src/views/doctor/utils/aiFeatureGate.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* AI 功能门闸:拉取醒目提示 + 首次知情同意
|
||||
* 同意按 VIP 功能码隔离,签署后该功能不再弹
|
||||
*/
|
||||
import { h } from 'vue';
|
||||
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export type AiFeatureCode = 'ai_medical_record' | 'ai_prescription';
|
||||
|
||||
export interface AiFeatureGate {
|
||||
id: number;
|
||||
feature_code: string;
|
||||
record_label: string;
|
||||
disclaimer_text: string;
|
||||
consent_text: string;
|
||||
consented: boolean;
|
||||
}
|
||||
|
||||
/** 拉取门闸信息 */
|
||||
export async function fetchAiFeatureGate(
|
||||
featureCode: AiFeatureCode,
|
||||
): Promise<AiFeatureGate> {
|
||||
const res = await requestClient.get<any>('doctor-reception/ai-feature-gate', {
|
||||
params: { feature_code: featureCode },
|
||||
});
|
||||
const data = res?.data || res || {};
|
||||
return {
|
||||
id: Number(data.id || 0),
|
||||
feature_code: String(data.feature_code || featureCode),
|
||||
record_label: String(data.record_label || ''),
|
||||
disclaimer_text: String(data.disclaimer_text || ''),
|
||||
consent_text: String(data.consent_text || ''),
|
||||
consented: !!data.consented,
|
||||
};
|
||||
}
|
||||
|
||||
/** 签署知情同意 */
|
||||
export async function agreeAiFeatureConsent(
|
||||
featureCode: AiFeatureCode,
|
||||
storeId?: number,
|
||||
): Promise<void> {
|
||||
await requestClient.post<any>('doctor-reception/ai-agree-consent', {
|
||||
feature_code: featureCode,
|
||||
store_id: storeId || 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开 AI 功能前:未签署则弹子模态框;拒绝则返回 null
|
||||
*/
|
||||
export async function ensureAiFeatureConsent(options: {
|
||||
featureCode: AiFeatureCode;
|
||||
storeId?: number;
|
||||
}): Promise<AiFeatureGate | null> {
|
||||
const gate = await fetchAiFeatureGate(options.featureCode);
|
||||
if (gate.consented) {
|
||||
return gate;
|
||||
}
|
||||
const ok = await new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: 'AI 使用知情同意',
|
||||
width: 560,
|
||||
centered: true,
|
||||
okText: '我已知悉并同意',
|
||||
cancelText: '取消',
|
||||
content: () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
maxHeight: '50vh',
|
||||
overflow: 'auto',
|
||||
lineHeight: '1.6',
|
||||
fontSize: '13px',
|
||||
},
|
||||
},
|
||||
gate.consent_text || '请先阅读并同意 AI 使用规范',
|
||||
),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await agreeAiFeatureConsent(options.featureCode, options.storeId);
|
||||
message.success('已记录知情同意');
|
||||
resolve(true);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || e?.msg || '签署失败');
|
||||
resolve(false);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
if (!ok) {
|
||||
return null;
|
||||
}
|
||||
return { ...gate, consented: true };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
@@ -10,11 +10,14 @@ import {
|
||||
Bell,
|
||||
CalendarClock,
|
||||
Clock,
|
||||
Crown,
|
||||
Megaphone,
|
||||
User,
|
||||
} from 'lucide-vue-next';
|
||||
import { MdPreview } from 'md-editor-v3';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||
import { formatTimeToRelative } from '#/util/tool';
|
||||
import { getNoticeDetailApi } from '#/views/notice/api';
|
||||
|
||||
@@ -22,7 +25,6 @@ import UnreadUserList from './UnreadUserList.vue';
|
||||
|
||||
// eslint-disable-next-line n/no-extraneous-import
|
||||
import 'md-editor-v3/lib/style.css';
|
||||
import {Icon} from "#/components/icon";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -34,7 +36,7 @@ const error = ref(null);
|
||||
|
||||
const { setTabTitle } = useTabs();
|
||||
|
||||
// 消息类型定义
|
||||
// 消息类型定义(含 VIP 通知 type=3)
|
||||
const messageTypes = [
|
||||
{
|
||||
id: 0,
|
||||
@@ -69,8 +71,40 @@ const messageTypes = [
|
||||
borderColor: 'border-emerald-200',
|
||||
darkBorderColor: 'dark:border-emerald-800',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'VIP通知',
|
||||
icon: Crown,
|
||||
color: 'text-violet-500',
|
||||
darkColor: 'dark:text-violet-400',
|
||||
bgColor: 'bg-violet-50',
|
||||
darkBgColor: 'dark:bg-violet-900/30',
|
||||
borderColor: 'border-violet-200',
|
||||
darkBorderColor: 'dark:border-violet-800',
|
||||
},
|
||||
];
|
||||
|
||||
/** 解析 VIP 站内信 content JSON */
|
||||
const vipContent = computed(() => {
|
||||
if (!notice.value || Number(notice.value.type) !== 3) return null;
|
||||
const raw = notice.value.content;
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'object') return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const vipSceneLabel = computed(() => {
|
||||
const type = vipContent.value?.type;
|
||||
if (type === 'vip_open_success') return '开通成功';
|
||||
if (type === 'vip_expiring') return '即将到期';
|
||||
if (type === 'vip_expired') return '已到期';
|
||||
return 'VIP通知';
|
||||
});
|
||||
|
||||
// 获取通知详情
|
||||
const getNoticeDetail = async () => {
|
||||
loading.value = true;
|
||||
@@ -232,9 +266,36 @@ const showUnreadList = () => {
|
||||
class="mb-6 h-px w-full bg-gray-100 transition-colors dark:bg-gray-700"
|
||||
></div>
|
||||
|
||||
<!-- VIP 通知:解析 content JSON,展示时效徽标 + 等级徽标 -->
|
||||
<div
|
||||
v-if="vipContent"
|
||||
class="rounded-lg bg-violet-50/80 p-4 transition-colors dark:bg-violet-900/20"
|
||||
>
|
||||
<div class="mb-3 text-sm font-medium text-violet-700 dark:text-violet-300">
|
||||
{{ vipSceneLabel }}
|
||||
</div>
|
||||
<div class="mb-3 flex items-center gap-4">
|
||||
<VipBadgeCombo
|
||||
size="lg"
|
||||
:badge-url="vipContent.badge_url"
|
||||
:duration-badge-url="vipContent.duration_badge_url"
|
||||
:level-name="vipContent.level_name"
|
||||
:duration-label="vipContent.duration_label"
|
||||
/>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
<div>{{ vipContent.duration_label || '-' }} · {{ vipContent.level_name || '-' }}({{ vipContent.level_code || '-' }})</div>
|
||||
<div class="mt-1 text-gray-500">门店:{{ vipContent.store_name || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
原价 ¥{{ vipContent.original_price || '0.00' }}
|
||||
· 实付 ¥{{ vipContent.pay_amount || '0.00' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div
|
||||
v-if="notice.edit_type === 0"
|
||||
v-else-if="notice.edit_type === 0"
|
||||
class="rounded-lg bg-gray-50 p-4 transition-colors dark:bg-gray-700"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -18,3 +18,13 @@ export function canEditDoctorServiceUser(userInfo: PlatformAdminUserInfo): boole
|
||||
export function canManageStoreBankCard(userInfo: PlatformAdminUserInfo): boolean {
|
||||
return isPlatformSuperAdmin(userInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否可在诊所页/诊所详情发起「添加医生」操作
|
||||
* 角色:1 超级管理员 / 2 系统管理员 / 3 省经理 / 4 市经理 / 6 业务员
|
||||
* 数据范围由后端 StoreService::assertCanViewStore 兜底校验
|
||||
*/
|
||||
export function canAddStoreDoctor(userInfo: PlatformAdminUserInfo): boolean {
|
||||
const roleId = Number(userInfo?.role_id ?? userInfo?.roles?.id);
|
||||
return [1, 2, 3, 4, 6].includes(roleId);
|
||||
}
|
||||
|
||||
33
apps/web-antd/src/views/system/ai/api-key/api/index.ts
Normal file
33
apps/web-antd/src/views/system/ai/api-key/api/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** AI API Key 管理 API */
|
||||
const prefix = 'ai-api-key/';
|
||||
|
||||
export async function getAiApiKeyList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function getAiApiKeyDetail(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
export async function getAiApiKeyOption(data?: { platform_id?: number }) {
|
||||
return requestClient.get<any>(`${prefix}option`, { params: data || {} });
|
||||
}
|
||||
|
||||
export async function createAiApiKey(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateAiApiKey(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteAiApiKey(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
/** 设为平台默认密钥 */
|
||||
export async function setAiApiKeyDefault(data: { id: number }) {
|
||||
return requestClient.post<any>(`${prefix}set-default`, data);
|
||||
}
|
||||
238
apps/web-antd/src/views/system/ai/api-key/components/modal.vue
Normal file
238
apps/web-antd/src/views/system/ai/api-key/components/modal.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI API Key 新增/编辑
|
||||
* 所属平台用带 Logo 的卡片单选(与系统配置 AI 卡片一致),敏感字段 api_key 走传输加密
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Radio, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import { getAiPlatformConfigOptions } from '../../platform/api';
|
||||
import {
|
||||
createAiApiKey,
|
||||
getAiApiKeyDetail,
|
||||
updateAiApiKey,
|
||||
} from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
interface PlatformCard {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
description: string;
|
||||
default_model: string;
|
||||
}
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
const onSuccess = ref<null | (() => void)>(null);
|
||||
const lockPlatformId = ref(false);
|
||||
const platforms = ref<PlatformCard[]>([]);
|
||||
const selectedPlatformId = ref<number | undefined>(undefined);
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
/** 点选平台卡片:同步到隐藏字段 platform_id */
|
||||
function onSelectPlatform(id: number) {
|
||||
if (lockPlatformId.value) return;
|
||||
selectedPlatformId.value = id;
|
||||
formApi.setValues({ platform_id: id });
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
if (!selectedPlatformId.value) {
|
||||
message.warning('请选择所属 AI 平台');
|
||||
return;
|
||||
}
|
||||
formApi.setValues({ platform_id: selectedPlatformId.value });
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
values.platform_id = selectedPlatformId.value;
|
||||
// 编辑时允许不改密钥:空串不传
|
||||
if (isUpdate.value && !String(values.api_key || '').trim()) {
|
||||
delete values.api_key;
|
||||
} else if (!isUpdate.value && !String(values.api_key || '').trim()) {
|
||||
message.warning('请填写 API Key');
|
||||
return;
|
||||
}
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const api = isUpdate.value ? updateAiApiKey : createAiApiKey;
|
||||
await api(values);
|
||||
message.success('保存成功');
|
||||
gridApi.value?.query?.();
|
||||
onSuccess.value?.();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
const openData = isOpen ? modalApi.getData<Record<string, any>>() || {} : {};
|
||||
gridApi.value = isOpen ? openData.gridApi : null;
|
||||
onSuccess.value = isOpen
|
||||
? (typeof openData.onSuccess === 'function' ? openData.onSuccess : null)
|
||||
: null;
|
||||
lockPlatformId.value = isOpen ? !!openData.lockPlatformId : false;
|
||||
if (!isOpen) {
|
||||
formApi.resetFields();
|
||||
selectedPlatformId.value = undefined;
|
||||
platforms.value = [];
|
||||
return;
|
||||
}
|
||||
// 拉取启用中的平台卡片(含 logo)
|
||||
try {
|
||||
const res = await getAiPlatformConfigOptions();
|
||||
const data = res?.data || res || {};
|
||||
platforms.value = Array.isArray(data.platforms) ? data.platforms : [];
|
||||
} catch {
|
||||
platforms.value = [];
|
||||
}
|
||||
const { values, update } = openData;
|
||||
isUpdate.value = !!update;
|
||||
if (values && update && values.id) {
|
||||
try {
|
||||
const detail = await getAiApiKeyDetail(values.id);
|
||||
const row = detail?.data || detail || {};
|
||||
const pid = Number(row.platform_id || 0) || undefined;
|
||||
selectedPlatformId.value = pid;
|
||||
formApi.setValues({
|
||||
...row,
|
||||
platform_id: pid,
|
||||
api_key: row.api_key || '',
|
||||
});
|
||||
} catch {
|
||||
const pid = Number(values.platform_id || 0) || undefined;
|
||||
selectedPlatformId.value = pid;
|
||||
formApi.setValues({ ...values, platform_id: pid, api_key: '' });
|
||||
}
|
||||
} else {
|
||||
formApi.resetFields();
|
||||
const presetPid = Number(openData.platform_id || values?.platform_id || 0) || undefined;
|
||||
const firstId = presetPid || platforms.value[0]?.id;
|
||||
selectedPlatformId.value = firstId;
|
||||
formApi.setValues({
|
||||
sort: 0,
|
||||
status: 1,
|
||||
api_key: '',
|
||||
platform_id: firstId,
|
||||
is_default: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}AI密钥`" class="w-[720px]">
|
||||
<div v-if="!lockPlatformId" class="ai-key-modal mb-4">
|
||||
<div class="mb-2 text-sm font-medium text-[hsl(var(--foreground))]">
|
||||
所属平台
|
||||
</div>
|
||||
<Radio.Group
|
||||
:value="selectedPlatformId"
|
||||
class="w-full"
|
||||
@update:value="(v: number) => onSelectPlatform(v)"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="p in platforms"
|
||||
:key="p.id"
|
||||
class="ai-plat-card"
|
||||
:class="{ 'ai-plat-card--active': selectedPlatformId === p.id }"
|
||||
@click.prevent="onSelectPlatform(p.id)"
|
||||
>
|
||||
<Radio :value="p.id" class="!hidden" />
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="ai-plat-logo">
|
||||
<img
|
||||
v-if="p.logo"
|
||||
:src="p.logo"
|
||||
:alt="p.name"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
<span v-else class="ai-plat-logo-fallback">
|
||||
{{ (p.name || p.code || '?').slice(0, 1) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-[hsl(var(--foreground))]">
|
||||
{{ p.name }}
|
||||
</div>
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{{ p.code }} · {{ p.default_model }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 line-clamp-2 text-xs text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
{{ p.description || '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
<div
|
||||
v-if="!platforms.length"
|
||||
class="mt-2 text-xs text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
暂无启用中的 AI 平台,请先到「AI平台管理」添加
|
||||
</div>
|
||||
</div>
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-plat-card {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
background: hsl(var(--card));
|
||||
color: hsl(var(--foreground));
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
.ai-plat-card:hover {
|
||||
border-color: hsl(var(--primary) / 0.55);
|
||||
}
|
||||
.ai-plat-card--active {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.22);
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
.ai-plat-logo {
|
||||
display: flex;
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.55);
|
||||
padding: 5px;
|
||||
}
|
||||
.ai-plat-logo-fallback {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
/** 复用平台状态选项 */
|
||||
export { STATUS_OPTIONS } from '../../platform/config/constants';
|
||||
84
apps/web-antd/src/views/system/ai/api-key/config/form.ts
Normal file
84
apps/web-antd/src/views/system/ai/api-key/config/form.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { STATUS_OPTIONS } from './constants';
|
||||
|
||||
/**
|
||||
* AI 密钥新增/编辑表单
|
||||
* 所属平台在弹窗外用卡片单选,不走下拉
|
||||
*/
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
// platform_id 由上方卡片单选写入,隐藏字段仅用于提交
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'platform_id',
|
||||
label: '所属平台',
|
||||
dependencies: { show: false, triggerFields: ['platform_id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '密钥别名',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { placeholder: '如:生产环境主密钥' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
fieldName: 'api_key',
|
||||
label: 'API Key',
|
||||
help: '新增必填;编辑留空表示不修改。库内 AES 加密,传输亦加密',
|
||||
componentProps: { placeholder: 'Bearer 用的密钥明文' },
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
formItemClass: 'col-span-3',
|
||||
componentProps: { options: STATUS_OPTIONS },
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
formItemClass: 'col-span-3',
|
||||
componentProps: { min: 0 },
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'is_default',
|
||||
label: '设为默认',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '否', value: 0 },
|
||||
{ label: '是', value: 1 },
|
||||
],
|
||||
},
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
componentProps: { type: 'textarea', rows: 2 },
|
||||
defaultValue: '',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
193
apps/web-antd/src/views/system/ai/api-key/index.vue
Normal file
193
apps/web-antd/src/views/system/ai/api-key/index.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI API Key 管理:列表脱敏,详情/编辑走传输加密
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Tag, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { getAiPlatformOption } from '../platform/api';
|
||||
import { deleteAiApiKey, getAiApiKeyList } from './api';
|
||||
import FormModal from './components/modal.vue';
|
||||
import { STATUS_OPTIONS } from './config/constants';
|
||||
|
||||
defineOptions({ name: 'AiApiKey' });
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const platformOptions = ref<{ label: string; value: number }[]>([]);
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'platform_id',
|
||||
label: '平台',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: platformOptions,
|
||||
placeholder: '全部平台',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '别名',
|
||||
componentProps: { placeholder: '密钥别名' },
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
componentProps: { allowClear: true, options: STATUS_OPTIONS },
|
||||
},
|
||||
],
|
||||
},
|
||||
gridOptions: {
|
||||
checkboxConfig: { highlight: true, labelField: '' },
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'platform_name', title: '平台', minWidth: 120 },
|
||||
{ field: 'platform_code', title: '编码', width: 100 },
|
||||
{ field: 'name', title: '别名', minWidth: 140 },
|
||||
{ field: 'api_key_mask', title: '密钥', minWidth: 160 },
|
||||
{ field: 'status', title: '状态', width: 90, slots: { default: 'status' } },
|
||||
{ field: 'sort', title: '排序', width: 80 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||
{ title: '操作', slots: { default: 'action' }, width: 160, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any, formValues: any) => {
|
||||
return await getAiApiKeyList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
slots: { buttons: 'toolbar-buttons' },
|
||||
},
|
||||
},
|
||||
gridEvents: {
|
||||
checkboxChange() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
} as VxeGridListeners<any>,
|
||||
});
|
||||
|
||||
const [FormModalComp, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModal,
|
||||
});
|
||||
|
||||
const showModal = (data: any = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
values: data,
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
});
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
const handleDelete = async (ids: number[]) => {
|
||||
await deleteAiApiKey({ ids });
|
||||
message.success('删除成功');
|
||||
gridApi.query();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const opts = await getAiPlatformOption();
|
||||
platformOptions.value = Array.isArray(opts) ? opts : opts?.data || [];
|
||||
gridApi.formApi?.updateSchema?.([
|
||||
{
|
||||
fieldName: 'platform_id',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: platformOptions.value,
|
||||
placeholder: '全部平台',
|
||||
},
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModalComp />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
onClick: () => showModal({}, false),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="
|
||||
hasTopTableDropDownActions
|
||||
? [
|
||||
{
|
||||
label: '批量删除',
|
||||
popConfirm: {
|
||||
title: '确认删除选中项?',
|
||||
confirm: () => {
|
||||
const rows = gridApi.grid.getCheckboxRecords();
|
||||
handleDelete(rows.map((r: any) => r.id));
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status === 1 ? 'success' : 'default'">
|
||||
{{ row.status_txt || (row.status === 1 ? '启用' : '禁用') }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
popConfirm: {
|
||||
title: '确认删除?',
|
||||
confirm: () => handleDelete([row.id]),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
22
apps/web-antd/src/views/system/ai/generation/api/index.ts
Normal file
22
apps/web-antd/src/views/system/ai/generation/api/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* AI 生成记录管理 API
|
||||
* 路由前缀 ai-generation/
|
||||
*/
|
||||
const prefix = 'ai-generation/';
|
||||
|
||||
/** 分页列表 */
|
||||
export async function getAiGenerationList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
/** 详情 */
|
||||
export async function getAiGenerationDetail(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
/** 用量统计(与 list 相同筛选) */
|
||||
export async function getAiGenerationUsageStats(data?: Record<string, any>) {
|
||||
return requestClient.get<any>(`${prefix}usage-stats`, { params: data || {} });
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 生成记录详情弹窗
|
||||
* 展示 input_snapshot / result_json / 错误与耗时,便于排障
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Descriptions, Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { getAiGenerationDetail } from '../api';
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = ref<Record<string, any>>({});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '生成记录详情',
|
||||
class: 'w-[820px]',
|
||||
showConfirmButton: false,
|
||||
cancelText: '关闭',
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
detail.value = {};
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{ id: number }>();
|
||||
const id = Number(data?.id || 0);
|
||||
if (!id) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getAiGenerationDetail(id);
|
||||
detail.value = res?.data || res || {};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
const n = Number(ms || 0);
|
||||
if (n <= 0) return '—';
|
||||
if (n < 1000) return `${n} ms`;
|
||||
return `${(n / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
function statusColor(status: number) {
|
||||
if (status === 1) return 'success';
|
||||
if (status === 2) return 'error';
|
||||
return 'processing';
|
||||
}
|
||||
|
||||
function prettyJson(v: any) {
|
||||
try {
|
||||
return JSON.stringify(v ?? {}, null, 2);
|
||||
} catch {
|
||||
return String(v ?? '');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal>
|
||||
<Spin :spinning="loading">
|
||||
<Descriptions bordered size="small" :column="2" class="text-sm">
|
||||
<Descriptions.Item label="ID">{{ detail.id || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag :color="statusColor(Number(detail.status))">
|
||||
{{ detail.status_txt || detail.status }}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="场景">{{ detail.scene_txt || detail.scene }}</Descriptions.Item>
|
||||
<Descriptions.Item label="名称">{{ detail.name || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="供应商">{{ detail.provider || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">{{ detail.model || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="平台">
|
||||
{{ detail.platform_name || detail.platform_code || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="密钥">{{ detail.api_key_name || detail.api_key_id || '—' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="Tokens">
|
||||
总 {{ detail.total_tokens || 0 }}
|
||||
(P {{ detail.prompt_tokens || 0 }} / C {{ detail.completion_tokens || 0 }})
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="耗时">
|
||||
{{ formatDuration(Number(detail.duration_ms || 0)) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="错误" :span="2">
|
||||
{{ detail.error_msg || '—' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="输入快照" :span="2">
|
||||
<pre class="max-h-40 overflow-auto whitespace-pre-wrap text-xs">{{
|
||||
prettyJson(detail.input_snapshot)
|
||||
}}</pre>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结果 JSON" :span="2">
|
||||
<pre class="max-h-56 overflow-auto whitespace-pre-wrap text-xs">{{
|
||||
prettyJson(detail.result_json)
|
||||
}}</pre>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* AI 生成记录常量
|
||||
*/
|
||||
export const AI_GENERATION_STATUS_OPTIONS = [
|
||||
{ label: '进行中', value: 0 },
|
||||
{ label: '成功', value: 1 },
|
||||
{ label: '失败', value: 2 },
|
||||
];
|
||||
|
||||
export const AI_GENERATION_SCENE_OPTIONS = [
|
||||
{ label: '写病历', value: 'medical_record' },
|
||||
{ label: '出方', value: 'prescription' },
|
||||
];
|
||||
108
apps/web-antd/src/views/system/ai/generation/config/search.ts
Normal file
108
apps/web-antd/src/views/system/ai/generation/config/search.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { AI_GENERATION_SCENE_OPTIONS, AI_GENERATION_STATUS_OPTIONS } from './constants';
|
||||
|
||||
/**
|
||||
* AI 生成记录搜索表单
|
||||
* 时间范围字段 search_time,查询前映射为 start_time/end_time 时间戳
|
||||
*/
|
||||
export const formOptions: VbenFormProps = {
|
||||
collapsed: false,
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '名称 / 模型 / 错误摘要',
|
||||
},
|
||||
defaultValue: '',
|
||||
fieldName: 'keyword',
|
||||
label: '关键词',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
placeholder: '状态',
|
||||
allowClear: true,
|
||||
options: AI_GENERATION_STATUS_OPTIONS,
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
placeholder: '场景',
|
||||
allowClear: true,
|
||||
options: AI_GENERATION_SCENE_OPTIONS,
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'scene',
|
||||
label: '场景',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '如 spark / deepseek',
|
||||
},
|
||||
defaultValue: '',
|
||||
fieldName: 'provider',
|
||||
label: '供应商',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '模型编码',
|
||||
},
|
||||
defaultValue: '',
|
||||
fieldName: 'model',
|
||||
label: '模型',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '密钥 ID',
|
||||
},
|
||||
defaultValue: '',
|
||||
fieldName: 'api_key_id',
|
||||
label: '密钥ID',
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
},
|
||||
defaultValue: undefined,
|
||||
fieldName: 'search_time',
|
||||
label: '时间范围',
|
||||
},
|
||||
],
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: '查询',
|
||||
},
|
||||
submitOnChange: true,
|
||||
submitOnEnter: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* 将表单值转为接口参数:search_time → start_time/end_time(当天起止秒级时间戳)
|
||||
*/
|
||||
export function normalizeAiGenerationFilters(formValues: Record<string, any> = {}) {
|
||||
const params: Record<string, any> = { ...formValues };
|
||||
const range = params.search_time;
|
||||
delete params.search_time;
|
||||
if (Array.isArray(range) && range.length === 2 && range[0] && range[1]) {
|
||||
params.start_time = dayjs(range[0]).startOf('day').unix();
|
||||
params.end_time = dayjs(range[1]).endOf('day').unix();
|
||||
}
|
||||
Object.keys(params).forEach((k) => {
|
||||
if (params[k] === '' || params[k] === undefined || params[k] === null) {
|
||||
delete params[k];
|
||||
}
|
||||
});
|
||||
return params;
|
||||
}
|
||||
106
apps/web-antd/src/views/system/ai/generation/config/table.ts
Normal file
106
apps/web-antd/src/views/system/ai/generation/config/table.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { getAiGenerationList } from '../api';
|
||||
import { normalizeAiGenerationFilters } from './search';
|
||||
|
||||
interface RowType {
|
||||
id: number;
|
||||
scene: string;
|
||||
name: string;
|
||||
status: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
api_key_name: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
duration_ms: number;
|
||||
error_msg: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 生成记录表格
|
||||
*/
|
||||
export const gridOptions: VxeGridProps<RowType> = {
|
||||
checkboxConfig: {
|
||||
highlight: false,
|
||||
},
|
||||
columnConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
rowConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'scene_txt', align: 'left', title: '场景', width: 90 },
|
||||
{ field: 'name', align: 'left', title: '名称', minWidth: 140 },
|
||||
{
|
||||
field: 'status',
|
||||
align: 'left',
|
||||
title: '状态',
|
||||
width: 90,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{ field: 'provider', align: 'left', title: '供应商', width: 100 },
|
||||
{ field: 'model', align: 'left', title: '模型', minWidth: 120 },
|
||||
{ field: 'api_key_name', align: 'left', title: '密钥', minWidth: 110 },
|
||||
{
|
||||
field: 'total_tokens',
|
||||
align: 'right',
|
||||
title: 'Tokens',
|
||||
width: 110,
|
||||
slots: { default: 'tokens' },
|
||||
},
|
||||
{
|
||||
field: 'duration_ms',
|
||||
align: 'right',
|
||||
title: '耗时',
|
||||
width: 90,
|
||||
slots: { default: 'duration' },
|
||||
},
|
||||
{
|
||||
field: 'error_msg',
|
||||
align: 'left',
|
||||
title: '错误',
|
||||
minWidth: 160,
|
||||
showOverflow: true,
|
||||
},
|
||||
{ field: 'created_at', align: 'left', title: '创建时间', width: 170 },
|
||||
{
|
||||
type: 'html',
|
||||
title: '操作',
|
||||
slots: { default: 'action' },
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
},
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const params = {
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...normalizeAiGenerationFilters(formValues || {}),
|
||||
};
|
||||
return await getAiGenerationList(params);
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
print: false,
|
||||
export: false,
|
||||
zoom: true,
|
||||
custom: {
|
||||
icon: 'vxe-icon-menu',
|
||||
},
|
||||
},
|
||||
showOverflow: true,
|
||||
};
|
||||
175
apps/web-antd/src/views/system/ai/generation/index.vue
Normal file
175
apps/web-antd/src/views/system/ai/generation/index.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 生成记录列表页
|
||||
* 顶部用量摘要(随当前筛选刷新)+ VxeGrid 明细
|
||||
*/
|
||||
import { onMounted, reactive } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Card, Col, Row, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { getAiGenerationList, getAiGenerationUsageStats } from './api';
|
||||
import DetailModal from './components/detail-modal.vue';
|
||||
import { formOptions, normalizeAiGenerationFilters } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import { formatDurationMs } from './utils/format';
|
||||
|
||||
defineOptions({ name: 'AiGeneration' });
|
||||
|
||||
const stats = reactive({
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
avg_duration_ms: 0,
|
||||
success_rate: 0,
|
||||
total_count: 0,
|
||||
success_count: 0,
|
||||
fail_count: 0,
|
||||
});
|
||||
|
||||
/** 把毫秒格式化为秒文案 */
|
||||
function formatDuration(ms: number) {
|
||||
return formatDurationMs(ms);
|
||||
}
|
||||
|
||||
/** 按当前搜索表单刷新摘要卡片 */
|
||||
async function refreshStats(formValues?: Record<string, any>) {
|
||||
const params = normalizeAiGenerationFilters(formValues || {});
|
||||
try {
|
||||
const res = await getAiGenerationUsageStats(params);
|
||||
const data = res?.data || res || {};
|
||||
stats.total_tokens = Number(data.total_tokens || 0);
|
||||
stats.prompt_tokens = Number(data.prompt_tokens || 0);
|
||||
stats.completion_tokens = Number(data.completion_tokens || 0);
|
||||
stats.avg_duration_ms = Number(data.avg_duration_ms || 0);
|
||||
stats.success_rate = Number(data.success_rate || 0);
|
||||
stats.total_count = Number(data.total_count || 0);
|
||||
stats.success_count = Number(data.success_count || 0);
|
||||
stats.fail_count = Number(data.fail_count || 0);
|
||||
} catch {
|
||||
// 摘要失败不阻断列表
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions: {
|
||||
...gridOptions,
|
||||
proxyConfig: {
|
||||
...gridOptions.proxyConfig,
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
void refreshStats(formValues);
|
||||
return await getAiGenerationList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...normalizeAiGenerationFilters(formValues || {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [DetailModalComp, detailModalApi] = useVbenModal({
|
||||
connectedComponent: DetailModal,
|
||||
});
|
||||
|
||||
function openDetail(row: any) {
|
||||
detailModalApi.setData({ id: Number(row?.id || 0) });
|
||||
detailModalApi.open();
|
||||
}
|
||||
|
||||
function statusColor(status: number) {
|
||||
if (status === 1) return 'success';
|
||||
if (status === 2) return 'error';
|
||||
return 'processing';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<DetailModalComp />
|
||||
<div class="mb-3">
|
||||
<Row :gutter="12">
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">总 Tokens</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.total_tokens }}</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">Prompt</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.prompt_tokens }}</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">Completion</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.completion_tokens }}</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">平均耗时</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ formatDuration(stats.avg_duration_ms) }}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">成功率</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ stats.success_rate }}%</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :xs="12" :sm="8" :md="4">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">请求数</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ stats.success_count }}/{{ stats.total_count }}
|
||||
<span class="ml-1 text-xs font-normal text-[hsl(var(--muted-foreground))]">
|
||||
失败 {{ stats.fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
<Grid>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="statusColor(Number(row.status))">
|
||||
{{ row.status_txt || row.status }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #tokens="{ row }">
|
||||
<span :title="`P ${row.prompt_tokens} / C ${row.completion_tokens}`">
|
||||
{{ row.total_tokens || 0 }}
|
||||
</span>
|
||||
</template>
|
||||
<template #duration="{ row }">
|
||||
{{ formatDuration(Number(row.duration_ms || 0)) }}
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
onClick: () => openDetail(row),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
19
apps/web-antd/src/views/system/ai/generation/utils/format.ts
Normal file
19
apps/web-antd/src/views/system/ai/generation/utils/format.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 毫秒耗时友好展示:≥1s 保留一位小数秒,否则展示 ms
|
||||
*/
|
||||
export function formatDurationMs(ms: number | string | null | undefined): string {
|
||||
const n = Number(ms);
|
||||
if (!n || n <= 0) return '—';
|
||||
if (n >= 1000) {
|
||||
const sec = n / 1000;
|
||||
return `${sec >= 10 ? Math.round(sec) : sec.toFixed(1)}s`;
|
||||
}
|
||||
return `${Math.round(n)}ms`;
|
||||
}
|
||||
|
||||
/** 数字千分位,空值显示 — */
|
||||
export function formatTokenCount(val: number | string | null | undefined): string {
|
||||
const n = Number(val);
|
||||
if (!n) return '0';
|
||||
return n.toLocaleString('zh-CN');
|
||||
}
|
||||
58
apps/web-antd/src/views/system/ai/platform/api/index.ts
Normal file
58
apps/web-antd/src/views/system/ai/platform/api/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** AI 平台管理 API */
|
||||
const prefix = 'ai-platform/';
|
||||
|
||||
export async function getAiPlatformList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
/** 平台页卡片看板(含模型/密钥,支持 keyword 模糊搜索) */
|
||||
export async function getAiPlatformManageBoard(data?: { keyword?: string }) {
|
||||
return requestClient.get<any>(`${prefix}manage-board`, { params: data || {} });
|
||||
}
|
||||
|
||||
/** 平台详情看板(概览 / Key·模型消耗 / 近期请求) */
|
||||
export async function getAiPlatformDetailBoard(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail-board`, { params: { id } });
|
||||
}
|
||||
|
||||
/** 设为系统当前选用平台 */
|
||||
export async function setAiPlatformActive(data: { id: number }) {
|
||||
return requestClient.post<any>(`${prefix}set-active`, data);
|
||||
}
|
||||
|
||||
export async function getAiPlatformDetail(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
export async function getAiPlatformOption() {
|
||||
return requestClient.get<any>(`${prefix}option`);
|
||||
}
|
||||
|
||||
/** 系统配置用:平台卡片 + 模型 + 密钥 + 当前选用 */
|
||||
export async function getAiPlatformConfigOptions() {
|
||||
return requestClient.get<any>(`${prefix}config-options`);
|
||||
}
|
||||
|
||||
/** 系统配置:AI 功能文案列表 */
|
||||
export async function getAiFeatureCopyList() {
|
||||
return requestClient.get<any>(`${prefix}copy-list`);
|
||||
}
|
||||
|
||||
/** 系统配置:保存 AI 功能文案 */
|
||||
export async function saveAiFeatureCopy(data: { items: Record<string, any>[] }) {
|
||||
return requestClient.post<any>(`${prefix}copy-save`, data);
|
||||
}
|
||||
|
||||
export async function createAiPlatform(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateAiPlatform(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteAiPlatform(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
29
apps/web-antd/src/views/system/ai/platform/api/model.ts
Normal file
29
apps/web-antd/src/views/system/ai/platform/api/model.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** AI 厂商模型 API */
|
||||
const prefix = 'ai-model/';
|
||||
|
||||
export async function getAiModelList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function getAiModelOption(data?: { platform_id?: number }) {
|
||||
return requestClient.get<any>(`${prefix}option`, { params: data || {} });
|
||||
}
|
||||
|
||||
export async function createAiModel(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateAiModel(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteAiModel(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
/** 设为平台默认模型 */
|
||||
export async function setAiModelDefault(data: { id: number }) {
|
||||
return requestClient.post<any>(`${prefix}set-default`, data);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 平台新增/编辑弹窗
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import { createAiPlatform, updateAiPlatform } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
const onSuccess = ref<null | (() => void)>(null);
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const api = isUpdate.value ? updateAiPlatform : createAiPlatform;
|
||||
await api(values);
|
||||
message.success('保存成功');
|
||||
gridApi.value?.query?.();
|
||||
onSuccess.value?.();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
onSuccess.value = isOpen
|
||||
? modalApi.getData()?.onSuccess || null
|
||||
: null;
|
||||
if (!isOpen) {
|
||||
formApi.resetFields();
|
||||
return;
|
||||
}
|
||||
const { values, update } = modalApi.getData<Record<string, any>>() || {};
|
||||
isUpdate.value = !!update;
|
||||
if (values && update) {
|
||||
formApi.setValues({ ...values });
|
||||
} else {
|
||||
formApi.resetFields();
|
||||
formApi.setValues({ sort: 0, status: 1 });
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}AI平台`" class="w-[640px]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 厂商模型新增/编辑弹窗
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import { createAiModel, updateAiModel } from '../api/model';
|
||||
import { STATUS_OPTIONS } from '../config/constants';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const onSuccess = ref<null | (() => void)>(null);
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'platform_id',
|
||||
label: '平台ID',
|
||||
dependencies: { show: false, triggerFields: ['platform_id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'code',
|
||||
label: '模型编码',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-6',
|
||||
help: '调用 API 的 model 参数,如 lite / deepseek-chat',
|
||||
componentProps: { placeholder: 'deepseek-chat' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '展示名称',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { placeholder: 'DeepSeek Chat' },
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
formItemClass: 'col-span-4',
|
||||
componentProps: { options: STATUS_OPTIONS },
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
formItemClass: 'col-span-4',
|
||||
componentProps: { min: 0 },
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'is_default',
|
||||
label: '设为默认',
|
||||
formItemClass: 'col-span-4',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '否', value: 0 },
|
||||
{ label: '是', value: 1 },
|
||||
],
|
||||
},
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'description',
|
||||
label: '说明',
|
||||
componentProps: { type: 'textarea', rows: 2, placeholder: '模型简介' },
|
||||
defaultValue: '',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const api = isUpdate.value ? updateAiModel : createAiModel;
|
||||
await api(values);
|
||||
message.success('保存成功');
|
||||
onSuccess.value?.();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formApi.resetFields();
|
||||
onSuccess.value = null;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<Record<string, any>>() || {};
|
||||
isUpdate.value = !!data.update;
|
||||
onSuccess.value = typeof data.onSuccess === 'function' ? data.onSuccess : null;
|
||||
if (data.values && data.update) {
|
||||
formApi.setValues({ ...data.values });
|
||||
} else {
|
||||
formApi.resetFields();
|
||||
formApi.setValues({
|
||||
platform_id: data.platform_id || data.values?.platform_id || 0,
|
||||
sort: 0,
|
||||
status: 1,
|
||||
is_default: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}模型`" class="w-[560px]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,498 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 平台详情抽屉(二级页)
|
||||
* Tabs:概览 | Key消耗 | 模型消耗 | 请求记录 | Key管理 | 模型管理
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Spin,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import KeyModal from '../../api-key/components/modal.vue';
|
||||
import {
|
||||
deleteAiApiKey,
|
||||
setAiApiKeyDefault,
|
||||
} from '../../api-key/api';
|
||||
import { getAiGenerationList } from '../../generation/api';
|
||||
import { getAiPlatformDetailBoard, setAiPlatformActive } from '../api';
|
||||
import { deleteAiModel, setAiModelDefault } from '../api/model';
|
||||
import ModelModal from './model-modal.vue';
|
||||
|
||||
defineOptions({ name: 'AiPlatformDetail' });
|
||||
|
||||
interface ModelCard {
|
||||
id: number;
|
||||
platform_id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_default: number;
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface KeyCard {
|
||||
id: number;
|
||||
platform_id: number;
|
||||
name: string;
|
||||
remark: string;
|
||||
is_default: number;
|
||||
status: number;
|
||||
api_key_mask: string;
|
||||
}
|
||||
|
||||
const activeTab = ref('overview');
|
||||
const loading = ref(false);
|
||||
const platformId = ref(0);
|
||||
const onChanged = ref<null | (() => void)>(null);
|
||||
const platform = ref<Record<string, any>>({});
|
||||
const overview = ref<Record<string, any>>({});
|
||||
const usageByKey = ref<any[]>([]);
|
||||
const usageByModel = ref<any[]>([]);
|
||||
const recentGenerations = ref<any[]>([]);
|
||||
const keys = ref<KeyCard[]>([]);
|
||||
const models = ref<ModelCard[]>([]);
|
||||
const requestRows = ref<any[]>([]);
|
||||
const requestLoading = ref(false);
|
||||
const requestTotal = ref(0);
|
||||
const requestPage = ref(1);
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '平台详情',
|
||||
class: 'w-[960px]',
|
||||
footer: false,
|
||||
contentClass: '!flex !flex-col !overflow-hidden',
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<{
|
||||
platformId: number;
|
||||
onChanged?: () => void;
|
||||
}>();
|
||||
platformId.value = Number(data?.platformId || 0);
|
||||
onChanged.value = data?.onChanged || null;
|
||||
activeTab.value = 'overview';
|
||||
await loadDetail();
|
||||
},
|
||||
});
|
||||
|
||||
const [ModelModalComp, modelModalApi] = useVbenModal({
|
||||
connectedComponent: ModelModal,
|
||||
});
|
||||
const [KeyModalComp, keyModalApi] = useVbenModal({
|
||||
connectedComponent: KeyModal,
|
||||
});
|
||||
|
||||
const drawerTitle = computed(
|
||||
() => `平台详情 · ${platform.value?.name || platform.value?.code || ''}`,
|
||||
);
|
||||
|
||||
function reset() {
|
||||
platformId.value = 0;
|
||||
platform.value = {};
|
||||
overview.value = {};
|
||||
usageByKey.value = [];
|
||||
usageByModel.value = [];
|
||||
recentGenerations.value = [];
|
||||
keys.value = [];
|
||||
models.value = [];
|
||||
requestRows.value = [];
|
||||
requestTotal.value = 0;
|
||||
requestPage.value = 1;
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!platformId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getAiPlatformDetailBoard(platformId.value);
|
||||
const data = res?.data || res || {};
|
||||
platform.value = data.platform || {};
|
||||
overview.value = data.overview || {};
|
||||
usageByKey.value = Array.isArray(data.usage_by_key) ? data.usage_by_key : [];
|
||||
usageByModel.value = Array.isArray(data.usage_by_model)
|
||||
? data.usage_by_model
|
||||
: [];
|
||||
recentGenerations.value = Array.isArray(data.recent_generations)
|
||||
? data.recent_generations
|
||||
: [];
|
||||
keys.value = Array.isArray(data.keys) ? data.keys : [];
|
||||
models.value = Array.isArray(data.models) ? data.models : [];
|
||||
drawerApi.setState({ title: drawerTitle.value });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyParent() {
|
||||
onChanged.value?.();
|
||||
}
|
||||
|
||||
async function reloadAll() {
|
||||
await loadDetail();
|
||||
notifyParent();
|
||||
if (activeTab.value === 'requests') {
|
||||
await loadRequests();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRequests(page = requestPage.value) {
|
||||
if (!platformId.value) return;
|
||||
requestLoading.value = true;
|
||||
requestPage.value = page;
|
||||
try {
|
||||
const res = await getAiGenerationList({
|
||||
platform_id: platformId.value,
|
||||
page,
|
||||
pageSize: 10,
|
||||
});
|
||||
const data = res?.data || res || {};
|
||||
requestRows.value = Array.isArray(data.items) ? data.items : [];
|
||||
requestTotal.value = Number(data.total || 0);
|
||||
} finally {
|
||||
requestLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange(key: string | number) {
|
||||
activeTab.value = String(key);
|
||||
if (String(key) === 'requests' && !requestRows.value.length) {
|
||||
void loadRequests(1);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
const n = Number(ms || 0);
|
||||
if (n <= 0) return '—';
|
||||
if (n < 1000) return `${n} ms`;
|
||||
return `${(n / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
function openModelModal(row?: ModelCard) {
|
||||
modelModalApi.setData({
|
||||
values: row || {},
|
||||
update: !!row?.id,
|
||||
platform_id: platformId.value,
|
||||
onSuccess: reloadAll,
|
||||
});
|
||||
modelModalApi.open();
|
||||
}
|
||||
|
||||
function openKeyModal(row?: KeyCard) {
|
||||
keyModalApi.setData({
|
||||
values: row || { platform_id: platformId.value },
|
||||
update: !!row?.id,
|
||||
platform_id: platformId.value,
|
||||
lockPlatformId: true,
|
||||
onSuccess: reloadAll,
|
||||
});
|
||||
keyModalApi.open();
|
||||
}
|
||||
|
||||
async function handleSetActive() {
|
||||
await setAiPlatformActive({ id: platformId.value });
|
||||
message.success('已设为当前选用平台');
|
||||
await reloadAll();
|
||||
}
|
||||
|
||||
async function handleDeleteModel(id: number) {
|
||||
await deleteAiModel({ ids: [id] });
|
||||
message.success('已删除模型');
|
||||
await reloadAll();
|
||||
}
|
||||
|
||||
async function handleDeleteKey(id: number) {
|
||||
await deleteAiApiKey({ ids: [id] });
|
||||
message.success('已删除密钥');
|
||||
await reloadAll();
|
||||
}
|
||||
|
||||
async function handleSetDefaultModel(id: number) {
|
||||
await setAiModelDefault({ id });
|
||||
message.success('已设为默认模型');
|
||||
await reloadAll();
|
||||
}
|
||||
|
||||
async function handleSetDefaultKey(id: number) {
|
||||
await setAiApiKeyDefault({ id });
|
||||
message.success('已设为默认密钥');
|
||||
await reloadAll();
|
||||
}
|
||||
|
||||
const usageKeyColumns = [
|
||||
{ title: '密钥', dataIndex: 'api_key_name', key: 'api_key_name' },
|
||||
{ title: '请求数', dataIndex: 'request_count', key: 'request_count', width: 90 },
|
||||
{ title: 'Tokens', dataIndex: 'total_tokens', key: 'total_tokens', width: 110 },
|
||||
{
|
||||
title: '平均耗时',
|
||||
dataIndex: 'avg_duration_ms',
|
||||
key: 'avg_duration_ms',
|
||||
width: 100,
|
||||
customRender: ({ text }: any) => formatDuration(Number(text || 0)),
|
||||
},
|
||||
];
|
||||
|
||||
const usageModelColumns = [
|
||||
{ title: '模型', dataIndex: 'model', key: 'model' },
|
||||
{ title: '请求数', dataIndex: 'request_count', key: 'request_count', width: 90 },
|
||||
{ title: 'Tokens', dataIndex: 'total_tokens', key: 'total_tokens', width: 110 },
|
||||
{
|
||||
title: '平均耗时',
|
||||
dataIndex: 'avg_duration_ms',
|
||||
key: 'avg_duration_ms',
|
||||
width: 100,
|
||||
customRender: ({ text }: any) => formatDuration(Number(text || 0)),
|
||||
},
|
||||
];
|
||||
|
||||
const requestColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 70 },
|
||||
{ title: '场景', dataIndex: 'scene_txt', key: 'scene_txt', width: 80 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status_txt', key: 'status_txt', width: 80 },
|
||||
{ title: '模型', dataIndex: 'model', key: 'model', width: 120, ellipsis: true },
|
||||
{ title: 'Tokens', dataIndex: 'total_tokens', key: 'total_tokens', width: 90 },
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'duration_ms',
|
||||
key: 'duration_ms',
|
||||
width: 90,
|
||||
customRender: ({ text }: any) => formatDuration(Number(text || 0)),
|
||||
},
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<ModelModalComp />
|
||||
<KeyModalComp />
|
||||
<Spin :spinning="loading">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden p-1">
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Tag v-if="platform.is_active === 1" color="processing">当前选用</Tag>
|
||||
<Tag :color="platform.status === 1 ? 'success' : 'default'">
|
||||
{{ platform.status === 1 ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
<span class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{{ platform.code }} · {{ platform.api_url || '—' }}
|
||||
</span>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<Button
|
||||
v-if="platform.is_active !== 1"
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
@click="handleSetActive"
|
||||
>
|
||||
设为当前
|
||||
</Button>
|
||||
<Button size="small" @click="reloadAll">刷新</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs :active-key="activeTab" class="ai-plat-tabs min-h-0 flex-1" @change="onTabChange">
|
||||
<Tabs.TabPane key="overview" tab="概览">
|
||||
<Row :gutter="12">
|
||||
<Col :span="8">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">密钥数</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ overview.key_count || 0 }}</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">模型数</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ overview.model_count || 0 }}</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">默认模型</div>
|
||||
<div class="mt-1 truncate text-lg font-semibold">
|
||||
{{ platform.default_model || '—' }}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="8" class="mt-3">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">总 Tokens</div>
|
||||
<div class="mt-1 text-lg font-semibold">{{ overview.total_tokens || 0 }}</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="8" class="mt-3">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">成功/失败</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ overview.success_count || 0 }}/{{ overview.fail_count || 0 }}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="8" class="mt-3">
|
||||
<Card size="small">
|
||||
<div class="text-xs text-[hsl(var(--muted-foreground))]">平均耗时</div>
|
||||
<div class="mt-1 text-lg font-semibold">
|
||||
{{ formatDuration(Number(overview.avg_duration_ms || 0)) }}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<div class="mt-4 text-sm font-medium">近期请求</div>
|
||||
<Table
|
||||
class="mt-2"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
:columns="requestColumns"
|
||||
:data-source="recentGenerations"
|
||||
:row-key="(r: any) => r.id"
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="key-usage" tab="Key消耗">
|
||||
<Table
|
||||
size="small"
|
||||
:pagination="false"
|
||||
:columns="usageKeyColumns"
|
||||
:data-source="usageByKey"
|
||||
:row-key="(r: any) => r.api_key_id"
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="model-usage" tab="模型消耗">
|
||||
<Table
|
||||
size="small"
|
||||
:pagination="false"
|
||||
:columns="usageModelColumns"
|
||||
:data-source="usageByModel"
|
||||
:row-key="(r: any) => r.model"
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="requests" tab="请求记录">
|
||||
<Table
|
||||
size="small"
|
||||
:loading="requestLoading"
|
||||
:columns="requestColumns"
|
||||
:data-source="requestRows"
|
||||
:row-key="(r: any) => r.id"
|
||||
:pagination="{
|
||||
current: requestPage,
|
||||
pageSize: 10,
|
||||
total: requestTotal,
|
||||
onChange: (p: number) => loadRequests(p),
|
||||
}"
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="keys" tab="Key管理">
|
||||
<div class="mb-2 flex justify-end">
|
||||
<Button type="primary" size="small" @click="openKeyModal()">新增密钥</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!keys.length"
|
||||
class="rounded-lg border border-dashed border-[hsl(var(--border))] p-4 text-sm text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
暂无密钥
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="k in keys"
|
||||
:key="k.id"
|
||||
class="rounded-lg border border-[hsl(var(--border))] p-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium">{{ k.name }}</span>
|
||||
<Tag v-if="k.is_default === 1" color="gold">默认</Tag>
|
||||
<Tag :color="k.status === 1 ? 'success' : 'default'">
|
||||
{{ k.status === 1 ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{{ k.api_key_mask || '—' }}
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-if="k.is_default !== 1"
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
@click="handleSetDefaultKey(k.id)"
|
||||
>
|
||||
设为默认
|
||||
</Button>
|
||||
<Button size="small" @click="openKeyModal(k)">编辑</Button>
|
||||
<Popconfirm title="确认删除该密钥?" @confirm="handleDeleteKey(k.id)">
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="models" tab="模型管理">
|
||||
<div class="mb-2 flex justify-end">
|
||||
<Button type="primary" size="small" @click="openModelModal()">新增模型</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!models.length"
|
||||
class="rounded-lg border border-dashed border-[hsl(var(--border))] p-4 text-sm text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
暂无模型
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="m in models"
|
||||
:key="m.id"
|
||||
class="rounded-lg border border-[hsl(var(--border))] p-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium">{{ m.name }}</span>
|
||||
<Tag v-if="m.is_default === 1" color="gold">默认</Tag>
|
||||
<Tag :color="m.status === 1 ? 'success' : 'default'">
|
||||
{{ m.status === 1 ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">{{ m.code }}</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-if="m.is_default !== 1"
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
@click="handleSetDefaultModel(m.id)"
|
||||
>
|
||||
设为默认
|
||||
</Button>
|
||||
<Button size="small" @click="openModelModal(m)">编辑</Button>
|
||||
<Popconfirm title="确认删除该模型?" @confirm="handleDeleteModel(m.id)">
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-plat-tabs :deep(.ant-tabs-content) {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
/** AI 平台 / 密钥共用状态选项 */
|
||||
export const STATUS_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
78
apps/web-antd/src/views/system/ai/platform/config/form.ts
Normal file
78
apps/web-antd/src/views/system/ai/platform/config/form.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { STATUS_OPTIONS } from './constants';
|
||||
|
||||
/** AI 平台新增/编辑表单 */
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'code',
|
||||
label: '平台编码',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-6',
|
||||
help: '小写蛇形,如 spark / deepseek,须与 Agent 工厂一致',
|
||||
componentProps: { placeholder: 'spark' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '平台名称',
|
||||
rules: 'required',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { placeholder: '讯飞星火' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'api_url',
|
||||
label: '接口地址',
|
||||
componentProps: {
|
||||
placeholder: 'https://xxx/v1/chat/completions',
|
||||
},
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { options: STATUS_OPTIONS },
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { min: 0 },
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'Avatar',
|
||||
fieldName: 'logo',
|
||||
label: 'Logo',
|
||||
help: '卡片展示用;默认模型请在下方「模型」卡片中设置',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'description',
|
||||
label: '说明',
|
||||
componentProps: { type: 'textarea', rows: 2, placeholder: '平台简介' },
|
||||
defaultValue: '',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
241
apps/web-antd/src/views/system/ai/platform/index.vue
Normal file
241
apps/web-antd/src/views/system/ai/platform/index.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AI 平台管理(一级仅平台卡片)
|
||||
* 点击平台打开详情抽屉(二级页):概览 / 消耗 / 请求 / Key·模型管理
|
||||
*/
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Input, Popconfirm, Spin, Tag, message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
deleteAiPlatform,
|
||||
getAiPlatformManageBoard,
|
||||
setAiPlatformActive,
|
||||
} from './api';
|
||||
import FormModal from './components/modal.vue';
|
||||
import PlatformDetail from './components/platform-detail.vue';
|
||||
|
||||
defineOptions({ name: 'AiPlatform' });
|
||||
|
||||
interface PlatformBoard {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
api_url: string;
|
||||
default_model: string;
|
||||
logo: string;
|
||||
description: string;
|
||||
status: number;
|
||||
is_active: number;
|
||||
models?: any[];
|
||||
keys?: any[];
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const keyword = ref('');
|
||||
const platforms = ref<PlatformBoard[]>([]);
|
||||
const activeProvider = ref('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function loadBoard() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getAiPlatformManageBoard({
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
});
|
||||
const data = res?.data || res || {};
|
||||
platforms.value = Array.isArray(data.platforms) ? data.platforms : [];
|
||||
activeProvider.value = String(data.active?.provider || '');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 输入防抖后模糊搜索 */
|
||||
function onKeywordChange() {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
void loadBoard();
|
||||
}, 280);
|
||||
}
|
||||
|
||||
watch(keyword, () => onKeywordChange());
|
||||
|
||||
const [PlatformModalComp, platformModalApi] = useVbenModal({
|
||||
connectedComponent: FormModal,
|
||||
});
|
||||
|
||||
const [DetailDrawer, detailDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: PlatformDetail,
|
||||
});
|
||||
|
||||
function openPlatformModal(row?: PlatformBoard) {
|
||||
platformModalApi.setData({
|
||||
values: row || {},
|
||||
update: !!row?.id,
|
||||
onSuccess: loadBoard,
|
||||
});
|
||||
platformModalApi.open();
|
||||
}
|
||||
|
||||
/** 打开平台详情抽屉(模拟二级页) */
|
||||
function openPlatformDetail(p: PlatformBoard) {
|
||||
detailDrawerApi.setData({
|
||||
platformId: p.id,
|
||||
onChanged: loadBoard,
|
||||
});
|
||||
detailDrawerApi.open();
|
||||
}
|
||||
|
||||
async function handleDeletePlatform(id: number) {
|
||||
await deleteAiPlatform({ ids: [id] });
|
||||
message.success('已删除平台');
|
||||
await loadBoard();
|
||||
}
|
||||
|
||||
async function handleSetActivePlatform(id: number) {
|
||||
await setAiPlatformActive({ id });
|
||||
message.success('已设为当前选用平台');
|
||||
await loadBoard();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadBoard();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<PlatformModalComp />
|
||||
<DetailDrawer />
|
||||
<Spin :spinning="loading">
|
||||
<div class="ai-board space-y-4 p-1">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
class="max-w-md"
|
||||
placeholder="模糊搜索平台 / 模型 / 密钥"
|
||||
/>
|
||||
<Button type="primary" @click="openPlatformModal()">新增平台</Button>
|
||||
<Button @click="loadBoard">刷新</Button>
|
||||
<span class="text-xs text-[hsl(var(--muted-foreground))]">
|
||||
当前选用:{{ activeProvider || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">平台</div>
|
||||
<div
|
||||
v-if="!platforms.length"
|
||||
class="rounded-lg border border-dashed border-[hsl(var(--border))] p-6 text-sm text-[hsl(var(--muted-foreground))]"
|
||||
>
|
||||
暂无平台,请先新增
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
v-for="p in platforms"
|
||||
:key="p.id"
|
||||
class="ai-board-card"
|
||||
:class="{ 'ai-board-card--current': p.is_active === 1 }"
|
||||
@click="openPlatformDetail(p)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="ai-board-logo">
|
||||
<img
|
||||
v-if="p.logo"
|
||||
:src="p.logo"
|
||||
:alt="p.name"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
<span v-else class="ai-board-logo-fallback">
|
||||
{{ (p.name || p.code || '?').slice(0, 1) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-[hsl(var(--foreground))]">
|
||||
{{ p.name }}
|
||||
</span>
|
||||
<Tag v-if="p.is_active === 1" color="processing">当前选用</Tag>
|
||||
<Tag :color="p.status === 1 ? 'success' : 'default'">
|
||||
{{ p.status === 1 ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{{ p.code }} · 默认 {{ p.default_model || '—' }}
|
||||
</div>
|
||||
<div class="mt-1 line-clamp-2 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
{{ p.description || p.api_url }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
|
||||
模型 {{ p.models?.length || 0 }} · 密钥 {{ p.keys?.length || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2" @click.stop>
|
||||
<Button
|
||||
v-if="p.is_active !== 1"
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
@click="handleSetActivePlatform(p.id)"
|
||||
>
|
||||
设为当前
|
||||
</Button>
|
||||
<Button size="small" @click="openPlatformModal(p)">编辑</Button>
|
||||
<Button size="small" type="link" @click="openPlatformDetail(p)">
|
||||
详情
|
||||
</Button>
|
||||
<Popconfirm title="确认删除该平台?" @confirm="handleDeletePlatform(p.id)">
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-board-card {
|
||||
cursor: pointer;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
background: hsl(var(--card));
|
||||
color: hsl(var(--foreground));
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
.ai-board-card:hover {
|
||||
border-color: hsl(var(--primary) / 0.55);
|
||||
}
|
||||
.ai-board-card--current {
|
||||
border-color: hsl(var(--primary) / 0.75);
|
||||
}
|
||||
.ai-board-logo {
|
||||
display: flex;
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.55);
|
||||
padding: 5px;
|
||||
}
|
||||
.ai-board-logo-fallback {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -3,8 +3,9 @@ import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Descriptions, Image, Tabs, Tag, Timeline } from 'ant-design-vue';
|
||||
import { Button, Descriptions, Image, Space, Tabs, Tag, Timeline } from 'ant-design-vue';
|
||||
|
||||
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
|
||||
import { getDeliveryWarehouseOrderDetail } from '#/views/system/delivery-warehouse-order/api';
|
||||
|
||||
defineOptions({
|
||||
@@ -25,6 +26,18 @@ const canShip = computed(() => {
|
||||
Number(data.value.status) === 1;
|
||||
return (mineUnsent || legacy) && typeof onShipFn.value === 'function';
|
||||
});
|
||||
|
||||
/** 详情内是否可查看处方:有 p_id 且非商城类订单 */
|
||||
const canViewPrescription = computed(() => {
|
||||
const pId = Number(data.value.p_id ?? 0);
|
||||
const orderType = Number(data.value.order_type ?? 0);
|
||||
return pId > 0 && orderType !== 2 && orderType !== 3;
|
||||
});
|
||||
|
||||
/** 顶部操作栏:有发货或查看处方任一即可显示 */
|
||||
const showToolbar = computed(
|
||||
() => canShip.value || canViewPrescription.value,
|
||||
);
|
||||
/** 仅使用仓侧详情返回的本仓 packages,避免平台物流接口带出他仓商品 */
|
||||
const packageTabs = computed(() => {
|
||||
return Array.isArray(data.value.packages) ? data.value.packages : [];
|
||||
@@ -91,6 +104,21 @@ function handleShip() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 详情内打开处方详情弹窗 */
|
||||
function openPrescriptionDetail() {
|
||||
const pId = Number(data.value.p_id ?? 0);
|
||||
if (!pId) {
|
||||
return;
|
||||
}
|
||||
PrescriptionDetailModalApi.setData({ values: pId });
|
||||
PrescriptionDetailModalApi.open();
|
||||
}
|
||||
|
||||
/** 复用医生端处方详情,详情内自建避免与列表页耦合 */
|
||||
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
|
||||
connectedComponent: PrescriptionDetail,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
@@ -119,9 +147,15 @@ const [Modal, modalApi] = useVbenModal({
|
||||
|
||||
<template>
|
||||
<Modal title="订单详情" class="w-[720px]">
|
||||
<PrescriptionDetailModal />
|
||||
<div class="wh-order-detail space-y-5">
|
||||
<div v-if="canShip" class="flex justify-end">
|
||||
<Button type="primary" @click="handleShip">发货</Button>
|
||||
<div v-if="showToolbar" class="flex justify-end">
|
||||
<Space>
|
||||
<Button v-if="canViewPrescription" @click="openPrescriptionDetail">
|
||||
查看处方
|
||||
</Button>
|
||||
<Button v-if="canShip" type="primary" @click="handleShip">发货</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<section>
|
||||
<h3 class="section-title">收货人信息</h3>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { getDeliveryWarehouseOrderList } from '#/views/system/delivery-warehouse-order/api';
|
||||
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
|
||||
|
||||
import DetailModalDemo from './components/detail-modal.vue';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
@@ -63,6 +64,11 @@ const [DetailModal, detailModalApi] = useVbenModal({
|
||||
connectedComponent: DetailModalDemo,
|
||||
});
|
||||
|
||||
/** 复用医生端处方详情弹窗,按 p_id 打开 */
|
||||
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
|
||||
connectedComponent: PrescriptionDetail,
|
||||
});
|
||||
|
||||
/**
|
||||
* 加载当前 Tab 订单卡片列表
|
||||
*/
|
||||
@@ -111,6 +117,26 @@ function wareSend(row: Record<string, any>) {
|
||||
formModalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开处方详情:与商品订单一致,values 传处方主键 p_id
|
||||
* 无处方或商城/非处方单不打开
|
||||
*/
|
||||
function openPrescriptionDetail(row: Record<string, any>) {
|
||||
const pId = Number(row?.p_id ?? 0);
|
||||
if (!pId) {
|
||||
return;
|
||||
}
|
||||
PrescriptionDetailModalApi.setData({ values: pId });
|
||||
PrescriptionDetailModalApi.open();
|
||||
}
|
||||
|
||||
/** 是否展示「查看处方」:有 p_id,且排除商城类 order_type */
|
||||
function canViewPrescription(row: Record<string, any>) {
|
||||
const pId = Number(row?.p_id ?? 0);
|
||||
const orderType = Number(row?.order_type ?? 0);
|
||||
return pId > 0 && orderType !== 2 && orderType !== 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开详情;scrollToLogistics=true 时详情加载后滚到物流区块
|
||||
* 待发货时传入 onShip,详情内可直接打开发货弹窗
|
||||
@@ -159,6 +185,7 @@ onMounted(() => {
|
||||
<Page auto-content-height class="bg-[#f7f8fa] dark:bg-[#0a0a0a] transition-colors duration-300" title="我的订单">
|
||||
<FormModal/>
|
||||
<DetailModal/>
|
||||
<PrescriptionDetailModal/>
|
||||
|
||||
<div class="flex flex-col gap-6 p-2 md:p-4">
|
||||
<!-- 顶部操作栏 -->
|
||||
@@ -243,7 +270,14 @@ onMounted(() => {
|
||||
<Button @click="openDetail(row, false)" shape="round" size="middle">
|
||||
订单详情
|
||||
</Button>
|
||||
<!-- 修复了此处的 v-if 闭合错误 -->
|
||||
<Button
|
||||
v-if="canViewPrescription(row)"
|
||||
shape="round"
|
||||
size="middle"
|
||||
@click="openPrescriptionDetail(row)"
|
||||
>
|
||||
查看处方
|
||||
</Button>
|
||||
<Button
|
||||
v-if="Number(row.is_send) === 1 || Number(row.status) >= 2 || !!row.waybill_no"
|
||||
shape="round"
|
||||
|
||||
27
apps/web-antd/src/views/system/dict/disease/api/index.ts
Normal file
27
apps/web-antd/src/views/system/dict/disease/api/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 疾病诊断字典 API(yii_disease)
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'disease-dict/';
|
||||
|
||||
export async function getDiseaseDictList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function createDiseaseDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateDiseaseDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteDiseaseDict(data: { ids: number[] }) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
/** 同步空拼音首拼/全拼 */
|
||||
export async function syncDiseaseDictPinyin() {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, {});
|
||||
}
|
||||
269
apps/web-antd/src/views/system/dict/disease/index.vue
Normal file
269
apps/web-antd/src/views/system/dict/disease/index.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 疾病诊断字典(yii_disease)
|
||||
* 挂在字典管理下,维护接诊诊断库
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Modal as AntModal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import {
|
||||
createDiseaseDict,
|
||||
deleteDiseaseDict,
|
||||
getDiseaseDictList,
|
||||
syncDiseaseDictPinyin,
|
||||
updateDiseaseDict,
|
||||
} from './api';
|
||||
|
||||
defineOptions({ name: 'DiseaseDict' });
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
const isUpdate = ref(false);
|
||||
|
||||
const formOptions = {
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'keyword',
|
||||
label: '关键字',
|
||||
componentProps: { placeholder: '名称/代码/首拼/全拼' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const gridOptions = {
|
||||
checkboxConfig: { highlight: true, labelField: '' },
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'diagnose_code', title: '诊断代码', width: 120 },
|
||||
{ field: 'name', title: '诊断名称', minWidth: 180 },
|
||||
{ field: 'pinyin', title: '首拼', width: 120 },
|
||||
{ field: 'pinyin_full', title: '全拼', minWidth: 160 },
|
||||
{ field: 'major_number', title: '主编号', width: 120 },
|
||||
{ field: 'ref_number', title: '次编号', width: 120 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||
{ title: '操作', slots: { default: 'action' }, width: 140, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any, formValues: any) => {
|
||||
return await getDiseaseDictList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
slots: { buttons: 'toolbar-buttons' },
|
||||
},
|
||||
};
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions: gridOptions as any,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '诊断名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'diagnose_code',
|
||||
label: '诊断代码',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'major_number',
|
||||
label: '主编号',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'ref_number',
|
||||
label: '次编号',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'pinyin',
|
||||
label: '拼音首拼',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { placeholder: '可留空,保存时按名称自动生成' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'pinyin_full',
|
||||
label: '拼音全拼',
|
||||
componentProps: { placeholder: '可留空,保存时按名称自动生成' },
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
class: 'w-[640px]',
|
||||
draggable: true,
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
formModalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
if (isUpdate.value) {
|
||||
await updateDiseaseDict(values);
|
||||
} else {
|
||||
await createDiseaseDict(values);
|
||||
}
|
||||
message.success('保存成功');
|
||||
gridApi.query();
|
||||
formModalApi.close();
|
||||
} finally {
|
||||
formModalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formApi.resetForm();
|
||||
return;
|
||||
}
|
||||
const data = formModalApi.getData<{ values?: any; update?: boolean }>();
|
||||
isUpdate.value = !!data?.update;
|
||||
if (data?.values && data.update) {
|
||||
formApi.setValues({ ...data.values });
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function showModal(row: any = {}, update = false) {
|
||||
formModalApi.setData({ values: row, update });
|
||||
formModalApi.open();
|
||||
}
|
||||
|
||||
async function handleDelete(ids: number[]) {
|
||||
await deleteDiseaseDict({ ids });
|
||||
message.success('删除成功');
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 手动同步空拼音(疾病库量大,可能稍慢) */
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音',
|
||||
content:
|
||||
'将扫描疾病诊断中首拼或全拼为空的记录并自动生成,已有值不会覆盖。确认执行?',
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncDiseaseDictPinyin();
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height title="疾病诊断">
|
||||
<FormModal :title="`${isUpdate ? '编辑' : '新增'}疾病诊断`">
|
||||
<Form />
|
||||
</FormModal>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
onClick: () => showModal({}, false),
|
||||
},
|
||||
{
|
||||
label: '批量删除',
|
||||
danger: true,
|
||||
ifShow: () => hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确认删除所选?',
|
||||
confirm: () => {
|
||||
const ids = gridApi.grid
|
||||
.getCheckboxRecords()
|
||||
.map((r: any) => r.id);
|
||||
handleDelete(ids);
|
||||
},
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音
|
||||
</Button>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{ label: '编辑', onClick: () => showModal(row, true) },
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确认删除?',
|
||||
confirm: () => handleDelete([row.id]),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 公共医嘱字典 API(xk_doctor_order)
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'doctor-order/';
|
||||
|
||||
export async function getDoctorOrderDictList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function createDoctorOrderDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateDoctorOrderDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteDoctorOrderDict(data: { ids?: number[]; id?: number }) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
/** 同步空拼音首拼/全拼 */
|
||||
export async function syncDoctorOrderDictPinyin() {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, {});
|
||||
}
|
||||
235
apps/web-antd/src/views/system/dict/doctor-order/index.vue
Normal file
235
apps/web-antd/src/views/system/dict/doctor-order/index.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 公共医嘱字典(xk_doctor_order)
|
||||
* 挂在字典管理下,维护接诊公共医嘱
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Modal as AntModal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import {
|
||||
createDoctorOrderDict,
|
||||
deleteDoctorOrderDict,
|
||||
getDoctorOrderDictList,
|
||||
syncDoctorOrderDictPinyin,
|
||||
updateDoctorOrderDict,
|
||||
} from './api';
|
||||
|
||||
defineOptions({ name: 'DoctorOrderDict' });
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
const isUpdate = ref(false);
|
||||
|
||||
const formOptions = {
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'keyword',
|
||||
label: '关键字',
|
||||
componentProps: { placeholder: '内容/首拼/全拼' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const gridOptions = {
|
||||
checkboxConfig: { highlight: true, labelField: '' },
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'name', title: '医嘱内容', minWidth: 220 },
|
||||
{ field: 'pinyin_initials', title: '首拼', width: 120 },
|
||||
{ field: 'pinyin_full', title: '全拼', minWidth: 160 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||
{ title: '操作', slots: { default: 'action' }, width: 140, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any, formValues: any) => {
|
||||
return await getDoctorOrderDictList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
slots: { buttons: 'toolbar-buttons' },
|
||||
},
|
||||
};
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions: gridOptions as any,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '医嘱内容',
|
||||
rules: 'required',
|
||||
componentProps: { placeholder: '请输入医嘱内容' },
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
class: 'w-[560px]',
|
||||
draggable: true,
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
formModalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
if (isUpdate.value) {
|
||||
await updateDoctorOrderDict(values);
|
||||
} else {
|
||||
await createDoctorOrderDict(values);
|
||||
}
|
||||
message.success('保存成功');
|
||||
gridApi.query();
|
||||
formModalApi.close();
|
||||
} finally {
|
||||
formModalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formApi.resetForm();
|
||||
return;
|
||||
}
|
||||
const data = formModalApi.getData<{ values?: any; update?: boolean }>();
|
||||
isUpdate.value = !!data?.update;
|
||||
if (data?.values && data.update) {
|
||||
formApi.setValues({ ...data.values });
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function showModal(row: any = {}, update = false) {
|
||||
formModalApi.setData({ values: row, update });
|
||||
formModalApi.open();
|
||||
}
|
||||
|
||||
async function handleDelete(ids: number[]) {
|
||||
await deleteDoctorOrderDict({ ids });
|
||||
message.success('删除成功');
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音',
|
||||
content:
|
||||
'将扫描公共医嘱中首拼或全拼为空的记录并自动生成,已有值不会覆盖。确认执行?',
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncDoctorOrderDictPinyin();
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height title="公共医嘱">
|
||||
<FormModal :title="`${isUpdate ? '编辑' : '新增'}公共医嘱`">
|
||||
<Form />
|
||||
</FormModal>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
onClick: () => showModal({}, false),
|
||||
},
|
||||
{
|
||||
label: '批量删除',
|
||||
danger: true,
|
||||
ifShow: () => hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确认删除所选?',
|
||||
confirm: () => {
|
||||
const ids = gridApi.grid
|
||||
.getCheckboxRecords()
|
||||
.map((r: any) => r.id);
|
||||
handleDelete(ids);
|
||||
},
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音
|
||||
</Button>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{ label: '编辑', onClick: () => showModal(row, true) },
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确认删除?',
|
||||
confirm: () => handleDelete([row.id]),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
34
apps/web-antd/src/views/system/dict/tcm/api/index.ts
Normal file
34
apps/web-antd/src/views/system/dict/tcm/api/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 中医字典 API(疾病 / 治法 / 证候)
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'tcm-dict/';
|
||||
|
||||
export type TcmDictType = 'diseases' | 'method' | 'syndrome';
|
||||
|
||||
export async function getTcmDictList(data: {
|
||||
type: TcmDictType;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function createTcmDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateTcmDict(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteTcmDict(data: { type: TcmDictType; ids: number[] }) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
/** 手动同步空拼音首拼 */
|
||||
export async function syncTcmDictPinyin(type: TcmDictType) {
|
||||
return requestClient.post<any>(`${prefix}sync-pinyin`, { type });
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 中医字典列表(疾病 / 治法 / 证候共用)
|
||||
* 支持 CRUD + 手动「同步拼音首拼」(仅补空值)
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Modal as AntModal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import {
|
||||
createTcmDict,
|
||||
deleteTcmDict,
|
||||
getTcmDictList,
|
||||
syncTcmDictPinyin,
|
||||
updateTcmDict,
|
||||
type TcmDictType,
|
||||
} from '../api';
|
||||
|
||||
const props = defineProps<{
|
||||
dictType: TcmDictType;
|
||||
pageTitle: string;
|
||||
pageName: string;
|
||||
}>();
|
||||
|
||||
defineOptions({ name: 'TcmDictListPage' });
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncing = ref(false);
|
||||
|
||||
const formOptions = {
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'keyword',
|
||||
label: '关键字',
|
||||
componentProps: { placeholder: '名称/编码/首拼' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const gridOptions = computed(() => ({
|
||||
checkboxConfig: { highlight: true, labelField: '' },
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'code', title: '代码', width: 120 },
|
||||
{ field: 'number', title: '编号', width: 120 },
|
||||
{ field: 'name', title: '名称', minWidth: 160 },
|
||||
{ field: 'alias', title: '别名', minWidth: 120 },
|
||||
{ field: 'initials_of_pinyin', title: '拼音首拼', width: 120 },
|
||||
{ field: 'pinyin_full', title: '拼音全拼', minWidth: 160 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||
{ title: '操作', slots: { default: 'action' }, width: 140, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any, formValues: any) => {
|
||||
return await getTcmDictList({
|
||||
type: props.dictType,
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
refresh: true,
|
||||
slots: { buttons: 'toolbar-buttons' },
|
||||
},
|
||||
}));
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
checkboxAll() {
|
||||
hasTopTableDropDownActions.value =
|
||||
gridApi.grid.getCheckboxRecords().length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions: gridOptions.value as any,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'code',
|
||||
label: '代码',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'number',
|
||||
label: '编号',
|
||||
formItemClass: 'col-span-6',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'alias',
|
||||
label: '别名',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'initials_of_pinyin',
|
||||
label: '拼音首拼',
|
||||
componentProps: { placeholder: '保存时按名称自动生成,可留空' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'pinyin_full',
|
||||
label: '拼音全拼',
|
||||
componentProps: { placeholder: '保存时按名称自动生成,可留空' },
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
class: 'w-[640px]',
|
||||
draggable: true,
|
||||
onConfirm: async () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
formModalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const payload = { ...values, type: props.dictType };
|
||||
if (isUpdate.value) {
|
||||
await updateTcmDict(payload);
|
||||
} else {
|
||||
await createTcmDict(payload);
|
||||
}
|
||||
message.success('保存成功');
|
||||
gridApi.query();
|
||||
formModalApi.close();
|
||||
} finally {
|
||||
formModalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formApi.resetFields();
|
||||
return;
|
||||
}
|
||||
const data = formModalApi.getData<{ values?: any; update?: boolean }>();
|
||||
isUpdate.value = !!data?.update;
|
||||
if (data?.values && data.update) {
|
||||
formApi.setValues({ ...data.values });
|
||||
} else {
|
||||
formApi.resetFields();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function showModal(row: any = {}, update = false) {
|
||||
formModalApi.setData({ values: row, update });
|
||||
formModalApi.open();
|
||||
}
|
||||
|
||||
async function handleDelete(ids: number[]) {
|
||||
await deleteTcmDict({ type: props.dictType, ids });
|
||||
message.success('删除成功');
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 手动同步:补全空首拼/全拼,并纠正名称含数字但旧拼音丢数字的脏数据 */
|
||||
function handleSyncPinyin() {
|
||||
AntModal.confirm({
|
||||
title: '同步拼音',
|
||||
content: `将扫描「${props.pageTitle}」中首拼/全拼为空(或含数字名称需纠正)的记录并自动生成。确认执行?`,
|
||||
onOk: async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncTcmDictPinyin(props.dictType);
|
||||
message.success(
|
||||
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||
);
|
||||
gridApi.query();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height :title="pageTitle">
|
||||
<FormModal :title="`${isUpdate ? '编辑' : '新增'}${pageTitle}`">
|
||||
<Form />
|
||||
</FormModal>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
onClick: () => showModal({}, false),
|
||||
},
|
||||
{
|
||||
label: '批量删除',
|
||||
danger: true,
|
||||
ifShow: () => hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确认删除所选?',
|
||||
confirm: () => {
|
||||
const ids = gridApi.grid
|
||||
.getCheckboxRecords()
|
||||
.map((r: any) => r.id);
|
||||
handleDelete(ids);
|
||||
},
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||
同步拼音
|
||||
</Button>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{ label: '编辑', onClick: () => showModal(row, true) },
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
popConfirm: {
|
||||
title: '确认删除?',
|
||||
confirm: () => handleDelete([row.id]),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
15
apps/web-antd/src/views/system/dict/tcm/diseases/index.vue
Normal file
15
apps/web-antd/src/views/system/dict/tcm/diseases/index.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 中医疾病字典(xk_traditional_chinese_medicine_diseases)
|
||||
*/
|
||||
import TcmDictListPage from '../components/TcmDictListPage.vue';
|
||||
|
||||
defineOptions({ name: 'TcmDiseasesDict' });
|
||||
</script>
|
||||
<template>
|
||||
<TcmDictListPage
|
||||
dict-type="diseases"
|
||||
page-title="中医疾病"
|
||||
page-name="TcmDiseasesDict"
|
||||
/>
|
||||
</template>
|
||||
15
apps/web-antd/src/views/system/dict/tcm/method/index.vue
Normal file
15
apps/web-antd/src/views/system/dict/tcm/method/index.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 中医治法字典(xk_traditional_chinese_medicine_method)
|
||||
*/
|
||||
import TcmDictListPage from '../components/TcmDictListPage.vue';
|
||||
|
||||
defineOptions({ name: 'TcmMethodDict' });
|
||||
</script>
|
||||
<template>
|
||||
<TcmDictListPage
|
||||
dict-type="method"
|
||||
page-title="中医治法"
|
||||
page-name="TcmMethodDict"
|
||||
/>
|
||||
</template>
|
||||
15
apps/web-antd/src/views/system/dict/tcm/syndrome/index.vue
Normal file
15
apps/web-antd/src/views/system/dict/tcm/syndrome/index.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 中医证候字典(xk_traditional_chinese_medicine_syndrome)
|
||||
*/
|
||||
import TcmDictListPage from '../components/TcmDictListPage.vue';
|
||||
|
||||
defineOptions({ name: 'TcmSyndromeDict' });
|
||||
</script>
|
||||
<template>
|
||||
<TcmDictListPage
|
||||
dict-type="syndrome"
|
||||
page-title="中医证候"
|
||||
page-name="TcmSyndromeDict"
|
||||
/>
|
||||
</template>
|
||||
29
apps/web-antd/src/views/system/golden-formula/api/index.ts
Normal file
29
apps/web-antd/src/views/system/golden-formula/api/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** 金方库管理 API */
|
||||
const prefix = 'golden-formula/';
|
||||
|
||||
export async function getGoldenFormulaList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function getGoldenFormulaDetail(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
export async function createGoldenFormula(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateGoldenFormula(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteGoldenFormula(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
/** Excel 解析后的行批量导入 */
|
||||
export async function importGoldenFormula(data: { rows: Record<string, any>[] }) {
|
||||
return requestClient.post<any>(`${prefix}import`, data);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 金方新增/编辑弹窗
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import { createGoldenFormula, updateGoldenFormula } 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 () => {
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
// 表单里 drugs_json 是字符串;空串转空数组交给后端
|
||||
const drugsRaw = values.drugs_json;
|
||||
let drugs_json: any = drugsRaw;
|
||||
if (typeof drugsRaw === 'string') {
|
||||
const t = drugsRaw.trim();
|
||||
if (t === '') {
|
||||
drugs_json = [];
|
||||
} else {
|
||||
try {
|
||||
drugs_json = JSON.parse(t);
|
||||
} catch {
|
||||
message.error('药品JSON 不是合法 JSON');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const payload = { ...values, drugs_json };
|
||||
const api = isUpdate.value ? updateGoldenFormula : createGoldenFormula;
|
||||
await api(payload);
|
||||
message.success('保存成功');
|
||||
gridApi.value?.query?.();
|
||||
gridApi.value?.reload?.();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (!isOpen) {
|
||||
formApi.resetFields();
|
||||
return;
|
||||
}
|
||||
const { values, update } = modalApi.getData<Record<string, any>>() || {};
|
||||
isUpdate.value = !!update;
|
||||
if (values && update) {
|
||||
const drugs = values.drugs_json;
|
||||
formApi.setValues({
|
||||
...values,
|
||||
drugs_json:
|
||||
typeof drugs === 'string'
|
||||
? drugs
|
||||
: drugs
|
||||
? JSON.stringify(drugs, null, 2)
|
||||
: '',
|
||||
});
|
||||
} else {
|
||||
formApi.resetFields();
|
||||
formApi.setValues({ sort: 0, status: 1, drugs_json: '' });
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}金方`" class="w-[720px]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user