This commit is contained in:
Your Name
2026-10-17 16:51:48 +08:00
parent 56f85f7d89
commit ba2e2e4087
41 changed files with 2851 additions and 96 deletions
@@ -0,0 +1,49 @@
import request from '@/utils/request'
export interface PrescriptionAiVisibilityConfig {
role_ids: number[]
admin_ids: number[]
}
export interface PrescriptionAiVisibilityOptions {
roles: { id: number; name: string }[]
admins: { id: number; name: string; account: string }[]
}
export interface PrescriptionAiCapabilities {
agreement_visible: boolean
can_manage_visibility: boolean
}
// A failed or malformed read must never become an empty policy that can be saved.
function readVisibilityConfig(value: unknown): PrescriptionAiVisibilityConfig {
const config = value as Partial<PrescriptionAiVisibilityConfig> | null
const validIds = (ids: unknown): ids is number[] =>
Array.isArray(ids) && ids.every((id) => Number.isSafeInteger(id) && id > 0)
if (!config || !validIds(config.role_ids) || !validIds(config.admin_ids)) {
throw new Error('可见范围配置响应格式不正确')
}
return { role_ids: [...config.role_ids], admin_ids: [...config.admin_ids] }
}
export async function getPrescriptionAiVisibilityConfig() {
const data = await request.get({ url: '/tcm.prescriptionAi/getVisibilityConfig' })
return readVisibilityConfig(data)
}
export async function setPrescriptionAiVisibilityConfig(params: PrescriptionAiVisibilityConfig) {
const data = await request.post({ url: '/tcm.prescriptionAi/setVisibilityConfig', params })
return readVisibilityConfig(data)
}
export function getPrescriptionAiVisibilityOptions() {
return request.get({
url: '/tcm.prescriptionAi/visibilityOptions'
}) as Promise<PrescriptionAiVisibilityOptions>
}
export function getPrescriptionAiCapabilities() {
return request.get({
url: '/tcm.prescriptionAi/capabilities'
}) as Promise<PrescriptionAiCapabilities>
}
+2
View File
@@ -490,8 +490,10 @@ export function prescriptionOrderPatchPrescriptionUsage(params: {
times_per_day: number
usage_days: number
medication_days: number
dosage_bag_count?: number
aux_times_per_day?: number
aux_usage_days?: number
aux_dosage_bag_count?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
}
@@ -312,6 +312,17 @@
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
<el-button
v-if="!readonly && detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
class="ml-2"
@click="openPatchUsageDialog"
>
修改
</el-button>
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
@@ -325,6 +336,17 @@
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
<el-button
v-if="!readonly && detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
class="ml-2"
@click="openPatchUsageDialog"
>
修改
</el-button>
</template>
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
@@ -943,6 +965,15 @@
label-width="108px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
label="每次袋数"
prop="dosage_bag_count"
>
<el-select v-model="patchUsageForm.dosage_bag_count" class="w-full">
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
</el-select>
</el-form-item>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
@@ -968,6 +999,15 @@
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
label="每次袋数"
prop="aux_dosage_bag_count"
>
<el-select v-model="patchUsageForm.aux_dosage_bag_count" class="w-full">
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
</el-select>
</el-form-item>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
@@ -1432,10 +1472,12 @@ const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
dosage_bag_count: 1 as number | undefined,
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
aux_dosage_bag_count: 1 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
@@ -1444,9 +1486,15 @@ const patchUsageRules = computed<FormRules>(() => {
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
rules.dosage_bag_count = [{ required: true, message: '请选择主方每次袋数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
rules.aux_dosage_bag_count = [{ required: true, message: '请选择辅方每次袋数', trigger: 'change' }]
}
}
return rules
})
@@ -1459,6 +1507,7 @@ function openPatchUsageDialog() {
return
}
const aux = detailAuxUsage.value
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
@@ -1468,16 +1517,20 @@ function openPatchUsageDialog() {
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.aux_dosage_bag_count =
aux && Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.dosage_bag_count = 1
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.aux_dosage_bag_count = 1
patchUsageForm.medication_days = undefined
}
@@ -1498,17 +1551,25 @@ async function submitPatchUsage() {
times_per_day: number
usage_days: number
medication_days: number
dosage_bag_count?: number
aux_times_per_day?: number
aux_usage_days?: number
aux_dosage_bag_count?: number
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
payload.aux_dosage_bag_count = Number(patchUsageForm.aux_dosage_bag_count)
}
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
@@ -1763,7 +1824,7 @@ async function refresh() {
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
void fetchLogisticsTrace()
}
if (props.loadRelatedData) void fetchLogs(id)
if (props.loadRelatedData) await fetchLogs(id)
const diagId = d.diagnosis_id
if (props.loadRelatedData && diagId) {
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
@@ -883,6 +883,17 @@
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
<el-button
v-if="detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
class="ml-2"
@click="openPatchUsageDialog"
>
修改
</el-button>
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
@@ -896,6 +907,17 @@
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
<el-button
v-if="detailData.prescription_id && !String(detailData.prescription_detail_error || '').trim()"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
class="ml-2"
@click="openPatchUsageDialog"
>
修改
</el-button>
</template>
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
@@ -2261,6 +2283,15 @@
label-width="96px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
label="每次袋数"
prop="dosage_bag_count"
>
<el-select v-model="patchUsageForm.dosage_bag_count" class="w-full">
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
</el-select>
</el-form-item>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
@@ -2286,6 +2317,15 @@
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item
v-if="detailPrescription?.prescription_type === '浓缩水丸'"
label="每次袋数"
prop="aux_dosage_bag_count"
>
<el-select v-model="patchUsageForm.aux_dosage_bag_count" class="w-full">
<el-option v-for="count in 5" :key="count" :label="`${count}袋`" :value="count" />
</el-select>
</el-form-item>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
@@ -3954,10 +3994,12 @@ const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
dosage_bag_count: 1 as number | undefined,
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
aux_dosage_bag_count: 1 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
@@ -3966,9 +4008,15 @@ const patchUsageRules = computed<FormRules>(() => {
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
rules.dosage_bag_count = [{ required: true, message: '请选择主方每次袋数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
rules.aux_dosage_bag_count = [{ required: true, message: '请选择辅方每次袋数', trigger: 'change' }]
}
}
return rules
})
@@ -3981,6 +4029,7 @@ function openPatchUsageDialog() {
return
}
const aux = detailAuxUsage.value
patchUsageForm.dosage_bag_count = Number(rx.dosage_bag_count) > 0 ? Number(rx.dosage_bag_count) : 1
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
@@ -3990,16 +4039,20 @@ function openPatchUsageDialog() {
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.aux_dosage_bag_count =
aux && Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.dosage_bag_count = 1
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.aux_dosage_bag_count = 1
patchUsageForm.medication_days = undefined
}
@@ -4020,17 +4073,25 @@ async function submitPatchUsage() {
times_per_day: number
usage_days: number
medication_days: number
dosage_bag_count?: number
aux_times_per_day?: number
aux_usage_days?: number
aux_dosage_bag_count?: number
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
payload.dosage_bag_count = Number(patchUsageForm.dosage_bag_count)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
if (detailPrescription.value?.prescription_type === '浓缩水丸') {
payload.aux_dosage_bag_count = Number(patchUsageForm.aux_dosage_bag_count)
}
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
@@ -0,0 +1,227 @@
<template>
<div class="prescription-ai-visibility">
<el-card v-loading="loading" shadow="never" class="!border-none">
<div class="text-xl font-medium mb-[20px]">处方 AI 一致度可见范围</div>
<el-alert type="info" :closable="false" show-icon class="mb-5">
<template #title>指定用户组或用户可查看一致度</template>
<div class="leading-6">
用户命中任一指定系统角色,或被单独指定,即可查看桌面端“与 AI
一致度”、逐味对照和一致度统计。AI
报告继续按原有权限开放,已有报告不受影响,患者与处方的数据范围保持原有规则。
</div>
</el-alert>
<el-alert
v-if="loadError"
:title="loadError"
type="error"
:closable="false"
show-icon
class="mb-5"
/>
<div v-if="loading" role="status" class="mb-5">正在读取当前可见范围…</div>
<el-form
v-if="hasLoaded"
class="ls-form"
:model="formData"
:disabled="!ready || loading || saving"
label-width="140px"
>
<el-form-item label="指定用户组" prop="role_ids">
<div class="w-full max-w-[640px]">
<el-select
v-model="formData.role_ids"
class="w-full"
multiple
filterable
clearable
placeholder="请选择系统角色,可搜索角色名称"
>
<el-option
v-for="role in roleOptions"
:key="role.id"
:label="role.name"
:value="role.id"
/>
</el-select>
<div class="form-tips">账号拥有任一指定角色即可命中。</div>
</div>
</el-form-item>
<el-form-item label="指定用户" prop="admin_ids">
<div class="w-full max-w-[640px]">
<el-select
v-model="formData.admin_ids"
class="w-full"
multiple
filterable
clearable
placeholder="请选择用户,可搜索姓名或账号"
>
<el-option
v-for="admin in adminOptions"
:key="admin.id"
:label="admin.label"
:value="admin.id"
/>
</el-select>
<div class="form-tips">
单独指定的用户无需同时命中用户组。当前不在可选列表中的已配置 ID
会保留,移除后保存才会取消该项授权。
</div>
</div>
</el-form-item>
<el-form-item label="生效规则">
<div>
<div class="leading-6">指定用户组与指定用户按“或”生效。</div>
<div class="form-tips">
两项均为空时,所有账号(包括超级管理员)均隐藏一致度、对照与统计。
拥有本页管理权限不自动获得一致度查看权,超级管理员也需命中上述名单。
</div>
</div>
</el-form-item>
</el-form>
<el-alert
v-if="ready && !formData.role_ids.length && !formData.admin_ids.length"
title="当前选择为空:保存后所有账号将隐藏一致度、对照与统计,AI 报告仍保留。"
type="warning"
:closable="false"
show-icon
class="mb-5"
/>
<el-alert
v-if="saveError"
:title="saveError"
type="error"
:closable="false"
show-icon
class="mb-5"
/>
<el-button :disabled="loading || saving" @click="loadConfig">
{{ loadError ? '重试加载' : '重新加载' }}
</el-button>
</el-card>
<footer-btns v-if="canManage">
<el-button
type="primary"
:loading="saving"
:disabled="!ready || loading || saving"
@click="handleSubmit"
>
{{ saving ? '正在保存' : '保存' }}
</el-button>
</footer-btns>
</div>
</template>
<script setup lang="ts" name="prescriptionAiVisibility">
import { computed, reactive, ref } from 'vue'
import type {
PrescriptionAiVisibilityConfig,
PrescriptionAiVisibilityOptions
} from '@/api/setting/prescription_ai_visibility'
import {
getPrescriptionAiCapabilities,
getPrescriptionAiVisibilityConfig,
getPrescriptionAiVisibilityOptions,
setPrescriptionAiVisibilityConfig
} from '@/api/setting/prescription_ai_visibility'
import feedback from '@/utils/feedback'
const loading = ref(false)
const saving = ref(false)
const ready = ref(false)
const hasLoaded = ref(false)
const canManage = ref(false)
const loadError = ref('')
const saveError = ref('')
const formData = reactive<PrescriptionAiVisibilityConfig>({ role_ids: [], admin_ids: [] })
const optionsData = reactive<PrescriptionAiVisibilityOptions>({ roles: [], admins: [] })
const roleOptions = computed(() => {
const options = new Map(optionsData.roles.map((role) => [role.id, role]))
for (const id of formData.role_ids) {
if (!options.has(id)) {
options.set(id, { id, name: `已配置角色 #${id}(当前不在可选列表)` })
}
}
return [...options.values()]
})
const adminOptions = computed(() => {
const options = new Map(
optionsData.admins.map((admin) => [
admin.id,
{ id: admin.id, label: `${admin.name || `用户 #${admin.id}`}(${admin.account})` }
])
)
for (const id of formData.admin_ids) {
if (!options.has(id)) {
options.set(id, { id, label: `已配置用户 #${id}(当前不在可选列表)` })
}
}
return [...options.values()]
})
const assignConfig = (config: PrescriptionAiVisibilityConfig) => {
formData.role_ids = [...config.role_ids]
formData.admin_ids = [...config.admin_ids]
}
const loadConfig = async () => {
if (loading.value || saving.value) return
loading.value = true
ready.value = false
canManage.value = false
loadError.value = ''
saveError.value = ''
try {
const capabilities = await getPrescriptionAiCapabilities()
if (capabilities.can_manage_visibility !== true) {
loadError.value = '当前账号无权管理处方 AI 一致度可见范围。'
return
}
canManage.value = true
const [config, options] = await Promise.all([
getPrescriptionAiVisibilityConfig(),
getPrescriptionAiVisibilityOptions()
])
if (!Array.isArray(options?.roles) || !Array.isArray(options?.admins)) {
throw new Error('可选用户组或用户响应格式不正确')
}
// Apply both responses together; an incomplete load cannot overwrite the current policy.
assignConfig(config)
optionsData.roles = options.roles
optionsData.admins = options.admins
hasLoaded.value = true
ready.value = true
} catch {
loadError.value = '可见范围加载失败,尚未读取完整配置。请重试加载后再保存。'
} finally {
loading.value = false
}
}
const handleSubmit = async () => {
if (!ready.value || !canManage.value || loading.value || saving.value) return
saving.value = true
saveError.value = ''
try {
const config = await setPrescriptionAiVisibilityConfig({
role_ids: [...formData.role_ids],
admin_ids: [...formData.admin_ids]
})
assignConfig(config)
feedback.msgSuccess('可见范围已保存')
} catch {
saveError.value = '保存结果未确认,当前选择已保留。请重试保存,或重新加载以确认服务端策略。'
} finally {
saving.value = false
}
}
void loadConfig()
</script>
@@ -0,0 +1,253 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const ts = require('typescript')
const vue = require('vue')
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const root = path.resolve(__dirname, '..')
const apiPath = path.join(root, 'src/api/setting/prescription_ai_visibility.ts')
const pagePath = path.join(root, 'src/views/setting/prescription_ai_visibility/index.vue')
const { descriptor, errors } = parse(fs.readFileSync(pagePath, 'utf8'), { filename: pagePath })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'prescription-ai-visibility' })
function moduleFrom(source, dependencies) {
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.CommonJS,
esModuleInterop: true
}
}).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', compiled)(dependencies, module, module.exports)
return module.exports
}
const makeApi = (request) =>
moduleFrom(fs.readFileSync(apiPath, 'utf8'), (name) => {
assert.equal(name, '@/utils/request')
return request
})
const tick = () => new Promise((resolve) => setImmediate(resolve))
const deferred = () => {
let resolve
let reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
function makePage(overrides = {}) {
const saved = []
const messages = []
const calls = []
const api = {
getPrescriptionAiCapabilities: async () => {
calls.push('capabilities')
return { agreement_visible: false, can_manage_visibility: true }
},
getPrescriptionAiVisibilityConfig: async () => {
calls.push('config')
return { role_ids: [2, 90], admin_ids: [3, 91] }
},
getPrescriptionAiVisibilityOptions: async () => {
calls.push('options')
return {
roles: [{ id: 2, name: '医生' }],
admins: [{ id: 3, name: '张医生', account: 'zhang.doctor' }]
}
},
setPrescriptionAiVisibilityConfig: async (params) => {
saved.push(params)
return params
},
...overrides
}
const component = moduleFrom(script.content, (name) => {
if (name === 'vue') return vue
if (name === '@/api/setting/prescription_ai_visibility') return api
if (name === '@/utils/feedback') {
return { msgSuccess: (message) => messages.push(message) }
}
throw new Error(`Unexpected import: ${name}`)
}).default
const page = component.setup({}, { expose() {} })
return { page, api, saved, messages, calls }
}
test('API uses the dedicated routes and rejects malformed policy responses', async () => {
const calls = []
let response = { role_ids: [2], admin_ids: [3] }
const api = makeApi({
get: async (params) => {
calls.push(['get', params])
return response
},
post: async (params) => {
calls.push(['post', params])
return response
}
})
const data = await api.getPrescriptionAiVisibilityConfig()
data.role_ids.push(5)
assert.deepEqual(response.role_ids, [2])
await api.setPrescriptionAiVisibilityConfig({ role_ids: [], admin_ids: [] })
await api.getPrescriptionAiVisibilityOptions()
await api.getPrescriptionAiCapabilities()
assert.deepEqual(calls, [
['get', { url: '/tcm.prescriptionAi/getVisibilityConfig' }],
[
'post',
{
url: '/tcm.prescriptionAi/setVisibilityConfig',
params: { role_ids: [], admin_ids: [] }
}
],
['get', { url: '/tcm.prescriptionAi/visibilityOptions' }],
['get', { url: '/tcm.prescriptionAi/capabilities' }]
])
for (response of [
null,
{},
{ role_ids: [], admin_ids: ['3'] },
{ role_ids: [-1], admin_ids: [] }
]) {
await assert.rejects(api.getPrescriptionAiVisibilityConfig(), /响应格式不正确/)
await assert.rejects(
api.setPrescriptionAiVisibilityConfig({ role_ids: [], admin_ids: [] }),
/响应格式不正确/
)
}
})
test('initial loading is pending and cannot submit before the real policy is read', async () => {
const pending = deferred()
const { page, saved } = makePage({ getPrescriptionAiVisibilityConfig: () => pending.promise })
assert.equal(page.loading.value, true)
assert.equal(page.ready.value, false)
assert.equal(page.hasLoaded.value, false)
await page.handleSubmit()
assert.deepEqual(saved, [])
pending.reject(new Error('offline'))
await tick()
assert.equal(page.loading.value, false)
assert.equal(page.ready.value, false)
assert.match(page.loadError.value, /加载失败/)
await page.handleSubmit()
assert.deepEqual(saved, [])
})
test('a failed reload cannot apply a partial policy or overwrite saved selections', async () => {
const { page, api, saved } = makePage()
await tick()
api.getPrescriptionAiVisibilityConfig = async () => ({ role_ids: [], admin_ids: [] })
api.getPrescriptionAiVisibilityOptions = async () => {
throw new Error('options failed')
}
await page.loadConfig()
assert.deepEqual(page.formData.role_ids, [2, 90])
assert.deepEqual(page.formData.admin_ids, [3, 91])
assert.equal(page.hasLoaded.value, true)
assert.equal(page.ready.value, false)
await page.handleSubmit()
assert.deepEqual(saved, [])
api.getPrescriptionAiVisibilityOptions = async () => ({ roles: [], admins: [] })
await page.loadConfig()
assert.equal(page.ready.value, true)
assert.equal(page.loadError.value, '')
assert.deepEqual(page.formData.role_ids, [])
})
test('missing selected IDs stay visible and user labels support both name and account search', async () => {
const { page, saved } = makePage()
await tick()
assert.equal(page.canManage.value, true)
assert.equal(page.ready.value, true)
assert.deepEqual(
page.roleOptions.value.map((row) => row.id),
[2, 90]
)
assert.match(page.roleOptions.value[1].name, /#90.*当前不在可选列表/)
assert.deepEqual(
page.adminOptions.value.map((row) => row.id),
[3, 91]
)
assert.match(page.adminOptions.value[0].label, /张医生.*zhang\.doctor/)
assert.match(page.adminOptions.value[1].label, /#91.*当前不在可选列表/)
await page.handleSubmit()
assert.deepEqual(saved, [{ role_ids: [2, 90], admin_ids: [3, 91] }])
})
test('explicitly clearing both selectors saves the empty policy and applies normalized response', async () => {
const { page, saved, messages, api } = makePage()
await tick()
page.formData.role_ids = []
page.formData.admin_ids = []
await page.handleSubmit()
assert.deepEqual(saved, [{ role_ids: [], admin_ids: [] }])
assert.equal(messages.length, 1)
page.formData.role_ids = [90, 2, 90]
api.setPrescriptionAiVisibilityConfig = async () => ({ role_ids: [2, 90], admin_ids: [] })
await page.handleSubmit()
assert.deepEqual(page.formData.role_ids, [2, 90])
assert.equal(page.saving.value, false)
})
test('pending save blocks duplicate writes and reload; failed write keeps the user selection', async () => {
const pending = deferred()
const saved = []
const { page, calls } = makePage({
setPrescriptionAiVisibilityConfig: (params) => {
saved.push(params)
return pending.promise
}
})
await tick()
page.formData.role_ids = [90]
const first = page.handleSubmit()
assert.equal(page.saving.value, true)
await page.handleSubmit()
await page.loadConfig()
assert.equal(saved.length, 1)
assert.equal(calls.filter((name) => name === 'capabilities').length, 1)
pending.reject(new Error('offline'))
await first
assert.equal(page.saving.value, false)
assert.equal(page.ready.value, true)
assert.deepEqual(page.formData.role_ids, [90])
assert.deepEqual(page.formData.admin_ids, [3, 91])
assert.match(page.saveError.value, /当前选择已保留/)
})
test('management capability is independent from viewing and denies policy reads when absent', async () => {
const { page, calls, saved } = makePage({
getPrescriptionAiCapabilities: async () => ({
agreement_visible: true,
can_manage_visibility: false
})
})
await tick()
assert.deepEqual(calls, [])
assert.equal(page.canManage.value, false)
assert.equal(page.hasLoaded.value, false)
assert.equal(page.ready.value, false)
assert.match(page.loadError.value, /无权管理/)
await page.handleSubmit()
assert.deepEqual(saved, [])
})
test('actual settings template compiles with its script bindings', () => {
const compiled = compileTemplate({
source: descriptor.template.content,
filename: pagePath,
id: 'prescription-ai-visibility',
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(compiled.errors, [])
})
@@ -0,0 +1,167 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const ts = require('typescript')
const vue = require('vue')
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const selected = [
'patchUsageVisible', 'patchUsageSaving', 'patchUsageFormRef', 'patchUsageForm',
'patchUsageRules', 'openPatchUsageDialog', 'resetPatchUsageForm', 'submitPatchUsage'
]
for (const page of ['components/PrescriptionOrderDetailDrawer.vue', 'order_list_h5.vue']) {
const filename = path.join(__dirname, '../src/views/consumer/prescription', page)
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: page })
const template = compileTemplate({
source: descriptor.template.content, filename, id: page,
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [])
// Run the real handlers; replace the network and the rest of the order page.
const ast = ts.createSourceFile(filename + '.ts', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true)
const declarations = new Map()
for (const node of ast.statements) {
if (ts.isFunctionDeclaration(node) && node.name) declarations.set(node.name.text, node.getText(ast))
if (ts.isVariableStatement(node)) {
for (const declaration of node.declarationList.declarations) {
declarations.set(declaration.name.getText(ast), `const ${declaration.getText(ast)}`)
}
}
}
const executed = page.startsWith('components/') ? [...selected, 'refresh'] : selected
for (const name of executed) assert.ok(declarations.has(name), `${page}: missing ${name}`)
const compiled = ts.transpileModule(executed.map(name => declarations.get(name)).join('\n'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
function instance({ hasAux = true, prescriptionType = '浓缩水丸', bagCount = 2, mainBagCount = 2, status = 2, fail = false } = {}) {
const calls = []
const events = []
const aux = { times_per_day: 3, usage_days: 10, dosage_bag_count: bagCount }
const detailPrescription = vue.ref({ prescription_type: prescriptionType, times_per_day: 3, usage_days: 10, dosage_bag_count: mainBagCount })
const detailAuxUsage = vue.ref(hasAux ? aux : null)
const detailData = vue.ref({
id: 42, prescription_id: 17, medication_days: 10, fulfillment_status: status,
gancao_reciperl_order_no: 'TEST-SUBMITTED', ej_pharmacy_order_no: 'TEST-EJ-SUBMITTED'
})
const refresh = async () => {
if (calls[0]?.dosage_bag_count) detailPrescription.value.dosage_bag_count = calls[0].dosage_bag_count
if (calls[0]?.aux_dosage_bag_count) detailAuxUsage.value.dosage_bag_count = calls[0].aux_dosage_bag_count
events.push('refresh')
return { ...detailData.value }
}
const globals = {
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, nextTick: vue.nextTick,
detailPrescription, detailAuxUsage, detailData, detailHasAuxHerbs: vue.ref(hasAux),
feedback: { msgWarning() {}, msgSuccess() {} },
prescriptionOrderPatchPrescriptionUsage: async payload => {
calls.push(payload)
if (fail) throw new Error('Save failed')
},
requestDetail: refresh, refreshCurrentPrescriptionOrderDetail: refresh,
props: { loadRelatedData: true },
emit: event => events.push(event),
fetchLogs: async () => {
events.push('logs:start')
await Promise.resolve()
events.push('logs:done')
},
getLists: () => events.push('lists')
}
const state = new Function(...Object.keys(globals), `${compiled}\nreturn { ${selected.join(', ')} }`)(...Object.values(globals))
state.patchUsageFormRef.value = { validate: async () => {}, clearValidate() {} }
return { state, calls, events, detailAuxUsage, detailPrescription }
}
test(`${page}: edits auxiliary bags and refreshes the persisted value without changing usage`, async () => {
const { state, calls, events, detailAuxUsage } = instance()
state.openPatchUsageDialog()
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 2)
state.patchUsageForm.aux_dosage_bag_count = 4
await state.submitPatchUsage()
assert.deepEqual(calls, [{
id: 42, times_per_day: 3, usage_days: 10, medication_days: 10,
dosage_bag_count: 2,
aux_times_per_day: 3, aux_usage_days: 10, aux_dosage_bag_count: 4
}])
assert.equal(state.patchUsageVisible.value, false)
assert.equal(detailAuxUsage.value.dosage_bag_count, 4)
assert.ok(events.includes('refresh'))
assert.ok(events.indexOf('logs:done') > events.indexOf('refresh'))
const completedEvent = page.startsWith('components/') ? 'detail-changed' : 'lists'
assert.ok(events.indexOf(completedEvent) > events.indexOf('logs:done'))
state.openPatchUsageDialog()
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 4)
})
test(`${page}: missing historical bag count defaults to one and resets on close`, () => {
const { state } = instance({ bagCount: null, mainBagCount: null })
state.openPatchUsageDialog()
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 1)
assert.equal(state.patchUsageForm.dosage_bag_count, 1)
state.patchUsageForm.dosage_bag_count = 4
state.patchUsageForm.aux_dosage_bag_count = 5
state.resetPatchUsageForm()
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 1)
assert.equal(state.patchUsageForm.dosage_bag_count, 1)
})
test(`${page}: only water pills with an auxiliary formula submit or require auxiliary bags`, async () => {
for (const options of [{ hasAux: false }, { prescriptionType: '饮片' }, { prescriptionType: '颗粒' }]) {
const { state, calls } = instance(options)
state.openPatchUsageDialog()
state.patchUsageForm.aux_dosage_bag_count = 5
assert.equal(state.patchUsageRules.value.aux_dosage_bag_count, undefined)
await state.submitPatchUsage()
assert.ok(!Object.hasOwn(calls[0], 'aux_dosage_bag_count'))
assert.equal(Object.hasOwn(calls[0], 'dosage_bag_count'), !options.prescriptionType)
}
const { state } = instance()
assert.equal(state.patchUsageRules.value.aux_dosage_bag_count[0].required, true)
assert.equal(state.patchUsageRules.value.dosage_bag_count[0].required, true)
})
test(`${page}: main and auxiliary bags can be edited independently in every submitted order status`, async () => {
for (let status = 1; status <= 12; status++) {
const { state, calls, detailPrescription, detailAuxUsage } = instance({ status })
state.openPatchUsageDialog()
assert.equal(state.patchUsageForm.dosage_bag_count, 2)
state.patchUsageForm.dosage_bag_count = 5
state.patchUsageForm.aux_dosage_bag_count = 1
await state.submitPatchUsage()
assert.equal(calls.length, 1, `status ${status}`)
assert.equal(calls[0].dosage_bag_count, 5)
assert.equal(calls[0].aux_dosage_bag_count, 1)
assert.equal(detailPrescription.value.dosage_bag_count, 5)
assert.equal(detailAuxUsage.value.dosage_bag_count, 1)
}
const { state, calls } = instance({ hasAux: false })
state.openPatchUsageDialog()
state.patchUsageForm.dosage_bag_count = 4
await state.submitPatchUsage()
assert.equal(calls[0].dosage_bag_count, 4)
assert.ok(!Object.hasOwn(calls[0], 'aux_dosage_bag_count'))
})
test(`${page}: validation blocks requests and a rejected save retains the selected bags`, async () => {
const { state, calls, events } = instance({ fail: true })
state.openPatchUsageDialog()
state.patchUsageForm.aux_dosage_bag_count = 3
state.patchUsageForm.dosage_bag_count = 4
state.patchUsageFormRef.value.validate = async () => { throw new Error('Required') }
await state.submitPatchUsage()
assert.equal(calls.length, 0)
state.patchUsageFormRef.value.validate = async () => {}
await state.submitPatchUsage()
assert.equal(state.patchUsageForm.aux_dosage_bag_count, 3)
assert.equal(state.patchUsageForm.dosage_bag_count, 4)
assert.equal(state.patchUsageVisible.value, true)
assert.equal(state.patchUsageSaving.value, false)
assert.deepEqual(events, [])
})
}
+2
View File
@@ -46,6 +46,8 @@ export default defineConfig(({ mode }) => {
return {
base: '/admin/',
// 临时 UI 预览可能共用 node_modules;主开发服务独立缓存,避免预构建互相覆盖。
cacheDir: path.resolve(__dirname, 'node_modules/.vite-admin'),
server: {
port: 5555,
host: '0.0.0.0',