This commit is contained in:
Your Name
2026-08-11 17:39:41 +08:00
parent cfe4c82c90
commit 25467b9d91
350 changed files with 201115 additions and 132208 deletions
+469 -469
View File
@@ -1,469 +1,469 @@
<template>
<div class="edit-drawer">
<el-drawer
v-model="visible"
:title="drawerTitle"
direction="rtl"
size="780px"
:before-close="handleClose"
>
<el-form ref="formRef" :model="formData" label-width="100px" :rules="formRules">
<el-divider content-position="left">基本信息</el-divider>
<!-- 医助创建诊单数仅编辑时显示 -->
<el-form-item v-if="mode === 'edit' && formData.diagnosis_count != null" label="创建诊单数">
<span class="text-primary font-medium">{{ formData.diagnosis_count }}</span>
<span class="text-tx-secondary text-sm ml-2"></span>
</el-form-item>
<!-- 账号输入框 -->
<el-form-item label="账号" prop="account">
<el-input
v-model="formData.account"
placeholder="请输入账号"
clearable
/>
</el-form-item>
<!-- 医生头像 -->
<el-form-item label="头像">
<div>
<div>
<material-picker v-model="formData.avatar" :limit="1" />
</div>
<div class="form-tips">建议尺寸100*100px支持jpgjpegpng格式</div>
</div>
</el-form-item>
<!-- 姓名输入框 -->
<el-form-item label="姓名" prop="name">
<el-input v-model="formData.name" placeholder="请输入姓名" clearable />
</el-form-item>
<!-- 性别 -->
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="formData.gender">
<el-radio :label="1"></el-radio>
<el-radio :label="2"></el-radio>
</el-radio-group>
</el-form-item>
<!-- 年龄 -->
<el-form-item label="年龄" prop="age">
<el-input-number v-model="formData.age" :min="18" :max="100" placeholder="请输入年龄" />
</el-form-item>
<!-- 手机号输入框 -->
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" placeholder="请输入手机号" clearable />
</el-form-item>
<el-divider content-position="left">组织信息</el-divider>
<!-- 归属部门数据权限内仅末级可选父级仅用于展开 -->
<el-form-item label="归属部门" prop="dept_id">
<div class="w-full">
<el-tree-select
class="flex-1 w-full"
v-model="formData.dept_id"
:data="optionsData.dept"
clearable
multiple
node-key="id"
:props="{
label: 'name',
disabled: deptTreeNodeDisabled
}"
check-strictly
:default-expand-all="true"
placeholder="请选择末级归属部门"
/>
<div class="form-tips">仅可选末级部门含下级的父部门不可选与数据权限范围一致</div>
</div>
</el-form-item>
<!-- 岗位 -->
<el-form-item label="岗位" prop="jobs_id">
<el-select
class="flex-1"
v-model="formData.jobs_id"
clearable
multiple
placeholder="请选择岗位"
>
<el-option
v-for="(item, index) in optionsData.jobs"
:key="index"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-divider content-position="left">专业信息</el-divider>
<!-- 职称 -->
<el-form-item label="职称" prop="title">
<el-input v-model="formData.title" placeholder="如:主任医师、副主任医师" clearable />
</el-form-item>
<!-- 所属科室 -->
<el-form-item label="所属科室" prop="department">
<el-select v-model="formData.department" placeholder="请选择所属科室" clearable class="flex-1">
<el-option v-for="item in departmentOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select>
</el-form-item>
<!-- 擅长领域 -->
<el-form-item label="擅长领域" prop="specialty">
<el-input
v-model="formData.specialty"
type="textarea"
:rows="3"
placeholder="请输入擅长领域"
/>
</el-form-item>
<el-divider content-position="left">履历信息</el-divider>
<!-- 教育背景 -->
<el-form-item label="教育背景" prop="education">
<el-input
v-model="formData.education"
type="textarea"
:rows="3"
placeholder="请输入教育背景,如:北京中医药大学 中医学博士"
/>
</el-form-item>
<!-- 工作经历 -->
<el-form-item label="工作经历" prop="experience">
<el-input
v-model="formData.experience"
type="textarea"
:rows="4"
placeholder="请输入工作经历"
/>
</el-form-item>
<!-- 荣誉资质 -->
<el-form-item label="荣誉资质" prop="honors">
<el-input
v-model="formData.honors"
type="textarea"
:rows="3"
placeholder="请输入荣誉资质"
/>
</el-form-item>
<el-divider content-position="left">账号设置</el-divider>
<!-- 密码输入框 -->
<el-form-item label="密码" prop="password">
<el-input
v-model="formData.password"
show-password
clearable
:placeholder="mode === 'edit' ? '不修改请留空' : '请输入密码'"
/>
</el-form-item>
<!-- 确认密码输入框 -->
<el-form-item label="确认密码" prop="password_confirm">
<el-input
v-model="formData.password_confirm"
show-password
clearable
:placeholder="mode === 'edit' ? '不修改请留空' : '请输入确认密码'"
/>
</el-form-item>
<!-- 医生状态 -->
<el-form-item label="状态">
<el-switch v-model="formData.disable" :active-value="0" :inactive-value="1" />
<span class="ml-2 text-gray-500">{{ formData.disable === 0 ? '启用' : '禁用' }}</span>
</el-form-item>
<!-- 多处登录 -->
<el-form-item label="多处登录">
<div>
<el-switch
v-model="formData.multipoint_login"
:active-value="1"
:inactive-value="0"
/>
<div class="form-tips">允许多人同时在线登录</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="flex justify-end">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">确定</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import { adminAdd, adminDetail, adminEdit } from '@/api/perms/admin'
import { deptAll } from '@/api/org/department'
import { jobsAll } from '@/api/org/post'
import { getDictData } from '@/api/app'
import { useDictOptions } from '@/hooks/useDictOptions'
import feedback from '@/utils/feedback'
/** 归属部门:禁止选父级,仅末级可选(status=1 且无子节点) */
function deptTreeNodeDisabled(data: { status?: number; children?: unknown[] }) {
if (data.status !== 1) return true
const ch = data.children
return Array.isArray(ch) && ch.length > 0
}
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const visible = ref(false)
const mode = ref('add')
const loading = ref(false)
const departmentOptions = ref<any[]>([])
const fetchDepartmentOptions = async () => {
const data = await getDictData({ type: 'department' })
departmentOptions.value = data?.department || []
}
const drawerTitle = computed(() => {
return mode.value == 'edit' ? '编辑医助' : '新增医助'
})
const formData = reactive({
id: '',
account: '',
name: '',
gender: 1,
age: null as number | null,
phone: '',
role_id: [2], // 固定为医助角色
dept_id: [],
jobs_id: [],
avatar: '',
title: '', // 职称
department: '', // 所属科室
specialty: '', // 擅长领域
education: '', // 教育背景
experience: '', // 工作经历
honors: '', // 荣誉资质
password: '',
password_confirm: '',
disable: 0,
multipoint_login: 1,
diagnosis_count: null as number | null // 医助创建诊单数(详情接口返回)
})
// 获取部门和岗位选项
const { optionsData } = useDictOptions<{
jobs: any[]
dept: any[]
}>({
jobs: {
api: jobsAll
},
dept: {
api: deptAll,
params: { apply_data_scope: 1 }
}
})
// 密码确认验证
const passwordConfirmValidator = (rule: object, value: string, callback: any) => {
if (formData.password) {
if (!value) callback(new Error('请再次输入密码'))
if (value !== formData.password) callback(new Error('两次输入密码不一致!'))
}
callback()
}
// 手机号验证
const phoneValidator = (rule: object, value: string, callback: any) => {
if (value && !/^1[3-9]\d{9}$/.test(value)) {
callback(new Error('请输入正确的手机号'))
} else {
callback()
}
}
const formRules = reactive({
account: [
{
required: true,
message: '请输入账号',
trigger: ['blur']
}
],
name: [
{
required: true,
message: '请输入姓名',
trigger: ['blur']
}
],
gender: [
{
required: true,
message: '请选择性别',
trigger: ['change']
}
],
dept_id: [
{
required: true,
type: 'array',
min: 1,
message: '请选择归属部门',
trigger: ['change']
}
],
phone: [
{
validator: phoneValidator,
trigger: 'blur'
}
],
password: [
{
required: true,
message: '请输入密码',
trigger: ['blur']
}
] as any[],
password_confirm: [
{
required: true,
message: '请输入确认密码',
trigger: ['blur']
},
{
validator: passwordConfirmValidator,
trigger: 'blur'
}
] as any[]
})
const handleSubmit = async () => {
try {
await formRef.value?.validate()
loading.value = true
mode.value == 'edit' ? await adminEdit(formData) : await adminAdd(formData)
feedback.msgSuccess(mode.value == 'edit' ? '编辑成功' : '新增成功')
visible.value = false
emit('success')
} catch (error) {
console.error('提交失败:', error)
} finally {
loading.value = false
}
}
const open = (type = 'add') => {
mode.value = type
visible.value = true
fetchDepartmentOptions()
// 重置表单
if (type === 'add') {
Object.assign(formData, {
id: '',
account: '',
name: '',
gender: 1,
age: null,
phone: '',
role_id: [2],
dept_id: [],
jobs_id: [],
avatar: '',
title: '',
department: '',
specialty: '',
education: '',
experience: '',
honors: '',
password: '',
password_confirm: '',
disable: 0,
multipoint_login: 1,
diagnosis_count: null
})
// 新增时密码必填
formRules.password = [
{
required: true,
message: '请输入密码',
trigger: ['blur']
}
]
formRules.password_confirm = [
{
required: true,
message: '请输入确认密码',
trigger: ['blur']
},
{
validator: passwordConfirmValidator,
trigger: 'blur'
}
]
}
}
const setFormData = async (row: any) => {
// 编辑时密码不是必填
formRules.password = []
formRules.password_confirm = [
{
validator: passwordConfirmValidator,
trigger: 'blur'
}
]
const data = await adminDetail({
id: row.id
})
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
//@ts-ignore
formData[key] = data[key]
}
}
// 确保role_id包含医助角色
if (!formData.role_id.includes(2)) {
formData.role_id.push(2)
}
}
const handleClose = () => {
visible.value = false
emit('close')
}
defineExpose({
open,
setFormData
})
</script>
<style scoped lang="scss">
.form-tips {
color: #999;
font-size: 12px;
line-height: 1.5;
margin-top: 4px;
}
</style>
<template>
<div class="edit-drawer">
<el-drawer
v-model="visible"
:title="drawerTitle"
direction="rtl"
size="780px"
:before-close="handleClose"
>
<el-form ref="formRef" :model="formData" label-width="100px" :rules="formRules">
<el-divider content-position="left">基本信息</el-divider>
<!-- 医助创建诊单数仅编辑时显示 -->
<el-form-item v-if="mode === 'edit' && formData.diagnosis_count != null" label="创建诊单数">
<span class="text-primary font-medium">{{ formData.diagnosis_count }}</span>
<span class="text-tx-secondary text-sm ml-2"></span>
</el-form-item>
<!-- 账号输入框 -->
<el-form-item label="账号" prop="account">
<el-input
v-model="formData.account"
placeholder="请输入账号"
clearable
/>
</el-form-item>
<!-- 医生头像 -->
<el-form-item label="头像">
<div>
<div>
<material-picker v-model="formData.avatar" :limit="1" />
</div>
<div class="form-tips">建议尺寸100*100px支持jpgjpegpng格式</div>
</div>
</el-form-item>
<!-- 姓名输入框 -->
<el-form-item label="姓名" prop="name">
<el-input v-model="formData.name" placeholder="请输入姓名" clearable />
</el-form-item>
<!-- 性别 -->
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="formData.gender">
<el-radio :label="1"></el-radio>
<el-radio :label="2"></el-radio>
</el-radio-group>
</el-form-item>
<!-- 年龄 -->
<el-form-item label="年龄" prop="age">
<el-input-number v-model="formData.age" :min="18" :max="100" placeholder="请输入年龄" />
</el-form-item>
<!-- 手机号输入框 -->
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" placeholder="请输入手机号" clearable />
</el-form-item>
<el-divider content-position="left">组织信息</el-divider>
<!-- 归属部门数据权限内仅末级可选父级仅用于展开 -->
<el-form-item label="归属部门" prop="dept_id">
<div class="w-full">
<el-tree-select
class="flex-1 w-full"
v-model="formData.dept_id"
:data="optionsData.dept"
clearable
multiple
node-key="id"
:props="{
label: 'name',
disabled: deptTreeNodeDisabled
}"
check-strictly
:default-expand-all="true"
placeholder="请选择末级归属部门"
/>
<div class="form-tips">仅可选末级部门含下级的父部门不可选与数据权限范围一致</div>
</div>
</el-form-item>
<!-- 岗位 -->
<el-form-item label="岗位" prop="jobs_id">
<el-select
class="flex-1"
v-model="formData.jobs_id"
clearable
multiple
placeholder="请选择岗位"
>
<el-option
v-for="(item, index) in optionsData.jobs"
:key="index"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-divider content-position="left">专业信息</el-divider>
<!-- 职称 -->
<el-form-item label="职称" prop="title">
<el-input v-model="formData.title" placeholder="如:主任医师、副主任医师" clearable />
</el-form-item>
<!-- 所属科室 -->
<el-form-item label="所属科室" prop="department">
<el-select v-model="formData.department" placeholder="请选择所属科室" clearable class="flex-1">
<el-option v-for="item in departmentOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select>
</el-form-item>
<!-- 擅长领域 -->
<el-form-item label="擅长领域" prop="specialty">
<el-input
v-model="formData.specialty"
type="textarea"
:rows="3"
placeholder="请输入擅长领域"
/>
</el-form-item>
<el-divider content-position="left">履历信息</el-divider>
<!-- 教育背景 -->
<el-form-item label="教育背景" prop="education">
<el-input
v-model="formData.education"
type="textarea"
:rows="3"
placeholder="请输入教育背景,如:北京中医药大学 中医学博士"
/>
</el-form-item>
<!-- 工作经历 -->
<el-form-item label="工作经历" prop="experience">
<el-input
v-model="formData.experience"
type="textarea"
:rows="4"
placeholder="请输入工作经历"
/>
</el-form-item>
<!-- 荣誉资质 -->
<el-form-item label="荣誉资质" prop="honors">
<el-input
v-model="formData.honors"
type="textarea"
:rows="3"
placeholder="请输入荣誉资质"
/>
</el-form-item>
<el-divider content-position="left">账号设置</el-divider>
<!-- 密码输入框 -->
<el-form-item label="密码" prop="password">
<el-input
v-model="formData.password"
show-password
clearable
:placeholder="mode === 'edit' ? '不修改请留空' : '请输入密码'"
/>
</el-form-item>
<!-- 确认密码输入框 -->
<el-form-item label="确认密码" prop="password_confirm">
<el-input
v-model="formData.password_confirm"
show-password
clearable
:placeholder="mode === 'edit' ? '不修改请留空' : '请输入确认密码'"
/>
</el-form-item>
<!-- 医生状态 -->
<el-form-item label="状态">
<el-switch v-model="formData.disable" :active-value="0" :inactive-value="1" />
<span class="ml-2 text-gray-500">{{ formData.disable === 0 ? '启用' : '禁用' }}</span>
</el-form-item>
<!-- 多处登录 -->
<el-form-item label="多处登录">
<div>
<el-switch
v-model="formData.multipoint_login"
:active-value="1"
:inactive-value="0"
/>
<div class="form-tips">允许多人同时在线登录</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="flex justify-end">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">确定</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import { adminAdd, adminDetail, adminEdit } from '@/api/perms/admin'
import { deptAll } from '@/api/org/department'
import { jobsAll } from '@/api/org/post'
import { getDictData } from '@/api/app'
import { useDictOptions } from '@/hooks/useDictOptions'
import feedback from '@/utils/feedback'
/** 归属部门:禁止选父级,仅末级可选(status=1 且无子节点) */
function deptTreeNodeDisabled(data: { status?: number; children?: unknown[] }) {
if (data.status !== 1) return true
const ch = data.children
return Array.isArray(ch) && ch.length > 0
}
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const visible = ref(false)
const mode = ref('add')
const loading = ref(false)
const departmentOptions = ref<any[]>([])
const fetchDepartmentOptions = async () => {
const data = await getDictData({ type: 'department' })
departmentOptions.value = data?.department || []
}
const drawerTitle = computed(() => {
return mode.value == 'edit' ? '编辑医助' : '新增医助'
})
const formData = reactive({
id: '',
account: '',
name: '',
gender: 1,
age: null as number | null,
phone: '',
role_id: [2], // 固定为医助角色
dept_id: [],
jobs_id: [],
avatar: '',
title: '', // 职称
department: '', // 所属科室
specialty: '', // 擅长领域
education: '', // 教育背景
experience: '', // 工作经历
honors: '', // 荣誉资质
password: '',
password_confirm: '',
disable: 0,
multipoint_login: 1,
diagnosis_count: null as number | null // 医助创建诊单数(详情接口返回)
})
// 获取部门和岗位选项
const { optionsData } = useDictOptions<{
jobs: any[]
dept: any[]
}>({
jobs: {
api: jobsAll
},
dept: {
api: deptAll,
params: { apply_data_scope: 1 }
}
})
// 密码确认验证
const passwordConfirmValidator = (rule: object, value: string, callback: any) => {
if (formData.password) {
if (!value) callback(new Error('请再次输入密码'))
if (value !== formData.password) callback(new Error('两次输入密码不一致!'))
}
callback()
}
// 手机号验证
const phoneValidator = (rule: object, value: string, callback: any) => {
if (value && !/^1[3-9]\d{9}$/.test(value)) {
callback(new Error('请输入正确的手机号'))
} else {
callback()
}
}
const formRules = reactive({
account: [
{
required: true,
message: '请输入账号',
trigger: ['blur']
}
],
name: [
{
required: true,
message: '请输入姓名',
trigger: ['blur']
}
],
gender: [
{
required: true,
message: '请选择性别',
trigger: ['change']
}
],
dept_id: [
{
required: true,
type: 'array',
min: 1,
message: '请选择归属部门',
trigger: ['change']
}
],
phone: [
{
validator: phoneValidator,
trigger: 'blur'
}
],
password: [
{
required: true,
message: '请输入密码',
trigger: ['blur']
}
] as any[],
password_confirm: [
{
required: true,
message: '请输入确认密码',
trigger: ['blur']
},
{
validator: passwordConfirmValidator,
trigger: 'blur'
}
] as any[]
})
const handleSubmit = async () => {
try {
await formRef.value?.validate()
loading.value = true
mode.value == 'edit' ? await adminEdit(formData) : await adminAdd(formData)
feedback.msgSuccess(mode.value == 'edit' ? '编辑成功' : '新增成功')
visible.value = false
emit('success')
} catch (error) {
console.error('提交失败:', error)
} finally {
loading.value = false
}
}
const open = (type = 'add') => {
mode.value = type
visible.value = true
fetchDepartmentOptions()
// 重置表单
if (type === 'add') {
Object.assign(formData, {
id: '',
account: '',
name: '',
gender: 1,
age: null,
phone: '',
role_id: [2],
dept_id: [],
jobs_id: [],
avatar: '',
title: '',
department: '',
specialty: '',
education: '',
experience: '',
honors: '',
password: '',
password_confirm: '',
disable: 0,
multipoint_login: 1,
diagnosis_count: null
})
// 新增时密码必填
formRules.password = [
{
required: true,
message: '请输入密码',
trigger: ['blur']
}
]
formRules.password_confirm = [
{
required: true,
message: '请输入确认密码',
trigger: ['blur']
},
{
validator: passwordConfirmValidator,
trigger: 'blur'
}
]
}
}
const setFormData = async (row: any) => {
// 编辑时密码不是必填
formRules.password = []
formRules.password_confirm = [
{
validator: passwordConfirmValidator,
trigger: 'blur'
}
]
const data = await adminDetail({
id: row.id
})
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
//@ts-ignore
formData[key] = data[key]
}
}
// 确保role_id包含医助角色
if (!formData.role_id.includes(2)) {
formData.role_id.push(2)
}
}
const handleClose = () => {
visible.value = false
emit('close')
}
defineExpose({
open,
setFormData
})
</script>
<style scoped lang="scss">
.form-tips {
color: #999;
font-size: 12px;
line-height: 1.5;
margin-top: 4px;
}
</style>
File diff suppressed because it is too large Load Diff
+165 -165
View File
@@ -1,165 +1,165 @@
<!-- 医生列表 -->
<template>
<div class="doctor-list">
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="formData" inline>
<el-form-item class="w-[280px]" label="医生账号">
<el-input
v-model="formData.account"
placeholder="请输入医生账号"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item class="w-[280px]" label="医生名称">
<el-input
v-model="formData.name"
placeholder="请输入医生名称"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-loading="pager.loading" class="mt-4 !border-none" shadow="never">
<el-button v-perms="['auth.admin/add']" type="primary" @click="handleAdd">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增医生
</el-button>
<div class="mt-4">
<el-table :data="pager.lists" size="large">
<el-table-column label="ID" prop="id" min-width="60" />
<el-table-column label="头像" min-width="100">
<template #default="{ row }">
<el-avatar :size="50" :src="row.avatar"></el-avatar>
</template>
</el-table-column>
<el-table-column label="账号" prop="account" min-width="120" />
<el-table-column label="姓名" prop="name" min-width="100" />
<el-table-column label="手机号" prop="phone" min-width="120" />
<el-table-column label="创建时间" prop="create_time" min-width="180" />
<el-table-column label="最近登录时间" prop="login_time" min-width="180" />
<el-table-column label="最近登录IP" prop="login_ip" min-width="120" />
<el-table-column label="状态" min-width="100" v-perms="['auth.admin/edit']">
<template #default="{ row }">
<el-switch
v-model="row.disable"
:active-value="0"
:inactive-value="1"
@change="changeStatus(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['auth.admin/edit']"
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['auth.admin/delete']"
type="danger"
link
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex mt-4 justify-end">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div>
</template>
<script lang="ts" setup name="doctorList">
import { adminDelete, adminEdit, adminLists } from '@/api/perms/admin'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import EditPopup from './edit.vue'
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
// 表单数据 - 固定 role_id=1 查询医生;数据范围与医助列表一致,仅看自己权限内成员
const formData = reactive({
account: '',
name: '',
role_id: 1,
apply_data_scope: 1
})
const showEdit = ref(false)
const { pager, getLists, resetParams, resetPage } = usePaging({
fetchFun: adminLists,
params: formData
})
// 修改状态
const changeStatus = (data: any) => {
adminEdit({
id: data.id,
avatar: data.avatar,
account: data.account,
name: data.name,
role_id: data.role_id,
jobs_id: data.jobs_id,
dept_id: data.dept_id,
disable: data.disable,
multipoint_login: data.multipoint_login
}).finally(() => {
getLists()
})
}
// 新增医生
const handleAdd = async () => {
showEdit.value = true
await nextTick()
editRef.value?.open('add')
}
// 编辑医生
const handleEdit = async (data: any) => {
showEdit.value = true
await nextTick()
editRef.value?.open('edit')
editRef.value?.setFormData(data)
}
// 删除医生
const handleDelete = async (id: number) => {
await feedback.confirm('确定要删除该医生吗?')
await adminDelete({ id })
getLists()
}
onMounted(() => {
getLists()
})
</script>
<style scoped lang="scss">
.doctor-list {
padding: 20px;
}
</style>
<!-- 医生列表 -->
<template>
<div class="doctor-list">
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="formData" inline>
<el-form-item class="w-[280px]" label="医生账号">
<el-input
v-model="formData.account"
placeholder="请输入医生账号"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item class="w-[280px]" label="医生名称">
<el-input
v-model="formData.name"
placeholder="请输入医生名称"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-loading="pager.loading" class="mt-4 !border-none" shadow="never">
<el-button v-perms="['auth.admin/add']" type="primary" @click="handleAdd">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增医生
</el-button>
<div class="mt-4">
<el-table :data="pager.lists" size="large">
<el-table-column label="ID" prop="id" min-width="60" />
<el-table-column label="头像" min-width="100">
<template #default="{ row }">
<el-avatar :size="50" :src="row.avatar"></el-avatar>
</template>
</el-table-column>
<el-table-column label="账号" prop="account" min-width="120" />
<el-table-column label="姓名" prop="name" min-width="100" />
<el-table-column label="手机号" prop="phone" min-width="120" />
<el-table-column label="创建时间" prop="create_time" min-width="180" />
<el-table-column label="最近登录时间" prop="login_time" min-width="180" />
<el-table-column label="最近登录IP" prop="login_ip" min-width="120" />
<el-table-column label="状态" min-width="100" v-perms="['auth.admin/edit']">
<template #default="{ row }">
<el-switch
v-model="row.disable"
:active-value="0"
:inactive-value="1"
@change="changeStatus(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['auth.admin/edit']"
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['auth.admin/delete']"
type="danger"
link
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex mt-4 justify-end">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div>
</template>
<script lang="ts" setup name="doctorList">
import { adminDelete, adminEdit, adminLists } from '@/api/perms/admin'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import EditPopup from './edit.vue'
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
// 表单数据 - 固定 role_id=1 查询医生;数据范围与医助列表一致,仅看自己权限内成员
const formData = reactive({
account: '',
name: '',
role_id: 1,
apply_data_scope: 1
})
const showEdit = ref(false)
const { pager, getLists, resetParams, resetPage } = usePaging({
fetchFun: adminLists,
params: formData
})
// 修改状态
const changeStatus = (data: any) => {
adminEdit({
id: data.id,
avatar: data.avatar,
account: data.account,
name: data.name,
role_id: data.role_id,
jobs_id: data.jobs_id,
dept_id: data.dept_id,
disable: data.disable,
multipoint_login: data.multipoint_login
}).finally(() => {
getLists()
})
}
// 新增医生
const handleAdd = async () => {
showEdit.value = true
await nextTick()
editRef.value?.open('add')
}
// 编辑医生
const handleEdit = async (data: any) => {
showEdit.value = true
await nextTick()
editRef.value?.open('edit')
editRef.value?.setFormData(data)
}
// 删除医生
const handleDelete = async (id: number) => {
await feedback.confirm('确定要删除该医生吗?')
await adminDelete({ id })
getLists()
}
onMounted(() => {
getLists()
})
</script>
<style scoped lang="scss">
.doctor-list {
padding: 20px;
}
</style>
@@ -0,0 +1,710 @@
<template>
<el-dialog
v-model="visible"
class="prescription-ai-batch-dialog"
width="min(820px, calc(100vw - 32px))"
top="6vh"
title="批量生成处方 AI 报告"
:close-on-click-modal="false"
destroy-on-close
:before-close="handleBeforeClose"
@closed="handleClosed"
>
<div class="batch-dialog-body">
<el-alert
type="info"
:closable="false"
show-icon
title="仅统计完全没有任何 AI 报告的处方;已有报告不会被本批次覆盖。"
/>
<div v-if="loadLoading" class="loading-state">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="loadError" class="load-error-state">
<el-empty description="待生成清单加载失败" :image-size="72">
<p class="state-description">{{ loadError }}</p>
<el-button type="primary" plain @click="loadMissingReports()">重新加载</el-button>
</el-empty>
</div>
<template v-else>
<section class="overview-panel">
<div>
<span class="overview-label">当前完全缺失报告</span>
<strong>{{ missingTotal }}</strong>
<span class="overview-unit">份处方</span>
</div>
<div class="overview-meta">
本次载入 {{ pendingItems.length }}
<el-tag size="small" type="info" effect="plain">上限 {{ responseLimit }}</el-tag>
</div>
</section>
<div v-if="refreshError" class="refresh-error">
<el-alert
type="error"
:closable="false"
show-icon
:title="refreshError"
/>
<el-button type="primary" plain @click="loadMissingReports()">重新读取清单</el-button>
</div>
<el-alert
v-if="truncated"
class="truncated-alert"
type="warning"
:closable="false"
show-icon
:title="truncatedMessage"
/>
<section v-if="running || runTotal > 0" class="progress-panel">
<div class="progress-heading">
<div>
<span class="section-kicker">本轮进度</span>
<strong>{{ processedCount }} / {{ runTotal }}</strong>
</div>
<el-tag v-if="running && !stopRequested" type="primary">顺序生成中</el-tag>
<el-tag v-else-if="running && stopRequested" type="warning">等待当前请求完成</el-tag>
<el-tag v-else-if="runStatus === 'stopped'" type="warning">已停止</el-tag>
<el-tag v-else type="success">本轮完成</el-tag>
</div>
<el-progress :percentage="progressPercentage" :stroke-width="10" />
<div v-if="currentItem" class="current-prescription">
<span>当前处方</span>
<strong>{{ currentItem.prescription_name || `处方 #${currentItem.id}` }}</strong>
<small>#{{ currentItem.id }} · {{ currentItem.formula_type || '未分类' }}</small>
</div>
<div class="counter-grid">
<div class="counter-item counter-processed">
<span>已处理</span>
<strong>{{ processedCount }}</strong>
</div>
<div class="counter-item counter-success">
<span>成功</span>
<strong>{{ successCount }}</strong>
</div>
<div class="counter-item counter-partial">
<span>部分成功</span>
<strong>{{ partialCount }}</strong>
</div>
<div class="counter-item counter-failed">
<span>失败</span>
<strong>{{ failedCount }}</strong>
</div>
</div>
<el-alert
v-if="stopRequested && running"
class="stop-alert"
type="warning"
:closable="false"
show-icon
title="已请求停止;当前处方生成完成后,不会继续发起下一条请求。"
/>
</section>
<section v-if="issues.length" class="issues-panel">
<div class="section-heading">
<div>
<span class="section-kicker">失败清单</span>
<strong>失败与部分成功明细</strong>
</div>
<el-tag type="danger" effect="plain">{{ issues.length }} </el-tag>
</div>
<el-table :data="issues" max-height="240" size="small">
<el-table-column label="处方" min-width="180">
<template #default="{ row }">
<div class="issue-name">{{ row.prescription_name }}</div>
<small>#{{ row.id }}</small>
</template>
</el-table-column>
<el-table-column label="结果" width="100">
<template #default="{ row }">
<el-tag
size="small"
:type="row.status === 'partial' ? 'warning' : 'danger'"
>
{{ row.status === 'partial' ? '部分成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="原因" min-width="300" show-overflow-tooltip prop="message" />
</el-table>
</section>
<el-empty
v-if="!pendingItems.length && !running"
:description="runTotal > 0 ? '当前范围已无待生成处方' : '暂无完全缺失 AI 报告的处方'"
:image-size="76"
>
<p class="state-description">
{{
runTotal > 0
? '已重新读取数据库中的最新待生成清单。'
: '所有可见处方均已有至少一份持久化报告。'
}}
</p>
</el-empty>
<section v-else-if="!running" class="pending-panel">
<div class="section-heading">
<div>
<span class="section-kicker">待生成清单</span>
<strong>本次将按以下顺序逐条处理</strong>
</div>
<span class="pending-count">{{ pendingItems.length }} </span>
</div>
<el-table :data="pendingItems" max-height="280" size="small">
<el-table-column label="ID" prop="id" width="80" />
<el-table-column label="处方名称" prop="prescription_name" min-width="220" show-overflow-tooltip />
<el-table-column label="类型" prop="formula_type" width="100" />
<el-table-column label="药材数" width="100" align="right">
<template #default="{ row }">{{ row.herb_count }} </template>
</el-table-column>
</el-table>
</section>
</template>
</div>
<template #footer>
<div class="dialog-footer">
<span v-if="running" class="footer-tip">生成请求不会自动重试也不会并发执行</span>
<span v-else></span>
<div>
<el-button v-if="!running" @click="visible = false">关闭</el-button>
<el-button
v-if="running"
type="warning"
plain
:disabled="stopRequested"
@click="requestStop"
>
{{ stopRequested ? '正在停止…' : '停止' }}
</el-button>
<el-button
v-else
type="primary"
:disabled="loadLoading || Boolean(loadError) || !pendingItems.length"
@click="startBatch"
>
{{ runTotal > 0 ? '继续生成剩余报告' : '开始批量生成' }}
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import {
prescriptionLibraryGenerateAiReports,
prescriptionLibraryMissingAiReports,
type PrescriptionAiReportsResponse,
type PrescriptionMissingAiReportItem
} from '@/api/tcm'
import feedback from '@/utils/feedback'
type RunStatus = 'idle' | 'completed' | 'stopped'
type IssueStatus = 'partial' | 'failed'
interface BatchIssue {
id: number
prescription_name: string
status: IssueStatus
message: string
}
const BATCH_LIMIT = 500
const emit = defineEmits<{
changed: []
}>()
const visible = ref(false)
const dialogGeneration = ref(0)
const loadGeneration = ref(0)
const loadLoading = ref(false)
const loadError = ref('')
const refreshError = ref('')
const pendingItems = ref<PrescriptionMissingAiReportItem[]>([])
const missingTotal = ref(0)
const responseLimit = ref(BATCH_LIMIT)
const truncated = ref(false)
const running = ref(false)
const stopRequested = ref(false)
const runStatus = ref<RunStatus>('idle')
const runTotal = ref(0)
const processedCount = ref(0)
const successCount = ref(0)
const partialCount = ref(0)
const failedCount = ref(0)
const currentItem = ref<PrescriptionMissingAiReportItem | null>(null)
const issues = ref<BatchIssue[]>([])
const deferredClose = shallowRef<(() => void) | null>(null)
const progressPercentage = computed(() => {
if (!runTotal.value) return 0
return Math.min(100, Math.round((processedCount.value / runTotal.value) * 100))
})
const truncatedMessage = computed(() => {
const remaining = Math.max(0, missingTotal.value - pendingItems.value.length)
if (remaining > 0) {
return `待生成数量超过单次上限,本次最多处理 ${pendingItems.value.length} 份;完成后可再次继续处理其余约 ${remaining} 份。`
}
return `后端标记清单已截断,本次最多处理 ${responseLimit.value} 份;完成后可再次继续。`
})
const resetRunState = () => {
stopRequested.value = false
runStatus.value = 'idle'
runTotal.value = 0
processedCount.value = 0
successCount.value = 0
partialCount.value = 0
failedCount.value = 0
currentItem.value = null
issues.value = []
}
const resetDialogState = () => {
loadLoading.value = false
loadError.value = ''
refreshError.value = ''
pendingItems.value = []
missingTotal.value = 0
responseLimit.value = BATCH_LIMIT
truncated.value = false
resetRunState()
}
const errorMessage = (error: unknown) => {
if (typeof error === 'string' && error.trim()) return error
if (error && typeof error === 'object') {
const value = error as Record<string, unknown>
const response = value.response as Record<string, unknown> | undefined
const responseData = response?.data as Record<string, unknown> | undefined
const message = value.msg || value.message || value.error || responseData?.msg || responseData?.message
if (typeof message === 'string' && message.trim()) return message
}
return '请求失败,请稍后重试'
}
const isCurrentDialog = (generation: number) =>
generation === dialogGeneration.value && visible.value
const loadMissingReports = async (
generation = dialogGeneration.value,
options: { silent?: boolean } = {}
) => {
const requestGeneration = ++loadGeneration.value
if (!options.silent) {
loadLoading.value = true
loadError.value = ''
}
try {
const response = await prescriptionLibraryMissingAiReports({ limit: BATCH_LIMIT })
if (!isCurrentDialog(generation) || requestGeneration !== loadGeneration.value) return
const items = Array.isArray(response.items) ? response.items.slice(0, BATCH_LIMIT) : []
pendingItems.value = items
missingTotal.value = Math.max(items.length, Number(response.total || 0))
responseLimit.value = Math.min(BATCH_LIMIT, Math.max(1, Number(response.limit || BATCH_LIMIT)))
truncated.value = response.truncated === true || missingTotal.value > items.length
refreshError.value = ''
} catch (error) {
if (!isCurrentDialog(generation) || requestGeneration !== loadGeneration.value) return
if (options.silent) {
refreshError.value = `本轮已结束,但最新待生成清单读取失败:${errorMessage(error)}`
} else {
loadError.value = errorMessage(error)
}
} finally {
if (isCurrentDialog(generation) && requestGeneration === loadGeneration.value) {
loadLoading.value = false
}
}
}
const generationIssueMessage = (response: PrescriptionAiReportsResponse) => {
const modelErrors = (response.results || [])
.filter((result) => result.status === 'error')
.map((result) => `${result.model_label || result.model_name}: ${result.error_message || '生成失败'}`)
return modelErrors.join('') || '模型未返回完整报告'
}
const recordGenerationResult = (
item: PrescriptionMissingAiReportItem,
response: PrescriptionAiReportsResponse
): boolean => {
const modelSuccessCount = Number(response.success_count || 0)
const modelFailureCount = Number(response.failure_count || 0)
const isPartial =
response.status === 'partial' ||
response.partial === true ||
(modelSuccessCount > 0 && modelFailureCount > 0)
const isFailure =
response.status === 'error' || (modelFailureCount > 0 && modelSuccessCount === 0)
if (isPartial) {
partialCount.value += 1
issues.value.push({
id: item.id,
prescription_name: item.prescription_name || `处方 #${item.id}`,
status: 'partial',
message: generationIssueMessage(response)
})
return true
}
if (isFailure) {
failedCount.value += 1
issues.value.push({
id: item.id,
prescription_name: item.prescription_name || `处方 #${item.id}`,
status: 'failed',
message: generationIssueMessage(response)
})
return false
}
successCount.value += 1
return true
}
const recordRequestFailure = (item: PrescriptionMissingAiReportItem, error: unknown) => {
failedCount.value += 1
issues.value.push({
id: item.id,
prescription_name: item.prescription_name || `处方 #${item.id}`,
status: 'failed',
message: errorMessage(error)
})
}
const requestStop = () => {
if (!running.value || stopRequested.value) return
stopRequested.value = true
}
const startBatch = async () => {
if (running.value || !pendingItems.value.length) return
const generation = dialogGeneration.value
const queue = pendingItems.value.slice(0, BATCH_LIMIT)
try {
await feedback.confirm(
`确定按顺序为 ${queue.length} 份处方生成 AI 报告吗?每份会调用双模型,过程中可请求停止。`
)
} catch {
return
}
if (!isCurrentDialog(generation) || running.value) return
resetRunState()
runTotal.value = queue.length
running.value = true
refreshError.value = ''
const noLongerMissingIds = new Set<number>()
try {
for (const item of queue) {
if (!isCurrentDialog(generation) || stopRequested.value) break
currentItem.value = item
try {
const response = await prescriptionLibraryGenerateAiReports({ id: item.id })
if (!isCurrentDialog(generation)) return
if (recordGenerationResult(item, response)) noLongerMissingIds.add(item.id)
} catch (error) {
if (!isCurrentDialog(generation)) return
recordRequestFailure(item, error)
}
processedCount.value += 1
currentItem.value = null
}
} finally {
if (!isCurrentDialog(generation)) return
const stoppedEarly = stopRequested.value && processedCount.value < runTotal.value
runStatus.value = stoppedEarly ? 'stopped' : 'completed'
running.value = false
currentItem.value = null
if (noLongerMissingIds.size) {
pendingItems.value = pendingItems.value.filter(
(item) => !noLongerMissingIds.has(item.id)
)
missingTotal.value = Math.max(0, missingTotal.value - noLongerMissingIds.size)
}
if (processedCount.value > 0) emit('changed')
const close = deferredClose.value
deferredClose.value = null
if (close) {
close()
return
}
await loadMissingReports(generation, { silent: true })
if (!isCurrentDialog(generation)) return
if (stoppedEarly) {
feedback.msgWarning(`已停止,本轮共处理 ${processedCount.value} 份处方`)
} else if (failedCount.value > 0 || partialCount.value > 0) {
feedback.msgWarning('批量生成已完成,请查看失败与部分成功明细')
} else {
feedback.msgSuccess('批量生成报告完成')
}
}
}
const handleBeforeClose = (done: () => void) => {
if (!running.value) {
done()
return
}
requestStop()
deferredClose.value = done
feedback.msgWarning('已请求停止,当前处方完成后将关闭窗口')
}
const handleClosed = () => {
++dialogGeneration.value
++loadGeneration.value
deferredClose.value = null
running.value = false
resetDialogState()
}
const open = () => {
if (running.value) {
visible.value = true
return
}
const generation = ++dialogGeneration.value
++loadGeneration.value
resetDialogState()
visible.value = true
void loadMissingReports(generation)
}
defineExpose({ open })
</script>
<style scoped lang="scss">
.batch-dialog-body {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 260px;
}
.loading-state,
.load-error-state {
padding: 28px 12px;
}
.state-description {
max-width: 520px;
margin: 0 auto 14px;
color: var(--el-text-color-secondary);
line-height: 1.6;
}
.overview-panel,
.progress-panel,
.pending-panel,
.issues-panel {
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: var(--el-bg-color);
}
.overview-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
strong {
margin-left: 10px;
color: var(--el-color-primary);
font-size: 28px;
line-height: 1;
}
}
.overview-label,
.overview-unit,
.overview-meta,
.pending-count,
.footer-tip {
color: var(--el-text-color-secondary);
}
.overview-unit {
margin-left: 5px;
}
.overview-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.truncated-alert,
.stop-alert {
flex-shrink: 0;
}
.refresh-error {
display: flex;
align-items: center;
gap: 12px;
:deep(.el-alert) {
flex: 1;
}
}
.progress-panel,
.pending-panel,
.issues-panel {
padding: 18px;
}
.progress-heading,
.section-heading,
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.progress-heading,
.section-heading {
margin-bottom: 14px;
}
.section-kicker {
display: block;
margin-bottom: 3px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.progress-heading strong,
.section-heading strong {
font-size: 16px;
}
.current-prescription {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
margin-top: 14px;
padding: 12px 14px;
border-radius: 8px;
background: var(--el-fill-color-light);
span,
small {
color: var(--el-text-color-secondary);
}
}
.counter-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
.counter-item {
padding: 12px;
border-radius: 8px;
background: var(--el-fill-color-light);
span {
display: block;
margin-bottom: 5px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
strong {
font-size: 22px;
}
}
.counter-success strong {
color: var(--el-color-success);
}
.counter-partial strong {
color: var(--el-color-warning);
}
.counter-failed strong {
color: var(--el-color-danger);
}
.stop-alert {
margin-top: 14px;
}
.issue-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.issues-panel small {
color: var(--el-text-color-secondary);
}
.dialog-footer {
width: 100%;
}
.footer-tip {
font-size: 12px;
}
@media (max-width: 640px) {
.overview-panel,
.overview-meta,
.refresh-error,
.dialog-footer {
align-items: flex-start;
flex-direction: column;
}
.counter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.current-prescription {
grid-template-columns: 1fr;
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,494 +1,494 @@
/**
* 处方业务订单——纯展示/格式化工具
*
* 唯一数据源:order_list.vue(处方业务订单)、PatientOrderList.vue(诊单业务订单 Tab
* 与 PrescriptionOrderDetailDrawer.vue(共享详情抽屉)共用,禁止在页面内重复定义同名函数,
* 否则两个详情页面会再次出现文案/口径漂移。
*/
/** 与诊间医助角色 ID 一致(server 角色表) */
export const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
export function isRemoteSnapshotLocked(row: Record<string, unknown> | null | undefined): boolean {
if (!row) return false
if (String(row.gancao_reciperl_order_no || '').trim()) return true
if (String(row.ej_pharmacy_order_no || '').trim()) return true
if (Number(row.gancao_submit_time || 0) > 0 || Number(row.ej_pharmacy_submit_time || 0) > 0) {
return true
}
return ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'].includes(
String(row.pharmacy_claim_status || '').toUpperCase()
)
}
export type SupplyMode = 'gancao' | 'direct' | 'self'
export function supplyModeKey(row: Record<string, unknown> | null | undefined): SupplyMode {
if (
String(row?.ship_mode || '')
.trim()
.toLowerCase() === 'direct'
)
return 'direct'
if (String(row?.gancao_reciperl_order_no || '').trim()) return 'gancao'
return 'self'
}
export function supplyModeLabel(row: Record<string, unknown> | null | undefined): string {
const mode = supplyModeKey(row)
if (mode === 'direct') return '洛阳直发'
return mode === 'gancao' ? '甘草' : '自营'
}
export function supplyModeTagType(
row: Record<string, unknown> | null | undefined
): 'success' | 'warning' | 'info' {
const mode = supplyModeKey(row)
if (mode === 'direct') return 'warning'
return mode === 'gancao' ? 'success' : 'info'
}
export function formatTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
const d = new Date(v * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
return String(v)
}
export function formatMoney(v: unknown) {
const n = Number(v)
if (Number.isNaN(n)) return '—'
return n.toFixed(2)
}
export function formatOrderTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'string' && String(v).includes('-')) return String(v)
return formatTime(v)
}
export function feeTypeText(t: number | undefined) {
const m: Record<number, string> = {
1: '挂号费',
2: '问诊费',
3: '药品费用',
4: '首付费用',
5: '尾款费用',
6: '其他费用',
7: '全部费用',
8: '驼奶费用'
}
return m[Number(t)] ?? '—'
}
export function fulfillmentText(s: number | undefined) {
const m: Record<number, string> = {
1: '待双审通过',
2: '待发货',
3: '已完成',
4: '已取消',
5: '已发货',
6: '已签收',
7: '进行中',
8: '暂不制药',
9: '拒收',
10: '退款',
11: '保留药方',
12: '制药缓发'
}
return m[Number(s)] ?? '—'
}
export function fulfillmentTagType(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
const n = Number(s)
if (n === 3) return 'success'
if (n === 6) return 'success'
if (n === 5) return 'primary'
if (n === 4) return 'danger'
if (n === 2) return 'warning'
if (n === 7) return 'warning'
if (n === 8 || n === 11 || n === 12) return 'info'
if (n === 9 || n === 10) return 'danger'
return 'info'
}
export function orderStatusText(s: number | undefined) {
const m: Record<number, string> = {
1: '待支付',
2: '已支付',
3: '已取消',
4: '已退款',
5: '待审核'
}
return m[Number(s)] ?? '—'
}
export function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 2) return 'success'
if (n === 1) return 'warning'
return 'info'
}
export function consumerRxAuditText(s: number | undefined) {
const n = Number(s)
if (n === 1) return '已通过'
if (n === 2) return '已驳回'
return '待审核'
}
export function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 1) return 'success'
if (n === 2) return 'danger'
return 'warning'
}
export function expressCompanyLabel(v: unknown) {
const s = String(v || '').toLowerCase()
if (s === 'sf') return '顺丰速运'
if (s === 'jd') return '京东快递'
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
return '自动识别'
}
export function logActionText(act: string) {
const m: Record<string, string> = {
create: '创建',
edit: '编辑',
audit_rx_approve: '处方审核',
audit_rx_reject: '处方审核',
audit_pay_approve: '支付审核',
audit_pay_reject: '支付审核',
fill_tracking: '填快递单',
ship: '确认发货',
withdraw: '撤销',
link_pay_order: '关联支付单',
completion_request: '完单申请',
auto_complete: '自动完成',
revoke_rx_audit: '撤回处方审核',
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
ej_pharmacy_submit: '洛阳药房下单',
ej_pharmacy_callback: '洛阳药房状态',
patch_rx_patient: '处方患者信息',
patch_rx_usage: '服用参数',
update_amount: '修改订单金额',
complete: '完成订单',
refund: '退款',
manual_log: '手工备注',
assign_assistant: '改派医助',
add_pay_order: '补齐支付单',
set_ship_mode: '发货类型'
}
return m[act] || act
}
/** 处方/支付单审核状态文案(0 待审核 / 1 已通过 / 2 已驳回) */
export function auditStatusText(s: number | undefined) {
if (s === 1) return '已通过'
if (s === 2) return '已驳回'
return '待审核'
}
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
const createType = String(row?.create_type || '')
if (createType === 'wechat_work') return '企业微信对外收款'
if (createType === 'fubei') return '付呗'
if (createType === 'express_cod') return '快递代收'
const paymentMethod = String(row?.payment_method || '')
const methodMap: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
wechat_work: '企业微信',
fubei: '付呗',
express_cod: '快递代收',
manual: '手动确认到账'
}
if (paymentMethod && methodMap[paymentMethod]) {
return methodMap[paymentMethod]
}
return '普通订单'
}
export function normalizeBizPhone(v: unknown): string {
if (v === null || v === undefined) return ''
return String(v).replace(/\s/g, '').trim()
}
export function recipientVsPrescriptionPhoneMismatch(recipient: unknown, rxPhone: unknown): boolean {
const a = normalizeBizPhone(recipient)
const b = normalizeBizPhone(rxPhone)
if (!a || !b) return false
return a !== b
}
export function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
if (!raw) return []
if (Array.isArray(raw)) {
return raw.map((x: any) => ({
name: String(x?.name ?? '').trim(),
dosage: Number(x?.dosage) || 0
}))
}
return []
}
export type SlipFormulaType = '主方' | '辅方'
export interface SlipAuxUsageForm {
dosage_amount?: number
dosage_bag_count: number
need_decoction: boolean
bags_per_dose: number
times_per_day: number
usage_days: number
}
export function normalizeSlipFormulaType(v: unknown): SlipFormulaType {
return v === '辅方' ? '辅方' : '主方'
}
export function defaultSlipAuxUsage(prescriptionType = '浓缩水丸'): SlipAuxUsageForm {
if (prescriptionType === '饮片') {
return {
dosage_amount: 50,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
if (prescriptionType === '浓缩水丸') {
return {
dosage_amount: 5,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
return {
dosage_amount: 1,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
export function normalizeSlipAuxUsageForm(raw: unknown, prescriptionType: string): SlipAuxUsageForm {
const base = defaultSlipAuxUsage(prescriptionType)
if (!raw || typeof raw !== 'object') return { ...base }
const o = raw as Record<string, unknown>
return {
dosage_amount:
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
? Number(o.dosage_amount)
: base.dosage_amount,
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
}
}
/** 是否含辅方药材(仅 formula_type=辅方;医助无药材权限时 herbs 被剥离,用后端 has_aux_formula */
export function prescriptionHasAuxFormula(rx: Record<string, unknown> | null | undefined): boolean {
if (!rx) return false
const herbs = rx.herbs
if (Array.isArray(herbs)) {
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
}
return Number(rx.has_aux_formula) === 1
}
/** 物流轨迹中需人工立即跟进的常见异常关键词 */
export const LOGISTICS_URGENT_KEYWORDS = [
'拒收',
'拒签',
'退件',
'客户拒收',
'拦截退回',
'退回发件',
'派件退回',
'无人签收',
'退回快件'
]
export function logisticsStringHasUrgentKeyword(s: unknown): boolean {
const t = String(s ?? '')
return LOGISTICS_URGENT_KEYWORDS.some((k) => t.includes(k))
}
export function logisticsTraceLineUrgent(context: unknown): boolean {
return logisticsStringHasUrgentKeyword(context)
}
/** 根据单次拉取的物流 payload 判断是否含拒收/退回等(详情抽屉、完成订单弹窗共用) */
export function analyzeLogisticsPayloadUrgent(p: Record<string, any> | null | undefined): {
show: boolean
keywords: string[]
lines: string[]
} {
if (!p) {
return { show: false, keywords: [], lines: [] }
}
const traces = Array.isArray(p.traces)
? (p.traces as Array<{ time?: string; context?: string }>)
: []
const chunks: string[] = []
if (p.state_text) chunks.push(String(p.state_text))
if (p.hint) chunks.push(String(p.hint))
for (const row of traces) {
if (row.context) chunks.push(String(row.context))
}
const joined = chunks.join('\n')
const keywords = LOGISTICS_URGENT_KEYWORDS.filter((k) => joined.includes(k))
if (!keywords.length) {
return { show: false, keywords: [], lines: [] }
}
const lines: string[] = []
for (const row of traces) {
const ctx = String(row.context || '')
if (logisticsStringHasUrgentKeyword(ctx)) {
lines.push(ctx)
if (lines.length >= 5) break
}
}
if (!lines.length && logisticsStringHasUrgentKeyword(p.state_text)) {
lines.push(`最新状态:${p.state_text}`)
}
if (!lines.length && logisticsStringHasUrgentKeyword(p.hint)) {
lines.push(String(p.hint))
}
return { show: true, keywords, lines }
}
/** 校验物流轨迹响应与请求上下文匹配(防止快速切换订单时旧响应串单) */
export function parseLogisticsTracePayload(
res: unknown,
expectedTrackingNumber: string,
expectedOrderId: number
): Record<string, any> | null {
const raw = (res as { data?: unknown })?.data ?? res
if (!raw || typeof raw !== 'object') return null
const payload = raw as Record<string, any>
const respNum = String(payload.tracking_number || '').trim()
if (respNum && respNum !== expectedTrackingNumber) return null
const respOrderId = Number(payload.order_id)
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
return payload
}
export function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
if (!row?.id) return false
const fs = Number(row.fulfillment_status)
return fs !== 3 && fs !== 4
}
/** 处方忌口:库内逗号分隔字符串或前端多选数组,统一为「、」连接展示 */
export function formatDietaryTaboo(raw: unknown): string {
if (Array.isArray(raw)) {
return raw.map((v) => String(v ?? '').trim()).filter(Boolean).join('、')
}
if (typeof raw === 'string' && raw.trim()) {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join('、')
}
return ''
}
/** 服务套餐 dictserver_order */
export type ServicePackageOption = { name: string; value: string; status?: number }
export function normalizeServicePackageValue(v: unknown): string {
return String(v ?? '').trim()
}
/** 解析订单 service_package(逗号串 / 数组 / 单值数字) */
export function parseServicePackageValues(raw: unknown): string[] {
if (raw == null || raw === '') return []
if (Array.isArray(raw)) {
return raw.map(normalizeServicePackageValue).filter(Boolean)
}
if (typeof raw === 'string') {
return raw.split(',').map((v) => v.trim()).filter(Boolean)
}
const one = normalizeServicePackageValue(raw)
return one ? [one] : []
}
export function servicePackageValueEquals(a: unknown, b: unknown): boolean {
const sa = normalizeServicePackageValue(a)
const sb = normalizeServicePackageValue(b)
if (!sa || !sb) return false
if (sa === sb) return true
const na = Number(sa)
const nb = Number(sb)
return Number.isFinite(na) && Number.isFinite(nb) && na === nb
}
export function normalizeServicePackageOptions(raw: unknown): ServicePackageOption[] {
if (!Array.isArray(raw)) return []
return raw
.map((item: any) => ({
name: String(item?.name ?? '').trim() || normalizeServicePackageValue(item?.value),
value: normalizeServicePackageValue(item?.value),
status: Number(item?.status ?? 1)
}))
.filter((item) => item.value !== '')
}
export function findServicePackageOption(
options: ServicePackageOption[],
value: unknown
): ServicePackageOption | undefined {
const key = normalizeServicePackageValue(value)
if (!key) return undefined
return options.find((opt) => servicePackageValueEquals(opt.value, key))
}
/** 展示用:value → 字典 name,多选用「、」连接 */
export function formatServicePackageLabels(
value: unknown,
options: ServicePackageOption[],
emptyText = '—'
): string {
const packages = parseServicePackageValues(value)
if (packages.length === 0) return emptyText
const names = packages.map((val) => {
const option = findServicePackageOption(options, val)
return option?.name || val
})
return names.join('、')
}
/** 编辑下拉:字典项 + 当前已选但字典缺失的兜底项 */
export function mergeServicePackageSelectOptions(
options: ServicePackageOption[],
selected: unknown[]
): ServicePackageOption[] {
const known = new Set(options.map((o) => o.value))
const extras: ServicePackageOption[] = []
for (const raw of selected) {
const val = normalizeServicePackageValue(raw)
if (!val || known.has(val)) continue
const matched = findServicePackageOption(options, val)
extras.push(matched ?? { name: val, value: val, status: 0 })
known.add(val)
}
return extras.length ? [...options, ...extras] : options
}
/**
* 处方业务订单——纯展示/格式化工具
*
* 唯一数据源:order_list.vue(处方业务订单)、PatientOrderList.vue(诊单业务订单 Tab
* 与 PrescriptionOrderDetailDrawer.vue(共享详情抽屉)共用,禁止在页面内重复定义同名函数,
* 否则两个详情页面会再次出现文案/口径漂移。
*/
/** 与诊间医助角色 ID 一致(server 角色表) */
export const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
export function isRemoteSnapshotLocked(row: Record<string, unknown> | null | undefined): boolean {
if (!row) return false
if (String(row.gancao_reciperl_order_no || '').trim()) return true
if (String(row.ej_pharmacy_order_no || '').trim()) return true
if (Number(row.gancao_submit_time || 0) > 0 || Number(row.ej_pharmacy_submit_time || 0) > 0) {
return true
}
return ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'].includes(
String(row.pharmacy_claim_status || '').toUpperCase()
)
}
export type SupplyMode = 'gancao' | 'direct' | 'self'
export function supplyModeKey(row: Record<string, unknown> | null | undefined): SupplyMode {
if (
String(row?.ship_mode || '')
.trim()
.toLowerCase() === 'direct'
)
return 'direct'
if (String(row?.gancao_reciperl_order_no || '').trim()) return 'gancao'
return 'self'
}
export function supplyModeLabel(row: Record<string, unknown> | null | undefined): string {
const mode = supplyModeKey(row)
if (mode === 'direct') return '洛阳直发'
return mode === 'gancao' ? '甘草' : '自营'
}
export function supplyModeTagType(
row: Record<string, unknown> | null | undefined
): 'success' | 'warning' | 'info' {
const mode = supplyModeKey(row)
if (mode === 'direct') return 'warning'
return mode === 'gancao' ? 'success' : 'info'
}
export function formatTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
const d = new Date(v * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
return String(v)
}
export function formatMoney(v: unknown) {
const n = Number(v)
if (Number.isNaN(n)) return '—'
return n.toFixed(2)
}
export function formatOrderTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'string' && String(v).includes('-')) return String(v)
return formatTime(v)
}
export function feeTypeText(t: number | undefined) {
const m: Record<number, string> = {
1: '挂号费',
2: '问诊费',
3: '药品费用',
4: '首付费用',
5: '尾款费用',
6: '其他费用',
7: '全部费用',
8: '驼奶费用'
}
return m[Number(t)] ?? '—'
}
export function fulfillmentText(s: number | undefined) {
const m: Record<number, string> = {
1: '待双审通过',
2: '待发货',
3: '已完成',
4: '已取消',
5: '已发货',
6: '已签收',
7: '进行中',
8: '暂不制药',
9: '拒收',
10: '退款',
11: '保留药方',
12: '制药缓发'
}
return m[Number(s)] ?? '—'
}
export function fulfillmentTagType(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
const n = Number(s)
if (n === 3) return 'success'
if (n === 6) return 'success'
if (n === 5) return 'primary'
if (n === 4) return 'danger'
if (n === 2) return 'warning'
if (n === 7) return 'warning'
if (n === 8 || n === 11 || n === 12) return 'info'
if (n === 9 || n === 10) return 'danger'
return 'info'
}
export function orderStatusText(s: number | undefined) {
const m: Record<number, string> = {
1: '待支付',
2: '已支付',
3: '已取消',
4: '已退款',
5: '待审核'
}
return m[Number(s)] ?? '—'
}
export function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 2) return 'success'
if (n === 1) return 'warning'
return 'info'
}
export function consumerRxAuditText(s: number | undefined) {
const n = Number(s)
if (n === 1) return '已通过'
if (n === 2) return '已驳回'
return '待审核'
}
export function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 1) return 'success'
if (n === 2) return 'danger'
return 'warning'
}
export function expressCompanyLabel(v: unknown) {
const s = String(v || '').toLowerCase()
if (s === 'sf') return '顺丰速运'
if (s === 'jd') return '京东快递'
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
return '自动识别'
}
export function logActionText(act: string) {
const m: Record<string, string> = {
create: '创建',
edit: '编辑',
audit_rx_approve: '处方审核',
audit_rx_reject: '处方审核',
audit_pay_approve: '支付审核',
audit_pay_reject: '支付审核',
fill_tracking: '填快递单',
ship: '确认发货',
withdraw: '撤销',
link_pay_order: '关联支付单',
completion_request: '完单申请',
auto_complete: '自动完成',
revoke_rx_audit: '撤回处方审核',
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
ej_pharmacy_submit: '洛阳药房下单',
ej_pharmacy_callback: '洛阳药房状态',
patch_rx_patient: '处方患者信息',
patch_rx_usage: '服用参数',
update_amount: '修改订单金额',
complete: '完成订单',
refund: '退款',
manual_log: '手工备注',
assign_assistant: '改派医助',
add_pay_order: '补齐支付单',
set_ship_mode: '发货类型'
}
return m[act] || act
}
/** 处方/支付单审核状态文案(0 待审核 / 1 已通过 / 2 已驳回) */
export function auditStatusText(s: number | undefined) {
if (s === 1) return '已通过'
if (s === 2) return '已驳回'
return '待审核'
}
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
const createType = String(row?.create_type || '')
if (createType === 'wechat_work') return '企业微信对外收款'
if (createType === 'fubei') return '付呗'
if (createType === 'express_cod') return '快递代收'
const paymentMethod = String(row?.payment_method || '')
const methodMap: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
wechat_work: '企业微信',
fubei: '付呗',
express_cod: '快递代收',
manual: '手动确认到账'
}
if (paymentMethod && methodMap[paymentMethod]) {
return methodMap[paymentMethod]
}
return '普通订单'
}
export function normalizeBizPhone(v: unknown): string {
if (v === null || v === undefined) return ''
return String(v).replace(/\s/g, '').trim()
}
export function recipientVsPrescriptionPhoneMismatch(recipient: unknown, rxPhone: unknown): boolean {
const a = normalizeBizPhone(recipient)
const b = normalizeBizPhone(rxPhone)
if (!a || !b) return false
return a !== b
}
export function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
if (!raw) return []
if (Array.isArray(raw)) {
return raw.map((x: any) => ({
name: String(x?.name ?? '').trim(),
dosage: Number(x?.dosage) || 0
}))
}
return []
}
export type SlipFormulaType = '主方' | '辅方'
export interface SlipAuxUsageForm {
dosage_amount?: number
dosage_bag_count: number
need_decoction: boolean
bags_per_dose: number
times_per_day: number
usage_days: number
}
export function normalizeSlipFormulaType(v: unknown): SlipFormulaType {
return v === '辅方' ? '辅方' : '主方'
}
export function defaultSlipAuxUsage(prescriptionType = '浓缩水丸'): SlipAuxUsageForm {
if (prescriptionType === '饮片') {
return {
dosage_amount: 50,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
if (prescriptionType === '浓缩水丸') {
return {
dosage_amount: 5,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
return {
dosage_amount: 1,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
export function normalizeSlipAuxUsageForm(raw: unknown, prescriptionType: string): SlipAuxUsageForm {
const base = defaultSlipAuxUsage(prescriptionType)
if (!raw || typeof raw !== 'object') return { ...base }
const o = raw as Record<string, unknown>
return {
dosage_amount:
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
? Number(o.dosage_amount)
: base.dosage_amount,
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
}
}
/** 是否含辅方药材(仅 formula_type=辅方;医助无药材权限时 herbs 被剥离,用后端 has_aux_formula */
export function prescriptionHasAuxFormula(rx: Record<string, unknown> | null | undefined): boolean {
if (!rx) return false
const herbs = rx.herbs
if (Array.isArray(herbs)) {
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
}
return Number(rx.has_aux_formula) === 1
}
/** 物流轨迹中需人工立即跟进的常见异常关键词 */
export const LOGISTICS_URGENT_KEYWORDS = [
'拒收',
'拒签',
'退件',
'客户拒收',
'拦截退回',
'退回发件',
'派件退回',
'无人签收',
'退回快件'
]
export function logisticsStringHasUrgentKeyword(s: unknown): boolean {
const t = String(s ?? '')
return LOGISTICS_URGENT_KEYWORDS.some((k) => t.includes(k))
}
export function logisticsTraceLineUrgent(context: unknown): boolean {
return logisticsStringHasUrgentKeyword(context)
}
/** 根据单次拉取的物流 payload 判断是否含拒收/退回等(详情抽屉、完成订单弹窗共用) */
export function analyzeLogisticsPayloadUrgent(p: Record<string, any> | null | undefined): {
show: boolean
keywords: string[]
lines: string[]
} {
if (!p) {
return { show: false, keywords: [], lines: [] }
}
const traces = Array.isArray(p.traces)
? (p.traces as Array<{ time?: string; context?: string }>)
: []
const chunks: string[] = []
if (p.state_text) chunks.push(String(p.state_text))
if (p.hint) chunks.push(String(p.hint))
for (const row of traces) {
if (row.context) chunks.push(String(row.context))
}
const joined = chunks.join('\n')
const keywords = LOGISTICS_URGENT_KEYWORDS.filter((k) => joined.includes(k))
if (!keywords.length) {
return { show: false, keywords: [], lines: [] }
}
const lines: string[] = []
for (const row of traces) {
const ctx = String(row.context || '')
if (logisticsStringHasUrgentKeyword(ctx)) {
lines.push(ctx)
if (lines.length >= 5) break
}
}
if (!lines.length && logisticsStringHasUrgentKeyword(p.state_text)) {
lines.push(`最新状态:${p.state_text}`)
}
if (!lines.length && logisticsStringHasUrgentKeyword(p.hint)) {
lines.push(String(p.hint))
}
return { show: true, keywords, lines }
}
/** 校验物流轨迹响应与请求上下文匹配(防止快速切换订单时旧响应串单) */
export function parseLogisticsTracePayload(
res: unknown,
expectedTrackingNumber: string,
expectedOrderId: number
): Record<string, any> | null {
const raw = (res as { data?: unknown })?.data ?? res
if (!raw || typeof raw !== 'object') return null
const payload = raw as Record<string, any>
const respNum = String(payload.tracking_number || '').trim()
if (respNum && respNum !== expectedTrackingNumber) return null
const respOrderId = Number(payload.order_id)
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
return payload
}
export function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
if (!row?.id) return false
const fs = Number(row.fulfillment_status)
return fs !== 3 && fs !== 4
}
/** 处方忌口:库内逗号分隔字符串或前端多选数组,统一为「、」连接展示 */
export function formatDietaryTaboo(raw: unknown): string {
if (Array.isArray(raw)) {
return raw.map((v) => String(v ?? '').trim()).filter(Boolean).join('、')
}
if (typeof raw === 'string' && raw.trim()) {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join('、')
}
return ''
}
/** 服务套餐 dictserver_order */
export type ServicePackageOption = { name: string; value: string; status?: number }
export function normalizeServicePackageValue(v: unknown): string {
return String(v ?? '').trim()
}
/** 解析订单 service_package(逗号串 / 数组 / 单值数字) */
export function parseServicePackageValues(raw: unknown): string[] {
if (raw == null || raw === '') return []
if (Array.isArray(raw)) {
return raw.map(normalizeServicePackageValue).filter(Boolean)
}
if (typeof raw === 'string') {
return raw.split(',').map((v) => v.trim()).filter(Boolean)
}
const one = normalizeServicePackageValue(raw)
return one ? [one] : []
}
export function servicePackageValueEquals(a: unknown, b: unknown): boolean {
const sa = normalizeServicePackageValue(a)
const sb = normalizeServicePackageValue(b)
if (!sa || !sb) return false
if (sa === sb) return true
const na = Number(sa)
const nb = Number(sb)
return Number.isFinite(na) && Number.isFinite(nb) && na === nb
}
export function normalizeServicePackageOptions(raw: unknown): ServicePackageOption[] {
if (!Array.isArray(raw)) return []
return raw
.map((item: any) => ({
name: String(item?.name ?? '').trim() || normalizeServicePackageValue(item?.value),
value: normalizeServicePackageValue(item?.value),
status: Number(item?.status ?? 1)
}))
.filter((item) => item.value !== '')
}
export function findServicePackageOption(
options: ServicePackageOption[],
value: unknown
): ServicePackageOption | undefined {
const key = normalizeServicePackageValue(value)
if (!key) return undefined
return options.find((opt) => servicePackageValueEquals(opt.value, key))
}
/** 展示用:value → 字典 name,多选用「、」连接 */
export function formatServicePackageLabels(
value: unknown,
options: ServicePackageOption[],
emptyText = '—'
): string {
const packages = parseServicePackageValues(value)
if (packages.length === 0) return emptyText
const names = packages.map((val) => {
const option = findServicePackageOption(options, val)
return option?.name || val
})
return names.join('、')
}
/** 编辑下拉:字典项 + 当前已选但字典缺失的兜底项 */
export function mergeServicePackageSelectOptions(
options: ServicePackageOption[],
selected: unknown[]
): ServicePackageOption[] {
const known = new Set(options.map((o) => o.value))
const extras: ServicePackageOption[] = []
for (const raw of selected) {
const val = normalizeServicePackageValue(raw)
if (!val || known.has(val)) continue
const matched = findServicePackageOption(options, val)
extras.push(matched ?? { name: val, value: val, status: 0 })
known.add(val)
}
return extras.length ? [...options, ...extras] : options
}
File diff suppressed because it is too large Load Diff
+73 -24
View File
@@ -31,7 +31,7 @@
</el-form-item>
</el-form>
</el-card>
<el-card v-loading="pager.loading" class="mt-4 !border-none" shadow="never">
<el-button type="primary" @click="handleAdd" v-perms="['wcf.prescription/add']">
<template #icon>
@@ -39,7 +39,15 @@
</template>
新增处方
</el-button>
<el-button
v-if="canBatchGenerateAiReports"
type="primary"
plain
@click="handleBatchAiReports"
>
批量生成报告
</el-button>
<div class="mt-4">
<el-table :data="pager.lists" size="large">
<el-table-column label="ID" prop="id" min-width="60" />
@@ -80,11 +88,22 @@
</el-table-column>
<el-table-column label="创建人" prop="creator_name" min-width="100" />
<el-table-column label="创建时间" prop="create_time" min-width="160" />
<el-table-column label="操作" width="180" fixed="right">
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleView(row)" v-perms="['wcf.prescription/read']">
查看
</el-button>
<el-button
type="primary"
link
@click="handleAiExplain(row)"
v-perms="[
'tcm.prescriptionLibrary/aiReports',
'wcf.prescription/read'
]"
>
AI解释
</el-button>
<el-button
v-if="canEditPrescription(row)"
type="primary"
@@ -107,12 +126,12 @@
</el-table-column>
</el-table>
</div>
<div class="flex mt-4 justify-end">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="showEdit"
@@ -142,7 +161,7 @@
<el-radio value="辅方">辅方</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="药材配方" prop="herbs" required>
<div class="w-full">
<el-button
@@ -191,7 +210,7 @@
</el-table>
</div>
</el-form-item>
<el-form-item label="是否公开" prop="is_public">
<el-switch
v-model="editForm.is_public"
@@ -220,7 +239,7 @@
</div>
</el-form-item>
</el-form>
<template #footer v-if="editMode !== 'view'">
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
@@ -228,21 +247,30 @@
</el-button>
</template>
</el-dialog>
<PrescriptionAiReportDialog ref="aiReportDialogRef" />
<PrescriptionAiBatchGenerateDialog
ref="aiBatchGenerateDialogRef"
@changed="getLists"
/>
</div>
</template>
<script lang="ts" setup name="prescriptionLibrary">
import {
prescriptionLibraryLists,
prescriptionLibraryAdd,
prescriptionLibraryEdit,
prescriptionLibraryDelete
import {
prescriptionLibraryLists,
prescriptionLibraryAdd,
prescriptionLibraryEdit,
prescriptionLibraryDelete,
type PrescriptionLibraryRow
} from '@/api/tcm'
import { usePaging } from '@/hooks/usePaging'
import useUserStore from '@/stores/modules/user'
import feedback from '@/utils/feedback'
import type { FormInstance, FormRules } from 'element-plus'
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
import PrescriptionAiBatchGenerateDialog from './components/PrescriptionAiBatchGenerateDialog.vue'
import PrescriptionAiReportDialog from './components/PrescriptionAiReportDialog.vue'
// 表单数据
const formData = reactive({
@@ -255,6 +283,8 @@ const showEdit = ref(false)
const editMode = ref<'add' | 'edit' | 'view'>('add')
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const aiReportDialogRef = ref<InstanceType<typeof PrescriptionAiReportDialog>>()
const aiBatchGenerateDialogRef = ref<InstanceType<typeof PrescriptionAiBatchGenerateDialog>>()
// 编辑表单
const editForm = reactive({
@@ -275,6 +305,17 @@ const rules: FormRules = {
const userStore = useUserStore()
const canBatchGenerateAiReports = computed(() => {
const permissions = userStore.perms || []
return (
permissions.includes('*') ||
(
permissions.includes('tcm.prescriptionLibrary/missingAiReports') &&
permissions.includes('tcm.prescriptionLibrary/generateAiReports')
)
)
})
/** 与 server/config/project.php prescription_library_manage_all_roles 保持一致 */
const PRESCRIPTION_MANAGE_ALL_ROLE_IDS = [0, 3]
@@ -283,7 +324,7 @@ const { pager, getLists, resetParams, resetPage } = usePaging({
params: formData
})
const isPrescriptionOwner = (row: { creator_id?: number | string }) =>
const isPrescriptionOwner = (row: Pick<PrescriptionLibraryRow, 'creator_id'>) =>
Number(row.creator_id) === Number(userStore.userInfo?.id)
/** 超管或管理员角色:可编辑/删除任意处方(与后端 canManageAllPrescriptions 一致) */
@@ -295,11 +336,19 @@ const userCanManageAllPrescriptions = () => {
return PRESCRIPTION_MANAGE_ALL_ROLE_IDS.some((rid) => ids.includes(rid))
}
const canEditPrescription = (row: { creator_id?: number | string }) =>
const canEditPrescription = (row: Pick<PrescriptionLibraryRow, 'creator_id'>) =>
userCanManageAllPrescriptions() || isPrescriptionOwner(row)
const canDeletePrescription = canEditPrescription
const handleAiExplain = (row: PrescriptionLibraryRow) => {
aiReportDialogRef.value?.open(row)
}
const handleBatchAiReports = () => {
aiBatchGenerateDialogRef.value?.open()
}
// 添加药材
const addHerb = () => {
editForm.herbs.push({
@@ -331,7 +380,7 @@ const handleAdd = () => {
}
// 查看处方
const handleView = (row: any) => {
const handleView = (row: PrescriptionLibraryRow) => {
editMode.value = 'view'
editForm.id = row.id
editForm.prescription_name = row.prescription_name || ''
@@ -343,7 +392,7 @@ const handleView = (row: any) => {
}
// 编辑处方
const handleEdit = (row: any) => {
const handleEdit = (row: PrescriptionLibraryRow) => {
editMode.value = 'edit'
editForm.id = row.id
editForm.prescription_name = row.prescription_name || ''
@@ -357,15 +406,15 @@ const handleEdit = (row: any) => {
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
// 验证药材
if (!editForm.herbs || editForm.herbs.length === 0) {
feedback.msgError('请至少添加一味药材')
return
}
for (let i = 0; i < editForm.herbs.length; i++) {
const herb = editForm.herbs[i]
if (!herb.name || !herb.name.trim()) {
@@ -377,12 +426,12 @@ const handleSubmit = async () => {
return
}
}
submitLoading.value = true
try {
const params = { ...editForm }
if (editMode.value === 'add') {
await prescriptionLibraryAdd(params)
feedback.msgSuccess('添加成功')
@@ -390,7 +439,7 @@ const handleSubmit = async () => {
await prescriptionLibraryEdit(params)
feedback.msgSuccess('编辑成功')
}
showEdit.value = false
getLists()
} catch (error) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff