Compare commits

...
6 Commits
Author SHA1 Message Date
Your Name 40319eea41 更新 2026-09-09 12:18:17 +08:00
Your Name 0cea43b027 Merge branch 'master' into UI-09-03 2026-09-08 16:16:25 +08:00
long d0e2422155 revert(iam): 统一登录 - 撤销 IAM Hub 接入 2026-09-08 16:14:59 +08:00
long 9206d5ea62 docs: record ZYT IAM integration evidence 2026-09-08 13:42:22 +08:00
long bd0a3d3b96 fix: complete IAM login after OIDC callback 2026-09-08 13:34:18 +08:00
long a5f4c4472d feat: integrate ZYT admin with IAM Hub 2026-09-08 13:26:41 +08:00
76 changed files with 4821 additions and 268 deletions
+65
View File
@@ -0,0 +1,65 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc')
const root = path.resolve(__dirname, '..')
const read = (relative) => fs.readFileSync(path.join(root, 'src', relative), 'utf8')
const utils = ts.transpileModule(read('utils/appointment-type.ts'), {
compilerOptions: { module: ts.ModuleKind.CommonJS }
}).outputText
const exportsObject = {}
vm.runInNewContext(utils, { exports: exportsObject })
for (const [value, expected] of [
['video', '视频问诊'], ['text', '图文问诊'], [undefined, '视频问诊'],
[null, '视频问诊'], ['', '视频问诊'], [' ', '视频问诊'],
['phone', '电话问诊'], ['other', '未知']
]) {
assert.equal(exportsObject.appointmentTypeDescription(value), expected)
}
const callers = [
'views/tcm/appointment/list.vue',
'views/tcm/appointment/list_h5.vue',
'views/patient/reception/index.vue'
]
for (const caller of callers) {
assert.match(read(caller), /chatDialogRef\.value\?\.open\(\{[\s\S]*?appointmentType: row\.appointment_type,/)
}
const chat = read('components/chat-dialog/index.vue')
assert.match(chat, /appointmentType\.value = data\.appointmentType/)
assert.match(chat, /class="chat-appointment-type">\{\{ appointmentTypeLabel \}\}/)
const form = read('views/tcm/diagnosis/appointment.vue')
assert.match(form, /<el-radio value="text">图文问诊<\/el-radio>/)
assert.match(form, /appointmentType: 'video'/)
assert.match(form, /form\.appointmentType = 'video'/)
assert.match(form, /appointment_type: form\.appointmentType/)
async function main() {
const components = [
...callers, 'components/chat-dialog/index.vue',
'views/tcm/diagnosis/appointment.vue', 'views/consumer/prescription/guahao.vue'
]
for (const filename of components) {
const { descriptor, errors } = parse(read(filename), { filename })
assert.deepEqual(errors, [], `${filename} parses`)
const script = compileScript(descriptor, { id: filename })
const template = compileTemplate({
source: descriptor.template.content,
filename, id: filename,
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [], `${filename} template compiles`)
for (const style of descriptor.styles) {
const result = await compileStyleAsync({
source: style.content, filename: path.join(root, 'src', filename),
id: filename, scoped: style.scoped, preprocessLang: style.lang
})
assert.deepEqual(result.errors, [], `${filename} styles compile`)
}
}
console.log(`Appointment type defaults/labels, ${callers.length} chat entry contracts, ${components.length} Vue components: OK`)
}
main().catch((error) => { console.error(error); process.exitCode = 1 })
+23 -3
View File
@@ -313,6 +313,7 @@ export interface WecomPromotionBatchUpdatePoolResult {
success: boolean
sync_error?: string
sync_queued?: boolean
sync_status?: WecomPromotionMemberSyncStatus
member_matched?: number
member_updated?: number
error?: string
@@ -357,9 +358,28 @@ export function wecomPromotionCheckApiPermission() {
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
}
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
}
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
}
export type WecomPromotionMemberSyncStatus = 'synced' | 'pending' | 'failed' | 'blocked'
export interface WecomPromotionMemberSyncResult {
pool_id: number
sync_status: WecomPromotionMemberSyncStatus
sync_error: string
sync_queued: boolean
range_userids: string[]
range_department_ids: string[]
}
export function wecomPromotionSyncMemberRange(params: { pool_id: number }) {
return request.post<WecomPromotionMemberSyncResult>({
url: '/firstvisit.wecomPromotion/syncMemberRange',
params,
timeout: 120000
}, { ignoreCancelToken: true, isOpenRetry: false })
}
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
+5
View File
@@ -424,6 +424,11 @@ export function prescriptionOrderEdit(params: Record<string, unknown>) {
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
}
/** 仅修改处方业务订单创建时间,需独立的 editTime 权限 */
export function prescriptionOrderEditTime(params: { id: number; create_time: string }) {
return request.post({ url: '/tcm.prescriptionOrder/editTime', params })
}
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
export function prescriptionOrderDdcode(params: {
id: number
+21 -2
View File
@@ -11,7 +11,10 @@
class="chat-window-header"
@mousedown="onHeaderMouseDown"
>
<span class="chat-window-title" :title="patientName"> {{ patientName }} 通讯</span>
<div class="chat-window-heading">
<span class="chat-window-title" :title="patientName"> {{ patientName }} 通讯</span>
<el-tag size="small" type="info" class="chat-appointment-type">{{ appointmentTypeLabel }}</el-tag>
</div>
<div class="chat-header-actions" @mousedown.stop>
<el-button type="danger" link class="chat-close-btn" @click="handleClose">
<el-icon><Close /></el-icon>
@@ -140,6 +143,7 @@ import {
import { CallLocalRecorder } from '@/utils/call-local-recorder'
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
import feedback from '@/utils/feedback'
import { appointmentTypeDescription } from '@/utils/appointment-type'
import {
formatTUICallUserError,
getTUICallPackageArrearsMessage,
@@ -180,6 +184,8 @@ const visible = ref(false)
const isReady = ref(false)
const error = ref('')
const patientName = ref('')
const appointmentType = ref<string | null | undefined>('video')
const appointmentTypeLabel = computed(() => appointmentTypeDescription(appointmentType.value))
const patientId = ref<number | null>(null)
const diagnosisId = ref<number | null>(null)
const loadingText = ref('正在初始化...')
@@ -1010,13 +1016,14 @@ watch(() => activeConversation.value, async (newConversation) => {
}
})
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number }) => {
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentType?: string | null }) => {
visible.value = true
isMinimized.value = false
posX.value = 100
posY.value = 80
resetCallKitPositionToLeft()
patientName.value = data.patientName
appointmentType.value = data.appointmentType
patientId.value = data.patientId
diagnosisId.value = data.diagnosisId || null
error.value = ''
@@ -1426,6 +1433,18 @@ defineExpose({ open })
justify-content: space-between;
flex-shrink: 0;
.chat-window-heading {
display: flex;
align-items: center;
min-width: 0;
gap: 4px;
}
.chat-appointment-type {
flex-shrink: 0;
margin-right: 8px;
}
.chat-window-title {
font-size: 16px;
font-weight: 500;
+8
View File
@@ -0,0 +1,8 @@
/** 展示当前挂号的问诊方式;旧记录的空值默认视频。 */
export function appointmentTypeDescription(value?: string | null): string {
if (value == null || value.trim() === '') return '视频问诊'
if (value === 'text') return '图文问诊'
if (value === 'video') return '视频问诊'
if (value === 'phone') return '电话问诊'
return '未知'
}
@@ -0,0 +1,93 @@
<template>
<el-dialog
v-model="visible"
title="修改订单创建时间"
width="min(440px, 94vw)"
append-to-body
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="!submitting"
:show-close="!submitting"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="业务订单">
<span class="break-all">{{ orderNo || `#${form.id}` }}</span>
</el-form-item>
<el-form-item label="创建时间" prop="create_time">
<el-date-picker
v-model="form.create_time"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择创建时间"
:clearable="false"
:disabled="submitting"
class="!w-full"
/>
</el-form-item>
</el-form>
<p class="text-xs text-gray-500">保存后列表和业绩统计将按新的创建时间归属日期修改记录可在订单日志中查看</p>
<template #footer>
<el-button :disabled="submitting" @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { nextTick, reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { prescriptionOrderEditTime } from '@/api/tcm'
import feedback from '@/utils/feedback'
const emit = defineEmits<{ (event: 'saved', id: number): void }>()
const visible = ref(false)
const submitting = ref(false)
const orderNo = ref('')
const formRef = ref<FormInstance>()
const form = reactive({ id: 0, create_time: '' })
const rules: FormRules = {
create_time: [{ required: true, message: '请选择创建时间', trigger: 'change' }]
}
// 列表可能返回日期字符串或历史 Unix 时间戳;保留秒,避免只打开弹窗就丢失精度。
function editableTime(value: unknown): string {
const raw = String(value ?? '').trim()
if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(raw)) {
return raw.replace('T', ' ').padEnd(19, ':00')
}
if (!/^\d+$/.test(raw) || Number(raw) <= 0) return ''
const timestamp = Number(raw)
const date = new Date(timestamp < 1e11 ? timestamp * 1000 : timestamp)
if (Number.isNaN(date.getTime())) return ''
const pad = (part: number) => String(part).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function open(row: { id?: unknown; order_no?: unknown; create_time?: unknown }) {
if (submitting.value || !Number(row.id)) return
form.id = Number(row.id)
form.create_time = editableTime(row.create_time)
orderNo.value = String(row.order_no || '')
visible.value = true
nextTick(() => formRef.value?.clearValidate())
}
async function submit() {
if (submitting.value || !formRef.value) return
submitting.value = true
try {
if (!(await formRef.value.validate().catch(() => false))) return
await prescriptionOrderEditTime({ id: form.id, create_time: form.create_time })
feedback.msgSuccess('订单创建时间已修改')
visible.value = false
emit('saved', form.id)
} catch {
// 请求拦截器展示错误,保留已填写的时间便于重试。
} finally {
submitting.value = false
}
}
defineExpose({ open })
</script>
@@ -178,7 +178,8 @@ export function logActionText(act: string) {
ej_pharmacy_callback: '洛阳药房状态',
patch_rx_patient: '处方患者信息',
patch_rx_usage: '服用参数',
update_amount: '修改订单金额',
update_amount: '修改订单金额',
edit_time: '修改创建时间',
complete: '完成订单',
refund: '退款',
manual_log: '手工备注',
@@ -182,7 +182,6 @@
<el-select v-model="editForm.appointment_type" class="!w-full">
<el-option label="视频问诊" value="video" />
<el-option label="图文问诊" value="text" />
<el-option label="电话问诊" value="phone" />
</el-select>
</el-form-item>
<el-form-item label="渠道来源" required>
@@ -1585,7 +1585,7 @@ import DaterangePicker from '@/components/daterange-picker/index.vue'
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
import PrescriptionSlip from '@/components/prescription-slip/index.vue'
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
import { computed, onMounted, onActivated, onDeactivated, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
import { useRouter } from 'vue-router'
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
@@ -3882,7 +3882,18 @@ const handleDelete = async (id: number) => {
getLists()
}
onMounted(async () => {
// 从订单页返回缓存的处方列表时,重新读取退款后的占用状态。
let refreshOnReturn = false
onDeactivated(() => {
refreshOnReturn = true
})
onActivated(() => {
if (!refreshOnReturn) return
refreshOnReturn = false
getLists()
})
onMounted(async () => {
await loadRoleOptions()
await loadRegionData()
await loadServicePackageOptions()
@@ -550,7 +550,13 @@
type="primary"
link
@click="openEdit(row)"
>编辑</el-button>
>编辑</el-button>
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
type="primary"
link
@click="orderTimeDialogRef?.open(row)"
>修改创建时间</el-button>
<el-button
v-if="canQuickTrackRow(row)"
v-perms="['tcm.prescriptionOrder/ddcode']"
@@ -649,7 +655,12 @@
>{{ shipModeLabel(detail.ship_mode) }}</el-tag>
</div>
</template>
<template #header-actions="{ detail }">
<template #header-actions="{ detail }">
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
size="small"
@click="orderTimeDialogRef?.open(detail)"
>修改创建时间</el-button>
<gancao-submission-reconcile-button
:order="detail"
@resolved="handleGancaoSubmissionResolved"
@@ -1511,7 +1522,9 @@
</el-button>
</template>
</el-dialog>
<!-- 订单退款须填写原因 -->
<prescription-order-time-dialog ref="orderTimeDialogRef" @saved="onOrderTimeSaved" />
<!-- 订单退款须填写原因 -->
<el-dialog
v-model="refundOrderDialogVisible"
title="订单退款"
@@ -2031,7 +2044,8 @@ import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncCompone
import { useRoute } from 'vue-router'
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
import PrescriptionOrderTimeDialog from './components/PrescriptionOrderTimeDialog.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import {
TCM_ASSISTANT_ROLE_ID,
@@ -3087,7 +3101,13 @@ function canUploadPharmacyRow(row: {
}
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
const orderTimeDialogRef = ref<InstanceType<typeof PrescriptionOrderTimeDialog>>()
async function onOrderTimeSaved(id: number) {
getLists()
await detailDrawerRef.value?.refreshIfCurrent(id)
}
/** 当前详情数据(组件内部 ref 的桥接;可原地修改属性,整体刷新请用 detailDrawerRef.refresh() */
const detailData = computed(() => (detailDrawerRef.value?.detail ?? null) as Record<string, any> | null)
@@ -474,7 +474,12 @@
v-perms="['tcm.prescriptionOrder/edit']"
size="small"
@click="openEdit(row)"
>编辑</el-button>
>编辑</el-button>
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
size="small"
@click="orderTimeDialogRef?.open(row)"
>修改创建时间</el-button>
<el-button
v-if="canQuickTrackRow(row)"
v-perms="['tcm.prescriptionOrder/ddcode']"
@@ -592,8 +597,13 @@
>
撤回支付审核
</el-button>
<el-button
v-if="canShipRow(detailData)"
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
size="small"
@click="orderTimeDialogRef?.open(detailData)"
>修改创建时间</el-button>
<el-button
v-if="canShipRow(detailData)"
v-perms="['tcm.prescriptionOrder/ship']"
type="primary"
size="small"
@@ -2566,12 +2576,13 @@
</div>
</el-drawer>
<!-- consumer/prescription/index诊单 edit 同一套界面只读诊单与全部分页签 -->
<TcmDiagnosisEditView ref="diagnosisViewRef" />
<!-- consumer/prescription/index诊单 edit 同一套界面只读诊单与全部分页签 -->
<TcmDiagnosisEditView ref="diagnosisViewRef" />
<prescription-order-time-dialog ref="orderTimeDialogRef" @saved="onOrderTimeSaved" />
</div>
</template>
<script lang="ts" setup name="prescriptionOrderListH5">
<script lang="ts" setup name="prescriptionOrderListH5">
import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncComponent } from 'vue'
import {
Refresh,
@@ -2585,7 +2596,8 @@ import {
Wallet,
User
} from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import PrescriptionOrderTimeDialog from './components/PrescriptionOrderTimeDialog.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import {
@@ -3513,7 +3525,15 @@ function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | nul
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<Record<string, any> | null>(null)
const detailData = ref<Record<string, any> | null>(null)
const orderTimeDialogRef = ref<InstanceType<typeof PrescriptionOrderTimeDialog>>()
async function onOrderTimeSaved(id: number) {
getLists()
if (detailVisible.value && Number(detailData.value?.id) === id) {
await openDetail(id)
}
}
const detailServicePackageText = computed(() =>
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
@@ -3838,7 +3858,8 @@ function logActionText(act: string) {
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单',
refund: '退款'
refund: '退款',
edit_time: '修改创建时间'
}
return m[act] || act
}
@@ -72,6 +72,23 @@
</div>
</div>
<section v-if="operationResults.length" class="member-sync-results" aria-live="polite">
<strong>本次保存与企微同步结果</strong>
<ul>
<li v-for="result in operationResults" :key="result.id">
<span>{{ result.name || `方案 ${result.id}` }}{{ operationResultText(result) }}</span>
<el-button
v-if="result.success && result.sync_status !== 'synced' && canSyncPool(result.id)"
type="primary"
link
:loading="syncingPoolId === result.id"
:disabled="syncingPoolId > 0 || savingBatchConfig"
@click="syncMemberRange(result.id)"
>重试同步</el-button>
</li>
</ul>
</section>
<div v-if="overview.pools.length" class="pool-layout">
<aside class="pool-sidebar">
<div class="pool-select-all">
@@ -126,6 +143,7 @@
</div>
<div class="toolbar-actions">
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
<el-button v-if="selectedPool.can_operate" :icon="Refresh" :loading="syncingPoolId === Number(selectedPool.id)" :disabled="!selectedPool.official_link || syncingPoolId > 0 || togglingMemberId > 0" @click="syncMemberRange(Number(selectedPool.id))">同步成员范围</el-button>
<el-button :icon="DocumentCopy" :disabled="!selectedPool.main_url" @click="copyText(selectedPool.main_url, '官方获客链接')">复制链接</el-button>
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
<el-button v-if="selectedPool.can_manage_access" :icon="User" @click="openAccessDialog([Number(selectedPool.id)])">设置访问操作</el-button>
@@ -150,7 +168,16 @@
show-icon
:closable="false"
title="企业微信原生多人路由"
description="当前全部可用医助会同时写入官方链接的成员范围,由企业微信在打开和添加阶段直接进行多人路由。回调只用于统计实际承接结果,并在禁用、过期或达到上限后更新成员范围。"
description="上线开关和可用性表示本地规则;企微范围显示上次远端确认结果。“待移出”的成员仍可能获客,只有同步完成才会从官方链接移除。"
/>
<el-alert
class="legacy-sync-alert member-sync-state"
:type="selectedSyncState.type"
show-icon
:closable="false"
:title="selectedSyncState.title"
:description="selectedSyncState.description"
/>
<div class="member-table-area">
@@ -163,10 +190,10 @@
</div>
</template>
</el-table-column>
<el-table-column label="企微范围同步" width="130" align="center">
<el-table-column label="企微范围(上次确认)" width="165" align="center">
<template #default="{ row }">
<span class="status-tag" :class="routeStatus(row).className">{{ routeStatus(row).label }}</span>
<div v-if="Number(row.sync_status) === 3" class="sync-retry-tip">同步失败后台重试中</div>
<div v-if="Number(row.sync_status) === 3" class="sync-retry-tip">同步失败请查看上方原因</div>
</template>
</el-table-column>
<el-table-column label="今日 / 上限" width="125" align="center">
@@ -180,7 +207,7 @@
</el-table-column>
<el-table-column label="上线" width="90" align="center">
<template #default="{ row }">
<el-switch :model-value="Number(row.enabled) === 1" :disabled="!selectedPool.can_operate" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
<el-switch :model-value="Number(row.enabled) === 1" :disabled="!selectedPool.can_operate || togglingMemberId > 0 || syncingPoolId > 0" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
@@ -584,6 +611,7 @@
</el-form>
</div>
<template #footer>
<span v-if="batchSyncProgress.total" class="uploading-save-tip" role="status">本地配置已保存正在同步企微 {{ batchSyncProgress.completed }} / {{ batchSyncProgress.total }} 个方案</span>
<span v-if="batchConfigBusy" class="uploading-save-tip">正在处理标签或素材请稍候</span>
<el-button :disabled="savingBatchConfig || batchConfigBusy" @click="batchConfigDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="savingBatchConfig" :disabled="batchConfigBusy" @click="saveBatchConfig">批量保存</el-button>
@@ -694,9 +722,10 @@ import {
wecomPromotionSaveMember,
wecomPromotionSavePool,
wecomPromotionSyncCustomers,
wecomPromotionSyncMemberRange,
wecomPromotionToggleMember
} from '@/api/first_visit'
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
import type { WecomPromotionBatchUpdatePoolResult, WecomPromotionCustomerChatStatus, WecomPromotionMemberSyncResult } from '@/api/first_visit'
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
import PromotionAutomationForm from './components/PromotionAutomationForm.vue'
@@ -778,6 +807,9 @@ const accessDialogVisible = ref(false)
const savingAccess = ref(false)
const batchConfigDialogVisible = ref(false)
const savingBatchConfig = ref(false)
const syncingPoolId = ref(0)
const batchSyncProgress = reactive({ completed: 0, total: 0 })
const operationResults = ref<WecomPromotionBatchUpdatePoolResult[]>([])
const batchConfigBusy = ref(false)
const batchConfigScroll = ref<HTMLElement>()
const batchConfigError = ref('')
@@ -808,6 +840,25 @@ const customerStats = reactive({
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
const selectedSyncState = computed(() => {
const pool = selectedPool.value
const sync = pool?.dispatch_sync || {}
const remote = Array.isArray(pool?.official_link?.range_userids) ? pool.official_link.range_userids.map(String) : []
const planned = selectedMemberRules.value.filter((row: any) => eligibility(row).className === 'is-ok').map((row: any) => String(row.userid))
const names = new Map<string, string>(selectedMemberRules.value.map((row: any) => [String(row.userid), String(row.name || row.userid)]))
const remoteNames = remote.map((id: string) => names.get(id) || id).join('、') || '暂无确认记录'
const plannedNames = planned.map((id: string) => names.get(id) || id).join('、') || '暂无可用成员'
const lastSync = pool?.official_link?.last_sync_time
const detail = `当前计划:${plannedNames};上次企微确认:${remoteNames}${lastSync ? `${formatCustomerTime(lastSync)}` : ''}`
const error = String(sync.last_error || pool?.official_link?.sync_error || '')
if (Number(sync.status) === 3 || error) return { type: 'warning' as const, title: '企微成员范围尚未同步成功', description: `${detail}${error || '同步失败,请点击“同步成员范围”重试。'}` }
if (Number(sync.status) === 4) return { type: 'warning' as const, title: '企微成员范围同步受阻', description: `${detail}请检查可用成员后重试同步。` }
if ([1, 2].includes(Number(sync.status))) return { type: 'warning' as const, title: '本地规则已保存,企微成员范围同步待完成', description: `${detail}可点击“同步成员范围”立即重试。` }
const hasDepartments = (pool?.official_link?.range_department_ids || []).length > 0
const matches = remote.length > 0 && new Set(remote).size === new Set(planned).size && planned.every((id: string) => remote.includes(id)) && !hasDepartments
if (!matches) return { type: 'warning' as const, title: '当前计划与上次企微范围不一致', description: `${detail}${hasDepartments ? '企微范围仍包含部门。' : ''}请点击“同步成员范围”更新并确认。` }
return { type: 'info' as const, title: '上次确认的企微成员范围与当前计划一致', description: detail }
})
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
const accessDialogPools = computed(() => overview.pools.filter((item: any) => accessForm.pool_ids.includes(Number(item.id))))
const manageablePoolIds = computed(() => overview.pools
@@ -1011,6 +1062,83 @@ function setBatchConfigError(message: string) {
ElMessage.warning(message)
}
function syncErrorMessage(error: unknown, fallback: string) {
if (typeof error === 'string' && error) return error
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message
return fallback
}
function operationResultText(result: WecomPromotionBatchUpdatePoolResult) {
if (!result.success) return `本地保存失败:${result.error || '请检查配置后重试'}`
if (result.sync_status === 'synced') return '本地已保存,企微成员范围已确认同步'
if (result.sync_status === 'blocked' || result.sync_status === 'failed' || result.sync_error) {
return `本地已保存,企微同步${result.sync_status === 'blocked' ? '受阻' : '失败'}${result.sync_error || '请重试同步'}`
}
return '本地已保存,企微成员范围尚未确认同步,请重试同步'
}
function recordOperationResult(result: WecomPromotionBatchUpdatePoolResult) {
const index = operationResults.value.findIndex((item) => item.id === result.id)
if (index < 0) operationResults.value.push(result)
else operationResults.value[index] = result
}
function notifySavedResult(label: string, result: Partial<WecomPromotionMemberSyncResult>) {
if (result.sync_status === 'synced') {
ElMessage.success(`${label},企微成员范围已确认同步`)
} else {
const reason = result.sync_error || '企微成员范围尚未确认同步,请点击“同步成员范围”重试'
ElMessage.warning({ message: `${label}${reason}`, duration: 8000 })
}
}
function recordSavedResult(poolId: number, name: string, result: Partial<WecomPromotionMemberSyncResult>) {
recordOperationResult({ id: poolId, name, success: true, sync_status: result.sync_status, sync_error: result.sync_error, sync_queued: result.sync_queued })
}
function canSyncPool(poolId: number) {
const pool = overview.pools.find((item: any) => Number(item.id) === poolId)
return Boolean(pool?.can_operate && pool?.official_link)
}
async function requestMemberSync(poolId: number): Promise<Partial<WecomPromotionMemberSyncResult>> {
try {
return await wecomPromotionSyncMemberRange({ pool_id: poolId })
} catch (error: unknown) {
return { pool_id: poolId, sync_status: 'failed', sync_error: syncErrorMessage(error, '同步请求失败,请重试确认企微范围'), sync_queued: false }
}
}
async function syncMemberRange(poolId: number) {
if (syncingPoolId.value || savingBatchConfig.value || !canSyncPool(poolId)) return
const pool = overview.pools.find((item: any) => Number(item.id) === poolId)
syncingPoolId.value = poolId
try {
const result = await requestMemberSync(poolId)
recordOperationResult({ id: poolId, name: pool.name, success: true, ...result })
await loadOverview()
notifySavedResult('本地规则已保存', result)
} finally {
syncingPoolId.value = 0
}
}
async function syncBatchResults(results: WecomPromotionBatchUpdatePoolResult[]) {
operationResults.value = results.map((item) => ({ ...item }))
const queued = results.filter((item) => item.success && item.sync_queued)
Object.assign(batchSyncProgress, { total: queued.length, completed: 0 })
let next = 0
// Two workers keep large batches responsive without flooding the enterprise API.
await Promise.all(Array.from({ length: Math.min(2, queued.length) }, async () => {
while (next < queued.length) {
const item = queued[next++]
const synced = await requestMemberSync(item.id)
recordOperationResult({ ...item, ...synced })
batchSyncProgress.completed++
}
}))
}
async function saveBatchConfig() {
if (savingBatchConfig.value || batchConfigBusy.value) return
batchConfigError.value = ''
@@ -1081,11 +1209,13 @@ async function saveBatchConfig() {
}
savingBatchConfig.value = true
operationResults.value = []
try {
const result = await wecomPromotionBatchUpdatePools({
pool_ids: [...batchConfigForm.pool_ids],
changes
})
operationResults.value = result.results.map((item) => ({ ...item }))
if (result.updated === 0) {
const detail = result.results
.slice(0, 2)
@@ -1093,33 +1223,27 @@ async function saveBatchConfig() {
.join('')
return setBatchConfigError(detail || '所选方案均未能保存,请检查配置后重试')
}
await syncBatchResults(result.results)
batchConfigDialogVisible.value = false
selectedPoolIds.value = []
selectedPoolIds.value = operationResults.value
.filter((item) => !item.success || item.sync_status !== 'synced')
.map((item) => item.id)
await loadOverview()
const memberStatusText = batchConfigForm.member_status === 1 ? '上线' : '下线'
const memberResultText = hasMemberStatusChange
? (result.member_updated > 0
? `${result.member_updated} 条员工规则已${memberStatusText}`
: `,所选员工均已处于${memberStatusText}状态`)
? `${result.member_updated}本地员工规则已${memberStatusText}`
: `,所选员工本地均已处于${memberStatusText}状态`)
: ''
if (result.failed > 0) {
const detail = result.results
.filter((item) => !item.success)
.slice(0, 2)
.map((item) => `${item.name || `方案 ${item.id}`}${item.error || '保存失败'}`)
.join('')
ElMessage.warning(`已更新 ${result.updated} 个方案,${result.failed} 个失败${memberResultText}${detail ? `${detail}` : ''}`)
} else if (result.sync_error_count > 0) {
ElMessage.warning(`已更新 ${result.updated} 个方案${memberResultText},其中 ${result.sync_error_count} 个企微范围将在后台自动重试同步`)
} else if (result.sync_queued_count > 0) {
ElMessage.success(`已批量更新 ${result.updated} 个分流方案${memberResultText}${result.sync_queued_count} 个企微链接配置将在后台同步`)
} else {
ElMessage.success(`已批量更新 ${result.updated} 个分流方案${memberResultText}`)
}
const synced = operationResults.value.filter((item) => item.success && item.sync_status === 'synced').length
const unconfirmed = result.updated - synced
const summary = `本地已保存 ${result.updated} 个方案${memberResultText};企微已确认同步 ${synced}${unconfirmed ? `${unconfirmed} 个尚未确认同步` : ''}${result.failed ? `${result.failed} 个本地保存失败` : ''}`
ElMessage({ type: unconfirmed || result.failed ? 'warning' : 'success', message: `${summary}。详情见方案列表上方。`, duration: 8000 })
} catch (error: any) {
setBatchConfigError(error?.message || '批量修改分流方案失败')
} finally {
savingBatchConfig.value = false
Object.assign(batchSyncProgress, { completed: 0, total: 0 })
}
}
@@ -1242,11 +1366,10 @@ async function savePool() {
throw new Error('方案已提交,但服务端未确认标签和欢迎语配置保存成功,请刷新后重试')
}
poolDialogVisible.value = false
await loadOverview()
if (result?.id) selectedPoolId.value = Number(result.id)
result?.sync_error
? ElMessage.warning('方案已保存,企业微信多人范围将在后台自动重试同步')
: ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建')
recordSavedResult(Number(result?.id || poolForm.id), poolForm.name, result || {})
await loadOverview()
if (result?.id) selectedPoolId.value = Number(result.id)
notifySavedResult(poolForm.id ? '本地方案已保存' : '分流方案已创建', result || {})
} catch (error: any) {
poolFormError.value = error?.message || (typeof error === 'string' ? error : '分流方案保存失败,请检查配置或稍后重试')
poolFormScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
@@ -1295,7 +1418,10 @@ function openMemberDialog(row: any) {
}
async function saveMemberRule() {
if (savingMember.value) return
savingMember.value = true
const poolId = Number(selectedPool.value?.id)
const poolName = String(selectedPool.value?.name || '')
try {
const result: any = await wecomPromotionSaveMember({
...memberForm,
@@ -1303,10 +1429,9 @@ async function saveMemberRule() {
active_end: memberForm.active_range?.[1] || ''
})
memberDialogVisible.value = false
recordSavedResult(poolId, poolName, result || {})
await loadOverview()
result?.sync_error
? ElMessage.warning('规则已保存,企业微信成员范围将在后台自动重试同步')
: ElMessage.success('医助分流规则已保存')
notifySavedResult('本地医助规则已保存', result || {})
} catch (error: any) {
ElMessage.error(error?.message || '医助分流规则保存失败')
} finally {
@@ -1315,13 +1440,15 @@ async function saveMemberRule() {
}
async function handleMemberToggle(row: any, value: unknown) {
if (togglingMemberId.value || syncingPoolId.value) return
togglingMemberId.value = Number(row.id)
const poolId = Number(selectedPool.value?.id)
const poolName = String(selectedPool.value?.name || '')
try {
const result: any = await wecomPromotionToggleMember({ id: Number(row.id), status: value ? 1 : 0 })
recordSavedResult(poolId, poolName, result || {})
await loadOverview()
result?.sync_error
? ElMessage.warning('成员状态已保存,企业微信成员范围将在后台自动重试同步')
: ElMessage.success('成员规则已保存,企微多人路由范围已重新计算')
notifySavedResult('本地成员状态已保存', result || {})
} catch (error: any) {
ElMessage.error(error?.message || '成员状态更新失败')
await loadOverview()
@@ -1459,8 +1586,10 @@ function eligibility(row: any) {
function routeStatus(row: any) {
const available = eligibility(row).className === 'is-ok'
if (row.is_in_remote_range) return { label: available ? '企微路由中' : '待移出', className: available ? 'is-online' : 'is-offline' }
return { label: available ? '待同步' : '未在路由范围', className: 'is-offline' }
if (row.is_in_remote_range === true || Number(row.is_in_remote_range) === 1) {
return { label: available ? '企微范围内' : '待移出(仍在企微)', className: available ? 'is-online' : 'is-pending' }
}
return { label: available ? '待加入企微' : '未在企微范围', className: 'is-offline' }
}
function todayCount(row: any) {
@@ -1815,6 +1944,12 @@ h1, h2, h3, p { margin: 0; }
.batch-section-tip { margin: -6px 0 12px; color: #8491a2; font-size: 11px; line-height: 1.6; }.batch-section-selectors { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }.batch-section-selectors :deep(.el-checkbox) { margin-right: 0; }.automation-batch-section :deep(.automation-note) { margin-top: 14px; }
.uploading-save-tip { color: #b88230; font-size: 12px; margin-right: 16px; }
.sync-retry-tip { color: #b88230; font-size: 10px; line-height: 1.6; margin-top: 4px; }
.status-tag.is-pending { color: #a36413; background: #fff1dd; }
.member-sync-state :deep(.el-alert__description) { max-height: 84px; overflow-y: auto; overflow-wrap: anywhere; }
.member-sync-results { margin-bottom: 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 8px; background: #f8fafb; font-size: 12px; }
.member-sync-results ul { max-height: 150px; margin: 8px 0 0; padding: 0; overflow-y: auto; list-style: none; }
.member-sync-results li { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 5px 0; }
.member-sync-results li span { overflow-wrap: anywhere; }.member-sync-results .el-button { flex-shrink: 0; }
:global(.promotion-pool-dialog) { max-width: calc(100vw - 32px); margin-top: 5vh; }
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; } }
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.section-heading-actions { width: 100%; justify-content: flex-start; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid, .rule-form-grid, .batch-field-grid, .batch-member-status-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { height: auto; min-height: 0; grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--line); scrollbar-gutter: auto; }.pool-select-all { position: static; min-width: 126px; flex: 0 0 126px; margin: 0 6px 0 0; border-right: 1px solid var(--line); border-bottom: 0; }.pool-main { overflow: visible; }.member-table-area { height: 420px; min-height: 320px; flex: none; }.pool-select-row { min-width: 220px; }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; }.access-pool-preview > div { grid-template-columns: 1fr; gap: 3px; } }
@@ -225,6 +225,7 @@ interface QueueRow {
assistant_name?: string
appointment_date?: string
appointment_time?: string
appointment_type?: string | null
gender?: number
age?: number | null
status?: number
@@ -456,6 +457,7 @@ const handleCall = async (row: QueueRow) => {
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId,
appointmentType: row.appointment_type,
signatureData: res
})
} catch (error: any) {
+1
View File
@@ -1022,6 +1022,7 @@ const handleChat = async (row: any) => {
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId,
appointmentType: row.appointment_type,
signatureData: res // 传入签名数据
})
} catch (error: any) {
@@ -662,6 +662,7 @@ const handleChat = async (row: any) => {
patientId: sourcePatientId,
patientName: row.patient_name,
diagnosisId,
appointmentType: row.appointment_type,
signatureData: res
})
} catch (e: any) {
@@ -32,8 +32,9 @@
<!-- 预约类型 -->
<el-form-item label="预约类型:">
<el-radio-group v-model="form.appointmentType">
<el-radio value="video">视频问诊</el-radio>
<el-radio-group v-model="form.appointmentType">
<el-radio value="video">视频问诊</el-radio>
<el-radio value="text">图文问诊</el-radio>
</el-radio-group>
</el-form-item>
@@ -0,0 +1,102 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const ts = require('typescript')
const vue = require('vue')
const filename = path.join(__dirname, '../src/views/consumer/prescription/components/PrescriptionOrderTimeDialog.vue')
const source = fs.readFileSync(filename, 'utf8')
const { descriptor, errors } = parse(source, { filename })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'order-time-test' })
const template = compileTemplate({
source: descriptor.template.content,
filename,
id: 'order-time-test',
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [])
const compiled = ts.transpileModule(script.content, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText
function dialog(save = async () => ({})) {
const calls = []
const events = []
const module = { exports: {} }
const mockRequire = (name) => {
if (name === 'vue') return vue
if (name === '@/api/tcm') return {
prescriptionOrderEditTime: async (payload) => {
calls.push(payload)
return save(payload)
}
}
if (name === '@/utils/feedback') return { default: { msgSuccess() {} } }
throw new Error(`Unexpected dependency: ${name}`)
}
new Function('require', 'module', 'exports', compiled)(mockRequire, module, module.exports)
const state = module.exports.default.setup({}, {
expose() {},
emit: (...event) => events.push(event)
})
state.formRef.value = { validate: async () => true, clearValidate() {} }
return { state, calls, events }
}
test('opening and saving preserves creation time seconds and submits only the selected order', async () => {
const { state, calls, events } = dialog()
state.open({ id: 42, order_no: 'RX42', create_time: '2026-09-09 11:12:37' })
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
state.form.create_time = '2026-08-31 09:08:07'
await state.submit()
assert.deepEqual(calls, [{ id: 42, create_time: '2026-08-31 09:08:07' }])
assert.deepEqual(events, [['saved', 42]])
assert.equal(state.visible.value, false)
})
test('legacy seconds, milliseconds and strings populate the same local picker time', () => {
const { state } = dialog()
const date = new Date(2026, 8, 9, 11, 12, 37)
for (const value of [date.getTime() / 1000, String(date.getTime() / 1000), date.getTime(), '2026-09-09T11:12:37']) {
state.open({ id: 1, create_time: value })
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
}
state.open({ id: 1, create_time: '2026-09-09 11:12' })
assert.equal(state.form.create_time, '2026-09-09 11:12:00')
state.open({ id: 1, create_time: 0 })
assert.equal(state.form.create_time, '')
})
test('validation failures do not send requests, and failed saves retain editable input', async () => {
const { state, calls, events } = dialog(async () => { throw new Error('denied') })
state.open({ id: 9, create_time: '2026-09-09 11:12:37' })
state.formRef.value.validate = async () => { throw new Error('required') }
await state.submit()
assert.equal(calls.length, 0)
state.formRef.value.validate = async () => true
await state.submit()
assert.equal(state.visible.value, true)
assert.equal(state.submitting.value, false)
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
assert.deepEqual(events, [])
})
test('a pending request cannot submit twice or switch its target order', async () => {
let finish
const pending = new Promise((resolve) => { finish = resolve })
const { state, calls, events } = dialog(() => pending)
state.open({ id: 7, create_time: '2026-09-09 11:12:37' })
const saving = state.submit()
await state.submit()
await new Promise((resolve) => setImmediate(resolve))
state.open({ id: 8, create_time: '2026-09-08 00:00:00' })
await state.submit()
assert.equal(calls.length, 1)
assert.equal(state.form.id, 7)
finish({})
await saving
assert.deepEqual(events, [['saved', 7]])
})
@@ -0,0 +1,154 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const { parse, compileScript, compileTemplate, compileStyle } = require('@vue/compiler-sfc')
const ts = require('typescript')
const vue = require('vue')
const filename = path.join(__dirname, '../src/views/first_visit/wecom_promotion/index.vue')
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'member-sync-test' })
const template = compileTemplate({ source: descriptor.template.content, filename, id: 'member-sync-test', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
assert.deepEqual(compileStyle({ source: descriptor.styles[0].content, filename, id: 'member-sync-test', scoped: true, preprocessLang: 'scss' }).errors, [])
function loadModule(source, mockRequire) {
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', compiled)(mockRequire, module, module.exports)
return module.exports
}
const automation = loadModule(fs.readFileSync(path.join(path.dirname(filename), 'components/promotion-automation.ts'), 'utf8'), require)
const member = (id, enabled = 1) => ({ id, admin_id: id, userid: `member-${id}`, name: `医助 ${id}`, enabled, reception_available: enabled === 1, is_in_remote_range: true })
const pool = (id = 1) => ({ id, name: `方案 ${id}`, status: 1, can_operate: true, can_manage_access: true, member_admin_ids: [1, 2], member_rules: [member(1), member(2, 0)], official_link: { range_userids: ['member-1', 'member-2'] }, dispatch_sync: { status: 1 } })
function page(api = {}, pools = [pool()]) {
const messages = []
const emitMessage = (type, value) => messages.push({ type, message: typeof value === 'string' ? value : value.message })
const ElMessage = (value) => emitMessage(value.type, value)
for (const type of ['success', 'warning', 'error']) ElMessage[type] = (value) => emitMessage(type, value)
const instance = loadModule(script.content, (name) => {
if (name === 'vue') return { ...vue, onMounted() {} }
if (name === 'element-plus') return { ElMessage, ElMessageBox: {} }
if (name === '@element-plus/icons-vue') return {}
if (name === '@/api/first_visit') return { wecomPromotionOverview: async () => ({ pools }), ...api }
if (name.endsWith('.vue')) return {}
if (name === './components/promotion-automation') return automation
throw new Error(`Unexpected dependency: ${name}`)
}).default.setup({}, { expose() {} })
Object.assign(instance.overview, { pools })
instance.selectedPoolId.value = pools[0]?.id
return { state: instance, messages }
}
test('disabled local member still in remote snapshot is explicitly pending removal', () => {
const { state } = page()
assert.equal(state.routeStatus(member(2, 0)).label, '待移出(仍在企微)')
assert.equal(state.routeStatus({ ...member(1), is_in_remote_range: '0' }).label, '待加入企微')
assert.match(state.selectedSyncState.value.description, /当前计划:医助 1;上次企微确认:医助 1、医助 2/)
state.overview.pools[0].dispatch_sync = { status: 3, last_error: '可信 IP 校验失败' }
assert.match(state.selectedSyncState.value.description, /可信 IP 校验失败/)
})
test('single toggles never infer remote success from an empty error or planned dispatch', async () => {
const calls = []
const { state, messages } = page({ wecomPromotionToggleMember: async (payload) => { calls.push(payload); return { sync_error: '', dispatch: { queued: true } } } })
await state.handleMemberToggle(member(2), false)
assert.deepEqual(calls, [{ id: 2, status: 0 }])
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /尚未确认同步/)
assert.doesNotMatch(messages.at(-1).message, /已确认同步|已重新计算/)
assert.match(state.operationResultText(state.operationResults.value[0]), /尚未确认同步/)
})
test('single rule save reports explicit remote confirmation and preserves precise failures', async () => {
const { state, messages } = page({ wecomPromotionSaveMember: async () => ({ sync_status: 'failed', sync_error: '企微成员范围不一致' }) })
Object.assign(state.memberForm, { id: 2, active_range: [] })
await state.saveMemberRule()
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /企微成员范围不一致/)
state.notifySavedResult('本地已保存', { sync_status: 'synced' })
assert.equal(messages.at(-1).type, 'success')
assert.match(messages.at(-1).message, /企微成员范围已确认同步/)
})
test('pool saves with queued work retain a warning instead of implying official link completion', async () => {
const { state, messages } = page({ wecomPromotionSavePool: async () => ({ id: 1, sync_status: 'pending', sync_queued: true, sync_error: '' }) })
Object.assign(state.poolForm, { id: 1, name: '方案 1', member_admin_ids: [1] })
await state.savePool()
assert.equal(state.poolDialogVisible.value, false)
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /尚未确认同步/)
assert.equal(state.operationResults.value[0].sync_queued, true)
})
test('manual retry targets only its pool and does not import remote links', async () => {
const calls = []
const { state, messages } = page({ wecomPromotionSyncMemberRange: async (payload) => { calls.push(payload); return { pool_id: 1, sync_status: 'synced', sync_error: '', sync_queued: false } } })
await state.syncMemberRange(1)
assert.deepEqual(calls, [{ pool_id: 1 }])
assert.equal(state.operationResults.value[0].sync_status, 'synced')
assert.equal(state.syncingPoolId.value, 0)
assert.equal(messages.at(-1).type, 'success')
state.overview.pools[0].can_operate = false
await state.syncMemberRange(1)
assert.equal(calls.length, 1)
})
test('batch sync awaits all queued successful pools with at most two concurrent requests', async () => {
const pending = []
let active = 0
let maximum = 0
const { state } = page({ wecomPromotionSyncMemberRange: ({ pool_id }) => new Promise((resolve) => {
active++
maximum = Math.max(maximum, active)
pending.push({ id: pool_id, finish: (result) => { active--; resolve({ pool_id, ...result }) } })
}) })
const results = [1, 2, 3].map((id) => ({ id, name: `方案 ${id}`, success: true, sync_queued: true }))
results.push({ id: 4, name: '保存失败方案', success: false, sync_queued: true, error: '无权限' })
const saving = state.syncBatchResults(results)
assert.deepEqual(pending.map((item) => item.id), [1, 2])
pending[0].finish({ sync_status: 'synced', sync_error: '' })
await new Promise((resolve) => setImmediate(resolve))
assert.deepEqual(pending.map((item) => item.id), [1, 2, 3])
assert.equal(state.batchSyncProgress.completed, 1)
pending[1].finish({ sync_status: 'failed', sync_error: '范围校验失败' })
pending[2].finish({ sync_status: 'pending', sync_error: '' })
await saving
assert.equal(maximum, 2)
assert.equal(state.batchSyncProgress.completed, 3)
assert.deepEqual(state.operationResults.value.map((item) => item.sync_status), ['synced', 'failed', 'pending', undefined])
assert.match(state.operationResultText(state.operationResults.value[1]), /范围校验失败/)
assert.match(state.operationResultText(state.operationResults.value[3]), /本地保存失败:无权限/)
})
test('batch save retains partial failures and never reports queued work as remote success', async () => {
const pools = [pool(1), pool(2), pool(3)]
const syncedIds = []
const { state, messages } = page({
wecomPromotionBatchUpdatePools: async () => ({ updated: 2, failed: 1, member_updated: 2, results: [
{ id: 1, name: '方案 1', success: true, sync_queued: true },
{ id: 2, name: '方案 2', success: true, sync_queued: true },
{ id: 3, name: '方案 3', success: false, error: '保存失败' }
] }),
wecomPromotionSyncMemberRange: async ({ pool_id }) => {
syncedIds.push(pool_id)
if (pool_id === 2) throw '企微请求超时'
return { pool_id, sync_status: 'synced', sync_error: '', sync_queued: false }
}
}, pools)
Object.assign(state.batchConfigApply, { member_status: true })
Object.assign(state.batchConfigForm, { pool_ids: [1, 2, 3], member_admin_ids: [2], member_status: 0 })
state.batchConfigDialogVisible.value = true
await state.saveBatchConfig()
assert.deepEqual(syncedIds, [1, 2])
assert.equal(state.savingBatchConfig.value, false)
assert.equal(state.batchConfigDialogVisible.value, false)
assert.deepEqual(state.selectedPoolIds.value, [2, 3])
assert.match(messages.at(-1).message, /企微已确认同步 1 个,1 个尚未确认同步;1 个本地保存失败/)
assert.equal(messages.at(-1).type, 'warning')
assert.match(state.operationResults.value[1].sync_error, /企微请求超时/)
})
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
# Single source of truth for runtime, package, installer, and executable versions.
__version__ = "1.4.1"
__version__ = "1.4.2"
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
@@ -489,7 +489,7 @@ class DemoDoctorRepository:
return detail
raise ApiBusinessError("挂号不存在", code=0)
def list_departments(self) -> list[dict[str, Any]]:
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
"""Return a small demo department tree."""
return [
@@ -335,8 +335,8 @@ class DoctorRepository(Protocol):
def get_appointment_detail(self, appointment_id: int) -> dict[str, Any]:
"""Return one appointment detail from ``doctor.appointment/detail``."""
def list_departments(self) -> list[dict[str, Any]]:
"""Return the department tree used by appointment list filters."""
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
"""Return a department tree, optionally scoped to the account's data permissions."""
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
"""Return an editable or permission-aware readonly diagnosis detail."""
@@ -976,17 +976,20 @@ class RemoteDoctorRepository:
payload = self.client.get("doctor.appointment/detail", {"id": appointment_id})
return dict(_require_mapping(payload, "doctor.appointment/detail"))
def list_departments(self) -> list[dict[str, Any]]:
"""Load the department tree through ``dept.dept/all``."""
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
"""Load ``dept.dept/all`` with optional server-enforced role data scope."""
payload = self.client.get("dept.dept/all")
if isinstance(payload, list):
return [dict(row) for row in payload if isinstance(row, Mapping)]
if isinstance(payload, Mapping):
rows = payload.get("lists", payload.get("data", payload.get("tree")))
if isinstance(rows, list):
return [dict(row) for row in rows if isinstance(row, Mapping)]
return []
payload = self.client.get(
"dept.dept/all", {"apply_data_scope": 1} if apply_data_scope else None
)
for _depth in range(5):
if isinstance(payload, list):
return [dict(row) for row in payload if isinstance(row, Mapping)]
if isinstance(payload, Mapping):
payload = payload.get("lists", payload.get("data", payload.get("tree")))
else:
break
raise ApiProtocolError("部门数据格式异常,请重试")
def list_reception_queue(
self,
@@ -2056,7 +2059,10 @@ class RemoteDoctorRepository:
) -> PageResult[Consultation]:
"""List diagnosis records using ``tcm.diagnosis/lists``."""
request_filters = dict(filters)
request_filters = dict(filters)
department_id = request_filters.pop("department_id", None)
if department_id not in (None, ""):
request_filters.setdefault("assistant_dept_id", department_id)
start_date = str(request_filters.pop("start_date", "") or "").strip()
end_date = str(request_filters.pop("end_date", "") or "").strip()
if start_date and start_date == end_date:
@@ -2799,8 +2799,9 @@ class NotesTimeline(QWidget):
add_requested = Signal()
upload_requested = Signal(str)
delete_attachment_requested = Signal(int, str, str)
open_attachment_requested = Signal(str)
delete_attachment_requested = Signal(int, str, str)
open_attachment_requested = Signal(str)
preview_images_requested = Signal(object, int)
def __init__(self, *, editable: bool = False, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -2879,11 +2880,14 @@ class NotesTimeline(QWidget):
attachments = QWidget()
attachments_layout = FlowLayout(attachments, horizontal_spacing=8, vertical_spacing=8)
images = _pick(note, "tongue_images", "images", default=[]) or []
if not isinstance(images, Sequence) or isinstance(images, (str, bytes, bytearray)):
images = [images]
note_id = int(_pick(note, "id", "note_id", default=0) or 0)
for image in images:
path = str(_pick(image, "url", "path", "file_url", default=image) or "")
if not isinstance(images, Sequence) or isinstance(images, (str, bytes, bytearray)):
images = [images]
image_paths = [
str(_pick(image, "url", "path", "file_url", default=image) or "")
for image in images
]
note_id = int(_pick(note, "id", "note_id", default=0) or 0)
for image_index, path in enumerate(image_paths):
image_host = QWidget()
image_layout = QGridLayout(image_host)
image_layout.setContentsMargins(0, 0, 0, 0)
@@ -2897,10 +2901,12 @@ class NotesTimeline(QWidget):
object_name="DiagnosisTongueThumb",
parent=image_host,
)
thumb.setEnabled(bool(path))
thumb.clicked.connect(
lambda _checked=False, target=path: self.open_attachment_requested.emit(target)
)
thumb.setEnabled(bool(path))
thumb.clicked.connect(
lambda _checked=False, sources=image_paths, index=image_index: (
self.preview_images_requested.emit(sources, index)
)
)
image_layout.addWidget(thumb, 0, 0)
if self._can_delete and note_id > 0 and path:
remove = QPushButton("×")
@@ -1973,6 +1973,8 @@ class DiagnosisTableHost(QFrame):
return button
def _columns_resized(self, *_args: int) -> None:
if getattr(self, "_fitting_columns", False):
return
if self.tech_blue:
self._sync_row_heights()
self._schedule_fixed_height_sync()
@@ -2001,6 +2003,7 @@ class DiagnosisTableHost(QFrame):
self._fixed_height_timer.start(0)
def _sync_fixed_height(self) -> None:
self._fit_main_columns()
# Use actual viewport geometry: scrollbar extent and QSS frame padding
# can differ from sizeHint(). The unmanaged sibling cannot raise any
# ancestor's minimum height, so style changes cannot create feedback.
@@ -2016,6 +2019,31 @@ class DiagnosisTableHost(QFrame):
self.fixed.verticalScrollBar().setValue(self.main.verticalScrollBar().value())
self._position_empty()
self._position_fixed_shadow()
def _fit_main_columns(self) -> None:
"""Fill wide viewports without shrinking clinical columns on small ones.
The right-hand view is pinned independently. A wider main viewport used
to expose unpainted space after its last fixed-width column, breaking
both the header and selected-row background after a search.
"""
widths = list(self.LEFT_WIDTHS)
extra = max(0, self.main.viewport().width() - sum(widths))
# Give longer patient, appointment and follow-up text most of the space.
for column, weight in ((2, 1), (4, 3), (6, 2), (7, 1), (9, 1)):
widths[column] += extra * weight // 8
widths[4] += max(0, self.main.viewport().width() - sum(widths))
if widths == [self.main.columnWidth(column) for column in range(10)]:
return
self._fitting_columns = True
try:
for column, width in enumerate(widths):
self.main.setColumnWidth(column, width)
finally:
self._fitting_columns = False
# Wrapping changes with the allocated width; both panes must use the
# same recalculated heights, including appointment action overlays.
self._sync_row_heights()
def _position_empty(self) -> None:
viewport = self.main.viewport()
@@ -1103,6 +1103,11 @@ class ImagePreviewDialog(QDialog):
self._invalidate_request()
super().closeEvent(event)
def done(self, result: int) -> None:
# QDialog.reject() (including Escape) bypasses closeEvent.
self._invalidate_request()
super().done(result)
def _clock(milliseconds: int) -> str:
seconds = max(0, int(milliseconds) // 1000)
@@ -59,7 +59,12 @@ from ..diagnosis_drawer import (
set_tag_item,
)
from ..diagnosis_editors import DailyRecordEditorDialog
from ..diagnosis_media import RecordingPlaybackCell, RecordingPlayerDialog
from ..diagnosis_media import (
ImagePreviewDialog,
RecordingPlaybackCell,
RecordingPlayerDialog,
safe_image_sources,
)
from ..infinite_list import InfiniteList
from ..widgets import (
display_text,
@@ -832,6 +837,8 @@ class DiagnosisDialog(QDialog):
self._saving = False
self._load_mode = "readonly"
self._generation = 0
self._dictionary_requested_generation = -1
self._dictionary_error_message = ""
self._save_generation = 0
self._orders_generation = 0
self._order_detail_generation = 0
@@ -846,6 +853,7 @@ class DiagnosisDialog(QDialog):
self._loading_tabs: set[str] = set()
self._daily_todo_status: int | None = None
self._recording_players: list[RecordingPlayerDialog] = []
self._image_preview: ImagePreviewDialog | None = None
self._inline_recording_cells: list[RecordingPlaybackCell] = []
self._last_order_detail_dialog: QDialog | None = None
self._local_audio_dialog: LocalAudioQueueDialog | None = None
@@ -965,6 +973,7 @@ class DiagnosisDialog(QDialog):
self._add_readonly_section("daily", "日常记录", daily)
notes = NotesTimeline(editable=False)
notes.open_attachment_requested.connect(self._open_safe_resource)
notes.preview_images_requested.connect(self._preview_note_images)
self._notes_timelines.append(notes)
self._add_readonly_section("notes", "医生备注 & 舌苔照片 & 检查报告", notes)
orders = self._new_table("orders", "DiagnosisReadonlyOrdersTable")
@@ -1437,6 +1446,7 @@ class DiagnosisDialog(QDialog):
timeline.upload_requested.connect(self._upload_doctor_note_material)
timeline.delete_attachment_requested.connect(self._delete_doctor_note_attachment)
timeline.open_attachment_requested.connect(self._open_safe_resource)
timeline.preview_images_requested.connect(self._preview_note_images)
self.drawer_notes_timeline = timeline
self._notes_timelines.append(timeline)
return self._wrap_tab("DiagnosisTabNotes", timeline)
@@ -1698,22 +1708,23 @@ class DiagnosisDialog(QDialog):
if hasattr(self, "readonly_ai_button"):
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
current_key = self.tabs.tabBar().tabData(self.tabs.currentIndex())
self.tabs.clear()
for key, label, codes in _TAB_DEFINITIONS:
if not self._tab_allowed(codes):
continue
index = self.tabs.addTab(self._tab_pages[key], label)
self.tabs.tabBar().setTabData(index, key)
target = next(
(
index
for index in range(self.tabs.count())
if self.tabs.tabBar().tabData(index) == current_key
),
0,
)
self.tabs.setCurrentIndex(target)
previous_key = self._current_tab_key()
allowed_tabs = [
(key, label) for key, label, codes in _TAB_DEFINITIONS if self._tab_allowed(codes)
]
if [self.tabs.tabBar().tabData(i) for i in range(self.tabs.count())] != [
key for key, _label in allowed_tabs
]:
blocked = self.tabs.blockSignals(True)
self.tabs.clear()
target = 0
for key, label in allowed_tabs:
index = self.tabs.addTab(self._tab_pages[key], label)
self.tabs.tabBar().setTabData(index, key)
if key == previous_key:
target = index
self.tabs.setCurrentIndex(target)
self.tabs.blockSignals(blocked)
section_codes = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
for key, section in self._readonly_sections.items():
section.setVisible(self._tab_allowed(section_codes.get(key, ())))
@@ -1743,6 +1754,9 @@ class DiagnosisDialog(QDialog):
self._sync_order_offset_actions()
for panel in self._chat_panels:
panel.sync_button.setVisible(self._can_chat_sync)
self._sync_save_button()
if self._current_tab_key() != previous_key:
self._tab_changed(self.tabs.currentIndex())
def _show_message(self, text: str, kind: str = "info", action_text: str = "") -> None:
self.drawer_banner.show_message(text, kind, action_text)
@@ -1751,6 +1765,9 @@ class DiagnosisDialog(QDialog):
self.readonly_error.show()
def _clear_message(self) -> None:
if self._dictionary_error_message and self._authoritative_detail_loaded:
self._show_message(self._dictionary_error_message, "warning", action_text="重试")
return
self.drawer_banner.clear()
self.readonly_error.hide()
@@ -1996,6 +2013,7 @@ class DiagnosisDialog(QDialog):
)
self._authoritative_detail_loaded = False
self._saving = False
self._dictionary_error_message = ""
self._generation += 1
self._save_generation += 1
self._orders_generation += 1
@@ -2125,14 +2143,26 @@ class DiagnosisDialog(QDialog):
),
0,
)
return {"detail": detail, "patient_id": patient_id}
def _load_dictionary_choices(self) -> dict[str, Any]:
"""Load optional editor labels without extending the authoritative-detail gate."""
dictionaries: dict[str, list[tuple[str, Any]]] = {}
dictionary_errors: list[str] = []
if callable(getattr(self.repository, "get_dictionary", None)):
dictionary_types = {
dictionary_types = sorted(
{
"diagnosis_type",
*(item[0] for item in _CHOICE_DICTIONARIES.values()),
}
for dictionary_type in sorted(dictionary_types):
)
if callable(getattr(self.repository, "get_dictionaries", None)):
raw = invoke(self.repository, "get_dictionaries", dictionary_types=dictionary_types)
dictionaries = {
key: _dictionary_choices(get_value(raw, key, []), key)
for key in dictionary_types
}
elif callable(getattr(self.repository, "get_dictionary", None)):
for dictionary_type in dictionary_types:
try:
raw = invoke(
self.repository,
@@ -2143,12 +2173,41 @@ class DiagnosisDialog(QDialog):
except Exception as error:
dictionary_errors.append(f"{dictionary_type} 字典:{friendly_error(error)}")
return {
"detail": detail,
"patient_id": patient_id,
"dictionaries": dictionaries,
"dictionary_errors": dictionary_errors,
}
def _ensure_dictionary_loaded(self, *, force: bool = False) -> None:
if self._standalone_readonly or not self._authoritative_detail_loaded:
return
generation = self._generation
if not force and self._dictionary_requested_generation == generation:
return
self._dictionary_requested_generation = generation
clear_error = self.drawer_banner.label.text() == self._dictionary_error_message
self._dictionary_error_message = ""
if clear_error:
self._clear_message()
run_async(
self._load_dictionary_choices,
on_success=lambda result: self._dictionary_choices_loaded(result, generation),
on_error=lambda error: self._dictionary_choices_loaded(
{"dictionary_errors": [f"病历选项加载失败:{friendly_error(error)}"]}, generation
),
)
def _dictionary_choices_loaded(self, result: Any, generation: int) -> None:
if generation != self._generation or not self._authoritative_detail_loaded:
return
# A clinician may already have edited fields while the optional labels load.
# Update only choices; re-rendering the detail would overwrite that draft.
self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {}, preserve=True)
errors = get_value(result, "dictionary_errors", []) or []
if errors:
self._dictionary_requested_generation = -1
self._dictionary_error_message = "".join(str(item) for item in errors)
self._show_message(self._dictionary_error_message, "warning", action_text="重试")
def _query_orders(self, diagnosis_id: int, patient_id: int, page: int) -> Any:
filters: dict[str, Any] = {
"context_diagnosis_id": diagnosis_id,
@@ -2170,12 +2229,12 @@ class DiagnosisDialog(QDialog):
detail = get_value(result, "detail", None)
self._detail = detail
self._patient_id = _int(get_value(result, "patient_id", 0), 0)
self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {})
self._authoritative_detail_loaded = True
self._show_authoritative_content(True)
self._render(detail, [], [])
self._sync_form_interactivity()
self._sync_save_button()
self._set_loading(False)
errors = get_value(result, "dictionary_errors", []) or []
if errors:
self._show_message("".join(str(item) for item in errors), "warning")
@@ -2202,7 +2261,9 @@ class DiagnosisDialog(QDialog):
if generation == self._generation:
self._set_loading(False)
def _apply_dictionary_choices(self, dictionaries: Mapping[str, Any]) -> None:
def _apply_dictionary_choices(
self, dictionaries: Mapping[str, Any], *, preserve: bool = False
) -> None:
diagnosis_type_editor = self.edit_fields.get("diagnosis_type")
if isinstance(diagnosis_type_editor, DiagnosisComboBox):
labels = {
@@ -2219,15 +2280,17 @@ class DiagnosisDialog(QDialog):
("会诊", "consultation"),
)
),
preserve=False,
preserve=preserve,
)
for key, (dictionary_type, _multiple) in _CHOICE_DICTIONARIES.items():
if dictionary_type not in dictionaries:
continue
editor = self._choice_fields.get(key)
choices = list(dictionaries.get(dictionary_type, []))
if not _multiple:
choices.insert(0, ("", ""))
if editor is not None:
editor.set_choices(choices, preserve=False)
editor.set_choices(choices, preserve=preserve)
# Defer until the drawer has a real width so wrapped chips get height.
QTimer.singleShot(0, self._sync_choice_field_heights)
@@ -2306,9 +2369,14 @@ class DiagnosisDialog(QDialog):
if not self._authoritative_detail_loaded:
self._retry_detail()
return
if (self._dictionary_error_message
and self.drawer_banner.label.text() == self._dictionary_error_message):
self._ensure_dictionary_loaded(force=True)
return
self._ensure_tab_loaded(self._current_tab_key(), force=True)
def _invalidate_requests(self) -> None:
self._close_image_preview()
self._stop_watching_local_audio_uploads()
self._video_reload_pending = False
self._generation += 1
@@ -2627,7 +2695,10 @@ class DiagnosisDialog(QDialog):
table.set_empty_text(message)
def _ensure_tab_loaded(self, key: str, *, force: bool = False) -> None:
if key == "basic" or not self._authoritative_detail_loaded or self._diagnosis_id <= 0:
if not self._authoritative_detail_loaded or self._diagnosis_id <= 0:
return
if key == "basic":
self._ensure_dictionary_loaded(force=force)
return
if key == "daily":
source = next(
@@ -3421,6 +3492,27 @@ class DiagnosisDialog(QDialog):
self._sync_order_offset_actions()
self._mutation_error(error, diagnosis_id, generation, "offset")
def _close_image_preview(self) -> None:
preview, self._image_preview = self._image_preview, None
if preview is not None:
with suppress(RuntimeError):
preview.close()
def _image_preview_finished(self, _result: int) -> None:
if self.sender() is self._image_preview:
self._image_preview = None
def _preview_note_images(self, sources: Sequence[str], index: int) -> None:
"""Keep the diagnosis open while viewing the clicked note's image group."""
if not safe_image_sources(sources):
QMessageBox.warning(self, "无法预览", "仅支持 HTTP(S) 服务端图片。")
return
self._close_image_preview()
preview = ImagePreviewDialog(sources, index=index, title="舌象图片预览", parent=self)
self._image_preview = preview
preview.finished.connect(self._image_preview_finished)
preview.open()
def _open_safe_resource(self, target: str) -> None:
url = QUrl(str(target).strip())
if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host():
@@ -14,7 +14,8 @@ from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QPixmap
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from PySide6.QtWidgets import (
QComboBox,
QComboBox,
QCompleter,
QDateEdit,
QDialog,
QDialogButtonBox,
@@ -98,7 +99,32 @@ def _as_int(value: Any, default: int = 0) -> int:
return default
def _as_bool(value: Any) -> bool:
def _department_options(rows: Any) -> list[tuple[int, str]]:
"""Flatten the server-scoped tree without deriving extra options from profiles."""
if not isinstance(rows, (list, tuple)):
raise ValueError("部门数据格式异常,请重试")
options: list[tuple[int, str]] = []
seen: set[int] = set()
def visit(nodes: Sequence[Any], parents: tuple[str, ...] = ()) -> None:
for node in nodes:
if not isinstance(node, Mapping):
continue
label = str(node.get("name") or "").strip()
department_id = _as_int(node.get("id"))
path = (*parents, label) if label else parents
if department_id > 0 and label and department_id not in seen:
options.append((department_id, " / ".join(path)))
seen.add(department_id)
children = node.get("children")
if isinstance(children, (list, tuple)):
visit(children, path)
visit(rows)
return options
def _as_bool(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
@@ -930,7 +956,10 @@ class ConsultationsPage(QWidget):
self._page_size = 15
self._generation = 0
self._count_generation = 0
self._options_generation = 0
self._options_generation = 0
self._departments_generation = 0
self._departments_loaded = False
self._departments_loading = False
self._prescription_generation = 0
self._mutation_generation = 0
self._menu_generation = 0
@@ -1172,15 +1201,21 @@ class ConsultationsPage(QWidget):
self.diagnosis_confirmed_combo.setObjectName("DiagnosisConfirmationFilter")
self.diagnosis_confirmed_combo.activated.connect(self._confirmation_combo_changed)
self.confirmed_combo = self.diagnosis_confirmed_combo
self.department_combo = self._fixed_combo((("全部部门", ""),))
self.department_combo.setObjectName("DiagnosisDepartmentFilter")
department_name = display_text(
first_value(current_user, "department_name", "department", "dept_name", default=""),
"",
)
department_id = first_value(current_user, "department_id", "dept_id", default="")
if department_name:
self.department_combo.addItem(department_name, department_id or department_name)
self.department_combo = self._fixed_combo((("全部部门", ""),))
self.department_combo.setObjectName("DiagnosisDepartmentFilter")
self.department_combo.setEditable(True)
self.department_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
self.department_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self.department_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains)
self.department_combo.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.department_combo.lineEdit().setPlaceholderText("输入部门名称搜索")
self.department_combo.lineEdit().returnPressed.connect(self._search)
self.department_retry_button = QToolButton()
self.department_retry_button.setObjectName("DiagnosisDepartmentRetry")
self.department_retry_button.setText("重试")
self.department_retry_button.setAccessibleName("重新加载部门")
self.department_retry_button.clicked.connect(lambda: self._load_departments(force=True))
self.department_retry_button.hide()
self.unserved_sort_combo = self._fixed_combo(
(
("未服务天数默认排序", ""),
@@ -1202,9 +1237,9 @@ class ConsultationsPage(QWidget):
self.secondary_filter_flow.flow.addWidget(
self._filter_field("确认诊单:", self.diagnosis_confirmed_combo)
)
self.secondary_filter_flow.flow.addWidget(
self._filter_field("部门:", self.department_combo)
)
department_field = self._filter_field("部门:", self.department_combo)
department_field.layout().addWidget(self.department_retry_button)
self.secondary_filter_flow.flow.addWidget(department_field)
self.keyword_edit = QLineEdit(secondary_filters)
self.keyword_edit.setObjectName("DiagnosisKeyword")
self.keyword_edit.setPlaceholderText("搜索患者姓名、手机号、病历号")
@@ -1864,7 +1899,16 @@ class ConsultationsPage(QWidget):
self._page = 1
self.refresh()
def _validate_ranges(self) -> bool:
def _validate_ranges(self) -> bool:
if (
self.department_combo.isEnabled()
and self.department_combo.currentText().strip()
and self.department_combo.currentText() != self.department_combo.itemText(
self.department_combo.currentIndex()
)
):
self.banner.show_message("请从部门搜索结果中选择部门,或清空部门筛选。", "warning")
return False
pairs = (
(
self._date_value(self.latest_appointment_start_date),
@@ -1918,7 +1962,11 @@ class ConsultationsPage(QWidget):
"keyword": self.keyword_edit.text().strip(),
"patient_name": self.patient_name_edit.text().strip(),
"doctor_name": self.doctor_edit.text().strip(),
"department_id": self._combo_value(self.department_combo),
"assistant_dept_id": (
self.department_combo.currentData() or ""
if self.department_combo.currentText().strip()
else ""
),
"has_appointment": self._combo_value(self.has_appointment_combo),
"diagnosis_confirmed": self._combo_value(self.diagnosis_confirmed_combo),
"diagnosis_type": self._combo_value(self.diagnosis_type_combo),
@@ -2130,7 +2178,60 @@ class ConsultationsPage(QWidget):
}
self._update_quick_buttons()
def _load_filter_options(self) -> None:
def _load_departments(self, *, force: bool = False) -> None:
if not force and (self._departments_loaded or self._departments_loading):
return
self._departments_generation += 1
generation = self._departments_generation
self._departments_loading = True
self.department_combo.setEnabled(False)
self.department_combo.setToolTip("正在加载可选部门…")
self.department_retry_button.hide()
def load() -> list[tuple[int, str]]:
# Call directly: invoke() can discard unsupported kwargs, which would
# silently remove the permission scope on an older repository.
return _department_options(self.repository.list_departments(apply_data_scope=True))
run_async(
load,
on_success=lambda options: self._apply_departments(options, generation),
on_error=lambda error: self._departments_error(error, generation),
)
def _apply_departments(self, options: list[tuple[int, str]], generation: int) -> None:
if generation != self._departments_generation:
return
current = self.department_combo.currentData()
blocked = self.department_combo.blockSignals(True)
self.department_combo.clear()
self.department_combo.addItem("全部部门" if options else "暂无可选部门", "")
for department_id, label in options:
self.department_combo.addItem(label, department_id)
self.department_combo.setCurrentIndex(max(0, self.department_combo.findData(current)))
self.department_combo.blockSignals(blocked)
self._departments_loaded = True
self._departments_loading = False
self.department_combo.setEnabled(bool(options))
self.department_combo.setToolTip(
"输入部门名称搜索;选择父级包含下级部门" if options else "当前账号暂无可选部门"
)
self.department_retry_button.setVisible(not options)
def _departments_error(self, error: Exception, generation: int) -> None:
if generation != self._departments_generation:
return
self._departments_loading = False
self._departments_loaded = False
self.department_combo.clear()
self.department_combo.addItem("部门加载失败", "")
self.department_combo.setEnabled(False)
message = f"部门加载失败:{friendly_error(error)}。点击重试。"
self.department_combo.setToolTip(message)
self.department_retry_button.setToolTip(message)
self.department_retry_button.show()
def _load_filter_options(self) -> None:
if self._options_loaded:
return
self._options_loaded = True
@@ -3421,9 +3522,10 @@ class ConsultationsPage(QWidget):
self.refresh(silent=True)
self._refresh_counts()
def showEvent(self, event: Any) -> None:
super().showEvent(event)
self._load_filter_options()
def showEvent(self, event: Any) -> None:
super().showEvent(event)
self._load_departments()
self._load_filter_options()
if not self.poll_timer.isActive():
self.poll_timer.start()
if self.table.rowCount() == 0 and not self._loading:
@@ -8,7 +8,7 @@ from html import escape
from math import ceil
from typing import Any
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
from PySide6.QtGui import (
QColor,
QFont,
@@ -19,7 +19,8 @@ from PySide6.QtGui import (
QTextDocument,
QTextOption,
)
from PySide6.QtWidgets import (
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QDateTimeEdit,
@@ -563,7 +564,165 @@ def _create_time_cell(value: Any, _row: Any) -> str:
return format_record_time(raw)
class DoctorMultiSelect(QWidget):
class _DoctorOptionDelegate(QStyledItemDelegate):
"""Draw an explicit checked state independent of native item indicators."""
def sizeHint(self, option: Any, index: QModelIndex) -> QSize:
return QSize(220, 36)
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
checked = index.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked.value
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = option.rect.adjusted(2, 1, -2, -1)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EAF2FF" if checked else "#F3F7FD" if hovered else "#FFFFFF"))
painter.drawRoundedRect(rect, 5, 5)
box = QRectF(rect.left() + 10, rect.center().y() - 8, 16, 16)
painter.setPen(QPen(QColor("#1769E8" if checked else "#B8C7DA"), 1))
painter.setBrush(QColor("#1769E8" if checked else "#FFFFFF"))
painter.drawRoundedRect(box, 3, 3)
if checked:
pen = QPen(QColor("#FFFFFF"), 2)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
x, y = int(box.x()), int(box.y())
painter.drawLine(x + 4, y + 8, x + 7, y + 11)
painter.drawLine(x + 7, y + 11, x + 12, y + 5)
font = QFont(body_family())
font.setPixelSize(13)
painter.setFont(font)
painter.setPen(QColor("#273244"))
text_rect = rect.adjusted(38, 0, -54, 0)
label = painter.fontMetrics().elidedText(str(index.data() or ""), Qt.TextElideMode.ElideRight, text_rect.width())
painter.drawText(text_rect, Qt.AlignmentFlag.AlignVCenter, label)
if checked:
painter.setPen(QColor("#1769E8"))
painter.drawText(rect.adjusted(0, 0, -12, 0), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, "已选")
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#ADC8F2"), 1))
painter.drawRoundedRect(rect, 5, 5)
painter.restore()
def editorEvent(self, event: Any, model: Any, option: Any, index: QModelIndex) -> bool:
# The list handles whole-row toggles, independent of the native checkbox hit area.
return False
class _DoctorOptionsList(QListWidget):
@staticmethod
def _toggle(item: QListWidgetItem | None) -> None:
if item is not None and item.flags() & Qt.ItemFlag.ItemIsEnabled:
item.setCheckState(Qt.CheckState.Unchecked if item.checkState() == Qt.CheckState.Checked
else Qt.CheckState.Checked)
def mousePressEvent(self, event: Any) -> None:
if event.button() == Qt.MouseButton.LeftButton:
item = self.itemAt(event.position().toPoint())
self.setCurrentItem(item)
self._toggle(item)
event.accept()
return
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: Any) -> None:
if event.button() == Qt.MouseButton.LeftButton:
event.accept()
return
super().mouseReleaseEvent(event)
def keyPressEvent(self, event: Any) -> None:
if event.key() == Qt.Key.Key_Space:
if not event.isAutoRepeat():
self._toggle(self.currentItem())
event.accept()
return
super().keyPressEvent(event)
class _DoctorSelectionDialog(QDialog):
def __init__(self, options: Mapping[int, str], selected: set[int], parent: QWidget) -> None:
super().__init__(parent)
self.setObjectName("DoctorSelectionDialog")
self.setWindowTitle("选择医生")
self.resize(420, 500)
self.setStyleSheet("""
QDialog#DoctorSelectionDialog { background: #FFFFFF; color: #273244; }
QListWidget#DoctorOptionsList { background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px; padding: 3px; }
QLabel#DoctorSelectionCount { color: #1769E8; font-size: 13px; }
QDialog#DoctorSelectionDialog QLineEdit { border: 1px solid #DBE5F2; border-radius: 6px; }
QDialog#DoctorSelectionDialog QLineEdit:focus { border-color: #1769E8; }
QDialog#DoctorSelectionDialog QPushButton { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; border-radius: 6px; }
QDialog#DoctorSelectionDialog QPushButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
QDialog#DoctorSelectionDialog QPushButton:disabled { color: #8A97A9; }
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection { background: #1769E8; color: #FFFFFF; border-color: #1769E8; }
QDialog#DoctorSelectionDialog QPushButton#ConfirmDoctorSelection:hover { background: #125DCE; border-color: #125DCE; }
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(10)
self.search = QLineEdit(self)
self.search.setObjectName("DoctorOptionSearch")
self.search.setPlaceholderText("搜索医生姓名")
self.search.setClearButtonEnabled(True)
layout.addWidget(self.search)
summary = QHBoxLayout()
self.count_label = QLabel(self)
self.count_label.setObjectName("DoctorSelectionCount")
summary.addWidget(self.count_label, 1)
self.clear_button = QPushButton("清空选择", self)
self.clear_button.clicked.connect(self._clear)
summary.addWidget(self.clear_button)
layout.addLayout(summary)
self.listing = _DoctorOptionsList(self)
self.listing.setObjectName("DoctorOptionsList")
self.listing.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.listing.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.listing.setMouseTracking(True)
self.listing.setItemDelegate(_DoctorOptionDelegate(self.listing))
for doctor_id, name in sorted(options.items(), key=lambda item: item[1]):
item = QListWidgetItem(name)
item.setToolTip(name)
item.setData(Qt.ItemDataRole.UserRole, doctor_id)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(Qt.CheckState.Checked if doctor_id in selected else Qt.CheckState.Unchecked)
self.listing.addItem(item)
layout.addWidget(self.listing, 1)
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok, self)
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setObjectName("ConfirmDoctorSelection")
self.buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
layout.addWidget(self.buttons)
self.listing.itemChanged.connect(self._update_summary)
self.search.textChanged.connect(self._filter)
self._update_summary()
def selected_ids(self) -> set[int]:
return {_int(self.listing.item(i).data(Qt.ItemDataRole.UserRole))
for i in range(self.listing.count())
if self.listing.item(i).checkState() == Qt.CheckState.Checked}
def _update_summary(self, *_args: Any) -> None:
count = len(self.selected_ids())
self.count_label.setText(f"已选 {count} 位医生")
self.clear_button.setEnabled(count > 0)
def _filter(self, text: str) -> None:
query = text.strip().casefold()
for i in range(self.listing.count()):
item = self.listing.item(i)
item.setHidden(query not in item.text().casefold())
def _clear(self) -> None:
for i in range(self.listing.count()):
self.listing.item(i).setCheckState(Qt.CheckState.Unchecked)
class DoctorMultiSelect(QWidget):
"""Compact checkable doctor selector fed by list rows/extend data."""
def __init__(self, parent: QWidget | None = None) -> None:
@@ -591,36 +750,14 @@ class DoctorMultiSelect(QWidget):
self._options[doctor_id] = name
self._update_text()
def _choose(self) -> None:
dialog = QDialog(self)
dialog.setWindowTitle("选择医生")
dialog.resize(360, 430)
layout = QVBoxLayout(dialog)
listing = QListWidget()
for doctor_id, name in sorted(self._options.items(), key=lambda item: item[1]):
item = QListWidgetItem(name)
item.setData(Qt.ItemDataRole.UserRole, doctor_id)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(
Qt.CheckState.Checked if doctor_id in self._selected else Qt.CheckState.Unchecked
)
listing.addItem(item)
layout.addWidget(listing)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok
)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
self._selected = {
_int(listing.item(index).data(Qt.ItemDataRole.UserRole))
for index in range(listing.count())
if listing.item(index).checkState() == Qt.CheckState.Checked
}
self._selected.discard(0)
self._update_text()
def _choose(self) -> None:
dialog = _DoctorSelectionDialog(self._options, self._selected, self)
try:
if dialog.exec() == QDialog.DialogCode.Accepted:
self._selected = dialog.selected_ids() - {0}
self._update_text()
finally:
dialog.deleteLater()
def _update_text(self) -> None:
if not self._selected:
+215
View File
@@ -0,0 +1,215 @@
"""Permission-scoped department loading and diagnosis HTTP filter contracts."""
from __future__ import annotations
import os
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QApplication, QWidget
from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui.pages import consultations as module
TREE = [
{"id": 10, "name": "医助部", "children": [
{"id": "11", "name": "一组", "children": [
{"id": 12, "name": "专病组", "children": []},
]},
]},
{"id": 20, "name": "另一部门", "children": []},
]
class Client:
def __init__(self, departments: Any = TREE) -> None:
self.departments = departments
self.calls: list[tuple[str, dict[str, Any]]] = []
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
self.calls.append((endpoint, dict(params or {})))
if endpoint == "dept.dept/all":
if isinstance(self.departments, Exception):
raise self.departments
return self.departments
return {"lists": [], "count": 0}
class DetailStub(QWidget):
saved = Signal()
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def page(application: QApplication, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(module, "DiagnosisDialog", DetailStub)
# Test only this page's controls; loading a global stylesheet is unrelated.
client = Client()
result = module.ConsultationsPage(
RemoteDoctorRepository(client),
current_user={"department_id": 99, "department_name": "个人所属部门"},
)
yield result, client
result.poll_timer.stop()
result.close()
result.deleteLater()
application.processEvents()
def immediate(function: Any, *, on_success: Any = None, on_error: Any = None,
on_finished: Any = None, **_kwargs: Any) -> None:
try:
value = function()
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(value)
finally:
if on_finished:
on_finished()
@pytest.mark.parametrize("payload", [TREE, {"lists": TREE}, {"tree": TREE}, {"data": {"lists": TREE}}])
def test_repository_scoped_tree_shapes(payload: Any) -> None:
client = Client(payload)
assert RemoteDoctorRepository(client).list_departments(apply_data_scope=True) == TREE
assert client.calls == [("dept.dept/all", {"apply_data_scope": 1})]
def test_repository_malformed_is_not_a_permission_empty_result() -> None:
with pytest.raises(ApiProtocolError):
RemoteDoctorRepository(Client({"unexpected": 1})).list_departments(apply_data_scope=True)
def test_repository_maps_legacy_department_key_and_preserves_canonical() -> None:
client = Client()
repository = RemoteDoctorRepository(client)
repository.list_consultations(department_id=12)
assert client.calls[-1][1]["assistant_dept_id"] == 12
assert "department_id" not in client.calls[-1][1]
repository.list_consultations(department_id=12, assistant_dept_id=20)
assert client.calls[-1][1]["assistant_dept_id"] == 20
def test_nested_options_search_and_selected_id_reach_list_and_counts(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, client = page
monkeypatch.setattr(module, "run_async", immediate)
widget._load_departments()
combo = widget.department_combo
assert combo.count() == 5
assert combo.itemText(3) == "医助部 / 一组 / 专病组"
assert combo.findData(99) == -1
assert combo.isEditable()
combo.completer().setCompletionPrefix("专病")
assert combo.completer().completionCount() == 1
combo.setCurrentIndex(combo.findData(12))
widget._search()
calls = [params for endpoint, params in client.calls if endpoint == "tcm.diagnosis/lists"]
assert calls
assert all(params["assistant_dept_id"] == 12 for params in calls)
assert all("department_id" not in params for params in calls)
assert calls[-1]["page_size"] == 15
def test_unselected_search_text_never_becomes_an_id_or_silently_searches_all(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, client = page
monkeypatch.setattr(module, "run_async", immediate)
widget._load_departments()
widget.department_combo.setEditText("不存在的部门")
widget._search()
assert not any(endpoint == "tcm.diagnosis/lists" for endpoint, _ in client.calls)
widget.department_combo.setEditText("")
widget._search()
assert widget._shared_filters()["assistant_dept_id"] == ""
def test_permission_empty_has_no_profile_fallback_and_can_retry(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, client = page
monkeypatch.setattr(module, "run_async", immediate)
client.departments = []
widget._load_departments()
assert widget.department_combo.count() == 1
assert widget.department_combo.currentData() == ""
assert widget.department_combo.currentText() == "暂无可选部门"
assert not widget.department_combo.isEnabled()
assert not widget.department_retry_button.isHidden()
client.departments = TREE
widget.department_retry_button.click()
assert widget.department_combo.isEnabled()
assert widget.department_combo.findData(12) > 0
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
def test_permission_error_stays_scoped_and_retry_recovers(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, client = page
monkeypatch.setattr(module, "run_async", immediate)
client.departments = ApiBusinessError("无权限访问部门")
widget._load_departments()
assert not widget._departments_loaded
assert not widget._departments_loading
assert widget.department_combo.currentText() == "部门加载失败"
assert "无权限" in widget.department_combo.toolTip()
assert not widget.department_retry_button.isHidden()
client.departments = TREE
widget.department_retry_button.click()
assert widget._departments_loaded
assert widget.department_retry_button.isHidden()
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
def test_inflight_dedup_and_stale_success_or_failure_cannot_override_selection(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, _client = page
jobs: list[dict[str, Any]] = []
monkeypatch.setattr(module, "run_async", lambda function, **callbacks: jobs.append(callbacks))
widget._load_departments()
widget._load_departments()
assert len(jobs) == 1
widget._load_departments(force=True)
assert len(jobs) == 2
jobs[1]["on_success"]([(12, "当前部门")])
widget.department_combo.setCurrentIndex(1)
jobs[0]["on_success"]([(99, "旧部门")])
jobs[0]["on_error"](RuntimeError("旧请求失败"))
assert widget.department_combo.currentData() == 12
assert widget.department_combo.findData(99) == -1
assert widget._departments_loaded
assert widget.department_combo.isEnabled()
def test_refresh_of_options_preserves_selected_id(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, _client = page
monkeypatch.setattr(module, "run_async", immediate)
widget._load_departments()
widget.department_combo.setCurrentIndex(widget.department_combo.findData(12))
widget._load_departments(force=True)
assert widget.department_combo.currentData() == 12
def test_legacy_repository_is_not_called_without_scope(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
widget, _client = page
monkeypatch.setattr(module, "run_async", immediate)
calls: list[bool] = []
class OldRepository:
def list_departments(self):
calls.append(True)
return TREE
widget.repository = OldRepository()
widget._load_departments()
assert calls == []
assert not widget._departments_loaded
assert not widget.department_retry_button.isHidden()
+240
View File
@@ -0,0 +1,240 @@
"""Critical-path request counts and late metadata safety, without global Qt styling."""
from __future__ import annotations
import os
import time
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui.dialogs import diagnosis as module
class Client:
def __init__(self, delay: float = 0) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
self.delay = delay
def get(self, endpoint: str, params: dict[str, Any]) -> Any:
self.calls.append((endpoint, params))
time.sleep(self.delay)
if endpoint == "config/dict":
return {
key: [{"name": "口干", "value": "dry"}, {"name": "口苦", "value": "bitter"}]
for key in params["type"].split(",")
}
if endpoint.endswith("Detail") or endpoint.endswith("/detail"):
return {
"id": params["id"], "patient_id": params["id"] + 1000,
"patient_name": f"Patient {params['id']}", "appetite": ["dry"],
"diagnosis_type": "first_visit", "chief_complaint": "Original",
}
return {"lists": [], "count": 0}
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def queued(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
jobs: list[dict[str, Any]] = []
def enqueue(function: Any, **callbacks: Any) -> object:
jobs.append({"function": function, **callbacks})
return object()
monkeypatch.setattr(module, "run_async", enqueue)
return jobs
def complete(job: dict[str, Any]) -> None:
job["on_success"](job["function"]())
if job.get("on_finished"):
job["on_finished"]()
def dialog_for(client: Client) -> module.DiagnosisDialog:
return module.DiagnosisDialog(
RemoteDoctorRepository(client), permissions=PermissionSet(["*"])
)
def test_detail_unlocks_before_single_batch_and_preserves_draft(
application: QApplication, queued: list[dict[str, Any]]
) -> None:
client = Client()
dialog = dialog_for(client)
try:
dialog.open_for(501, editable=True, seed={"patient_name": "Unverified"})
assert dialog.isVisible()
assert dialog.drawer_loading.isVisibleTo(dialog)
assert dialog.edit_fields["patient_name"].isReadOnly()
assert not dialog.save_button.isEnabled()
assert len(queued) == 1
complete(queued[0])
assert client.calls == [("tcm.diagnosis/detail", {"id": 501})]
assert dialog._authoritative_detail_loaded
assert dialog.save_button.isEnabled()
assert not dialog.drawer_loading.isVisibleTo(dialog)
assert len(queued) == 2
dialog.edit_fields["chief_complaint"].setPlainText("Unsaved draft")
dialog.edit_fields["appetite"].setPlainText("bitter")
complete(queued[1])
assert len(client.calls) == 2
assert client.calls[1][0] == "config/dict"
assert len(client.calls[1][1]["type"].split(",")) == 16
assert dialog.edit_fields["appetite"].toPlainText() == "bitter"
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Unsaved draft"
assert dialog._loaded_tabs == set()
assert not any("getCallRecords" in endpoint for endpoint, _params in client.calls)
dialog._ensure_tab_loaded("basic")
dialog.refresh_permissions()
assert len(queued) == 2
assert len(client.calls) == 2
finally:
dialog.close()
@pytest.mark.parametrize("close", [False, True])
def test_late_detail_and_dictionary_callbacks_cannot_touch_another_opening(
application: QApplication, queued: list[dict[str, Any]], close: bool
) -> None:
dialog = dialog_for(Client())
try:
dialog.open_for(501, editable=True)
detail_job = queued[0]
complete(detail_job)
metadata_job = queued[1]
if close:
dialog.close()
else:
dialog.open_for(502, editable=True)
complete(queued[2])
dialog.edit_fields["chief_complaint"].setPlainText("Keep current draft")
before = dialog.edit_fields["patient_name"].toPlainText()
complete(detail_job)
complete(metadata_job)
assert dialog.edit_fields["patient_name"].toPlainText() == before
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Keep current draft"
if not close:
assert before == "Patient 502"
assert dialog._patient_id == 1502
finally:
dialog.close()
def test_dictionary_failure_does_not_relock_valid_detail_or_erase_choices(
application: QApplication, queued: list[dict[str, Any]]
) -> None:
dialog = dialog_for(Client())
try:
dialog.open_for(501, editable=True)
complete(queued[0])
queued[1]["on_error"](RuntimeError("Delayed metadata unavailable"))
assert dialog._authoritative_detail_loaded
assert dialog.save_button.isEnabled()
assert dialog.edit_fields["appetite"].toPlainText() == "dry"
assert not dialog.drawer_loading.isVisibleTo(dialog)
dialog._retry_current()
assert len(queued) == 3
complete(queued[2])
finally:
dialog.close()
def test_inactive_basic_tab_defers_dictionary_request_until_selected(
application: QApplication, queued: list[dict[str, Any]]
) -> None:
client = Client()
dialog = dialog_for(client)
try:
notes_index = next(
i for i in range(dialog.tabs.count()) if dialog.tabs.tabBar().tabData(i) == "notes"
)
dialog.tabs.setCurrentIndex(notes_index)
dialog.open_for(501, editable=True)
complete(queued[0])
assert dialog._current_tab_key() == "notes"
assert dialog._dictionary_requested_generation != dialog._generation
assert len(client.calls) == 1
# The only secondary request is for the active notes tab, not all tabs.
assert len(queued) == 2
dialog.tabs.setCurrentIndex(0)
assert len(queued) == 3
complete(queued[2])
assert client.calls[-1][0] == "config/dict"
dialog.tabs.setCurrentIndex(notes_index)
dialog.tabs.setCurrentIndex(0)
assert len(queued) == 3
finally:
dialog.close()
@pytest.mark.parametrize("retry_from_banner", [False, True])
def test_dictionary_error_survives_other_tab_success_and_retries_correct_request(
application: QApplication, queued: list[dict[str, Any]], retry_from_banner: bool,
) -> None:
client = Client()
dialog = dialog_for(client)
try:
dialog.open_for(501, editable=True)
complete(queued[0])
metadata_job = queued[1]
notes_index = next(i for i in range(dialog.tabs.count())
if dialog.tabs.tabBar().tabData(i) == "notes")
dialog.tabs.setCurrentIndex(notes_index)
notes_job = queued[2]
metadata_job["on_error"](RuntimeError("Metadata unavailable"))
complete(notes_job)
assert dialog.drawer_banner.label.text() == dialog._dictionary_error_message
assert dialog.drawer_banner.label.text().startswith("病历选项加载失败")
dialog.edit_fields["chief_complaint"].setPlainText("Keep draft across retry")
if retry_from_banner:
dialog.drawer_banner.action_requested.emit()
else:
dialog.tabs.setCurrentIndex(0)
assert len(queued) == 4
complete(queued[3])
assert client.calls[-1][0] == "config/dict"
assert not dialog._dictionary_error_message
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Keep draft across retry"
assert dialog._authoritative_detail_loaded
finally:
dialog.close()
def test_controlled_latency_removes_sixteen_round_trips_from_detail_gate(
application: QApplication,
) -> None:
client = Client(delay=0.02)
dialog = dialog_for(client)
try:
types = sorted({"diagnosis_type", *(row[0] for row in module._CHOICE_DICTIONARIES.values())})
started = time.perf_counter()
dialog.repository.get_diagnosis_detail(501)
for key in types:
dialog.repository.get_dictionary(key)
old_time = time.perf_counter() - started
old_calls = len(client.calls)
client.calls.clear()
started = time.perf_counter()
detail = dialog._load_bundle(501, "edit")
new_time = time.perf_counter() - started
assert detail["detail"]["id"] == 501
assert len(client.calls) == 1
dialog._load_dictionary_choices()
assert len(client.calls) == 2
assert old_calls == 17
assert new_time < old_time / 4
print(f"controlled 20 ms RTT: old gate={old_time:.3f}s/17 calls; "
f"new gate={new_time:.3f}s/1 call; total new=2 calls")
finally:
dialog.close()
@@ -356,8 +356,11 @@ def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
assert thumb._apply_payload(_png_bytes(192, 96), thumb._generation)
assert thumb.property("loadState") == "ready"
assert thumb._rendered_pixmap.size() == QSize(64, 64)
previews: list[tuple[list[str], int]] = []
timeline.preview_images_requested.connect(lambda sources, index: previews.append((sources, index)))
thumb.click()
assert opened == [tongue_url]
assert opened == []
assert previews == [([tongue_url], 0)]
remove_buttons = timeline.findChildren(QPushButton, "DiagnosisAttachmentRemove")
assert len(remove_buttons) == 2
@@ -0,0 +1,149 @@
from __future__ import annotations
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QBuffer, QCoreApplication, QEvent, QIODevice, QPoint, Qt
from PySide6.QtGui import QColor, QDesktopServices, QFont, QImage, QPainter
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from test_diagnosis_drawer_visual import (
ActionRepository,
_open_dialog,
_select_tab,
)
from test_diagnosis_drawer_visual import (
application as application,
)
from test_diagnosis_drawer_visual import (
immediate_async as immediate_async,
)
from test_diagnosis_media_thumbnail_visual import (
_FakeManager,
_hold_remote_load,
_png_bytes,
)
from doctor_workstation.ui.diagnosis_drawer import _RemoteImageButton
from doctor_workstation.ui.diagnosis_media import ImagePreviewDialog
from doctor_workstation.ui.theme import _register_preferred_cjk_fonts
def _synthetic_image() -> bytes:
image = QImage(1600, 1200, QImage.Format.Format_ARGB32)
image.fill(QColor("#F4E3D8"))
painter = QPainter(image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#C37B85"))
painter.drawEllipse(440, 160, 720, 900)
painter.setPen(QColor("#67434A"))
painter.setFont(QFont("Arial", 36))
painter.drawText(image.rect(), Qt.AlignmentFlag.AlignCenter, "SYNTHETIC TEST IMAGE")
painter.end()
buffer = QBuffer()
assert buffer.open(QIODevice.OpenModeFlag.WriteOnly)
assert image.save(buffer, "PNG")
return bytes(buffer.data())
@pytest.mark.parametrize("mode", ["edit", "readonly"])
def test_real_note_thumbnail_click_keeps_drawer_and_previews_all_eight_images(
application: QApplication, monkeypatch: pytest.MonkeyPatch, mode: str,
) -> None:
"""Exercise the wired drawer click, not just the standalone lightbox class."""
monkeypatch.setattr(_RemoteImageButton, "load_url", _hold_remote_load)
external: list[str] = []
monkeypatch.setattr(QDesktopServices, "openUrl", lambda url: external.append(url.toString()) or True)
application.setFont(QFont(_register_preferred_cjk_fonts(), 10))
payload = _synthetic_image()
def offline_request(preview: ImagePreviewDialog, target: str) -> None:
preview._invalidate_request()
preview.apply_payload(payload, preview._generation)
monkeypatch.setattr(ImagePreviewDialog, "_request", offline_request)
dialog = _open_dialog(application, (1440, 900), mode=mode, repository=ActionRepository())
urls = [f"https://media.example.invalid/tongue-{index}.jpg" for index in range(8)]
if mode == "edit":
_select_tab(dialog, "notes")
dialog._fill_notes([{"id": 7001, "note_date": "2026-09-08", "content": "合成测试图片", "tongue_images": urls}])
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
timeline = dialog.drawer_notes_timeline if mode == "edit" else dialog._notes_timelines[0]
application.processEvents()
thumbs = timeline.findChildren(_RemoteImageButton, "DiagnosisTongueThumb")
assert len(thumbs) == 8
for thumb in thumbs:
assert thumb._apply_payload(payload, thumb._generation)
original_parent = dialog.parentWidget()
original_generation = dialog._generation
for index in (0, 7, 3):
QTest.mouseClick(thumbs[index], Qt.MouseButton.LeftButton, pos=QPoint(24, 32))
application.processEvents()
assert external == [], "Thumbnail click must not launch the system browser"
preview = dialog._image_preview
assert isinstance(preview, ImagePreviewDialog)
assert preview.isVisible() and preview.parentWidget() is dialog
assert dialog.isVisible() and dialog.parentWidget() is original_parent
assert dialog._generation == original_generation
assert preview.current_source == urls[index]
assert preview.sources == urls
assert not preview.canvas.pixmap().isNull()
fitted_width = preview.canvas.pixmap().width()
QTest.mouseClick(preview.zoom_button, Qt.MouseButton.LeftButton)
assert preview.canvas.pixmap().width() == 1600 > fitted_width
QTest.mouseClick(preview.next_button, Qt.MouseButton.LeftButton)
assert preview.current_source == urls[(index + 1) % 8]
QTest.mouseClick(preview.previous_button, Qt.MouseButton.LeftButton)
assert preview.current_source == urls[index]
if mode == "edit" and index == 7:
output = Path(__file__).resolve().parents[2] / "artifacts/patient-render-image-fix"
output.mkdir(parents=True, exist_ok=True)
assert dialog.grab().save(str(output / "notes-eight-thumbnails.png"))
QTest.mouseClick(preview.zoom_button, Qt.MouseButton.LeftButton)
application.processEvents()
assert preview.grab().save(str(output / "notes-image-preview.png"))
if index == 3:
QTest.keyClick(preview, Qt.Key.Key_Escape)
else:
QTest.mouseClick(preview.close_button, Qt.MouseButton.LeftButton)
application.processEvents()
assert dialog.isVisible() and timeline.isVisible()
assert dialog._image_preview is None
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert external == []
QTest.mouseClick(thumbs[4], Qt.MouseButton.LeftButton, pos=QPoint(24, 32))
preview = dialog._image_preview
QTest.mouseClick(preview.external_button, Qt.MouseButton.LeftButton)
assert external == [urls[4]]
dialog.reject()
application.processEvents()
assert dialog._image_preview is None
@pytest.mark.parametrize("close_with_escape", [False, True])
def test_preview_close_cancels_pending_response(
application: QApplication, monkeypatch: pytest.MonkeyPatch, close_with_escape: bool,
) -> None:
monkeypatch.setattr(ImagePreviewDialog, "_request", lambda self, target: None)
preview = ImagePreviewDialog(["https://media.example.invalid/pending.jpg"])
manager = _FakeManager(preview)
manager.queue(_png_bytes(80, 60))
preview._manager = manager
monkeypatch.undo()
preview.reload_current()
reply = manager.replies[-1]
generation = preview._generation
preview.show()
if close_with_escape:
QTest.keyClick(preview, Qt.Key.Key_Escape)
else:
QTest.mouseClick(preview.close_button, Qt.MouseButton.LeftButton)
assert reply.aborted
assert preview._generation > generation
reply.finished.emit()
assert preview._pixmap.isNull()
application.processEvents()
+75
View File
@@ -0,0 +1,75 @@
"""Regressions for the blank strip between query results and pinned actions."""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost
from doctor_workstation.ui.theme import apply_theme
@pytest.fixture(scope="module")
def application():
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
def settle(app):
for _ in range(4):
app.processEvents()
QTest.qWait(15)
def rows(count):
return [{"id": i + 1, "patient_name": f"演示患者{i + 1}", "age": 55,
"gender": 1, "has_appointment": 0, "diagnosis_confirmed": 1}
for i in range(count)]
@pytest.mark.parametrize("blue", [True, False])
def test_filter_results_fill_viewport_through_scrollbar_and_window_changes(application, blue):
host = DiagnosisTableHost(tech_blue=blue, action_policy={"view": True, "edit": True})
host.resize(1650, 680)
host.show()
for width, count in ((1650, 70), (1650, 2), (1900, 2), (900, 2),
(1900, 0), (1900, 2), (1366, 80), (1650, 2)):
host.resize(width, 680)
host.set_rows(rows(count))
settle(application)
main = host.main
viewport = main.viewport()
base_width = sum(host.LEFT_WIDTHS)
assert main.horizontalHeader().length() == max(base_width, viewport.width())
assert all(main.columnWidth(i) >= minimum for i, minimum in enumerate(host.LEFT_WIDTHS))
assert main.viewport().height() == host.fixed.viewport().height()
assert main.verticalScrollBar().maximum() == host.fixed.verticalScrollBar().maximum()
if viewport.width() >= base_width:
assert main.horizontalScrollBar().maximum() == 0
if count:
main.selectRow(1)
settle(application)
row = main.visualRect(host.model.index(1, 9))
point = QPoint(viewport.width() - 4, row.bottom() - 5)
# A real data cell, rather than the QTableView's blank background,
# must occupy the entire strip leading into the pinned actions.
assert main.indexAt(point).row() == 1
assert main.indexAt(point).column() == 9
if blue:
capture = viewport.grab()
scale = capture.devicePixelRatio()
pixel = QPoint(round(point.x() * scale), round(point.y() * scale))
assert capture.toImage().pixelColor(pixel).name() == "#eaf2ff"
else:
assert main.horizontalScrollBar().maximum() > 0
main.horizontalScrollBar().setValue(main.horizontalScrollBar().maximum())
settle(application)
assert main.visualRect(host.model.index(0, 9)).right() < viewport.width()
host.close()
host.deleteLater()
+102
View File
@@ -0,0 +1,102 @@
"""Doctor choices remain explicit and transactional across mouse/search/keyboard use."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.ui.pages.prescriptions import DoctorMultiSelect, _DoctorSelectionDialog
from doctor_workstation.ui.theme import apply_theme
@pytest.fixture(scope="module")
def application():
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.fixture
def dialog(application):
parent = DoctorMultiSelect()
widget = _DoctorSelectionDialog({1: "医生甲", 2: "医生乙", 3: "医生丙"}, {1}, parent)
widget.show()
application.processEvents()
yield widget
widget.close()
parent.deleteLater()
application.processEvents()
def item_for(dialog, doctor_id):
return next(dialog.listing.item(i) for i in range(dialog.listing.count())
if dialog.listing.item(i).data(Qt.ItemDataRole.UserRole) == doctor_id)
def test_row_and_checkbox_toggle_once_and_keyboard(dialog, application):
item = item_for(dialog, 2)
rect = dialog.listing.visualItemRect(item)
QTest.mouseClick(dialog.listing.viewport(), Qt.MouseButton.LeftButton, pos=rect.center())
assert dialog.selected_ids() == {1, 2}
assert dialog.count_label.text() == "已选 2 位医生"
QTest.mouseClick(dialog.listing.viewport(), Qt.MouseButton.LeftButton,
pos=QPoint(rect.left() + 20, rect.center().y()))
assert dialog.selected_ids() == {1}
dialog.listing.setCurrentItem(item)
dialog.listing.setFocus()
QTest.keyClick(dialog.listing, Qt.Key.Key_Space)
assert dialog.selected_ids() == {1, 2}
QTest.keyClick(dialog.listing, Qt.Key.Key_Space)
assert dialog.selected_ids() == {1}
def test_filter_preserves_checks_and_clear_includes_hidden(dialog):
dialog.search.setText("")
assert item_for(dialog, 1).isHidden()
assert not item_for(dialog, 2).isHidden()
assert dialog.selected_ids() == {1}
dialog.clear_button.click()
assert dialog.selected_ids() == set()
assert not dialog.clear_button.isEnabled()
dialog.search.clear()
assert not item_for(dialog, 1).isHidden()
def test_checked_row_blue_and_unchecked_white(dialog, application):
application.processEvents()
image = dialog.listing.viewport().grab().toImage()
scale = image.devicePixelRatio()
colors = []
for doctor_id in (1, 2):
rect = dialog.listing.visualItemRect(item_for(dialog, doctor_id))
colors.append(image.pixelColor(int((rect.right() - 70) * scale),
int(rect.center().y() * scale)).name())
assert colors == ["#eaf2ff", "#ffffff"]
def test_accept_commits_cancel_preserves_and_reopen_restores(application):
selector = DoctorMultiSelect()
selector.update_options([{"id": 1, "name": "医生甲"}, {"id": 2, "name": "医生乙"}])
seen = []
def choose(accept):
modal = application.activeModalWidget()
seen.append(modal.selected_ids())
item_for(modal, 2).setCheckState(Qt.CheckState.Checked)
modal.accept() if accept else modal.reject()
QTimer.singleShot(0, lambda: choose(False))
selector._choose()
assert selector.values() == []
QTimer.singleShot(0, lambda: choose(True))
selector._choose()
assert selector.values() == [2]
assert selector.button.text() == "医生乙"
QTimer.singleShot(0, lambda: choose(False))
selector._choose()
assert seen == [set(), set(), {2}]
assert selector.values() == [2]
selector.deleteLater()
@@ -0,0 +1,40 @@
# Doctor-notes image preview fix
## Confirmed cause
The actual editable and readonly diagnosis note thumbnail click chain was:
`NotesTimeline._RemoteImageButton.clicked``open_attachment_requested(url)``DiagnosisDialog._open_safe_resource(url)``QDesktopServices.openUrl(url)`.
The existing `ImagePreviewDialog` was not connected to this path. A regression using `QTest.mouseClick` on the real eight-image doctor-notes timeline failed before the implementation change because the system opener received `https://media.example.invalid/tongue-0.jpg`. This confirms an external-open bug. This reproduction did not establish an application crash or drawer reparenting bug.
## Changes
- Notes thumbnails now emit a dedicated image-group signal containing the note's image URLs and clicked index. Report-file attachment routing and deletion remain on their existing signals.
- Both editable and readonly diagnosis note timelines connect that signal to a retained, diagnosis-owned `ImagePreviewDialog`. It opens inside the application and supports previous/next image, fit/original-size zoom, and close. Only its explicit `安全外部打开` button launches the system opener.
- Closing the diagnosis closes its image preview. Closing the preview clears the retained reference without rejecting or reparenting the diagnosis.
- The preview now cancels outstanding image requests in `done()` as well as `closeEvent()`, covering Escape/reject, which bypasses Qt's close event. Late responses cannot update the closed preview.
Changed application files:
- `app/src/doctor_workstation/ui/diagnosis_drawer.py`
- `app/src/doctor_workstation/ui/dialogs/diagnosis.py`
- `app/src/doctor_workstation/ui/diagnosis_media.py`
## Validation
All tests used synthetic local image bytes and fake HTTP transport; no patient-image requests were made.
- `.venv/Scripts/python.exe -m pytest tests/test_diagnosis_notes_image_preview.py -x -p no:cacheprovider`**4 passed**, latest run 1.73 seconds. Actual clicks on first, eighth, and later thumbnails in both editable/readonly modes; selected image and eight-image group; decoded full-size preview; zoom; previous/next and wrap; close/reopen; Escape; diagnosis visibility/parent/generation retention; explicit external-open action; parent cleanup; pending-response cancellation with close button and Escape.
- `.venv/Scripts/python.exe -m pytest tests/test_diagnosis_media_thumbnail_visual.py -p no:cacheprovider`**9 passed**, 0.36 seconds. Existing thumbnail decode, stale-generation, URL/redirect/oversize safety, preview paging/cache, failures, deletion, chat and daily-toolbar checks. The tongue-click assertion now verifies the image-group signal rather than the external-attachment signal.
- Ruff checks on all five edited Python files — **passed**.
New regression: `app/tests/test_diagnosis_notes_image_preview.py`.
Updated regression: `app/tests/test_diagnosis_media_thumbnail_visual.py`.
## Reviewed screenshots
- `notes-eight-thumbnails.png`: editable doctor-notes tab with eight synthetic thumbnails and individual delete controls.
- `notes-image-preview.png`: image 8/8 displayed in the application's preview, with navigation, original-size zoom, reload, explicit external action, and close controls.
Screenshots were visually inspected after registering the bundled CJK font and settling the Qt layout. Images are clearly labelled synthetic test imagery. No build or installation was performed by this worker; packaging belongs to the parent task.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -0,0 +1,31 @@
# 处方订单与问诊功能交付
本次完成 Web 管理端功能,未发布到线上,未改动桌面 app 的现有开发内容。
## 功能
1. 全额退款后释放处方,可在已开处方列表再次创建订单。部分退款仍有余额时继续占用;保留历史订单和收款关联,并发创建只允许一个有效订单。返回缓存的处方列表会重新读取占用状态。
2. PC / H5 处方业务订单列表、详情新增“修改创建时间”。时间保留秒,兼容旧时间戳,保存后刷新列表和详情。接口校验独立权限、订单访问范围,日志记录修改前后时间;失败整体回滚。
3. 预约/挂号支持视频问诊和图文问诊,默认视频。医生 PC / H5 面诊及接诊台聊天窗口显示当前挂号的问诊类型。历史空值默认视频,已有电话记录保留历史描述。
## 部署
将本次 admin 与 server 源码一起部署。执行以下 SQL(当前仅准备文件,未运行):
- [订单时间权限菜单](../../server/sql/1.9.20260909/add_prescription_order_edit_time_menu.sql):执行后在角色管理中为指定角色勾选“修改业务订单创建时间”;普通角色不会自动获得权限。
- [问诊类型默认值](../../server/sql/1.9.20260909/appointment_type_video_default.sql):回填空值并设默认视频,不改已有图文/电话记录。
脚本表前缀为 `zyt_`。退款释放本身无需改表或清理历史订单。
## 验证
- 前端 Vite 生产构建通过(日志 [build.log](build.log)),未执行会发布静态文件的 release 脚本。
- 10 个修改/新增 Vue 组件的 script、template 和样式编译通过。
- 订单创建时间组件 4 项行为测试通过:秒数保留、时间戳兼容、失败保留输入、提交期间防重入。
- 浏览器隔离页面验证真实 Element Plus 弹窗:提交准确的订单 ID 和日期秒数、成功触发刷新事件、取消不发请求;390px 窄屏可正常显示。测试数据均为虚构,无业务 API 调用。
- 新增订单后端 112 项真实 MySQL 断言、原收款关联 131 项回归均通过;覆盖退款重建、并发、权限、日志回滚和 INT/DATETIME。
- 问诊类型默认值/校验/历史读取测试、三个聊天入口数据传递检查通过,相关既有 PHP 回归通过。
- 全量 `vue-tsc` 未通过:57 个错误,位置均在本次未修改的代码行,包括既有地区数据 `never[]`、隐式 any、`searchPatientsAPI` 未定义和依赖类型问题。详见 [typecheck.log](typecheck.log) 与 [定位记录](typecheck-triage.json)。这不是一轮独立基线类型检查,未将其表述为全项目类型检查通过。
- 未进行真实挂号、退款、腾讯云聊天或视频通话联调;测试 MySQL 已关闭并清理。生产构建产物及临时缓存已清理,仅保留日志和复现脚本。
更详细的后端和问诊说明见 [order-backend.md](order-backend.md)、[consultation-type.md](consultation-type.md)。
@@ -0,0 +1,494 @@
vite v6.4.2 building for production...
WARN
(!) outDir D:\web\zyt\artifacts\prescription-enhancements\admin-build is not inside project root and will not be emptied.
Use --emptyOutDir to override.
transforming...
✓ 4060 modules transformed.
WARN node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js (3199:26): Use of eval in "node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js" is strongly discouraged as it poses security risks and may cause issues with minification.
rendering chunks...
computing gzip size...
../artifacts/prescription-enhancements/admin-build/assets/default_avatar-C6VB7PGm.png 6.09 kB
../artifacts/prescription-enhancements/admin-build/assets/no_perms-jDxcYpYC.png 14.62 kB
../artifacts/prescription-enhancements/admin-build/index.html 31.87 kB │ gzip: 16.16 kB
../artifacts/prescription-enhancements/admin-build/assets/login_bg-BkIjQ0FB.png 59.27 kB
../artifacts/prescription-enhancements/admin-build/assets/red3-NOuWP8DK.png 105.00 kB
../artifacts/prescription-enhancements/admin-build/assets/pink3-BxZ4Y6CS.png 108.36 kB
../artifacts/prescription-enhancements/admin-build/assets/blue3-D3K9OqGO.png 108.98 kB
../artifacts/prescription-enhancements/admin-build/assets/yellow3-C_qd9cqN.png 109.27 kB
../artifacts/prescription-enhancements/admin-build/assets/green3-CPorIQiC.png 109.99 kB
../artifacts/prescription-enhancements/admin-build/assets/purple3-BGd0LxTa.png 110.15 kB
../artifacts/prescription-enhancements/admin-build/assets/my_topbg-BiU0PleK.png 142.47 kB
../artifacts/prescription-enhancements/admin-build/assets/red2-Dw8p71sP.png 750.50 kB
../artifacts/prescription-enhancements/admin-build/assets/purple2-C0tQkldV.png 752.33 kB
../artifacts/prescription-enhancements/admin-build/assets/green2-C-VRKLSN.png 756.86 kB
../artifacts/prescription-enhancements/admin-build/assets/yellow2-B-WtqITJ.png 762.86 kB
../artifacts/prescription-enhancements/admin-build/assets/pink2-BpEp33zy.png 806.48 kB
../artifacts/prescription-enhancements/admin-build/assets/blue2-CRQPdLZd.png 806.81 kB
../artifacts/prescription-enhancements/admin-build/assets/red1-C6Y3UuNB.png 1,660.63 kB
../artifacts/prescription-enhancements/admin-build/assets/yellow1-Ebw0T5sw.png 1,662.23 kB
../artifacts/prescription-enhancements/admin-build/assets/pink1-BWNZrP7C.png 1,668.56 kB
../artifacts/prescription-enhancements/admin-build/assets/blue1-gLOo1H0w.png 1,676.54 kB
../artifacts/prescription-enhancements/admin-build/assets/purple1-BpMq9FWz.png 1,680.01 kB
../artifacts/prescription-enhancements/admin-build/assets/green1-h1zqes95.png 1,688.20 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CmFE2aZQ.css 0.04 kB │ gzip: 0.06 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CjSHFu-R.css 0.04 kB │ gzip: 0.06 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Oppei429.css 0.04 kB │ gzip: 0.06 kB
../artifacts/prescription-enhancements/admin-build/assets/CaseRecordList-Cl-c3q7S.css 0.05 kB │ gzip: 0.07 kB
../artifacts/prescription-enhancements/admin-build/assets/DietRecordList-Cc-xUr2B.css 0.05 kB │ gzip: 0.07 kB
../artifacts/prescription-enhancements/admin-build/assets/roster-CqNlv_rj.css 0.05 kB │ gzip: 0.07 kB
../artifacts/prescription-enhancements/admin-build/assets/BloodRecordList-DhVK-Y_e.css 0.05 kB │ gzip: 0.07 kB
../artifacts/prescription-enhancements/admin-build/assets/index-B3imPWFk.css 0.05 kB │ gzip: 0.07 kB
../artifacts/prescription-enhancements/admin-build/assets/list-BluNBZln.css 0.05 kB │ gzip: 0.07 kB
../artifacts/prescription-enhancements/admin-build/assets/ExerciseRecordList-DGtgtzou.css 0.05 kB │ gzip: 0.07 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-CObVTcPU.css 0.09 kB │ gzip: 0.10 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-D-D8bVPM.css 0.09 kB │ gzip: 0.10 kB
../artifacts/prescription-enhancements/admin-build/assets/pc_details-nlJo1_D0.css 0.09 kB │ gzip: 0.10 kB
../artifacts/prescription-enhancements/admin-build/assets/AppointmentRecordPanel-DC22GDgn.css 0.09 kB │ gzip: 0.10 kB
../artifacts/prescription-enhancements/admin-build/assets/theme-picker-BsELUxM9.css 0.11 kB │ gzip: 0.09 kB
../artifacts/prescription-enhancements/admin-build/assets/content-Cl-9UIip.css 0.13 kB │ gzip: 0.13 kB
../artifacts/prescription-enhancements/admin-build/assets/AssignLogPanel-DkQMoEkX.css 0.13 kB │ gzip: 0.13 kB
../artifacts/prescription-enhancements/admin-build/assets/content-DXAsZ7EV.css 0.13 kB │ gzip: 0.13 kB
../artifacts/prescription-enhancements/admin-build/assets/content-DaxRy47P.css 0.14 kB │ gzip: 0.14 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-CAJZpU7c.css 0.14 kB │ gzip: 0.12 kB
../artifacts/prescription-enhancements/admin-build/assets/content-DxNvUNZR.css 0.15 kB │ gzip: 0.15 kB
../artifacts/prescription-enhancements/admin-build/assets/useListTimeFilter-DI6SIumd.css 0.16 kB │ gzip: 0.14 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-C1NtFi7N.css 0.16 kB │ gzip: 0.11 kB
../artifacts/prescription-enhancements/admin-build/assets/content-Bd3rHJ5J.css 0.18 kB │ gzip: 0.15 kB
../artifacts/prescription-enhancements/admin-build/assets/change-password-DRfsLJ26.css 0.19 kB │ gzip: 0.16 kB
../artifacts/prescription-enhancements/admin-build/assets/content-Dy_alH9u.css 0.19 kB │ gzip: 0.16 kB
../artifacts/prescription-enhancements/admin-build/assets/decoration-img-C5XvHl9_.css 0.19 kB │ gzip: 0.16 kB
../artifacts/prescription-enhancements/admin-build/assets/account_cost-fbCWUO9k.css 0.20 kB │ gzip: 0.16 kB
../artifacts/prescription-enhancements/admin-build/assets/index-57RdOFNo.css 0.23 kB │ gzip: 0.16 kB
../artifacts/prescription-enhancements/admin-build/assets/error-Cz3CexuM.css 0.24 kB │ gzip: 0.18 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-D4atl_7z.css 0.25 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-lNortPsv.css 0.27 kB │ gzip: 0.19 kB
../artifacts/prescription-enhancements/admin-build/assets/setting-CxqqGetv.css 0.27 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DX96drV3.css 0.28 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/menu-DYFusokT.css 0.29 kB │ gzip: 0.16 kB
../artifacts/prescription-enhancements/admin-build/assets/OrderActionHost-DiqybThz.css 0.31 kB │ gzip: 0.22 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Bk2rMrqw.css 0.32 kB │ gzip: 0.20 kB
../artifacts/prescription-enhancements/admin-build/assets/index-D0f1Mn00.css 0.33 kB │ gzip: 0.22 kB
../artifacts/prescription-enhancements/admin-build/assets/prescription-drawer-BjwiqvPc.css 0.35 kB │ gzip: 0.16 kB
../artifacts/prescription-enhancements/admin-build/assets/bind-work-wechat-BX1gqwq1.css 0.35 kB │ gzip: 0.22 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DhR2yeiO.css 0.39 kB │ gzip: 0.19 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DoFDMZdP.css 0.40 kB │ gzip: 0.23 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DBNvK3ZK.css 0.45 kB │ gzip: 0.21 kB
../artifacts/prescription-enhancements/admin-build/assets/PatientOrderList-D8-uh2iu.css 0.45 kB │ gzip: 0.25 kB
../artifacts/prescription-enhancements/admin-build/assets/DiagnosisTodoList-D0SBmTHG.css 0.46 kB │ gzip: 0.23 kB
../artifacts/prescription-enhancements/admin-build/assets/CallRecordPanel-TsjZtTO8.css 0.47 kB │ gzip: 0.23 kB
../artifacts/prescription-enhancements/admin-build/assets/index-C_LxAEkS.css 0.48 kB │ gzip: 0.27 kB
../artifacts/prescription-enhancements/admin-build/assets/login-Bt4SvQsz.css 0.52 kB │ gzip: 0.28 kB
../artifacts/prescription-enhancements/admin-build/assets/content-sMbqkta2.css 0.55 kB │ gzip: 0.29 kB
../artifacts/prescription-enhancements/admin-build/assets/RecordingPlaybackBlock-B3KYFgvg.css 0.58 kB │ gzip: 0.26 kB
../artifacts/prescription-enhancements/admin-build/assets/medicine-DO6x6zrS.css 0.58 kB │ gzip: 0.29 kB
../artifacts/prescription-enhancements/admin-build/assets/preview-pc-BRQDo0AR.css 0.67 kB │ gzip: 0.36 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BzrSkGWL.css 0.67 kB │ gzip: 0.27 kB
../artifacts/prescription-enhancements/admin-build/assets/tabbar-DJsOahKR.css 0.69 kB │ gzip: 0.31 kB
../artifacts/prescription-enhancements/admin-build/assets/qywx-Dym8iFe0.css 0.74 kB │ gzip: 0.37 kB
../artifacts/prescription-enhancements/admin-build/assets/AssistantWatchCallDialog-BIPZRgaH.css 0.76 kB │ gzip: 0.38 kB
../artifacts/prescription-enhancements/admin-build/assets/picker-JBYDNsl5.css 0.84 kB │ gzip: 0.32 kB
../artifacts/prescription-enhancements/admin-build/assets/oa-phone-CctIyraX.css 0.94 kB │ gzip: 0.34 kB
../artifacts/prescription-enhancements/admin-build/assets/preview-C7oaKmYo.css 0.98 kB │ gzip: 0.41 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Bidrbn2Z.css 1.16 kB │ gzip: 0.51 kB
../artifacts/prescription-enhancements/admin-build/assets/TrackingNoteTimeline-B9T8pFso.css 1.18 kB │ gzip: 0.47 kB
../artifacts/prescription-enhancements/admin-build/assets/RecordingVideoPlayer-4DegHAwm.css 1.19 kB │ gzip: 0.53 kB
../artifacts/prescription-enhancements/admin-build/assets/index-55HtuLpn.css 1.21 kB │ gzip: 0.55 kB
../artifacts/prescription-enhancements/admin-build/assets/PatientInfoCard-n6_UPo9e.css 1.32 kB │ gzip: 0.55 kB
../artifacts/prescription-enhancements/admin-build/assets/TrackingMatrix-zyxtXYPb.css 1.32 kB │ gzip: 0.44 kB
../artifacts/prescription-enhancements/admin-build/assets/SendPanel-DaGPWlQG.css 1.39 kB │ gzip: 0.50 kB
../artifacts/prescription-enhancements/admin-build/assets/picker-XZbUgFct.css 1.57 kB │ gzip: 0.50 kB
../artifacts/prescription-enhancements/admin-build/assets/PrescriptionOrderDetailDrawer-B4rggqa1.css 1.73 kB │ gzip: 0.71 kB
../artifacts/prescription-enhancements/admin-build/assets/ImChatRecordPanel-aX8zpWRQ.css 1.74 kB │ gzip: 0.59 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BYOmK9R5.css 1.93 kB │ gzip: 0.58 kB
../artifacts/prescription-enhancements/admin-build/assets/index-OgnT0gUo.css 1.98 kB │ gzip: 0.56 kB
../artifacts/prescription-enhancements/admin-build/assets/MessageBubble-BwtdPM6K.css 2.04 kB │ gzip: 0.60 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BmDW9vcs.css 2.44 kB │ gzip: 0.86 kB
../artifacts/prescription-enhancements/admin-build/assets/PatientCaseCard-C3Mob6yG.css 2.49 kB │ gzip: 0.81 kB
../artifacts/prescription-enhancements/admin-build/assets/tongji-C0VnzFxy.css 2.49 kB │ gzip: 0.77 kB
../artifacts/prescription-enhancements/admin-build/assets/dept-tongji-ky7rOYoi.css 2.67 kB │ gzip: 0.72 kB
../artifacts/prescription-enhancements/admin-build/assets/readonly-CdP3eZN0.css 2.69 kB │ gzip: 0.92 kB
../artifacts/prescription-enhancements/admin-build/assets/NoteTimeline-I2FSxX4s.css 2.74 kB │ gzip: 0.84 kB
../artifacts/prescription-enhancements/admin-build/assets/patient-call-k8V9hV2G.css 2.85 kB │ gzip: 0.91 kB
../artifacts/prescription-enhancements/admin-build/assets/DailyMatrix-DCZnxI8i.css 2.87 kB │ gzip: 0.85 kB
../artifacts/prescription-enhancements/admin-build/assets/PaibanPanel-aqm8azGc.css 3.35 kB │ gzip: 0.88 kB
../artifacts/prescription-enhancements/admin-build/assets/paiban-BTUHWpUu.css 3.35 kB │ gzip: 0.88 kB
../artifacts/prescription-enhancements/admin-build/assets/appointment-gzuOW_Wp.css 3.43 kB │ gzip: 0.93 kB
../artifacts/prescription-enhancements/admin-build/assets/PrescriptionAiBatchGenerateDialog-CKkykmdr.css 3.45 kB │ gzip: 0.86 kB
../artifacts/prescription-enhancements/admin-build/assets/h5-BSFcFSny.css 3.52 kB │ gzip: 0.99 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Bk7b_sL2.css 3.57 kB │ gzip: 0.96 kB
../artifacts/prescription-enhancements/admin-build/assets/add-ByXRFm8M.css 3.60 kB │ gzip: 0.87 kB
../artifacts/prescription-enhancements/admin-build/assets/PromotionAutomationForm-BMhNCJa-.css 3.64 kB │ gzip: 1.03 kB
../artifacts/prescription-enhancements/admin-build/assets/mubiao-B6IOi2bJ.css 3.78 kB │ gzip: 1.08 kB
../artifacts/prescription-enhancements/admin-build/assets/WelcomeMessageEditor-DD5mXWP2.css 4.41 kB │ gzip: 1.23 kB
../artifacts/prescription-enhancements/admin-build/assets/PrescriptionAiReportDialog-CV6AuHV3.css 4.46 kB │ gzip: 1.11 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CMsGEvq_.css 5.19 kB │ gzip: 1.18 kB
../artifacts/prescription-enhancements/admin-build/assets/mubiao-dept-node-ChUPkaxq.css 5.47 kB │ gzip: 1.29 kB
../artifacts/prescription-enhancements/admin-build/assets/OrderPanel-BzElZPEq.css 5.67 kB │ gzip: 1.46 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DYEnq1RY.css 6.11 kB │ gzip: 1.70 kB
../artifacts/prescription-enhancements/admin-build/assets/list-q8oaox07.css 6.51 kB │ gzip: 1.45 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DtDaTXOB.css 7.01 kB │ gzip: 1.69 kB
../artifacts/prescription-enhancements/admin-build/assets/mubiao-dept-card-naMqyghz.css 7.09 kB │ gzip: 1.53 kB
../artifacts/prescription-enhancements/admin-build/assets/index-AnyUcdTT.css 7.21 kB │ gzip: 1.86 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BqGStdXW.css 7.95 kB │ gzip: 2.30 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Bw7BJ7QR.css 8.03 kB │ gzip: 1.99 kB
../artifacts/prescription-enhancements/admin-build/assets/progress-99NT9ysL.css 8.14 kB │ gzip: 1.91 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Cmf8QBsF.css 8.75 kB │ gzip: 1.78 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CSIcxqFk.css 8.85 kB │ gzip: 2.21 kB
../artifacts/prescription-enhancements/admin-build/assets/index-B_6Taw4k.css 8.97 kB │ gzip: 1.91 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-DOd7Lhrw.css 9.47 kB │ gzip: 2.05 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Dnn9ddtX.css 9.49 kB │ gzip: 2.20 kB
../artifacts/prescription-enhancements/admin-build/assets/index-D9Td5Y2a.css 9.52 kB │ gzip: 2.10 kB
../artifacts/prescription-enhancements/admin-build/assets/ProgressPanel-C_aSqDJh.css 10.10 kB │ gzip: 2.10 kB
../artifacts/prescription-enhancements/admin-build/assets/index-nUsN4URL.css 10.57 kB │ gzip: 2.04 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DV5JwFnL.css 11.95 kB │ gzip: 2.61 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Dc4ifSiV.css 13.65 kB │ gzip: 3.05 kB
../artifacts/prescription-enhancements/admin-build/assets/commission-settlement-Gy2UsUPr.css 15.55 kB │ gzip: 3.19 kB
../artifacts/prescription-enhancements/admin-build/assets/list_h5-jFsYJrxI.css 17.68 kB │ gzip: 3.23 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CFNELBI3.css 18.14 kB │ gzip: 3.70 kB
../artifacts/prescription-enhancements/admin-build/assets/order_list-CMCIYVRT.css 18.15 kB │ gzip: 3.76 kB
../artifacts/prescription-enhancements/admin-build/assets/WecomFloatingWidgetBuilder-DRWt4rvh.css 19.20 kB │ gzip: 4.00 kB
../artifacts/prescription-enhancements/admin-build/assets/index_h5-CWq8J0dT.css 19.54 kB │ gzip: 3.51 kB
../artifacts/prescription-enhancements/admin-build/assets/yeji-7-gSVFmd.css 25.24 kB │ gzip: 4.60 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BSycZlsS.css 45.91 kB │ gzip: 9.91 kB
../artifacts/prescription-enhancements/admin-build/assets/order_list_h5-DB3qJv9G.css 48.13 kB │ gzip: 7.68 kB
../artifacts/prescription-enhancements/admin-build/assets/.pnpm-B3v8nGpq.css 723.50 kB │ gzip: 100.49 kB
../artifacts/prescription-enhancements/admin-build/assets/getExposeType-BhVtb25-.js 0.07 kB │ gzip: 0.09 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-qqeREw9s.js 0.13 kB │ gzip: 0.13 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-DcOELR-K.js 0.13 kB │ gzip: 0.13 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-tHefvLXa.js 0.13 kB │ gzip: 0.13 kB
../artifacts/prescription-enhancements/admin-build/assets/MediaSourceSelect-8A82adC4.js 0.14 kB │ gzip: 0.14 kB
../artifacts/prescription-enhancements/admin-build/assets/code-preview-CIVunYqB.js 0.16 kB │ gzip: 0.15 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-Co7L4ttZ.js 0.18 kB │ gzip: 0.16 kB
../artifacts/prescription-enhancements/admin-build/assets/refund-log-BRlaZOKB.js 0.19 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/account-adjust-BFgKsZdH.js 0.19 kB │ gzip: 0.16 kB
../artifacts/prescription-enhancements/admin-build/assets/content-BpYUj_Ut.js 0.19 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/content-D0fdiu4F.js 0.19 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/content-CPJX7aZq.js 0.19 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/content-BjInnxER.js 0.19 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/perm-HaYrMCWO.js 0.20 kB │ gzip: 0.18 kB
../artifacts/prescription-enhancements/admin-build/assets/diabetes-discovery-display-B_wmGXQJ.js 0.20 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/PrescriptionOrderTimeDialog-B-3khR26.js 0.20 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/weapp-C-XSq1fW.js 0.20 kB │ gzip: 0.15 kB
../artifacts/prescription-enhancements/admin-build/assets/GancaoSubmissionReconcileButton-DGXcUKkq.js 0.21 kB │ gzip: 0.18 kB
../artifacts/prescription-enhancements/admin-build/assets/content-BRTIPuah.js 0.21 kB │ gzip: 0.19 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-C_uM_guK.js 0.21 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-CBCWkQoP.js 0.21 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-BpTpWOO4.js 0.21 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-CFcNI6WL.js 0.21 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-iNYgkylr.js 0.21 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-BD0rjr9u.js 0.21 kB │ gzip: 0.17 kB
../artifacts/prescription-enhancements/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DG0CrN4I.js 0.22 kB │ gzip: 0.18 kB
../artifacts/prescription-enhancements/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-awcvFaxy.js 0.22 kB │ gzip: 0.18 kB
../artifacts/prescription-enhancements/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DvEPH3sF.js 0.22 kB │ gzip: 0.18 kB
../artifacts/prescription-enhancements/admin-build/assets/oa-menu-form-DwPolhQ3.js 0.22 kB │ gzip: 0.18 kB
../artifacts/prescription-enhancements/admin-build/assets/useLockFn-DZaCbVGv.js 0.22 kB │ gzip: 0.19 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-CY53iZ3B.js 0.23 kB │ gzip: 0.19 kB
../artifacts/prescription-enhancements/admin-build/assets/auth-sRUlaYll.js 0.24 kB │ gzip: 0.18 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-CeKDYHFc.js 0.25 kB │ gzip: 0.19 kB
../artifacts/prescription-enhancements/admin-build/assets/relations-add-CGiQKnhD.js 0.25 kB │ gzip: 0.19 kB
../artifacts/prescription-enhancements/admin-build/assets/mobile-style-DeIlq2qM.js 0.26 kB │ gzip: 0.19 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-BT4K02ki.js 0.27 kB │ gzip: 0.19 kB
../artifacts/prescription-enhancements/admin-build/assets/cost-edit-vkFB05CX.js 0.30 kB │ gzip: 0.21 kB
../artifacts/prescription-enhancements/admin-build/assets/yeji-edit-BKejg2AB.js 0.30 kB │ gzip: 0.21 kB
../artifacts/prescription-enhancements/admin-build/assets/404-DS-JItKz.js 0.31 kB │ gzip: 0.29 kB
../artifacts/prescription-enhancements/admin-build/assets/data-table-C_9fAFGx.js 0.31 kB │ gzip: 0.20 kB
../artifacts/prescription-enhancements/admin-build/assets/oa-menu-form-edit-DUkWZWOB.js 0.33 kB │ gzip: 0.21 kB
../artifacts/prescription-enhancements/admin-build/assets/medicine-BvHkP-D_.js 0.35 kB │ gzip: 0.18 kB
../artifacts/prescription-enhancements/admin-build/assets/consumer-Ts8Nf5G7.js 0.35 kB │ gzip: 0.20 kB
../artifacts/prescription-enhancements/admin-build/assets/user-DmbsZ13s.js 0.37 kB │ gzip: 0.18 kB
../artifacts/prescription-enhancements/admin-build/assets/role-YClNZf5Q.js 0.39 kB │ gzip: 0.18 kB
../artifacts/prescription-enhancements/admin-build/assets/useDictOptions-LHfhurwu.js 0.42 kB │ gzip: 0.32 kB
../artifacts/prescription-enhancements/admin-build/assets/index-vo74IWVd.js 0.42 kB │ gzip: 0.32 kB
../artifacts/prescription-enhancements/admin-build/assets/admin-3EbFrp6E.js 0.42 kB │ gzip: 0.21 kB
../artifacts/prescription-enhancements/admin-build/assets/content-D_ZcGxxc.js 0.44 kB │ gzip: 0.36 kB
../artifacts/prescription-enhancements/admin-build/assets/pay-D3e9X3mp.js 0.45 kB │ gzip: 0.21 kB
../artifacts/prescription-enhancements/admin-build/assets/department-CGgh1HrL.js 0.46 kB │ gzip: 0.19 kB
../artifacts/prescription-enhancements/admin-build/assets/menu-GspVOHy4.js 0.46 kB │ gzip: 0.19 kB
../artifacts/prescription-enhancements/admin-build/assets/content-B3I2SVNo.js 0.47 kB │ gzip: 0.37 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-Csjfp-oa.js 0.48 kB │ gzip: 0.25 kB
../artifacts/prescription-enhancements/admin-build/assets/post-BPMBvuit.js 0.49 kB │ gzip: 0.21 kB
../artifacts/prescription-enhancements/admin-build/assets/overflow-BtDvKS8r.js 0.49 kB │ gzip: 0.37 kB
../artifacts/prescription-enhancements/admin-build/assets/decoration-DUVYhum6.js 0.50 kB │ gzip: 0.22 kB
../artifacts/prescription-enhancements/admin-build/assets/message-BfNw89JV.js 0.50 kB │ gzip: 0.21 kB
../artifacts/prescription-enhancements/admin-build/assets/403-46I84rPC.js 0.52 kB │ gzip: 0.44 kB
../artifacts/prescription-enhancements/admin-build/assets/footer.vue_vue_type_script_setup_true_lang-gw0L8407.js 0.53 kB │ gzip: 0.38 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-BMjPwU_u.js 0.54 kB │ gzip: 0.27 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-DvdtgClO.js 0.54 kB │ gzip: 0.27 kB
../artifacts/prescription-enhancements/admin-build/assets/add-nav-VGs4Jf5G.js 0.54 kB │ gzip: 0.28 kB
../artifacts/prescription-enhancements/admin-build/assets/content-D-yWR6FI.js 0.54 kB │ gzip: 0.27 kB
../artifacts/prescription-enhancements/admin-build/assets/menu-set-Qx9AdTEM.js 0.54 kB │ gzip: 0.27 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-Dv5CKOh8.js 0.55 kB │ gzip: 0.26 kB
../artifacts/prescription-enhancements/admin-build/assets/patient-CCCssgYh.js 0.56 kB │ gzip: 0.22 kB
../artifacts/prescription-enhancements/admin-build/assets/fans-B-ehwCmW.js 0.60 kB │ gzip: 0.21 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-C6e5Dzq3.js 0.60 kB │ gzip: 0.28 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-CqFmU0Hd.js 0.61 kB │ gzip: 0.29 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-BzUj7yBN.js 0.61 kB │ gzip: 0.29 kB
../artifacts/prescription-enhancements/admin-build/assets/diag-display-DCz_VAqj.js 0.61 kB │ gzip: 0.39 kB
../artifacts/prescription-enhancements/admin-build/assets/content.vue_vue_type_script_setup_true_lang-DbOSzyLx.js 0.61 kB │ gzip: 0.41 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CKzB9GO3.js 0.63 kB │ gzip: 0.43 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-B3I_m_DB.js 0.63 kB │ gzip: 0.31 kB
../artifacts/prescription-enhancements/admin-build/assets/link-BoRd5k4g.js 0.64 kB │ gzip: 0.45 kB
../artifacts/prescription-enhancements/admin-build/assets/asset-ACfJS9AQ.js 0.67 kB │ gzip: 0.22 kB
../artifacts/prescription-enhancements/admin-build/assets/content.vue_vue_type_script_setup_true_lang-gj34gUXN.js 0.69 kB │ gzip: 0.44 kB
../artifacts/prescription-enhancements/admin-build/assets/useMediaSourceOptions-DIRGbMwZ.js 0.70 kB │ gzip: 0.42 kB
../artifacts/prescription-enhancements/admin-build/assets/content.vue_vue_type_script_setup_true_lang-CQZowpwb.js 0.70 kB │ gzip: 0.45 kB
../artifacts/prescription-enhancements/admin-build/assets/website-C0FFzOT-.js 0.74 kB │ gzip: 0.24 kB
../artifacts/prescription-enhancements/admin-build/assets/menu-BPR_9j2P.js 0.76 kB │ gzip: 0.49 kB
../artifacts/prescription-enhancements/admin-build/assets/usePaging-oaNM6A9T.js 0.77 kB │ gzip: 0.47 kB
../artifacts/prescription-enhancements/admin-build/assets/self_input_stats-CovRoU7_.js 0.79 kB │ gzip: 0.27 kB
../artifacts/prescription-enhancements/admin-build/assets/decoration-img-BP9RSmFp.js 0.81 kB │ gzip: 0.50 kB
../artifacts/prescription-enhancements/admin-build/assets/dict-D0OPXnmW.js 0.81 kB │ gzip: 0.25 kB
../artifacts/prescription-enhancements/admin-build/assets/code-DxsqLu3c.js 0.82 kB │ gzip: 0.26 kB
../artifacts/prescription-enhancements/admin-build/assets/blood-thresholds-CJ1fqMjh.js 0.83 kB │ gzip: 0.34 kB
../artifacts/prescription-enhancements/admin-build/assets/content-BcUmyOhn.js 0.84 kB │ gzip: 0.55 kB
../artifacts/prescription-enhancements/admin-build/assets/content.vue_vue_type_script_setup_true_lang-CaLzOPk2.js 0.86 kB │ gzip: 0.55 kB
../artifacts/prescription-enhancements/admin-build/assets/index.vue_vue_type_script_setup_true_lang-kAZgL5aN.js 0.86 kB │ gzip: 0.52 kB
../artifacts/prescription-enhancements/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CDLrQsa9.js 0.88 kB │ gzip: 0.51 kB
../artifacts/prescription-enhancements/admin-build/assets/error-tECyVHzh.js 0.89 kB │ gzip: 0.60 kB
../artifacts/prescription-enhancements/admin-build/assets/theme-picker-bwjfSLsU.js 0.92 kB │ gzip: 0.59 kB
../artifacts/prescription-enhancements/admin-build/assets/wecomOauthPostMessage-C_e6VGrO.js 0.95 kB │ gzip: 0.53 kB
../artifacts/prescription-enhancements/admin-build/assets/index.vue_vue_type_script_setup_true_lang-Cmgm_Z5P.js 0.98 kB │ gzip: 0.53 kB
../artifacts/prescription-enhancements/admin-build/assets/wx_oa-bkNxJd6g.js 1.04 kB │ gzip: 0.29 kB
../artifacts/prescription-enhancements/admin-build/assets/article-DzmHbnqD.js 1.07 kB │ gzip: 0.27 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-setting.vue_vue_type_script_setup_true_lang-fGWC_p68.js 1.08 kB │ gzip: 0.62 kB
../artifacts/prescription-enhancements/admin-build/assets/rich_text-DnKDty0t.js 1.10 kB │ gzip: 0.58 kB
../artifacts/prescription-enhancements/admin-build/assets/cache-rFsSNc7I.js 1.13 kB │ gzip: 0.69 kB
../artifacts/prescription-enhancements/admin-build/assets/index-C-DfuUWJ.js 1.21 kB │ gzip: 0.70 kB
../artifacts/prescription-enhancements/admin-build/assets/popover_input-pPM8UqwF.js 1.25 kB │ gzip: 0.59 kB
../artifacts/prescription-enhancements/admin-build/assets/finance-DzNxfqmR.js 1.30 kB │ gzip: 0.37 kB
../artifacts/prescription-enhancements/admin-build/assets/pc-OjWOSsuI.js 1.31 kB │ gzip: 0.78 kB
../artifacts/prescription-enhancements/admin-build/assets/doctor-Da4YFg7J.js 1.32 kB │ gzip: 0.37 kB
../artifacts/prescription-enhancements/admin-build/assets/oa-menu-form-edit.vue_vue_type_script_setup_true_lang-oX2l2ki2.js 1.33 kB │ gzip: 0.79 kB
../artifacts/prescription-enhancements/admin-build/assets/content-DCtJsuLU.js 1.33 kB │ gzip: 0.71 kB
../artifacts/prescription-enhancements/admin-build/assets/content-D_izAx69.js 1.37 kB │ gzip: 0.70 kB
../artifacts/prescription-enhancements/admin-build/assets/content-B7vyV0rD.js 1.38 kB │ gzip: 0.79 kB
../artifacts/prescription-enhancements/admin-build/assets/file-Dg1ApOWb.js 1.41 kB │ gzip: 0.64 kB
../artifacts/prescription-enhancements/admin-build/assets/upload-dvCF66Xt.js 1.41 kB │ gzip: 0.64 kB
../artifacts/prescription-enhancements/admin-build/assets/content-BiveGCWR.js 1.42 kB │ gzip: 0.79 kB
../artifacts/prescription-enhancements/admin-build/assets/code-preview.vue_vue_type_script_setup_true_lang-DXGCRZf-.js 1.44 kB │ gzip: 0.85 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DtkMtnp5.js 1.44 kB │ gzip: 0.86 kB
../artifacts/prescription-enhancements/admin-build/assets/index.vue_vue_type_style_index_0_lang-C6MqkrEH.js 1.46 kB │ gzip: 0.79 kB
../artifacts/prescription-enhancements/admin-build/assets/oa-phone-CS5d8r5m.js 1.47 kB │ gzip: 0.79 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-Cv7gctaD.js 1.50 kB │ gzip: 0.76 kB
../artifacts/prescription-enhancements/admin-build/assets/menu-SfOVRvgS.js 1.55 kB │ gzip: 0.88 kB
../artifacts/prescription-enhancements/admin-build/assets/MediaSourceSelect.vue_vue_type_script_setup_true_lang-xuHF5G6T.js 1.58 kB │ gzip: 0.80 kB
../artifacts/prescription-enhancements/admin-build/assets/PatientInfoCard-CScsOFUA.js 1.60 kB │ gzip: 0.83 kB
../artifacts/prescription-enhancements/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-COHLxSGu.js 1.62 kB │ gzip: 0.71 kB
../artifacts/prescription-enhancements/admin-build/assets/style-4RIo8i5b.js 1.62 kB │ gzip: 0.88 kB
../artifacts/prescription-enhancements/admin-build/assets/refund-log.vue_vue_type_script_setup_true_lang-Coj6OpRO.js 1.66 kB │ gzip: 0.87 kB
../artifacts/prescription-enhancements/admin-build/assets/statistics-Dgjfiuew.js 1.69 kB │ gzip: 1.02 kB
../artifacts/prescription-enhancements/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-Dspuxz5n.js 1.71 kB │ gzip: 0.81 kB
../artifacts/prescription-enhancements/admin-build/assets/order-Bz4jYsfz.js 1.74 kB │ gzip: 0.44 kB
../artifacts/prescription-enhancements/admin-build/assets/environment-CCy1DPK4.js 1.74 kB │ gzip: 0.74 kB
../artifacts/prescription-enhancements/admin-build/assets/setup-X7GyN1W3.js 1.77 kB │ gzip: 1.00 kB
../artifacts/prescription-enhancements/admin-build/assets/useMenuOa-CouOh5gF.js 1.82 kB │ gzip: 0.84 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-setting-DQees9nS.js 1.88 kB │ gzip: 0.53 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BOuOly12.js 1.90 kB │ gzip: 0.99 kB
../artifacts/prescription-enhancements/admin-build/assets/edit.vue_vue_type_script_setup_true_name_articleColumnEdit_lang-OnodxOHZ.js 1.94 kB │ gzip: 1.07 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Du2LWZH8.js 2.03 kB │ gzip: 0.98 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BDaB0E-W.js 2.04 kB │ gzip: 1.14 kB
../artifacts/prescription-enhancements/admin-build/assets/TrackingNoteTimeline-qLkjW9mz.js 2.08 kB │ gzip: 1.17 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CmaqjF1i.js 2.09 kB │ gzip: 1.17 kB
../artifacts/prescription-enhancements/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DeLGxVBS.js 2.09 kB │ gzip: 0.95 kB
../artifacts/prescription-enhancements/admin-build/assets/open_setting-DZOSv2TW.js 2.10 kB │ gzip: 1.12 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DeSiLVkl.js 2.12 kB │ gzip: 1.23 kB
../artifacts/prescription-enhancements/admin-build/assets/account-adjust.vue_vue_type_script_setup_true_lang-DLFHtDrF.js 2.16 kB │ gzip: 1.12 kB
../artifacts/prescription-enhancements/admin-build/assets/filing-CYd3ENah.js 2.17 kB │ gzip: 1.17 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BTZEVdSa.js 2.18 kB │ gzip: 1.28 kB
../artifacts/prescription-enhancements/admin-build/assets/icon-Cu3rKo7D.js 2.23 kB │ gzip: 0.82 kB
../artifacts/prescription-enhancements/admin-build/assets/content.vue_vue_type_script_setup_true_lang-D1QTCkhZ.js 2.24 kB │ gzip: 1.16 kB
../artifacts/prescription-enhancements/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DM08I_Vq.js 2.28 kB │ gzip: 1.16 kB
../artifacts/prescription-enhancements/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-NioqwkUU.js 2.29 kB │ gzip: 1.09 kB
../artifacts/prescription-enhancements/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BBhGStE3.js 2.37 kB │ gzip: 1.15 kB
../artifacts/prescription-enhancements/admin-build/assets/RecordingPlaybackBlock-BF6oq8o0.js 2.40 kB │ gzip: 1.19 kB
../artifacts/prescription-enhancements/admin-build/assets/auth.vue_vue_type_script_setup_true_lang-DUpi8wXf.js 2.40 kB │ gzip: 1.34 kB
../artifacts/prescription-enhancements/admin-build/assets/stats-6NrBN28H.js 2.45 kB │ gzip: 0.58 kB
../artifacts/prescription-enhancements/admin-build/assets/data-table.vue_vue_type_script_setup_true_lang-iygDMeqN.js 2.51 kB │ gzip: 1.28 kB
../artifacts/prescription-enhancements/admin-build/assets/oa-attr-Dn9KQ_47.js 2.51 kB │ gzip: 1.25 kB
../artifacts/prescription-enhancements/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DlwU_i68.js 2.52 kB │ gzip: 1.20 kB
../artifacts/prescription-enhancements/admin-build/assets/h5-zrKe_i5f.js 2.56 kB │ gzip: 1.22 kB
../artifacts/prescription-enhancements/admin-build/assets/change-password-DSsVBcWi.js 2.57 kB │ gzip: 1.35 kB
../artifacts/prescription-enhancements/admin-build/assets/im-business-message-parse-kN-fWnvR.js 2.60 kB │ gzip: 1.28 kB
../artifacts/prescription-enhancements/admin-build/assets/add-nav.vue_vue_type_script_setup_true_lang-p5lTMb02.js 2.61 kB │ gzip: 1.26 kB
../artifacts/prescription-enhancements/admin-build/assets/tabbar-Dihz2HuZ.js 2.66 kB │ gzip: 1.33 kB
../artifacts/prescription-enhancements/admin-build/assets/file-B23popwU.js 2.73 kB │ gzip: 1.06 kB
../artifacts/prescription-enhancements/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DXvpViJ0.js 2.74 kB │ gzip: 1.25 kB
../artifacts/prescription-enhancements/admin-build/assets/PrescriptionOrderTimeDialog.vue_vue_type_script_setup_true_lang-C4jZNm37.js 2.77 kB │ gzip: 1.53 kB
../artifacts/prescription-enhancements/admin-build/assets/oa-menu-form.vue_vue_type_script_setup_true_lang-DsJbOGlT.js 2.77 kB │ gzip: 1.07 kB
../artifacts/prescription-enhancements/admin-build/assets/CaseRecordList-Dh9DC_fX.js 2.80 kB │ gzip: 1.52 kB
../artifacts/prescription-enhancements/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-BJzZntTv.js 2.81 kB │ gzip: 1.06 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CKx87Ubw.js 2.85 kB │ gzip: 1.34 kB
../artifacts/prescription-enhancements/admin-build/assets/follow_reply-DiTghw3P.js 2.85 kB │ gzip: 1.50 kB
../artifacts/prescription-enhancements/admin-build/assets/index-B_uxSMlB.js 2.86 kB │ gzip: 1.44 kB
../artifacts/prescription-enhancements/admin-build/assets/cost-edit.vue_vue_type_script_setup_true_lang-BTUf_yHp.js 2.86 kB │ gzip: 1.35 kB
../artifacts/prescription-enhancements/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-BM6CiMfa.js 2.89 kB │ gzip: 1.41 kB
../artifacts/prescription-enhancements/admin-build/assets/default_reply-5uZeRby-.js 2.91 kB │ gzip: 1.54 kB
../artifacts/prescription-enhancements/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CC49n2nI.js 2.96 kB │ gzip: 1.14 kB
../artifacts/prescription-enhancements/admin-build/assets/protocol-CvGh1xGi.js 2.98 kB │ gzip: 1.10 kB
../artifacts/prescription-enhancements/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DwBA6-ZO.js 2.99 kB │ gzip: 1.45 kB
../artifacts/prescription-enhancements/admin-build/assets/mubiao-dept-node-DXQEmjMn.js 3.03 kB │ gzip: 1.25 kB
../artifacts/prescription-enhancements/admin-build/assets/relations-add.vue_vue_type_script_setup_true_lang-BvGKlLLh.js 3.04 kB │ gzip: 1.26 kB
../artifacts/prescription-enhancements/admin-build/assets/picker.vue_vue_type_script_setup_true_lang-3aLBqC48.js 3.05 kB │ gzip: 1.50 kB
../artifacts/prescription-enhancements/admin-build/assets/AssignLogPanel-BqArThE3.js 3.06 kB │ gzip: 1.48 kB
../artifacts/prescription-enhancements/admin-build/assets/keyword_reply-rznXpeKO.js 3.11 kB │ gzip: 1.60 kB
../artifacts/prescription-enhancements/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DhKEOAee.js 3.12 kB │ gzip: 1.51 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Ca4paL5P.js 3.21 kB │ gzip: 1.52 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-BtXPz-s7.js 3.25 kB │ gzip: 1.47 kB
../artifacts/prescription-enhancements/admin-build/assets/menu-set.vue_vue_type_script_setup_true_lang-DOQPvZ0t.js 3.26 kB │ gzip: 1.40 kB
../artifacts/prescription-enhancements/admin-build/assets/pc_details-Uxx4oina.js 3.39 kB │ gzip: 1.49 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BEqR6PLL.js 3.39 kB │ gzip: 1.45 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DkByXqoV.js 3.42 kB │ gzip: 1.54 kB
../artifacts/prescription-enhancements/admin-build/assets/index-UQUSU7dW.js 3.43 kB │ gzip: 1.64 kB
../artifacts/prescription-enhancements/admin-build/assets/GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-wi7xah8q.js 3.44 kB │ gzip: 1.70 kB
../artifacts/prescription-enhancements/admin-build/assets/balance_details-7lcctCpR.js 3.48 kB │ gzip: 1.63 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CNroqSvL.js 3.48 kB │ gzip: 1.64 kB
../artifacts/prescription-enhancements/admin-build/assets/index.vue_vue_type_script_setup_true_lang-B2RpTCrr.js 3.52 kB │ gzip: 1.56 kB
../artifacts/prescription-enhancements/admin-build/assets/journal-NmX3wbNg.js 3.73 kB │ gzip: 1.46 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-Cawh5jcC.js 3.73 kB │ gzip: 1.57 kB
../artifacts/prescription-enhancements/admin-build/assets/detail-StjgxJHl.js 3.76 kB │ gzip: 1.55 kB
../artifacts/prescription-enhancements/admin-build/assets/AssistantWatchCallDialog-BsYlFxXL.js 3.78 kB │ gzip: 1.85 kB
../artifacts/prescription-enhancements/admin-build/assets/CallRecordPanel-DBKR-X7H.js 3.78 kB │ gzip: 1.77 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-1otTjhv8.js 3.91 kB │ gzip: 1.71 kB
../artifacts/prescription-enhancements/admin-build/assets/preview-pc-B5zPgP_e.js 3.98 kB │ gzip: 1.63 kB
../artifacts/prescription-enhancements/admin-build/assets/first_visit-DbNl62uk.js 3.98 kB │ gzip: 0.86 kB
../artifacts/prescription-enhancements/admin-build/assets/AppointmentRecordPanel-CDaS_4pU.js 4.02 kB │ gzip: 1.73 kB
../artifacts/prescription-enhancements/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-G-35h9K1.js 4.11 kB │ gzip: 1.65 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CaFIrKIw.js 4.11 kB │ gzip: 1.79 kB
../artifacts/prescription-enhancements/admin-build/assets/detail-Blb0B2h-.js 4.15 kB │ gzip: 1.56 kB
../artifacts/prescription-enhancements/admin-build/assets/MessageBubble-CA1nfUaB.js 4.17 kB │ gzip: 1.79 kB
../artifacts/prescription-enhancements/admin-build/assets/index-B4EmSVS2.js 4.22 kB │ gzip: 1.92 kB
../artifacts/prescription-enhancements/admin-build/assets/useListTimeFilter-CdH21Uys.js 4.26 kB │ gzip: 1.52 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CjkZgoWj.js 4.37 kB │ gzip: 2.14 kB
../artifacts/prescription-enhancements/admin-build/assets/bind-work-wechat-BRvEWoWp.js 4.40 kB │ gzip: 2.27 kB
../artifacts/prescription-enhancements/admin-build/assets/recharge_record-Bwz6to5J.js 4.43 kB │ gzip: 1.87 kB
../artifacts/prescription-enhancements/admin-build/assets/index-LX4iOnTi.js 4.45 kB │ gzip: 1.79 kB
../artifacts/prescription-enhancements/admin-build/assets/index-D8WElAVW.js 4.47 kB │ gzip: 1.91 kB
../artifacts/prescription-enhancements/admin-build/assets/index-jmopfA9M.js 4.49 kB │ gzip: 1.95 kB
../artifacts/prescription-enhancements/admin-build/assets/index-LQo_rtaS.js 4.49 kB │ gzip: 1.96 kB
../artifacts/prescription-enhancements/admin-build/assets/preview-B45i9day.js 4.49 kB │ gzip: 1.70 kB
../artifacts/prescription-enhancements/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-DlzXqFLn.js 4.51 kB │ gzip: 1.75 kB
../artifacts/prescription-enhancements/admin-build/assets/RecordingVideoPlayer-BcJus6KG.js 4.53 kB │ gzip: 2.26 kB
../artifacts/prescription-enhancements/admin-build/assets/ImChatRecordPanel-DWKR7L-S.js 4.54 kB │ gzip: 2.40 kB
../artifacts/prescription-enhancements/admin-build/assets/index-C8lE85Dl.js 4.55 kB │ gzip: 1.92 kB
../artifacts/prescription-enhancements/admin-build/assets/paiban-CF7tnZWS.js 4.56 kB │ gzip: 2.13 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BKYq8s9A.js 4.57 kB │ gzip: 1.33 kB
../artifacts/prescription-enhancements/admin-build/assets/login_register-CSM9Kik4.js 4.59 kB │ gzip: 2.01 kB
../artifacts/prescription-enhancements/admin-build/assets/dept-tongji-C5bUQulT.js 4.66 kB │ gzip: 2.04 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CulsBGOT.js 4.76 kB │ gzip: 2.05 kB
../artifacts/prescription-enhancements/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-Dja1yQfO.js 4.92 kB │ gzip: 2.04 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-COQereMa.js 5.04 kB │ gzip: 1.99 kB
../artifacts/prescription-enhancements/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CDx_-bAQ.js 5.18 kB │ gzip: 2.03 kB
../artifacts/prescription-enhancements/admin-build/assets/PaibanPanel-CdKCdBTV.js 5.28 kB │ gzip: 2.43 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DFClWNML.js 5.32 kB │ gzip: 2.19 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DjCsQj8A.js 5.53 kB │ gzip: 2.20 kB
../artifacts/prescription-enhancements/admin-build/assets/mubiao-D3sRFAGe.js 5.55 kB │ gzip: 2.56 kB
../artifacts/prescription-enhancements/admin-build/assets/yeji-edit.vue_vue_type_script_setup_true_lang-CYUehHGQ.js 5.56 kB │ gzip: 1.81 kB
../artifacts/prescription-enhancements/admin-build/assets/mobile-style.vue_vue_type_script_setup_true_lang-DUHo1XPs.js 5.59 kB │ gzip: 1.86 kB
../artifacts/prescription-enhancements/admin-build/assets/ExerciseRecordList-CZDXIKcq.js 5.62 kB │ gzip: 2.15 kB
../artifacts/prescription-enhancements/admin-build/assets/attr-BEgqAtig.js 5.62 kB │ gzip: 2.24 kB
../artifacts/prescription-enhancements/admin-build/assets/tongji-z5Fih1ec.js 5.66 kB │ gzip: 2.31 kB
../artifacts/prescription-enhancements/admin-build/assets/PatientOrderList-Czd024sx.js 5.82 kB │ gzip: 2.84 kB
../artifacts/prescription-enhancements/admin-build/assets/DietRecordList-CYr0fCm3.js 5.84 kB │ gzip: 2.01 kB
../artifacts/prescription-enhancements/admin-build/assets/mubiao-dept-card-BNQancbO.js 5.91 kB │ gzip: 2.38 kB
../artifacts/prescription-enhancements/admin-build/assets/login-KCPIh0nm.js 6.03 kB │ gzip: 2.72 kB
../artifacts/prescription-enhancements/admin-build/assets/refund_record-CMWk9-O3.js 6.27 kB │ gzip: 2.33 kB
../artifacts/prescription-enhancements/admin-build/assets/DiagnosisTodoList-Bhqobaeg.js 6.34 kB │ gzip: 2.89 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DybjGNHj.js 6.35 kB │ gzip: 3.20 kB
../artifacts/prescription-enhancements/admin-build/assets/information-DwPa0fkX.js 6.36 kB │ gzip: 1.81 kB
../artifacts/prescription-enhancements/admin-build/assets/NoteTimeline-DVDEdlxQ.js 6.48 kB │ gzip: 2.60 kB
../artifacts/prescription-enhancements/admin-build/assets/account_cost-CExJsJtt.js 6.56 kB │ gzip: 2.86 kB
../artifacts/prescription-enhancements/admin-build/assets/PatientCaseCard-8kM3csHH.js 6.61 kB │ gzip: 2.34 kB
../artifacts/prescription-enhancements/admin-build/assets/index-hEmlHfgd.js 6.61 kB │ gzip: 2.62 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CeBge0dD.js 6.77 kB │ gzip: 2.89 kB
../artifacts/prescription-enhancements/admin-build/assets/readonly-C6uAn3T-.js 6.98 kB │ gzip: 2.69 kB
../artifacts/prescription-enhancements/admin-build/assets/picker-DzWLch3S.js 7.32 kB │ gzip: 3.18 kB
../artifacts/prescription-enhancements/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-CyKpWZxa.js 7.35 kB │ gzip: 2.66 kB
../artifacts/prescription-enhancements/admin-build/assets/setting-Dlth4NoX.js 7.35 kB │ gzip: 2.93 kB
../artifacts/prescription-enhancements/admin-build/assets/weapp-BilWEKOP.js 7.39 kB │ gzip: 2.13 kB
../artifacts/prescription-enhancements/admin-build/assets/prescription-order-utils-CXfs2Bra.js 7.62 kB │ gzip: 3.11 kB
../artifacts/prescription-enhancements/admin-build/assets/index-C2aowR7v.js 7.65 kB │ gzip: 3.13 kB
../artifacts/prescription-enhancements/admin-build/assets/config-BAKzfPlG.js 7.77 kB │ gzip: 2.59 kB
../artifacts/prescription-enhancements/admin-build/assets/TrackingMatrix-BA03fK_p.js 7.84 kB │ gzip: 3.07 kB
../artifacts/prescription-enhancements/admin-build/assets/patient-call-y9ag_iTO.js 7.85 kB │ gzip: 3.24 kB
../artifacts/prescription-enhancements/admin-build/assets/dayjs-CF5xpNFg.js 8.07 kB │ gzip: 3.45 kB
../artifacts/prescription-enhancements/admin-build/assets/tcm-D9nD2Ol1.js 8.99 kB │ gzip: 1.57 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-CHe9Dfjn.js 9.00 kB │ gzip: 2.83 kB
../artifacts/prescription-enhancements/admin-build/assets/tim-upload-plugin-B7yxlBgj.js 9.47 kB │ gzip: 3.61 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Di3gFWXl.js 9.49 kB │ gzip: 3.84 kB
../artifacts/prescription-enhancements/admin-build/assets/BloodRecordList-B8yTz_Tz.js 9.58 kB │ gzip: 3.33 kB
../artifacts/prescription-enhancements/admin-build/assets/medicine-v_W1vhCm.js 9.74 kB │ gzip: 3.55 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-a1XCehrj.js 9.99 kB │ gzip: 3.55 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BTeEtmW8.js 10.06 kB │ gzip: 3.98 kB
../artifacts/prescription-enhancements/admin-build/assets/index-PBq5viNw.js 10.33 kB │ gzip: 3.89 kB
../artifacts/prescription-enhancements/admin-build/assets/PrescriptionAiBatchGenerateDialog-BZDXI40z.js 10.36 kB │ gzip: 4.29 kB
../artifacts/prescription-enhancements/admin-build/assets/SendPanel-r3TfoEF0.js 10.37 kB │ gzip: 3.57 kB
../artifacts/prescription-enhancements/admin-build/assets/progress-BWtny4Wt.js 11.18 kB │ gzip: 4.59 kB
../artifacts/prescription-enhancements/admin-build/assets/list-BKszrwkD.js 11.18 kB │ gzip: 4.02 kB
../artifacts/prescription-enhancements/admin-build/assets/index-KpgyDKEY.js 11.30 kB │ gzip: 4.22 kB
../artifacts/prescription-enhancements/admin-build/assets/appointment-I3BGfzIl.js 11.34 kB │ gzip: 4.24 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-4mncObtW.js 11.44 kB │ gzip: 3.76 kB
../artifacts/prescription-enhancements/admin-build/assets/OrderPanel-WKPUGsCa.js 11.74 kB │ gzip: 4.22 kB
../artifacts/prescription-enhancements/admin-build/assets/picker-sezG1KHk.js 12.15 kB │ gzip: 4.49 kB
../artifacts/prescription-enhancements/admin-build/assets/ProgressPanel-BUHkILAg.js 12.58 kB │ gzip: 4.48 kB
../artifacts/prescription-enhancements/admin-build/assets/prescription-drawer-wzPrwF6j.js 12.76 kB │ gzip: 3.87 kB
../artifacts/prescription-enhancements/admin-build/assets/index-D09YPXtL.js 13.00 kB │ gzip: 4.23 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DKTZIx4g.js 13.11 kB │ gzip: 3.91 kB
../artifacts/prescription-enhancements/admin-build/assets/h5-2YOqn3nI.js 13.83 kB │ gzip: 4.40 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BrB35imQ.js 14.26 kB │ gzip: 5.90 kB
../artifacts/prescription-enhancements/admin-build/assets/guahao-pxg547CQ.js 14.96 kB │ gzip: 4.41 kB
../artifacts/prescription-enhancements/admin-build/assets/WecomFloatingWidgetBuilder-DN-H_bHO.js 15.22 kB │ gzip: 6.02 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DLwugLSk.js 15.52 kB │ gzip: 6.40 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Co1sYRjN.js 15.90 kB │ gzip: 5.20 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-EBLK7IMQ.js 15.90 kB │ gzip: 4.70 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CMFEoXkU.js 16.34 kB │ gzip: 6.06 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CdWQT_Py.js 16.35 kB │ gzip: 5.11 kB
../artifacts/prescription-enhancements/admin-build/assets/index-BFGqGjsy.js 16.60 kB │ gzip: 6.07 kB
../artifacts/prescription-enhancements/admin-build/assets/PrescriptionAiReportDialog-BqOt_-ti.js 17.15 kB │ gzip: 6.47 kB
../artifacts/prescription-enhancements/admin-build/assets/index-D3rT0Mvw.js 17.84 kB │ gzip: 6.02 kB
../artifacts/prescription-enhancements/admin-build/assets/roster-DvICKm55.js 18.09 kB │ gzip: 5.72 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CEwBxoDh.js 18.19 kB │ gzip: 6.88 kB
../artifacts/prescription-enhancements/admin-build/assets/PromotionAutomationForm-B1uWR2mJ.js 18.23 kB │ gzip: 6.36 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DEL1rgiP.js 18.60 kB │ gzip: 5.23 kB
../artifacts/prescription-enhancements/admin-build/assets/WelcomeMessageEditor-a_tb4fnt.js 19.11 kB │ gzip: 6.95 kB
../artifacts/prescription-enhancements/admin-build/assets/OrderActionHost-C4DqnjCB.js 21.16 kB │ gzip: 6.02 kB
../artifacts/prescription-enhancements/admin-build/assets/index-5PRvhCFL.js 21.91 kB │ gzip: 7.87 kB
../artifacts/prescription-enhancements/admin-build/assets/add-BbndHWat.js 22.29 kB │ gzip: 5.36 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DSJuHc0c.js 22.76 kB │ gzip: 7.97 kB
../artifacts/prescription-enhancements/admin-build/assets/DailyMatrix-HQBijfZ6.js 24.97 kB │ gzip: 7.09 kB
../artifacts/prescription-enhancements/admin-build/assets/list_h5-CpVyXjRp.js 27.36 kB │ gzip: 9.28 kB
../artifacts/prescription-enhancements/admin-build/assets/list-DrsIy_hb.js 29.93 kB │ gzip: 9.61 kB
../artifacts/prescription-enhancements/admin-build/assets/qywx-CpFscJx8.js 30.35 kB │ gzip: 10.01 kB
../artifacts/prescription-enhancements/admin-build/assets/commission-settlement-OjE5LP5q.js 35.83 kB │ gzip: 9.75 kB
../artifacts/prescription-enhancements/admin-build/assets/edit-s5raOMrp.js 35.86 kB │ gzip: 9.03 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CPHxXlTR.js 36.24 kB │ gzip: 12.99 kB
../artifacts/prescription-enhancements/admin-build/assets/index-JUq5sTZq.js 36.70 kB │ gzip: 11.81 kB
../artifacts/prescription-enhancements/admin-build/assets/index_h5-FfUrDajA.js 37.44 kB │ gzip: 11.45 kB
../artifacts/prescription-enhancements/admin-build/assets/index-Cyxgawcy.js 40.10 kB │ gzip: 11.87 kB
../artifacts/prescription-enhancements/admin-build/assets/PrescriptionOrderDetailDrawer-Bna8yji5.js 48.77 kB │ gzip: 13.48 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CqFdR5pc.js 50.72 kB │ gzip: 14.97 kB
../artifacts/prescription-enhancements/admin-build/assets/index-D1YB6a1X.js 55.03 kB │ gzip: 15.89 kB
../artifacts/prescription-enhancements/admin-build/assets/tim-profanity-filter-plugin-BJ7z5puq.js 55.90 kB │ gzip: 21.04 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CA84xqNx.js 62.86 kB │ gzip: 19.09 kB
../artifacts/prescription-enhancements/admin-build/assets/rtc-detect-CAvkmauD.js 74.80 kB │ gzip: 25.93 kB
../artifacts/prescription-enhancements/admin-build/assets/yeji-Db3Ivgkq.js 87.40 kB │ gzip: 23.27 kB
../artifacts/prescription-enhancements/admin-build/assets/index-DOdLmw6B.js 88.42 kB │ gzip: 25.65 kB
../artifacts/prescription-enhancements/admin-build/assets/order_list-De0sJbu0.js 114.89 kB │ gzip: 31.80 kB
../artifacts/prescription-enhancements/admin-build/assets/order_list_h5-CAW3276M.js 139.58 kB │ gzip: 36.82 kB
../artifacts/prescription-enhancements/admin-build/assets/@tencentcloud/chat-uikit-engine-BJz4IWsw.js 162.84 kB │ gzip: 41.72 kB
../artifacts/prescription-enhancements/admin-build/assets/index-CB1231KF.js 290.16 kB │ gzip: 91.99 kB
../artifacts/prescription-enhancements/admin-build/assets/@tencentcloud/chat-Bg29VzHQ.js 725.88 kB │ gzip: 178.89 kB
../artifacts/prescription-enhancements/admin-build/assets/@tencentcloud/call-engine-js-CRx4G1jN.js 2,290.42 kB │ gzip: 749.64 kB
../artifacts/prescription-enhancements/admin-build/assets/.pnpm-BHOjf1ZS.js 18,525.72 kB │ gzip: 5,519.46 kB
WARN
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
✓ built in 1m 54s
@@ -0,0 +1,39 @@
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
import { execFileSync } from 'node:child_process'
const root = 'D:/web/zyt'
const require = createRequire(path.join(root, 'admin/package.json'))
const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc')
const changed = execFileSync('git', ['diff', '--name-only', '--', 'admin/src'], { cwd: root, encoding: 'utf8' })
const files = changed.trim().split(/\r?\n/).filter((file) => file.endsWith('.vue'))
files.push('admin/src/views/consumer/prescription/components/PrescriptionOrderTimeDialog.vue')
const results = []
for (const file of [...new Set(files)]) {
const filename = path.join(root, file)
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
const diagnostics = [...errors]
try {
const script = compileScript(descriptor, { id: file })
if (descriptor.template) {
diagnostics.push(...compileTemplate({
id: file, filename, source: descriptor.template.content,
compilerOptions: { bindingMetadata: script.bindings }
}).errors)
}
for (const style of descriptor.styles) {
const output = await compileStyleAsync({
id: file, filename, source: style.content, scoped: style.scoped,
preprocessLang: style.lang, preprocessCustomRequire: require
})
diagnostics.push(...output.errors)
}
} catch (error) {
diagnostics.push(error)
}
results.push({ file, errors: diagnostics.map(String) })
}
fs.writeFileSync(path.join(root, 'artifacts/prescription-enhancements/vue-compile.json'), JSON.stringify(results, null, 2))
console.log(JSON.stringify(results, null, 2))
process.exitCode = results.some((result) => result.errors.length) ? 1 : 0
@@ -0,0 +1,65 @@
# 预约问诊类型:Web 管理端交付记录
日期:2026-09-09
## 完成行为
- 复用 `doctor_appointment.appointment_type``video` 为视频问诊,`text` 为图文问诊;没有改动诊单的 `diagnosis_type`(诊断/初复诊相关字段),也没有新增冗余诊单字段。
- 预约问诊抽屉新增图文问诊选项;初次打开及每次重开默认视频问诊,提交所选值。
- 消费者处方的挂号编辑仅允许选择视频问诊、图文问诊。
- 医生面诊 PC 列表、H5 列表(包括详情的操作入口)、接诊台均把**当前挂号行**的问诊类型传入共享聊天窗口。窗口标题展示类型标签,缩小后仍在标题显示。切换患者或新开会话时重新设置,不沿用上次类型。
- 类型仅用作展示,不改变聊天、视频通话、处方、排班、权限及挂号状态的现有业务行为。
## 服务端数据与兼容性
- 新增 `server/app/common/enum/AppointmentTypeEnum.php`,统一严格写入枚举、缺省请求处理及读取标签。
- `AppointmentValidate` 创建场景允许旧客户端省略类型,按视频校验;显式 null、空串、空白、电话、大小写变体、数组、布尔及数值均拒绝。
- `AppointmentLogic::create` 同样补省略字段的默认视频值,并在任何数据库操作前严格校验,避免其他内部调用绕过控制器。
- 编辑场景要求显式提供合法类型。不能因为旧客户端省略类型而把已有图文记录悄悄重置为视频;内部 `adminEdit` 同样校验。
- 保存兼容逻辑发现预约表没有 `appointment_type` 列时,会明确报错,避免成功提示但丢弃图文选择。
- 预约模型、预约详情、预约列表及接诊聚合详情统一把旧 null/空白类型按视频返回。
- 诊单列表返回的当前挂号、逐条 `appointments[]` 和最近挂号摘要均携带类型与描述。
- 已明确记录为 `phone` 的历史记录继续显示“电话问诊”,不把真实历史问诊方式改写为视频;新建/编辑不能再写入 phone。未知历史值保持未知,避免伪造已知类型。
## 覆盖文件
前端:
- `admin/src/views/tcm/diagnosis/appointment.vue`
- `admin/src/views/consumer/prescription/guahao.vue`
- `admin/src/components/chat-dialog/index.vue`
- `admin/src/views/tcm/appointment/list.vue`
- `admin/src/views/tcm/appointment/list_h5.vue`
- `admin/src/views/patient/reception/index.vue`
- `admin/src/utils/appointment-type.ts`
后端:
- `server/app/common/enum/AppointmentTypeEnum.php`
- `server/app/common/model/doctor/Appointment.php`
- `server/app/adminapi/validate/doctor/AppointmentValidate.php`
- `server/app/adminapi/logic/doctor/AppointmentLogic.php`
- `server/app/adminapi/lists/doctor/AppointmentLists.php`
- `server/app/adminapi/lists/tcm/DiagnosisLists.php`
## 验证结果
全部通过,未连接业务数据库:
1. `php server/tests/AppointmentTypeTest.php`video/text 双向校验、13 种非法入参、内部逻辑 DB 前拒绝、创建默认值、编辑显式选值、模型历史标签、保存字段保留/缺列报错、最近挂号摘要切换。
2. `node admin/scripts/verify-appointment-type.cjs`:类型标签默认/兼容行为、3 个聊天入口使用当前挂号行、预约控件与提交映射、6 个 Vue 组件的 script/template/style 编译。
3. `php server/tests/DiagnosisValidateAddSceneTest.php`
4. `php server/tests/DiagnosisWorkspaceRowAuthorizationTest.php`
5. `php server/tests/PrescriptionAppointmentScopeContractTest.php`
6. 所有修改 PHP 文件与新增测试文件 `php -l`
7. 本子任务所属文件 `git diff --check`
没有进行真实挂号写入、腾讯云聊天登录/视频通话或线上数据库迁移;本次前端验证为真实组件编译和数据传递约束检查,不冒充浏览器完整联调。
## 部署
- 新安装建表脚本 `server/sql/doctor_appointment.sql` 已将字段设为 `NOT NULL DEFAULT 'video'`
- 现有环境部署时运行 `server/sql/1.9.20260909/appointment_type_video_default.sql`:只回填 null/空白历史值为 video,然后设置非空及默认值,不改 text 或 phone。
- SQL 尚未执行。表前缀按仓库现有脚本使用 `zyt_`,若目标环境前缀不同,需要对应调整。
- 将 server 源码随前端构建一起部署;这项改动复用现有预约/挂号接口和权限,无新增权限菜单。
- 本次覆盖用户指定的 Web 管理端。桌面 `app/` 保持现有进行中的用户改动,未扩展桌面预约入口。
@@ -0,0 +1,41 @@
# 处方业务订单后端变更
## 交付行为
- 取消(fulfillment_status=4)和退款(10)的未删除历史订单不再占用处方。已有 10 状态数据立即兼容,无需回填 refund_amount 或删除关联。
- 列表与详情的 `has_prescription_order`、业务处方驳回角标、风险置顶、作废校验和订单创建校验使用同一状态常量。其余非删除订单(含已完成、拒收、保留药方)仍占用处方。
- 现有退款逻辑:全额退款或退款后已付余额归零进入状态 10;部分退款且仍有余额则保留原履约状态。因此部分退款不会提前释放处方。
- 已通过审核的处方可以在释放后直接重新创建业务订单;已作废、已删除处方仍禁止创建。退款不重置处方审核。
- 历史业务订单、prescription_id、支付单关联和药房编号保持原样。处方详情中的历史打印收件/金额仍可回看最近未取消订单,新订单建立后自然优先新订单。
- 创建过程先取得处方行锁,随后检查有效订单,写入业务订单、收款关联、代收快照与日志,同一事务提交。两个同时提交的创建请求只有一个成功。日志失败回滚创建,避免半成品订单占用处方。
## 前端接口
`POST /adminapi/tcm.prescriptionOrder/editTime`
请求:`{"id":123,"create_time":"2026-09-09 10:20:30"}`。日期必须为有效且完整的 `YYYY-MM-DD HH:mm:ss`,按应用现有服务器时区解释。id 为正整数。
权限:`tcm.prescriptionOrder/editTime`,超管或明确授予该权限的角色可用。逻辑层显式校验,因此未执行菜单 SQL 时不会因中间件默认放行而越权。
成功数据:`{"id":123,"create_time":1788920430}`,具体时间取决于服务器时区;INT 库保留时间戳,DATETIME 历史变体保留规范日期字符串。返回数据只有 id/create_time。
本接口不受履约状态或远端药房快照锁影响,可以修正退款、完成、发货等订单的创建时间;遵守现有业务订单访问规则。只更新 create_time、自动 update_time,并记录 `edit_time` 操作日志(操作者、旧时间、新时间)。日志失败回滚;重复提交相同时间不重复写日志。其他额外请求字段不参与更新。
处方前端继续用 `has_prescription_order === 0` 判断是否已有有效业务订单,并保留现有作废/权限条件即可;不需要根据 refund_amount 做前端猜测。
## SQL 部署
执行 `server/sql/1.9.20260909/add_prescription_order_edit_time_menu.sql`,默认表前缀 zyt_。脚本可重复执行,将“修改业务订单创建时间”按钮放在处方业务订单列表菜单下;不会给任何普通角色自动授予权限。再于角色管理中给指定角色勾选该权限。
退款释放不涉及表结构或业务数据迁移。没有运行任何业务数据库 SQL。
## 验证
- `server/tests/PrescriptionOrderReleaseAndTimeTest.php`112 项真实 ORM/MySQL 断言通过。覆盖旧退款/取消/软删历史、所有活跃状态、真实全退/部分退/剩余退款/撤回流程、审批不重置、支付与远端关联历史、作废/删除限制、创建及时间编辑日志失败回滚、缺失权限/授权角色、严格时间校验、INT/DATETIME、重复时间幂等、两条独立 PHP/数据库连接并发创建(有/无退款历史)。
- `server/tests/PrescriptionOrderUnlinkPayOrderTest.php`131 项断言通过。
- `server/tests/OrderEditTimeContractTest.php`:通过。
- 修改 PHP 文件语法检查通过;使用仓库现有换行风格,`git -c core.whitespace=cr-at-eol diff --check` 对订单相关改动通过。
验证只使用显式启动的 127.0.0.1:13379 临时 MySQL 5.7,不初始化应用配置,不连接业务数据库。测试自行创建随机名测试库并在 finally 删除,独立 MySQL 已关闭,临时数据目录已清理。
重新运行新测试:先启动独立 MySQL,然后在 PowerShell 设置 `$env:ZYT_RX_ORDER_TEST_MYSQL_PORT='独立端口'`,运行 `php server/tests/PrescriptionOrderReleaseAndTimeTest.php`
@@ -0,0 +1,53 @@
import { createServer } from '../../admin/node_modules/vite/dist/node/index.js'
import vue from '../../admin/node_modules/@vitejs/plugin-vue/dist/index.mjs'
const root = 'D:/web/zyt/admin'
const entry = `
import { createApp, h, onMounted, reactive, ref } from 'vue';
import ElementPlus from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import 'element-plus/dist/index.css';
import Dialog from '/src/views/consumer/prescription/components/PrescriptionOrderTimeDialog.vue';
import { state } from 'time-fixture-state';
createApp({setup() {
const dialog = ref(null);
const row = {id: 42, order_no: '测试订单-42', create_time: '2026-09-09 11:12:37'};
onMounted(() => dialog.value.open(row));
return () => h('main', {style:'font-family:system-ui;padding:20px'}, [
h('h2', '订单时间隔离验证(虚构数据)'),
h('button', {onClick:() => dialog.value.open(row)}, '打开弹窗'),
h('pre', {id:'fixture-state'}, JSON.stringify(state, null, 2)),
h(Dialog, {ref:dialog, onSaved:id => state.saved.push(id)})
]);
}}).use(ElementPlus, {locale:zhCn}).mount('#app');`
const server = await createServer({
configFile: false, root, base: '/',
cacheDir: 'D:/web/zyt/artifacts/prescription-enhancements/vite-cache',
optimizeDeps: { entries: [], include: ['vue', 'element-plus'] },
resolve: { alias: { '@': `${root}/src` } },
plugins: [{
name: 'time-dialog-isolated-fixture', enforce: 'pre',
resolveId(id) {
if (id === 'time-fixture-state') return '\0time-state'
if (id === '@/api/tcm' || id.endsWith('/src/api/tcm')) return '\0time-api'
if (id === '/__time-entry.js') return '\0time-entry'
},
load(id) {
if (id === '\0time-state') return `import {reactive} from 'vue'; export const state = reactive({requests:[],saved:[]});`
if (id === '\0time-api') return `import {state} from 'time-fixture-state'; export async function prescriptionOrderEditTime(params){state.requests.push(params);return {...params};}`
if (id === '\0time-entry') return entry
},
configureServer(dev) {
dev.middlewares.use((req, res, next) => {
if (!req.url?.startsWith('/__time-check')) return next()
res.setHeader('Content-Type', 'text/html;charset=utf-8')
res.end('<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>订单创建时间验证</title></head><body><div id="app"></div><script type="module" src="/__time-entry.js"></script></body></html>')
})
}
}, vue()],
server: { host: '127.0.0.1', port: 5586, strictPort: true, open: false }
})
await server.listen()
console.log('Isolated fixture: http://127.0.0.1:5586/__time-check')
for (const event of ['SIGINT', 'SIGTERM']) process.on(event, async () => { await server.close(); process.exit(0) })
@@ -0,0 +1,5 @@
{
"errors": 57,
"errorsOnChangedLines": [],
"note": "Reported diagnostic locations are checked against the diff; this is not a separate baseline typecheck run."
}
@@ -0,0 +1,91 @@
src/components/chat-dialog/ChatMessageItem.vue(29,7): error TS7022: 'attrs' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
src/components/editor/index.vue(29,44): error TS7016: Could not find a declaration file for module '@wangeditor/editor-for-vue'. 'D:/web/zyt/admin/node_modules/.pnpm/@wangeditor+editor-for-vue@_d49ef1161b4f4b880c450fdbfe3a0001/node_modules/@wangeditor/editor-for-vue/dist/index.esm.js' implicitly has an 'any' type.
There are types at 'D:/web/zyt/admin/node_modules/@wangeditor/editor-for-vue/dist/src/index.d.ts', but this result could not be resolved when respecting package.json "exports". The '@wangeditor/editor-for-vue' library may need to update its package.json or typings.
src/components/link/mini-program.vue(11,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
src/components/link/mini-program.vue(22,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
src/components/link/mini-program.vue(33,30): error TS7006: Parameter 'value' implicitly has an 'any' type.
src/components/link/mini-program.vue(48,31): error TS7006: Parameter 'value' implicitly has an 'any' type.
src/utils/call-local-recorder.ts(335,26): error TS2339: Property 'captureStream' does not exist on type 'HTMLVideoElement'.
src/views/asset/user/index.vue(230,20): error TS2339: Property 'remark' does not exist on type 'never'.
src/views/consumer/prescription/index.vue(1952,9): error TS2322: Type '{ name: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
Type '{ name: any; code: any; children: any; }' is not assignable to type 'never'.
src/views/consumer/prescription/index.vue(1958,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
src/views/consumer/prescription/index.vue(1986,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
src/views/consumer/prescription/index.vue(2001,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
src/views/consumer/prescription/index.vue(2016,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'.
src/views/consumer/prescription/index.vue(2256,32): error TS2304: Cannot find name 'searchPatientsAPI'.
src/views/consumer/prescription/order_list.vue(1939,68): error TS7006: Parameter 'r' implicitly has an 'any' type.
src/views/consumer/prescription/order_list.vue(2379,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'.
src/views/consumer/prescription/order_list.vue(3775,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'.
Type 'string' is not assignable to type 'number'.
src/views/consumer/prescription/order_list_h5.vue(497,96): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
src/views/consumer/prescription/order_list_h5.vue(566,47): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
src/views/consumer/prescription/order_list_h5.vue(576,58): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
src/views/consumer/prescription/order_list_h5.vue(586,47): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
src/views/consumer/prescription/order_list_h5.vue(596,59): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
src/views/consumer/prescription/order_list_h5.vue(611,46): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'.
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'.
src/views/consumer/prescription/order_list_h5.vue(621,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'.
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'.
src/views/consumer/prescription/order_list_h5.vue(631,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
src/views/consumer/prescription/order_list_h5.vue(641,53): error TS2345: Argument of type 'Record<string, any>' is not assignable to parameter of type '{ id: number; }'.
Property 'id' is missing in type 'Record<string, any>' but required in type '{ id: number; }'.
src/views/consumer/prescription/order_list_h5.vue(2509,68): error TS7006: Parameter 'r' implicitly has an 'any' type.
src/views/consumer/prescription/order_list_h5.vue(2848,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'.
Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'.
src/views/consumer/prescription/order_list_h5.vue(4694,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'.
Type 'string' is not assignable to type 'number'.
src/views/decoration/component/tabbar/pc/attr.vue(10,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'.
src/views/decoration/component/tabbar/pc/attr.vue(13,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'.
src/views/decoration/component/widgets/middle-banner/content.vue(6,33): error TS2339: Property 'height' does not exist on type '{}'.
src/views/doctor/dept-tongji.vue(131,31): error TS7006: Parameter 'depts' implicitly has an 'any' type.
src/views/doctor/dept-tongji.vue(132,17): error TS7034: Variable 'result' implicitly has type 'any[]' in some locations where its type cannot be determined.
src/views/doctor/dept-tongji.vue(133,27): error TS7006: Parameter 'dept' implicitly has an 'any' type.
src/views/doctor/dept-tongji.vue(136,30): error TS7005: Variable 'result' implicitly has an 'any[]' type.
src/views/doctor/dept-tongji.vue(139,20): error TS7005: Variable 'result' implicitly has an 'any[]' type.
src/views/doctor/tongji.vue(230,48): error TS2769: No overload matches this call.
Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error.
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'.
Types of parameters 'total' and 'previousValue' are incompatible.
Type 'unknown' is not assignable to type 'number'.
Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error.
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'.
Types of parameters 'count' and 'currentValue' are incompatible.
Type 'unknown' is not assignable to type 'number'.
src/views/doctor/tongji.vue(236,51): error TS2769: No overload matches this call.
Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error.
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'.
Types of parameters 'total' and 'previousValue' are incompatible.
Type 'unknown' is not assignable to type 'number'.
Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error.
Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'.
Types of parameters 'count' and 'currentValue' are incompatible.
Type 'unknown' is not assignable to type 'number'.
src/views/first_visit/my_patients/components/OrderActionHost.vue(13,28): error TS7006: Parameter 'command' implicitly has an 'any' type.
src/views/first_visit/my_patients/components/OrderPanel.vue(202,40): error TS7006: Parameter 'command' implicitly has an 'any' type.
src/views/first_visit/wecom_promotion/index.vue(97,43): error TS7006: Parameter 'checked' implicitly has an 'any' type.
src/views/first_visit/wecom_promotion/index.vue(183,184): error TS7006: Parameter 'value' implicitly has an 'any' type.
src/views/order/index.vue(446,33): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap.
src/views/order/index.vue(1542,13): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap.
src/views/tcm/appointment/list.vue(263,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
src/views/tcm/diagnosis/add.vue(744,49): error TS2349: This expression is not callable.
Type 'String' has no call signatures.
src/views/tcm/diagnosis/add.vue(772,45): error TS2349: This expression is not callable.
Type 'String' has no call signatures.
src/views/tcm/diagnosis/components/BloodRecordList.vue(416,5): error TS2322: Type 'string' is not assignable to type 'number'.
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(145,54): error TS2554: Expected 0 arguments, but got 1.
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(146,50): error TS2554: Expected 0 arguments, but got 1.
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(147,47): error TS2554: Expected 0 arguments, but got 1.
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(148,47): error TS2554: Expected 0 arguments, but got 1.
src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(154,26): error TS2554: Expected 0 arguments, but got 1.
src/views/tcm/diagnosis/index.vue(379,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
src/views/tcm/follow/index.vue(239,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type.
src/views/workbench/index.vue(541,18): error TS7006: Parameter 'd' implicitly has an 'any' type.
@@ -0,0 +1,42 @@
[
{
"file": "admin/src/components/chat-dialog/index.vue",
"errors": []
},
{
"file": "admin/src/views/consumer/prescription/guahao.vue",
"errors": []
},
{
"file": "admin/src/views/consumer/prescription/index.vue",
"errors": []
},
{
"file": "admin/src/views/consumer/prescription/order_list.vue",
"errors": []
},
{
"file": "admin/src/views/consumer/prescription/order_list_h5.vue",
"errors": []
},
{
"file": "admin/src/views/patient/reception/index.vue",
"errors": []
},
{
"file": "admin/src/views/tcm/appointment/list.vue",
"errors": []
},
{
"file": "admin/src/views/tcm/appointment/list_h5.vue",
"errors": []
},
{
"file": "admin/src/views/tcm/diagnosis/appointment.vue",
"errors": []
},
{
"file": "admin/src/views/consumer/prescription/components/PrescriptionOrderTimeDialog.vue",
"errors": []
}
]
+29
View File
@@ -0,0 +1,29 @@
# 企业微信成员范围同步修复
## 原因与处理
截图中的“待移出”来自本地禁用状态与上次企微范围不一致。批量修改原来只写入同步队列,依赖后台分钟任务;单独保存忽略了同步器的 `noop`/未完成状态。另有两个一致性缺陷:同步期间配置版本变化后仍返回 synced;周期重算可能将超时租约或尚未应用的版本直接视为完成。
本次修改:
- 新增 POST `/firstvisit.wecomPromotion/syncMemberRange`,只向当前有权限操作的方案推送完整可用成员名单,再回读确认。不使用会导入其他链接的 `syncRemoteLinks`
- 批量保存后由页面最多 2 并发调用专用同步接口,等待每个方案结果,不以后台任务启动为本次修改立即生效的前提。
- 单独保存、批量保存、手动同步均返回明确的 `synced` / `pending` / `failed` / `blocked` 状态。保存本地成功与远端确认分开显示。
- 已下线成员从推送名单排除,部门范围清空;回读仍含旧成员或部门时判失败,不能标同步成功。
- 同步完成时检查租约、令牌、配置版本。过期工作不能覆盖新确认快照;变更未完成时继续排队。
- 同样的开关状态再次批量保存仍会重算,以修复之前留下的待同步任务。
- 页面新增“同步成员范围”按钮和批量失败结果重试,并展示当前计划、上次确认的远端范围与具体同步错误。
## 部署与现有方案
本次修改源代码,未部署,未读取业务数据库或调用真实企业微信修改接口。不新增表结构或权限菜单,沿用获客助手页面和方案操作范围权限;需将 admin 构建及 server 修改一起部署。已有队列表仍按原项目要求安装。
部署后,对已经出现“待移出”的方案点击“同步成员范围”;只有显示“企微成员范围已确认同步”,才能认为当前计划已经由企业微信回读确认。批量重新保存也会主动同步各方案。后台分钟任务仍用于上限、有效期和失败补偿。
## 验证记录
见 [前端验证](frontend.md) 与 [隔离数据库验证](backend-tests.md)。所有测试使用虚构数据,未向真实企业微信发送请求。
生产构建通过(Vite,2 分 4 秒),输出到任务临时目录,未覆盖线上目录。完整输出保留在 `build.log`;仍有既有的大体积分包警告。前端 7 项回归与后端 24 个场景、52 个断言均通过。全量类型检查仍有其他模块的已有报错,详情见前端验证记录。
浏览器隔离验证已通过:使用实际 Vue 页面和本地 API 替身,初始仅“测试许可”上线、“测试旧医助”仍在远端快照中。手动同步成功后,页面显示仅许可在企微范围,旧医助变为“未在企微范围”;模拟接口失败后,页面保留旧医助“待移出(仍在企微)”,展示具体错误和“重试同步”。验证页及临时服务已关闭。复现入口为 `ui-preview.mjs`,使用本机回环地址和虚构数据。
@@ -0,0 +1,31 @@
PASS only XuKe is sent and both remote range dimensions are verified
PASS GET mismatch rejects old user and remains retryable
PASS GET mismatch rejects department expansion and remains retryable
PASS transport failure preserves last confirmed snapshot and retry
PASS active sync lease cannot be reported as synced or stolen
PASS version change during GET reports pending and resyncs
PASS lease expiry during GET cannot commit success
PASS superseded lease cannot overwrite newer worker snapshot
PASS reconcile keeps expired running lease pending despite matching cache
PASS reconcile keeps unconfirmed version pending despite matching cache
PASS reconcile removes cached departments even when users match
PASS explicit retry repairs old pending without changing member switches
PASS explicit retry repairs old failed backoff without changing member switches
PASS explicit retry active lease returns pending without false success
PASS explicit retry API failure remains failed with saved local state
PASS no eligible member is blocked and never sends empty official range
PASS explicit retry rejects pools outside operator scope
PASS batch repeated offline selection requeues the unsynced remote range
PASS batch disables both old assistants and queue confirms XuKe only
PASS single offline with active worker reports pending then retries successfully
PASS single and batch preserve at least one eligible assistant
PASS shared operator can retry its own assigned pool
PASS retry never revives deleting pool
PASS retry never revives delete failed pool
{
"passed": 24,
"failed": 0,
"checks": 52,
"failures": []
}
Disposable test database dropped.
@@ -0,0 +1,31 @@
# 企微成员范围同步回归验证
2026-09-09 验证通过:**24 个场景,52 个断言,0 失败**。
- 测试:`server/tests/WecomPromotionMemberSyncTest.php`
- 启动脚本:`artifacts/wecom-member-sync/run-backend-tests.ps1`
- 完整输出:`artifacts/wecom-member-sync/backend-test-output.log`
- 环境:PHP 8.2.9、独立 MySQL 5.7.26、真实 Think ORM 与事务。
运行命令(仓库根目录):
```powershell
& ./artifacts/wecom-member-sync/run-backend-tests.ps1
```
启动脚本使用随机空闲回环端口、`--no-defaults` 和独立临时 datadir;测试另建随机 `wecom_member_test_*` 数据库。测试仅加载 vendor、框架辅助函数、未初始化的 `think\App` 及显式测试 DB 配置,不读取业务数据库配置或 `.env`。测试使用部署 SQL 中的真实建表定义,API fake 不访问企微。
验证覆盖:
1. 仅许可 `XuKe` 启用时,update 精确发送 `user_list=[XuKe]``department_list=[]`,随后 GET 校验并保存真实返回范围。
2. GET 仍含旧医助或非空部门时失败,保留失败原因、原已确认版本和重试计划;本地下线状态保持不变。
3. 接口异常保留原已确认远端快照及重试退避。
4. 活跃租约不会被抢占或误报已同步;GET 期间版本变化、租约过期或被新工作接管均返回 pending;旧工作不能覆盖新快照。
5. 重算不会把过期租约、未应用版本或残留部门误置为已同步。
6. 显式重试可修复旧 pending、失败退避以及开关未变化的状态,并准确返回 synced / pending / failed / blocked。
7. 实际批量下线两个旧医助后,真实队列服务仅同步 XuKe;重复选择已下线医助仍会重新排队。
8. 实际单独下线遇到活跃工作时返回 pending,过期后可重试完成。
9. 单独和批量下线都保持至少一名可用医助;空可用范围不会向企微发送空列表。
10. 已分配共享操作人可重试自己的方案;无关账号被拒绝;删除中或删除失败的方案不会被同步操作复活。
最终运行已删除测试数据库、关闭临时 MySQL 进程并移除临时 datadir。未访问线上企微,也未验证真实租户接口权限或实际远端数据;该报告证明本地同步与状态机回归通过。
+494
View File
@@ -0,0 +1,494 @@
vite v6.4.2 building for production...
WARN
(!) outDir D:\web\zyt\artifacts\wecom-member-sync\admin-build is not inside project root and will not be emptied.
Use --emptyOutDir to override.
transforming...
✓ 4060 modules transformed.
WARN node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js (3199:26): Use of eval in "node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js" is strongly discouraged as it poses security risks and may cause issues with minification.
rendering chunks...
computing gzip size...
../artifacts/wecom-member-sync/admin-build/assets/default_avatar-C6VB7PGm.png 6.09 kB
../artifacts/wecom-member-sync/admin-build/assets/no_perms-jDxcYpYC.png 14.62 kB
../artifacts/wecom-member-sync/admin-build/index.html 31.87 kB │ gzip: 16.16 kB
../artifacts/wecom-member-sync/admin-build/assets/login_bg-BkIjQ0FB.png 59.27 kB
../artifacts/wecom-member-sync/admin-build/assets/red3-NOuWP8DK.png 105.00 kB
../artifacts/wecom-member-sync/admin-build/assets/pink3-BxZ4Y6CS.png 108.36 kB
../artifacts/wecom-member-sync/admin-build/assets/blue3-D3K9OqGO.png 108.98 kB
../artifacts/wecom-member-sync/admin-build/assets/yellow3-C_qd9cqN.png 109.27 kB
../artifacts/wecom-member-sync/admin-build/assets/green3-CPorIQiC.png 109.99 kB
../artifacts/wecom-member-sync/admin-build/assets/purple3-BGd0LxTa.png 110.15 kB
../artifacts/wecom-member-sync/admin-build/assets/my_topbg-BiU0PleK.png 142.47 kB
../artifacts/wecom-member-sync/admin-build/assets/red2-Dw8p71sP.png 750.50 kB
../artifacts/wecom-member-sync/admin-build/assets/purple2-C0tQkldV.png 752.33 kB
../artifacts/wecom-member-sync/admin-build/assets/green2-C-VRKLSN.png 756.86 kB
../artifacts/wecom-member-sync/admin-build/assets/yellow2-B-WtqITJ.png 762.86 kB
../artifacts/wecom-member-sync/admin-build/assets/pink2-BpEp33zy.png 806.48 kB
../artifacts/wecom-member-sync/admin-build/assets/blue2-CRQPdLZd.png 806.81 kB
../artifacts/wecom-member-sync/admin-build/assets/red1-C6Y3UuNB.png 1,660.63 kB
../artifacts/wecom-member-sync/admin-build/assets/yellow1-Ebw0T5sw.png 1,662.23 kB
../artifacts/wecom-member-sync/admin-build/assets/pink1-BWNZrP7C.png 1,668.56 kB
../artifacts/wecom-member-sync/admin-build/assets/blue1-gLOo1H0w.png 1,676.54 kB
../artifacts/wecom-member-sync/admin-build/assets/purple1-BpMq9FWz.png 1,680.01 kB
../artifacts/wecom-member-sync/admin-build/assets/green1-h1zqes95.png 1,688.20 kB
../artifacts/wecom-member-sync/admin-build/assets/index-CmFE2aZQ.css 0.04 kB │ gzip: 0.06 kB
../artifacts/wecom-member-sync/admin-build/assets/index-CjSHFu-R.css 0.04 kB │ gzip: 0.06 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Oppei429.css 0.04 kB │ gzip: 0.06 kB
../artifacts/wecom-member-sync/admin-build/assets/CaseRecordList-Cl-c3q7S.css 0.05 kB │ gzip: 0.07 kB
../artifacts/wecom-member-sync/admin-build/assets/DietRecordList-Cc-xUr2B.css 0.05 kB │ gzip: 0.07 kB
../artifacts/wecom-member-sync/admin-build/assets/roster-CqNlv_rj.css 0.05 kB │ gzip: 0.07 kB
../artifacts/wecom-member-sync/admin-build/assets/BloodRecordList-DhVK-Y_e.css 0.05 kB │ gzip: 0.07 kB
../artifacts/wecom-member-sync/admin-build/assets/index-B3imPWFk.css 0.05 kB │ gzip: 0.07 kB
../artifacts/wecom-member-sync/admin-build/assets/list-BluNBZln.css 0.05 kB │ gzip: 0.07 kB
../artifacts/wecom-member-sync/admin-build/assets/ExerciseRecordList-DGtgtzou.css 0.05 kB │ gzip: 0.07 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-CObVTcPU.css 0.09 kB │ gzip: 0.10 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-D-D8bVPM.css 0.09 kB │ gzip: 0.10 kB
../artifacts/wecom-member-sync/admin-build/assets/pc_details-nlJo1_D0.css 0.09 kB │ gzip: 0.10 kB
../artifacts/wecom-member-sync/admin-build/assets/AppointmentRecordPanel-DC22GDgn.css 0.09 kB │ gzip: 0.10 kB
../artifacts/wecom-member-sync/admin-build/assets/theme-picker-BsELUxM9.css 0.11 kB │ gzip: 0.09 kB
../artifacts/wecom-member-sync/admin-build/assets/content-Cl-9UIip.css 0.13 kB │ gzip: 0.13 kB
../artifacts/wecom-member-sync/admin-build/assets/AssignLogPanel-DkQMoEkX.css 0.13 kB │ gzip: 0.13 kB
../artifacts/wecom-member-sync/admin-build/assets/content-DXAsZ7EV.css 0.13 kB │ gzip: 0.13 kB
../artifacts/wecom-member-sync/admin-build/assets/content-DaxRy47P.css 0.14 kB │ gzip: 0.14 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-CAJZpU7c.css 0.14 kB │ gzip: 0.12 kB
../artifacts/wecom-member-sync/admin-build/assets/content-DxNvUNZR.css 0.15 kB │ gzip: 0.15 kB
../artifacts/wecom-member-sync/admin-build/assets/useListTimeFilter-DI6SIumd.css 0.16 kB │ gzip: 0.14 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-C1NtFi7N.css 0.16 kB │ gzip: 0.11 kB
../artifacts/wecom-member-sync/admin-build/assets/content-Bd3rHJ5J.css 0.18 kB │ gzip: 0.15 kB
../artifacts/wecom-member-sync/admin-build/assets/change-password-DRfsLJ26.css 0.19 kB │ gzip: 0.16 kB
../artifacts/wecom-member-sync/admin-build/assets/content-Dy_alH9u.css 0.19 kB │ gzip: 0.16 kB
../artifacts/wecom-member-sync/admin-build/assets/decoration-img-C5XvHl9_.css 0.19 kB │ gzip: 0.16 kB
../artifacts/wecom-member-sync/admin-build/assets/account_cost-fbCWUO9k.css 0.20 kB │ gzip: 0.16 kB
../artifacts/wecom-member-sync/admin-build/assets/index-57RdOFNo.css 0.23 kB │ gzip: 0.16 kB
../artifacts/wecom-member-sync/admin-build/assets/error-Cz3CexuM.css 0.24 kB │ gzip: 0.18 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-D4atl_7z.css 0.25 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-lNortPsv.css 0.27 kB │ gzip: 0.19 kB
../artifacts/wecom-member-sync/admin-build/assets/setting-CxqqGetv.css 0.27 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DX96drV3.css 0.28 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/menu-DYFusokT.css 0.29 kB │ gzip: 0.16 kB
../artifacts/wecom-member-sync/admin-build/assets/OrderActionHost-DiqybThz.css 0.31 kB │ gzip: 0.22 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Bk2rMrqw.css 0.32 kB │ gzip: 0.20 kB
../artifacts/wecom-member-sync/admin-build/assets/index-D0f1Mn00.css 0.33 kB │ gzip: 0.22 kB
../artifacts/wecom-member-sync/admin-build/assets/prescription-drawer-BjwiqvPc.css 0.35 kB │ gzip: 0.16 kB
../artifacts/wecom-member-sync/admin-build/assets/bind-work-wechat-BX1gqwq1.css 0.35 kB │ gzip: 0.22 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DhR2yeiO.css 0.39 kB │ gzip: 0.19 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DoFDMZdP.css 0.40 kB │ gzip: 0.23 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DBNvK3ZK.css 0.45 kB │ gzip: 0.21 kB
../artifacts/wecom-member-sync/admin-build/assets/PatientOrderList-D8-uh2iu.css 0.45 kB │ gzip: 0.25 kB
../artifacts/wecom-member-sync/admin-build/assets/DiagnosisTodoList-D0SBmTHG.css 0.46 kB │ gzip: 0.23 kB
../artifacts/wecom-member-sync/admin-build/assets/CallRecordPanel-TsjZtTO8.css 0.47 kB │ gzip: 0.23 kB
../artifacts/wecom-member-sync/admin-build/assets/index-C_LxAEkS.css 0.48 kB │ gzip: 0.27 kB
../artifacts/wecom-member-sync/admin-build/assets/login-Bt4SvQsz.css 0.52 kB │ gzip: 0.28 kB
../artifacts/wecom-member-sync/admin-build/assets/content-sMbqkta2.css 0.55 kB │ gzip: 0.29 kB
../artifacts/wecom-member-sync/admin-build/assets/RecordingPlaybackBlock-B3KYFgvg.css 0.58 kB │ gzip: 0.26 kB
../artifacts/wecom-member-sync/admin-build/assets/medicine-DO6x6zrS.css 0.58 kB │ gzip: 0.29 kB
../artifacts/wecom-member-sync/admin-build/assets/preview-pc-BRQDo0AR.css 0.67 kB │ gzip: 0.36 kB
../artifacts/wecom-member-sync/admin-build/assets/index-BzrSkGWL.css 0.67 kB │ gzip: 0.27 kB
../artifacts/wecom-member-sync/admin-build/assets/tabbar-DJsOahKR.css 0.69 kB │ gzip: 0.31 kB
../artifacts/wecom-member-sync/admin-build/assets/qywx-Dym8iFe0.css 0.74 kB │ gzip: 0.37 kB
../artifacts/wecom-member-sync/admin-build/assets/AssistantWatchCallDialog-BIPZRgaH.css 0.76 kB │ gzip: 0.38 kB
../artifacts/wecom-member-sync/admin-build/assets/picker-JBYDNsl5.css 0.84 kB │ gzip: 0.32 kB
../artifacts/wecom-member-sync/admin-build/assets/oa-phone-CctIyraX.css 0.94 kB │ gzip: 0.34 kB
../artifacts/wecom-member-sync/admin-build/assets/preview-C7oaKmYo.css 0.98 kB │ gzip: 0.41 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Bidrbn2Z.css 1.16 kB │ gzip: 0.51 kB
../artifacts/wecom-member-sync/admin-build/assets/TrackingNoteTimeline-B9T8pFso.css 1.18 kB │ gzip: 0.47 kB
../artifacts/wecom-member-sync/admin-build/assets/RecordingVideoPlayer-4DegHAwm.css 1.19 kB │ gzip: 0.53 kB
../artifacts/wecom-member-sync/admin-build/assets/index-55HtuLpn.css 1.21 kB │ gzip: 0.55 kB
../artifacts/wecom-member-sync/admin-build/assets/PatientInfoCard-n6_UPo9e.css 1.32 kB │ gzip: 0.55 kB
../artifacts/wecom-member-sync/admin-build/assets/TrackingMatrix-zyxtXYPb.css 1.32 kB │ gzip: 0.44 kB
../artifacts/wecom-member-sync/admin-build/assets/SendPanel-DaGPWlQG.css 1.39 kB │ gzip: 0.50 kB
../artifacts/wecom-member-sync/admin-build/assets/picker-XZbUgFct.css 1.57 kB │ gzip: 0.50 kB
../artifacts/wecom-member-sync/admin-build/assets/PrescriptionOrderDetailDrawer-B4rggqa1.css 1.73 kB │ gzip: 0.71 kB
../artifacts/wecom-member-sync/admin-build/assets/ImChatRecordPanel-aX8zpWRQ.css 1.74 kB │ gzip: 0.59 kB
../artifacts/wecom-member-sync/admin-build/assets/index-BYOmK9R5.css 1.93 kB │ gzip: 0.58 kB
../artifacts/wecom-member-sync/admin-build/assets/index-OgnT0gUo.css 1.98 kB │ gzip: 0.56 kB
../artifacts/wecom-member-sync/admin-build/assets/MessageBubble-BwtdPM6K.css 2.04 kB │ gzip: 0.60 kB
../artifacts/wecom-member-sync/admin-build/assets/index-BmDW9vcs.css 2.44 kB │ gzip: 0.86 kB
../artifacts/wecom-member-sync/admin-build/assets/PatientCaseCard-C3Mob6yG.css 2.49 kB │ gzip: 0.81 kB
../artifacts/wecom-member-sync/admin-build/assets/tongji-C0VnzFxy.css 2.49 kB │ gzip: 0.77 kB
../artifacts/wecom-member-sync/admin-build/assets/dept-tongji-ky7rOYoi.css 2.67 kB │ gzip: 0.72 kB
../artifacts/wecom-member-sync/admin-build/assets/readonly-CdP3eZN0.css 2.69 kB │ gzip: 0.92 kB
../artifacts/wecom-member-sync/admin-build/assets/NoteTimeline-I2FSxX4s.css 2.74 kB │ gzip: 0.84 kB
../artifacts/wecom-member-sync/admin-build/assets/patient-call-k8V9hV2G.css 2.85 kB │ gzip: 0.91 kB
../artifacts/wecom-member-sync/admin-build/assets/DailyMatrix-DCZnxI8i.css 2.87 kB │ gzip: 0.85 kB
../artifacts/wecom-member-sync/admin-build/assets/PaibanPanel-aqm8azGc.css 3.35 kB │ gzip: 0.88 kB
../artifacts/wecom-member-sync/admin-build/assets/paiban-BTUHWpUu.css 3.35 kB │ gzip: 0.88 kB
../artifacts/wecom-member-sync/admin-build/assets/appointment-gzuOW_Wp.css 3.43 kB │ gzip: 0.93 kB
../artifacts/wecom-member-sync/admin-build/assets/PrescriptionAiBatchGenerateDialog-CKkykmdr.css 3.45 kB │ gzip: 0.86 kB
../artifacts/wecom-member-sync/admin-build/assets/h5-BSFcFSny.css 3.52 kB │ gzip: 0.99 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Bk7b_sL2.css 3.57 kB │ gzip: 0.96 kB
../artifacts/wecom-member-sync/admin-build/assets/add-ByXRFm8M.css 3.60 kB │ gzip: 0.87 kB
../artifacts/wecom-member-sync/admin-build/assets/PromotionAutomationForm-BMhNCJa-.css 3.64 kB │ gzip: 1.03 kB
../artifacts/wecom-member-sync/admin-build/assets/mubiao-B6IOi2bJ.css 3.78 kB │ gzip: 1.08 kB
../artifacts/wecom-member-sync/admin-build/assets/WelcomeMessageEditor-DD5mXWP2.css 4.41 kB │ gzip: 1.23 kB
../artifacts/wecom-member-sync/admin-build/assets/PrescriptionAiReportDialog-CV6AuHV3.css 4.46 kB │ gzip: 1.11 kB
../artifacts/wecom-member-sync/admin-build/assets/index-CMsGEvq_.css 5.19 kB │ gzip: 1.18 kB
../artifacts/wecom-member-sync/admin-build/assets/mubiao-dept-node-ChUPkaxq.css 5.47 kB │ gzip: 1.29 kB
../artifacts/wecom-member-sync/admin-build/assets/OrderPanel-BzElZPEq.css 5.67 kB │ gzip: 1.46 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DYEnq1RY.css 6.11 kB │ gzip: 1.70 kB
../artifacts/wecom-member-sync/admin-build/assets/list-q8oaox07.css 6.51 kB │ gzip: 1.45 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DtDaTXOB.css 7.01 kB │ gzip: 1.69 kB
../artifacts/wecom-member-sync/admin-build/assets/mubiao-dept-card-naMqyghz.css 7.09 kB │ gzip: 1.53 kB
../artifacts/wecom-member-sync/admin-build/assets/index-AnyUcdTT.css 7.21 kB │ gzip: 1.86 kB
../artifacts/wecom-member-sync/admin-build/assets/index-BqGStdXW.css 7.95 kB │ gzip: 2.30 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Bw7BJ7QR.css 8.03 kB │ gzip: 1.99 kB
../artifacts/wecom-member-sync/admin-build/assets/progress-99NT9ysL.css 8.14 kB │ gzip: 1.91 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Cmf8QBsF.css 8.75 kB │ gzip: 1.78 kB
../artifacts/wecom-member-sync/admin-build/assets/index-CSIcxqFk.css 8.85 kB │ gzip: 2.21 kB
../artifacts/wecom-member-sync/admin-build/assets/index-B_6Taw4k.css 8.97 kB │ gzip: 1.91 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-DOd7Lhrw.css 9.47 kB │ gzip: 2.05 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Dnn9ddtX.css 9.49 kB │ gzip: 2.20 kB
../artifacts/wecom-member-sync/admin-build/assets/index-D9Td5Y2a.css 9.52 kB │ gzip: 2.10 kB
../artifacts/wecom-member-sync/admin-build/assets/ProgressPanel-C_aSqDJh.css 10.10 kB │ gzip: 2.10 kB
../artifacts/wecom-member-sync/admin-build/assets/index-nUsN4URL.css 10.57 kB │ gzip: 2.04 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DV5JwFnL.css 11.95 kB │ gzip: 2.61 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Dc4ifSiV.css 13.65 kB │ gzip: 3.05 kB
../artifacts/wecom-member-sync/admin-build/assets/commission-settlement-Gy2UsUPr.css 15.55 kB │ gzip: 3.19 kB
../artifacts/wecom-member-sync/admin-build/assets/list_h5-jFsYJrxI.css 17.68 kB │ gzip: 3.23 kB
../artifacts/wecom-member-sync/admin-build/assets/order_list-CMCIYVRT.css 18.15 kB │ gzip: 3.76 kB
../artifacts/wecom-member-sync/admin-build/assets/index-C6H7fmYO.css 18.86 kB │ gzip: 3.83 kB
../artifacts/wecom-member-sync/admin-build/assets/WecomFloatingWidgetBuilder-DRWt4rvh.css 19.20 kB │ gzip: 4.00 kB
../artifacts/wecom-member-sync/admin-build/assets/index_h5-CWq8J0dT.css 19.54 kB │ gzip: 3.51 kB
../artifacts/wecom-member-sync/admin-build/assets/yeji-7-gSVFmd.css 25.24 kB │ gzip: 4.60 kB
../artifacts/wecom-member-sync/admin-build/assets/index-BSycZlsS.css 45.91 kB │ gzip: 9.91 kB
../artifacts/wecom-member-sync/admin-build/assets/order_list_h5-DB3qJv9G.css 48.13 kB │ gzip: 7.68 kB
../artifacts/wecom-member-sync/admin-build/assets/.pnpm-B3v8nGpq.css 723.50 kB │ gzip: 100.49 kB
../artifacts/wecom-member-sync/admin-build/assets/getExposeType-BhVtb25-.js 0.07 kB │ gzip: 0.09 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-qqeREw9s.js 0.13 kB │ gzip: 0.13 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-DcOELR-K.js 0.13 kB │ gzip: 0.13 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-tHefvLXa.js 0.13 kB │ gzip: 0.13 kB
../artifacts/wecom-member-sync/admin-build/assets/MediaSourceSelect-8A82adC4.js 0.14 kB │ gzip: 0.14 kB
../artifacts/wecom-member-sync/admin-build/assets/code-preview-DRmC7RiQ.js 0.16 kB │ gzip: 0.15 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-X53VrVRm.js 0.18 kB │ gzip: 0.16 kB
../artifacts/wecom-member-sync/admin-build/assets/refund-log-B9It8rlX.js 0.19 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/account-adjust-qMgPFEex.js 0.19 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/content-Bl11rHMa.js 0.19 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/content-ne8NsUKb.js 0.19 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/content-Dms_y08P.js 0.19 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/content-tYml8wxW.js 0.19 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/perm-Cs7Dcdwt.js 0.20 kB │ gzip: 0.18 kB
../artifacts/wecom-member-sync/admin-build/assets/diabetes-discovery-display-B_wmGXQJ.js 0.20 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/PrescriptionOrderTimeDialog-Dqm-s38p.js 0.20 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/weapp-z86WO93x.js 0.20 kB │ gzip: 0.15 kB
../artifacts/wecom-member-sync/admin-build/assets/GancaoSubmissionReconcileButton-IPCTzvIw.js 0.21 kB │ gzip: 0.18 kB
../artifacts/wecom-member-sync/admin-build/assets/content-Ddgk3Vgk.js 0.21 kB │ gzip: 0.19 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-CE55pahX.js 0.21 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-AvD9VAin.js 0.21 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-CboBRRXJ.js 0.21 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-DjOH93eC.js 0.21 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-C4ieP-Hx.js 0.21 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-Dxe0CJRt.js 0.21 kB │ gzip: 0.17 kB
../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DG0CrN4I.js 0.22 kB │ gzip: 0.18 kB
../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-awcvFaxy.js 0.22 kB │ gzip: 0.18 kB
../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DvEPH3sF.js 0.22 kB │ gzip: 0.18 kB
../artifacts/wecom-member-sync/admin-build/assets/oa-menu-form-_sZJZZlo.js 0.22 kB │ gzip: 0.18 kB
../artifacts/wecom-member-sync/admin-build/assets/useLockFn-DZaCbVGv.js 0.22 kB │ gzip: 0.19 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-DbL-VfLn.js 0.23 kB │ gzip: 0.18 kB
../artifacts/wecom-member-sync/admin-build/assets/auth-CzTX0mcP.js 0.24 kB │ gzip: 0.18 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-BVUVZ4z1.js 0.25 kB │ gzip: 0.19 kB
../artifacts/wecom-member-sync/admin-build/assets/relations-add-Bbfd_-Kp.js 0.25 kB │ gzip: 0.19 kB
../artifacts/wecom-member-sync/admin-build/assets/mobile-style-DS1gdFbp.js 0.26 kB │ gzip: 0.19 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-XEIomOE9.js 0.27 kB │ gzip: 0.19 kB
../artifacts/wecom-member-sync/admin-build/assets/cost-edit-BOiGaL17.js 0.30 kB │ gzip: 0.20 kB
../artifacts/wecom-member-sync/admin-build/assets/yeji-edit-F8OmljrO.js 0.30 kB │ gzip: 0.21 kB
../artifacts/wecom-member-sync/admin-build/assets/404-cLkY5rts.js 0.31 kB │ gzip: 0.29 kB
../artifacts/wecom-member-sync/admin-build/assets/data-table-BybaVEqV.js 0.31 kB │ gzip: 0.20 kB
../artifacts/wecom-member-sync/admin-build/assets/oa-menu-form-edit-CNLd_xiS.js 0.33 kB │ gzip: 0.21 kB
../artifacts/wecom-member-sync/admin-build/assets/medicine-DHDpGXRz.js 0.35 kB │ gzip: 0.18 kB
../artifacts/wecom-member-sync/admin-build/assets/consumer-FZiu13hZ.js 0.35 kB │ gzip: 0.19 kB
../artifacts/wecom-member-sync/admin-build/assets/user-CVW3IWKF.js 0.37 kB │ gzip: 0.18 kB
../artifacts/wecom-member-sync/admin-build/assets/role-BMscz-0E.js 0.39 kB │ gzip: 0.18 kB
../artifacts/wecom-member-sync/admin-build/assets/useDictOptions-B6OsldKP.js 0.42 kB │ gzip: 0.32 kB
../artifacts/wecom-member-sync/admin-build/assets/index-D4MbHviX.js 0.42 kB │ gzip: 0.32 kB
../artifacts/wecom-member-sync/admin-build/assets/admin-BG9sUQT0.js 0.42 kB │ gzip: 0.21 kB
../artifacts/wecom-member-sync/admin-build/assets/content-Czz9V8bo.js 0.44 kB │ gzip: 0.36 kB
../artifacts/wecom-member-sync/admin-build/assets/pay-CLNugi0F.js 0.45 kB │ gzip: 0.21 kB
../artifacts/wecom-member-sync/admin-build/assets/department-CNIAiYg7.js 0.46 kB │ gzip: 0.19 kB
../artifacts/wecom-member-sync/admin-build/assets/menu-p4ALi4Sf.js 0.46 kB │ gzip: 0.19 kB
../artifacts/wecom-member-sync/admin-build/assets/content-uGlbGuas.js 0.47 kB │ gzip: 0.37 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-CWDxq6pQ.js 0.48 kB │ gzip: 0.25 kB
../artifacts/wecom-member-sync/admin-build/assets/post-C0FRzdXA.js 0.49 kB │ gzip: 0.21 kB
../artifacts/wecom-member-sync/admin-build/assets/overflow-Bh-ERwlc.js 0.49 kB │ gzip: 0.37 kB
../artifacts/wecom-member-sync/admin-build/assets/decoration-CvKJtp9z.js 0.50 kB │ gzip: 0.22 kB
../artifacts/wecom-member-sync/admin-build/assets/message-B01Piuuj.js 0.50 kB │ gzip: 0.21 kB
../artifacts/wecom-member-sync/admin-build/assets/403-WJpkLdIc.js 0.52 kB │ gzip: 0.44 kB
../artifacts/wecom-member-sync/admin-build/assets/footer.vue_vue_type_script_setup_true_lang-CJd2gVU1.js 0.53 kB │ gzip: 0.38 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-Bj_piHuB.js 0.54 kB │ gzip: 0.27 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-DwQmZN66.js 0.54 kB │ gzip: 0.27 kB
../artifacts/wecom-member-sync/admin-build/assets/add-nav-CxIe0DSN.js 0.54 kB │ gzip: 0.27 kB
../artifacts/wecom-member-sync/admin-build/assets/content-C8KmKRfj.js 0.54 kB │ gzip: 0.27 kB
../artifacts/wecom-member-sync/admin-build/assets/menu-set-C2QaR06b.js 0.54 kB │ gzip: 0.27 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-CxOIVUbW.js 0.55 kB │ gzip: 0.26 kB
../artifacts/wecom-member-sync/admin-build/assets/patient-CAccvuDU.js 0.56 kB │ gzip: 0.22 kB
../artifacts/wecom-member-sync/admin-build/assets/fans-ymq1y7mi.js 0.60 kB │ gzip: 0.22 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-CRgmMBI6.js 0.60 kB │ gzip: 0.28 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-C65vISyn.js 0.61 kB │ gzip: 0.29 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-DepTsRMB.js 0.61 kB │ gzip: 0.29 kB
../artifacts/wecom-member-sync/admin-build/assets/diag-display-DCz_VAqj.js 0.61 kB │ gzip: 0.39 kB
../artifacts/wecom-member-sync/admin-build/assets/content.vue_vue_type_script_setup_true_lang-BOyQbtb7.js 0.61 kB │ gzip: 0.41 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Bgu1hGId.js 0.63 kB │ gzip: 0.43 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-BRVMaOaW.js 0.63 kB │ gzip: 0.32 kB
../artifacts/wecom-member-sync/admin-build/assets/link-D9GAHWRN.js 0.64 kB │ gzip: 0.44 kB
../artifacts/wecom-member-sync/admin-build/assets/asset-MS9grbtE.js 0.67 kB │ gzip: 0.22 kB
../artifacts/wecom-member-sync/admin-build/assets/content.vue_vue_type_script_setup_true_lang-D2f7dQtt.js 0.69 kB │ gzip: 0.45 kB
../artifacts/wecom-member-sync/admin-build/assets/useMediaSourceOptions-B-KC30lN.js 0.70 kB │ gzip: 0.42 kB
../artifacts/wecom-member-sync/admin-build/assets/content.vue_vue_type_script_setup_true_lang-CZ5ik_hN.js 0.70 kB │ gzip: 0.45 kB
../artifacts/wecom-member-sync/admin-build/assets/website-B8vbukz_.js 0.74 kB │ gzip: 0.24 kB
../artifacts/wecom-member-sync/admin-build/assets/menu-_vTr8Gju.js 0.76 kB │ gzip: 0.49 kB
../artifacts/wecom-member-sync/admin-build/assets/usePaging-oaNM6A9T.js 0.77 kB │ gzip: 0.47 kB
../artifacts/wecom-member-sync/admin-build/assets/self_input_stats-DN-gl7Ot.js 0.79 kB │ gzip: 0.27 kB
../artifacts/wecom-member-sync/admin-build/assets/decoration-img-D9zjI4VN.js 0.81 kB │ gzip: 0.50 kB
../artifacts/wecom-member-sync/admin-build/assets/dict-BeF70n9_.js 0.81 kB │ gzip: 0.25 kB
../artifacts/wecom-member-sync/admin-build/assets/code-IeXe1BXO.js 0.82 kB │ gzip: 0.25 kB
../artifacts/wecom-member-sync/admin-build/assets/blood-thresholds-CJ1fqMjh.js 0.83 kB │ gzip: 0.34 kB
../artifacts/wecom-member-sync/admin-build/assets/content-DhfW0oMX.js 0.84 kB │ gzip: 0.55 kB
../artifacts/wecom-member-sync/admin-build/assets/content.vue_vue_type_script_setup_true_lang-B7uTRM6j.js 0.86 kB │ gzip: 0.55 kB
../artifacts/wecom-member-sync/admin-build/assets/index.vue_vue_type_script_setup_true_lang-kAZgL5aN.js 0.86 kB │ gzip: 0.52 kB
../artifacts/wecom-member-sync/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CDLrQsa9.js 0.88 kB │ gzip: 0.51 kB
../artifacts/wecom-member-sync/admin-build/assets/error-DqXjSnDQ.js 0.89 kB │ gzip: 0.60 kB
../artifacts/wecom-member-sync/admin-build/assets/theme-picker-CJcYdGjg.js 0.92 kB │ gzip: 0.59 kB
../artifacts/wecom-member-sync/admin-build/assets/wecomOauthPostMessage-C_e6VGrO.js 0.95 kB │ gzip: 0.53 kB
../artifacts/wecom-member-sync/admin-build/assets/index.vue_vue_type_script_setup_true_lang-Cmgm_Z5P.js 0.98 kB │ gzip: 0.53 kB
../artifacts/wecom-member-sync/admin-build/assets/wx_oa-BX1EksXU.js 1.04 kB │ gzip: 0.29 kB
../artifacts/wecom-member-sync/admin-build/assets/article-C_uwRm-g.js 1.07 kB │ gzip: 0.26 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-setting.vue_vue_type_script_setup_true_lang-C1N5GjJr.js 1.08 kB │ gzip: 0.61 kB
../artifacts/wecom-member-sync/admin-build/assets/rich_text-DHfEzg2F.js 1.10 kB │ gzip: 0.58 kB
../artifacts/wecom-member-sync/admin-build/assets/cache-DwXb7UJT.js 1.13 kB │ gzip: 0.69 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DCOziDmg.js 1.21 kB │ gzip: 0.70 kB
../artifacts/wecom-member-sync/admin-build/assets/popover_input-pPM8UqwF.js 1.25 kB │ gzip: 0.59 kB
../artifacts/wecom-member-sync/admin-build/assets/finance-aNuyRBoJ.js 1.30 kB │ gzip: 0.37 kB
../artifacts/wecom-member-sync/admin-build/assets/pc-Bg3ZB7sY.js 1.31 kB │ gzip: 0.78 kB
../artifacts/wecom-member-sync/admin-build/assets/doctor-CK2s5tr0.js 1.32 kB │ gzip: 0.37 kB
../artifacts/wecom-member-sync/admin-build/assets/oa-menu-form-edit.vue_vue_type_script_setup_true_lang-CRAXblPZ.js 1.33 kB │ gzip: 0.78 kB
../artifacts/wecom-member-sync/admin-build/assets/content-BYP2_Tvt.js 1.33 kB │ gzip: 0.72 kB
../artifacts/wecom-member-sync/admin-build/assets/content-CbdFdpr9.js 1.37 kB │ gzip: 0.70 kB
../artifacts/wecom-member-sync/admin-build/assets/content-DNecD5Ej.js 1.38 kB │ gzip: 0.79 kB
../artifacts/wecom-member-sync/admin-build/assets/file-Bp3s6tGU.js 1.41 kB │ gzip: 0.64 kB
../artifacts/wecom-member-sync/admin-build/assets/upload-B7edCrM6.js 1.41 kB │ gzip: 0.64 kB
../artifacts/wecom-member-sync/admin-build/assets/content-Btz_MAcB.js 1.42 kB │ gzip: 0.79 kB
../artifacts/wecom-member-sync/admin-build/assets/code-preview.vue_vue_type_script_setup_true_lang-uoGImbA_.js 1.44 kB │ gzip: 0.85 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Eef1y49A.js 1.44 kB │ gzip: 0.85 kB
../artifacts/wecom-member-sync/admin-build/assets/index.vue_vue_type_style_index_0_lang-YvOGf2LN.js 1.46 kB │ gzip: 0.79 kB
../artifacts/wecom-member-sync/admin-build/assets/oa-phone-BSc3cQZO.js 1.47 kB │ gzip: 0.79 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-C5crH6QC.js 1.50 kB │ gzip: 0.76 kB
../artifacts/wecom-member-sync/admin-build/assets/menu-D0XeSt7x.js 1.55 kB │ gzip: 0.87 kB
../artifacts/wecom-member-sync/admin-build/assets/MediaSourceSelect.vue_vue_type_script_setup_true_lang-xuHF5G6T.js 1.58 kB │ gzip: 0.80 kB
../artifacts/wecom-member-sync/admin-build/assets/PatientInfoCard-DS1nmIqs.js 1.60 kB │ gzip: 0.82 kB
../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CzFCY2cx.js 1.62 kB │ gzip: 0.71 kB
../artifacts/wecom-member-sync/admin-build/assets/style-Dr3deMtC.js 1.62 kB │ gzip: 0.88 kB
../artifacts/wecom-member-sync/admin-build/assets/refund-log.vue_vue_type_script_setup_true_lang-MUi35Imc.js 1.66 kB │ gzip: 0.87 kB
../artifacts/wecom-member-sync/admin-build/assets/statistics-CJjYO2rr.js 1.69 kB │ gzip: 1.02 kB
../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-I0pt-6bF.js 1.71 kB │ gzip: 0.81 kB
../artifacts/wecom-member-sync/admin-build/assets/order-BUKOtASo.js 1.74 kB │ gzip: 0.44 kB
../artifacts/wecom-member-sync/admin-build/assets/environment-DWwJCNO7.js 1.74 kB │ gzip: 0.74 kB
../artifacts/wecom-member-sync/admin-build/assets/setup-DiCR4FTt.js 1.77 kB │ gzip: 1.00 kB
../artifacts/wecom-member-sync/admin-build/assets/useMenuOa-BkNkTb8E.js 1.82 kB │ gzip: 0.85 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-setting-u7gF0hf4.js 1.88 kB │ gzip: 0.53 kB
../artifacts/wecom-member-sync/admin-build/assets/index-d48aBo9A.js 1.90 kB │ gzip: 0.99 kB
../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_name_articleColumnEdit_lang-QYQSHa-u.js 1.94 kB │ gzip: 1.07 kB
../artifacts/wecom-member-sync/admin-build/assets/index-C2cPGRvp.js 2.03 kB │ gzip: 0.98 kB
../artifacts/wecom-member-sync/admin-build/assets/index-bKgIS70c.js 2.04 kB │ gzip: 1.14 kB
../artifacts/wecom-member-sync/admin-build/assets/TrackingNoteTimeline-xYwE1xTb.js 2.08 kB │ gzip: 1.17 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Xkc1_OtQ.js 2.09 kB │ gzip: 1.17 kB
../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-B6HGq-9K.js 2.09 kB │ gzip: 0.95 kB
../artifacts/wecom-member-sync/admin-build/assets/open_setting-9okEzl7R.js 2.10 kB │ gzip: 1.12 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Bgd9wlhP.js 2.12 kB │ gzip: 1.23 kB
../artifacts/wecom-member-sync/admin-build/assets/account-adjust.vue_vue_type_script_setup_true_lang-D-3zXgHH.js 2.16 kB │ gzip: 1.12 kB
../artifacts/wecom-member-sync/admin-build/assets/filing-CyXPFn3v.js 2.17 kB │ gzip: 1.17 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Cj99LS1q.js 2.18 kB │ gzip: 1.28 kB
../artifacts/wecom-member-sync/admin-build/assets/icon-Bns0PfYA.js 2.23 kB │ gzip: 0.82 kB
../artifacts/wecom-member-sync/admin-build/assets/content.vue_vue_type_script_setup_true_lang-DhnZ5uUj.js 2.24 kB │ gzip: 1.16 kB
../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BOJvKXiZ.js 2.28 kB │ gzip: 1.16 kB
../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BStMM2L3.js 2.29 kB │ gzip: 1.09 kB
../artifacts/wecom-member-sync/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BBhGStE3.js 2.37 kB │ gzip: 1.15 kB
../artifacts/wecom-member-sync/admin-build/assets/RecordingPlaybackBlock-DqgFGONg.js 2.40 kB │ gzip: 1.19 kB
../artifacts/wecom-member-sync/admin-build/assets/auth.vue_vue_type_script_setup_true_lang-gsN33aVY.js 2.40 kB │ gzip: 1.33 kB
../artifacts/wecom-member-sync/admin-build/assets/stats-DnnjDn0w.js 2.45 kB │ gzip: 0.58 kB
../artifacts/wecom-member-sync/admin-build/assets/data-table.vue_vue_type_script_setup_true_lang--ailqOCz.js 2.51 kB │ gzip: 1.27 kB
../artifacts/wecom-member-sync/admin-build/assets/oa-attr-CUvkQdHO.js 2.51 kB │ gzip: 1.25 kB
../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-C5GzP6lG.js 2.52 kB │ gzip: 1.21 kB
../artifacts/wecom-member-sync/admin-build/assets/h5-DSNR1Mm7.js 2.56 kB │ gzip: 1.22 kB
../artifacts/wecom-member-sync/admin-build/assets/change-password-ta6h_6OO.js 2.57 kB │ gzip: 1.35 kB
../artifacts/wecom-member-sync/admin-build/assets/im-business-message-parse-kN-fWnvR.js 2.60 kB │ gzip: 1.28 kB
../artifacts/wecom-member-sync/admin-build/assets/add-nav.vue_vue_type_script_setup_true_lang-CFknNh-L.js 2.61 kB │ gzip: 1.26 kB
../artifacts/wecom-member-sync/admin-build/assets/tabbar-C88gPo2h.js 2.66 kB │ gzip: 1.33 kB
../artifacts/wecom-member-sync/admin-build/assets/file-CMovQJ90.js 2.73 kB │ gzip: 1.06 kB
../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-RehJztW-.js 2.74 kB │ gzip: 1.25 kB
../artifacts/wecom-member-sync/admin-build/assets/PrescriptionOrderTimeDialog.vue_vue_type_script_setup_true_lang-Cpki_pAM.js 2.77 kB │ gzip: 1.53 kB
../artifacts/wecom-member-sync/admin-build/assets/oa-menu-form.vue_vue_type_script_setup_true_lang-C-68I4vC.js 2.77 kB │ gzip: 1.08 kB
../artifacts/wecom-member-sync/admin-build/assets/CaseRecordList-8zdwttyu.js 2.80 kB │ gzip: 1.53 kB
../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CLLhcmpE.js 2.81 kB │ gzip: 1.06 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DiASvNNV.js 2.85 kB │ gzip: 1.34 kB
../artifacts/wecom-member-sync/admin-build/assets/follow_reply-CSorma7I.js 2.85 kB │ gzip: 1.49 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Bffw7YqJ.js 2.86 kB │ gzip: 1.44 kB
../artifacts/wecom-member-sync/admin-build/assets/cost-edit.vue_vue_type_script_setup_true_lang-Cmvbgg8a.js 2.86 kB │ gzip: 1.35 kB
../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CtES0DRg.js 2.89 kB │ gzip: 1.41 kB
../artifacts/wecom-member-sync/admin-build/assets/default_reply-CscRdIJ0.js 2.91 kB │ gzip: 1.54 kB
../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-0LKRFKuR.js 2.96 kB │ gzip: 1.13 kB
../artifacts/wecom-member-sync/admin-build/assets/protocol-ZLC8fa4K.js 2.98 kB │ gzip: 1.10 kB
../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-B-G1Tu-E.js 2.99 kB │ gzip: 1.45 kB
../artifacts/wecom-member-sync/admin-build/assets/mubiao-dept-node-CBylWf1i.js 3.03 kB │ gzip: 1.25 kB
../artifacts/wecom-member-sync/admin-build/assets/relations-add.vue_vue_type_script_setup_true_lang-aR2rU0ed.js 3.04 kB │ gzip: 1.27 kB
../artifacts/wecom-member-sync/admin-build/assets/picker.vue_vue_type_script_setup_true_lang-BfUAGTj-.js 3.05 kB │ gzip: 1.50 kB
../artifacts/wecom-member-sync/admin-build/assets/AssignLogPanel-bKGiDzvs.js 3.06 kB │ gzip: 1.48 kB
../artifacts/wecom-member-sync/admin-build/assets/keyword_reply-CsSFhqK2.js 3.11 kB │ gzip: 1.60 kB
../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BpbTpEr6.js 3.12 kB │ gzip: 1.52 kB
../artifacts/wecom-member-sync/admin-build/assets/index-C7CPelVN.js 3.21 kB │ gzip: 1.53 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-Ci8Hxg9H.js 3.25 kB │ gzip: 1.47 kB
../artifacts/wecom-member-sync/admin-build/assets/menu-set.vue_vue_type_script_setup_true_lang-DIhktTQ1.js 3.26 kB │ gzip: 1.40 kB
../artifacts/wecom-member-sync/admin-build/assets/pc_details-DLsyGZVY.js 3.39 kB │ gzip: 1.49 kB
../artifacts/wecom-member-sync/admin-build/assets/index-EK1ZoXzo.js 3.39 kB │ gzip: 1.45 kB
../artifacts/wecom-member-sync/admin-build/assets/index-B4DqQN1y.js 3.42 kB │ gzip: 1.53 kB
../artifacts/wecom-member-sync/admin-build/assets/index-F1TVtlEt.js 3.43 kB │ gzip: 1.64 kB
../artifacts/wecom-member-sync/admin-build/assets/GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-CXsYk_L_.js 3.44 kB │ gzip: 1.70 kB
../artifacts/wecom-member-sync/admin-build/assets/balance_details-D1em5rcF.js 3.48 kB │ gzip: 1.63 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Dz-tv5pp.js 3.48 kB │ gzip: 1.64 kB
../artifacts/wecom-member-sync/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BkG4Pyop.js 3.52 kB │ gzip: 1.56 kB
../artifacts/wecom-member-sync/admin-build/assets/journal-B3VHOvYL.js 3.73 kB │ gzip: 1.46 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-S15Fn-vv.js 3.73 kB │ gzip: 1.57 kB
../artifacts/wecom-member-sync/admin-build/assets/detail-BN8q6bc8.js 3.76 kB │ gzip: 1.55 kB
../artifacts/wecom-member-sync/admin-build/assets/AssistantWatchCallDialog-D6ItC7fG.js 3.78 kB │ gzip: 1.85 kB
../artifacts/wecom-member-sync/admin-build/assets/CallRecordPanel-BkgzguRi.js 3.78 kB │ gzip: 1.77 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-D8D8cWM0.js 3.91 kB │ gzip: 1.71 kB
../artifacts/wecom-member-sync/admin-build/assets/preview-pc-BUr4tqb-.js 3.98 kB │ gzip: 1.63 kB
../artifacts/wecom-member-sync/admin-build/assets/AppointmentRecordPanel-DofowFlu.js 4.02 kB │ gzip: 1.73 kB
../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-ccE_MiO_.js 4.11 kB │ gzip: 1.65 kB
../artifacts/wecom-member-sync/admin-build/assets/index-CyvXSol2.js 4.11 kB │ gzip: 1.79 kB
../artifacts/wecom-member-sync/admin-build/assets/first_visit-BgsXe0rB.js 4.13 kB │ gzip: 0.89 kB
../artifacts/wecom-member-sync/admin-build/assets/detail-D2efPnyJ.js 4.15 kB │ gzip: 1.56 kB
../artifacts/wecom-member-sync/admin-build/assets/MessageBubble-CNAEZB2M.js 4.17 kB │ gzip: 1.79 kB
../artifacts/wecom-member-sync/admin-build/assets/index-C1yhlKda.js 4.22 kB │ gzip: 1.92 kB
../artifacts/wecom-member-sync/admin-build/assets/useListTimeFilter-DmwzA9pt.js 4.26 kB │ gzip: 1.52 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Cf0PaHkM.js 4.37 kB │ gzip: 2.14 kB
../artifacts/wecom-member-sync/admin-build/assets/bind-work-wechat-D86sStWr.js 4.40 kB │ gzip: 2.27 kB
../artifacts/wecom-member-sync/admin-build/assets/recharge_record-CYVPA2q8.js 4.43 kB │ gzip: 1.87 kB
../artifacts/wecom-member-sync/admin-build/assets/index-C4phnhkw.js 4.45 kB │ gzip: 1.79 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Dcp58BAx.js 4.47 kB │ gzip: 1.91 kB
../artifacts/wecom-member-sync/admin-build/assets/index-BYfcmOIK.js 4.49 kB │ gzip: 1.96 kB
../artifacts/wecom-member-sync/admin-build/assets/index-D7s6xUn-.js 4.49 kB │ gzip: 1.96 kB
../artifacts/wecom-member-sync/admin-build/assets/preview-C8KaKJOT.js 4.49 kB │ gzip: 1.70 kB
../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BO2nUIFI.js 4.51 kB │ gzip: 1.75 kB
../artifacts/wecom-member-sync/admin-build/assets/RecordingVideoPlayer-DG_ItJGl.js 4.53 kB │ gzip: 2.26 kB
../artifacts/wecom-member-sync/admin-build/assets/ImChatRecordPanel-DBsLrdAR.js 4.54 kB │ gzip: 2.40 kB
../artifacts/wecom-member-sync/admin-build/assets/index-BGjkic1N.js 4.55 kB │ gzip: 1.91 kB
../artifacts/wecom-member-sync/admin-build/assets/paiban-BHVNKfBv.js 4.56 kB │ gzip: 2.13 kB
../artifacts/wecom-member-sync/admin-build/assets/index-CCjsau_X.js 4.57 kB │ gzip: 1.33 kB
../artifacts/wecom-member-sync/admin-build/assets/login_register-BUwvSc4G.js 4.59 kB │ gzip: 2.01 kB
../artifacts/wecom-member-sync/admin-build/assets/dept-tongji-D3yDbmue.js 4.66 kB │ gzip: 2.04 kB
../artifacts/wecom-member-sync/admin-build/assets/index-diGjfvCp.js 4.76 kB │ gzip: 2.05 kB
../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-BrN1pP87.js 4.92 kB │ gzip: 2.04 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-B9rCxYXv.js 5.04 kB │ gzip: 1.99 kB
../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BKQXUJgT.js 5.18 kB │ gzip: 2.03 kB
../artifacts/wecom-member-sync/admin-build/assets/PaibanPanel-LklYduy4.js 5.28 kB │ gzip: 2.43 kB
../artifacts/wecom-member-sync/admin-build/assets/index-CQBKhK32.js 5.32 kB │ gzip: 2.19 kB
../artifacts/wecom-member-sync/admin-build/assets/index-C5quTqHH.js 5.53 kB │ gzip: 2.20 kB
../artifacts/wecom-member-sync/admin-build/assets/mubiao-spsl5dpC.js 5.55 kB │ gzip: 2.56 kB
../artifacts/wecom-member-sync/admin-build/assets/yeji-edit.vue_vue_type_script_setup_true_lang-CpWA2Fm9.js 5.56 kB │ gzip: 1.81 kB
../artifacts/wecom-member-sync/admin-build/assets/mobile-style.vue_vue_type_script_setup_true_lang-Cd_o1G2a.js 5.59 kB │ gzip: 1.86 kB
../artifacts/wecom-member-sync/admin-build/assets/ExerciseRecordList-2DnzVhG1.js 5.62 kB │ gzip: 2.16 kB
../artifacts/wecom-member-sync/admin-build/assets/attr-BEKvhJVW.js 5.62 kB │ gzip: 2.24 kB
../artifacts/wecom-member-sync/admin-build/assets/tongji-DdsvLKqf.js 5.66 kB │ gzip: 2.31 kB
../artifacts/wecom-member-sync/admin-build/assets/PatientOrderList-SLHonpPV.js 5.82 kB │ gzip: 2.84 kB
../artifacts/wecom-member-sync/admin-build/assets/DietRecordList-DXFlk9Hu.js 5.84 kB │ gzip: 2.01 kB
../artifacts/wecom-member-sync/admin-build/assets/mubiao-dept-card-BvVBrj2J.js 5.91 kB │ gzip: 2.38 kB
../artifacts/wecom-member-sync/admin-build/assets/login-DahMaDyR.js 6.03 kB │ gzip: 2.72 kB
../artifacts/wecom-member-sync/admin-build/assets/refund_record-tVmHQHkz.js 6.27 kB │ gzip: 2.33 kB
../artifacts/wecom-member-sync/admin-build/assets/DiagnosisTodoList-DL7sSeE_.js 6.34 kB │ gzip: 2.89 kB
../artifacts/wecom-member-sync/admin-build/assets/index-B3CV61hr.js 6.35 kB │ gzip: 3.20 kB
../artifacts/wecom-member-sync/admin-build/assets/information-CkVlFtW3.js 6.36 kB │ gzip: 1.80 kB
../artifacts/wecom-member-sync/admin-build/assets/NoteTimeline-DrUjWbF8.js 6.48 kB │ gzip: 2.60 kB
../artifacts/wecom-member-sync/admin-build/assets/account_cost-D0LoQOBc.js 6.56 kB │ gzip: 2.86 kB
../artifacts/wecom-member-sync/admin-build/assets/PatientCaseCard-CGXlt3-4.js 6.61 kB │ gzip: 2.34 kB
../artifacts/wecom-member-sync/admin-build/assets/index-CBmrTp4t.js 6.61 kB │ gzip: 2.62 kB
../artifacts/wecom-member-sync/admin-build/assets/index-MiIcWS8C.js 6.77 kB │ gzip: 2.89 kB
../artifacts/wecom-member-sync/admin-build/assets/readonly-BvagAVZz.js 6.98 kB │ gzip: 2.69 kB
../artifacts/wecom-member-sync/admin-build/assets/picker-L0C-vjJL.js 7.32 kB │ gzip: 3.18 kB
../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-B2Ih0vdW.js 7.35 kB │ gzip: 2.66 kB
../artifacts/wecom-member-sync/admin-build/assets/setting-B0hbjCIa.js 7.35 kB │ gzip: 2.93 kB
../artifacts/wecom-member-sync/admin-build/assets/weapp-DXM2HiOo.js 7.39 kB │ gzip: 2.13 kB
../artifacts/wecom-member-sync/admin-build/assets/prescription-order-utils-CXfs2Bra.js 7.62 kB │ gzip: 3.11 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Dlg6Bi8D.js 7.65 kB │ gzip: 3.13 kB
../artifacts/wecom-member-sync/admin-build/assets/config-BcLOIFO3.js 7.77 kB │ gzip: 2.59 kB
../artifacts/wecom-member-sync/admin-build/assets/TrackingMatrix-DOwGys12.js 7.84 kB │ gzip: 3.07 kB
../artifacts/wecom-member-sync/admin-build/assets/patient-call-Y8nXiKQB.js 7.85 kB │ gzip: 3.24 kB
../artifacts/wecom-member-sync/admin-build/assets/dayjs-CF5xpNFg.js 8.07 kB │ gzip: 3.45 kB
../artifacts/wecom-member-sync/admin-build/assets/tcm-DXNwz16C.js 8.99 kB │ gzip: 1.57 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-CNbGRUOL.js 9.00 kB │ gzip: 2.83 kB
../artifacts/wecom-member-sync/admin-build/assets/tim-upload-plugin-B7yxlBgj.js 9.47 kB │ gzip: 3.61 kB
../artifacts/wecom-member-sync/admin-build/assets/index-C3eHLn0f.js 9.49 kB │ gzip: 3.84 kB
../artifacts/wecom-member-sync/admin-build/assets/BloodRecordList-DDUvca7F.js 9.58 kB │ gzip: 3.33 kB
../artifacts/wecom-member-sync/admin-build/assets/medicine-C5ovRLd1.js 9.74 kB │ gzip: 3.56 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-BU-RSgIR.js 9.99 kB │ gzip: 3.55 kB
../artifacts/wecom-member-sync/admin-build/assets/index-BaNoRHB2.js 10.06 kB │ gzip: 3.98 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DPwU-iXs.js 10.33 kB │ gzip: 3.89 kB
../artifacts/wecom-member-sync/admin-build/assets/PrescriptionAiBatchGenerateDialog-MAUu97Gq.js 10.36 kB │ gzip: 4.29 kB
../artifacts/wecom-member-sync/admin-build/assets/SendPanel-CP6ywpBj.js 10.37 kB │ gzip: 3.57 kB
../artifacts/wecom-member-sync/admin-build/assets/progress-Bdpacf-D.js 11.18 kB │ gzip: 4.59 kB
../artifacts/wecom-member-sync/admin-build/assets/list-DLWc-1Ay.js 11.18 kB │ gzip: 4.03 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DI3xU_bx.js 11.30 kB │ gzip: 4.22 kB
../artifacts/wecom-member-sync/admin-build/assets/appointment-BnHZdDEA.js 11.34 kB │ gzip: 4.24 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-D7zgLcEk.js 11.44 kB │ gzip: 3.76 kB
../artifacts/wecom-member-sync/admin-build/assets/OrderPanel-BeU2BFQj.js 11.74 kB │ gzip: 4.22 kB
../artifacts/wecom-member-sync/admin-build/assets/picker-BmsTkGwL.js 12.15 kB │ gzip: 4.49 kB
../artifacts/wecom-member-sync/admin-build/assets/ProgressPanel-ud8QcUjn.js 12.58 kB │ gzip: 4.48 kB
../artifacts/wecom-member-sync/admin-build/assets/prescription-drawer-FdfeYO6a.js 12.76 kB │ gzip: 3.88 kB
../artifacts/wecom-member-sync/admin-build/assets/index-hktqVhuc.js 13.00 kB │ gzip: 4.23 kB
../artifacts/wecom-member-sync/admin-build/assets/index-Brf9bxlj.js 13.11 kB │ gzip: 3.91 kB
../artifacts/wecom-member-sync/admin-build/assets/h5-CcXRh9Cg.js 13.83 kB │ gzip: 4.40 kB
../artifacts/wecom-member-sync/admin-build/assets/index-CWW_NfST.js 14.26 kB │ gzip: 5.90 kB
../artifacts/wecom-member-sync/admin-build/assets/guahao-DcHTagDB.js 14.96 kB │ gzip: 4.41 kB
../artifacts/wecom-member-sync/admin-build/assets/WecomFloatingWidgetBuilder--gBah4yP.js 15.22 kB │ gzip: 6.02 kB
../artifacts/wecom-member-sync/admin-build/assets/index-6M0lWcGt.js 15.52 kB │ gzip: 6.41 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DZLpymXv.js 15.90 kB │ gzip: 5.20 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-BbgFd2kT.js 15.90 kB │ gzip: 4.70 kB
../artifacts/wecom-member-sync/admin-build/assets/index-D0fXfjGU.js 16.34 kB │ gzip: 6.06 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DogyaUiZ.js 16.35 kB │ gzip: 5.11 kB
../artifacts/wecom-member-sync/admin-build/assets/index-D58SNEFF.js 16.60 kB │ gzip: 6.07 kB
../artifacts/wecom-member-sync/admin-build/assets/PrescriptionAiReportDialog-Bu6VbnFg.js 17.15 kB │ gzip: 6.47 kB
../artifacts/wecom-member-sync/admin-build/assets/index-mbKhz0ZD.js 17.84 kB │ gzip: 6.02 kB
../artifacts/wecom-member-sync/admin-build/assets/roster-BiU7KYHE.js 18.09 kB │ gzip: 5.71 kB
../artifacts/wecom-member-sync/admin-build/assets/index-KaH-WGp4.js 18.19 kB │ gzip: 6.88 kB
../artifacts/wecom-member-sync/admin-build/assets/PromotionAutomationForm-Dl6eSdrv.js 18.23 kB │ gzip: 6.37 kB
../artifacts/wecom-member-sync/admin-build/assets/index-KtwiGqvr.js 18.60 kB │ gzip: 5.23 kB
../artifacts/wecom-member-sync/admin-build/assets/WelcomeMessageEditor-lo-w_F0X.js 19.11 kB │ gzip: 6.95 kB
../artifacts/wecom-member-sync/admin-build/assets/OrderActionHost-B1_31moq.js 21.16 kB │ gzip: 6.02 kB
../artifacts/wecom-member-sync/admin-build/assets/index-B6abcro-.js 21.91 kB │ gzip: 7.87 kB
../artifacts/wecom-member-sync/admin-build/assets/add-DZ_NopBp.js 22.29 kB │ gzip: 5.36 kB
../artifacts/wecom-member-sync/admin-build/assets/index-CPsxxVLT.js 22.76 kB │ gzip: 7.97 kB
../artifacts/wecom-member-sync/admin-build/assets/DailyMatrix-CUZg4BRS.js 24.97 kB │ gzip: 7.09 kB
../artifacts/wecom-member-sync/admin-build/assets/list_h5-BQk1KH7b.js 27.36 kB │ gzip: 9.29 kB
../artifacts/wecom-member-sync/admin-build/assets/list-CHJTYjNq.js 29.93 kB │ gzip: 9.61 kB
../artifacts/wecom-member-sync/admin-build/assets/qywx-vbiMVYqv.js 30.35 kB │ gzip: 10.01 kB
../artifacts/wecom-member-sync/admin-build/assets/commission-settlement-rcwbrTnB.js 35.83 kB │ gzip: 9.75 kB
../artifacts/wecom-member-sync/admin-build/assets/edit-DUaMYFnP.js 35.86 kB │ gzip: 9.02 kB
../artifacts/wecom-member-sync/admin-build/assets/index-BxaPQXKm.js 36.24 kB │ gzip: 12.99 kB
../artifacts/wecom-member-sync/admin-build/assets/index-CwotNYII.js 36.70 kB │ gzip: 11.82 kB
../artifacts/wecom-member-sync/admin-build/assets/index_h5-BKLNAJoN.js 37.44 kB │ gzip: 11.46 kB
../artifacts/wecom-member-sync/admin-build/assets/index-GLDXkTd_.js 40.10 kB │ gzip: 11.87 kB
../artifacts/wecom-member-sync/admin-build/assets/PrescriptionOrderDetailDrawer-C0hb8NZr.js 48.77 kB │ gzip: 13.48 kB
../artifacts/wecom-member-sync/admin-build/assets/index-JRqlrlqw.js 50.72 kB │ gzip: 14.98 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DmecxuyG.js 55.03 kB │ gzip: 15.89 kB
../artifacts/wecom-member-sync/admin-build/assets/tim-profanity-filter-plugin-BJ7z5puq.js 55.90 kB │ gzip: 21.04 kB
../artifacts/wecom-member-sync/admin-build/assets/index-DYj8tK9D.js 67.65 kB │ gzip: 20.68 kB
../artifacts/wecom-member-sync/admin-build/assets/rtc-detect-CAvkmauD.js 74.80 kB │ gzip: 25.93 kB
../artifacts/wecom-member-sync/admin-build/assets/yeji-5JxNzYB_.js 87.40 kB │ gzip: 23.27 kB
../artifacts/wecom-member-sync/admin-build/assets/index-RIkBLGnV.js 88.42 kB │ gzip: 25.66 kB
../artifacts/wecom-member-sync/admin-build/assets/order_list-C6JoI8wj.js 114.89 kB │ gzip: 31.81 kB
../artifacts/wecom-member-sync/admin-build/assets/order_list_h5-DFg5JSxg.js 139.58 kB │ gzip: 36.82 kB
../artifacts/wecom-member-sync/admin-build/assets/@tencentcloud/chat-uikit-engine-BJz4IWsw.js 162.84 kB │ gzip: 41.72 kB
../artifacts/wecom-member-sync/admin-build/assets/index-CE4okNZ0.js 290.16 kB │ gzip: 92.01 kB
../artifacts/wecom-member-sync/admin-build/assets/@tencentcloud/chat-Bg29VzHQ.js 725.88 kB │ gzip: 178.89 kB
../artifacts/wecom-member-sync/admin-build/assets/@tencentcloud/call-engine-js-CRx4G1jN.js 2,290.42 kB │ gzip: 749.64 kB
../artifacts/wecom-member-sync/admin-build/assets/.pnpm-BHOjf1ZS.js 18,525.72 kB │ gzip: 5,519.46 kB
WARN
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
✓ built in 2m 4s
+45
View File
@@ -0,0 +1,45 @@
# 企业微信获客成员范围同步:前端修复
## 诊断
- 单独开关原先只检查 `sync_error`,为空就提示“企微多人路由范围已重新计算”;该字段无法区分同步已确认、任务仍在排队、活跃租约导致未执行等情况。
- 批量保存原先只提示后台同步,没有主动消费成功入队的方案;分钟任务未运行时本地规则可以长期与官方链接不一致。
- `is_in_remote_range` 来自官方链接上次确认的 `range_user_json`,并非本地 enabled 状态。“待移出”代表成员仍在记录中的企微范围,确实可能继续获客。
- 原有 `syncRemoteLinks` 是企业微信链接列表导入接口,会扫描 `listLinks/getLink` 并 upsert,本次没有将它误用为推送当前方案成员范围的入口。
## 最终行为
- 接入新的 `POST /firstvisit.wecomPromotion/syncMemberRange`,只提交指定 `pool_id`,使用 120 秒超时并关闭 HTTP 自动重试。
- 保存方案、保存成员规则、单独切换成员,仅在后端明确返回 `sync_status: synced` 时提示企微成员范围已确认同步;旧服务端缺少该字段时保守显示尚未确认同步。
- 批量保存成功且 `sync_queued` 的方案立即进行专用范围同步,最多并发 2 个,全部等待结束再完成交互。对话框页脚显示同步进度,未完成时禁止关闭。
- 按方案保留本地保存结果、企微同步结果及具体错误。保存失败或同步未确认的方案保持勾选,允许重试;pending 不做无界重复请求。
- 方案顶部提供“同步成员范围”,结果列表提供“重试同步”。权限沿用方案 `can_operate`,必须已有官方链接。
- 页面明确展示“当前计划”和“上次企微确认”成员及确认时间,保留服务端 `last_error`;行状态改为“企微范围内”“待移出(仍在企微)”“待加入企微”“未在企微范围”。
## 文件
- `admin/src/views/first_visit/wecom_promotion/index.vue`
- `admin/src/api/first_visit.ts`
- `admin/tests/wecom-promotion-member-sync.test.cjs`
## 验证
命令:`cd admin && node --test tests/wecom-promotion-member-sync.test.cjs`
7 项通过,使用 Vue SFC 真实 setup 编译后执行,并编译模板及 SCSS;API 均为测试替身,不连接数据库或企业微信。
1. 本地禁用而远端仍包含的成员明确显示待移出,展示实际同步错误。
2. 单成员切换不把空错误和计划入队误判为企微同步成功。
3. 单成员规则保存保留企微失败原因,只接受明确 synced 确认。
4. 方案保存 pending 时保持警告。
5. 手动重试只调用指定方案专用接口,并检查操作权限。
6. 批量同步最多两并发,等待全部结果;本地保存失败项不会请求企微同步。
7. 批量保存部分失败和企微超时保留逐项错误与选择,不误报整体成功。
`git diff --check` 对修改文件通过。
全量 `npx vue-tsc --noEmit --pretty false` 已执行,退出 1。报错位于工作区其他文件(处方、聊天、录音、装修、医生统计等);本次修改的获客助手页面和 `first_visit.ts` 无报错。未为本次修复扩展修改这些独立模块。
## 验证边界
没有访问业务数据库、真实企微凭据,也没有发送消息或调用企业微信修改接口。尚未对真实已投放链接执行修复后的同步;需部署匹配的前后端后,在目标方案点击“同步成员范围”,以远端回读确认结果为准。
@@ -0,0 +1,65 @@
param(
[string]$Php = 'D:/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe',
[string]$MySqlRoot = 'D:/phpstudy_pro/Extensions/MySQL5.7.26'
)
$ErrorActionPreference = 'Stop'
$artifactRoot = [System.IO.Path]::GetFullPath($PSScriptRoot)
$workspaceRoot = [System.IO.Path]::GetFullPath((Join-Path $artifactRoot '../..'))
$runRoot = [System.IO.Path]::GetFullPath((Join-Path $artifactRoot ('mysql-test-' + [guid]::NewGuid().ToString('N'))))
if (-not $runRoot.StartsWith($artifactRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) {
throw 'Disposable data directory escaped its artifact directory.'
}
$dataRoot = Join-Path $runRoot 'data'
$mysqld = Join-Path $MySqlRoot 'bin/mysqld.exe'
$mysqladmin = Join-Path $MySqlRoot 'bin/mysqladmin.exe'
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
$listener.Start()
$port = $listener.LocalEndpoint.Port
$listener.Stop()
$mysqlProcess = $null
$testExit = 1
$previousPort = $env:ZYT_WECOM_MEMBER_TEST_MYSQL_PORT
try {
New-Item -ItemType Directory -Path $dataRoot -Force | Out-Null
$initArguments = @('--no-defaults', '--initialize-insecure', ('--basedir=' + $MySqlRoot), ('--datadir=' + $dataRoot))
$initProcess = Start-Process -FilePath $mysqld -ArgumentList $initArguments -WindowStyle Hidden -PassThru -Wait -RedirectStandardError (Join-Path $runRoot 'initialize.log')
if ($initProcess.ExitCode -ne 0) { throw 'Disposable MySQL initialization failed.' }
$startArguments = @('--no-defaults', ('--basedir=' + $MySqlRoot), ('--datadir=' + $dataRoot), ('--port=' + $port), '--bind-address=127.0.0.1', '--innodb-buffer-pool-size=32M')
$mysqlProcess = Start-Process -FilePath $mysqld -ArgumentList $startArguments -WindowStyle Hidden -PassThru -RedirectStandardError (Join-Path $runRoot 'server.log')
$ready = $false
for ($attempt = 0; $attempt -lt 60; $attempt++) {
if ($mysqlProcess.HasExited) { throw 'Disposable MySQL exited before becoming ready.' }
$client = [System.Net.Sockets.TcpClient]::new()
try {
$client.Connect('127.0.0.1', $port)
$ready = $true
} catch {
Start-Sleep -Milliseconds 250
} finally {
$client.Dispose()
}
if ($ready) { break }
}
if (-not $ready) { throw 'Disposable MySQL did not become ready.' }
$env:ZYT_WECOM_MEMBER_TEST_MYSQL_PORT = [string]$port
& $Php (Join-Path $workspaceRoot 'server/tests/WecomPromotionMemberSyncTest.php') 2>&1 | Tee-Object -FilePath (Join-Path $artifactRoot 'backend-test-output.log')
$testExit = $LASTEXITCODE
} finally {
$env:ZYT_WECOM_MEMBER_TEST_MYSQL_PORT = $previousPort
if ($null -ne $mysqlProcess -and -not $mysqlProcess.HasExited) {
& $mysqladmin --no-defaults --host=127.0.0.1 ('--port=' + $port) --user=root shutdown 2>$null
$mysqlProcess.WaitForExit(10000) | Out-Null
if (-not $mysqlProcess.HasExited) {
Stop-Process -Id $mysqlProcess.Id -Force
$mysqlProcess.WaitForExit(5000) | Out-Null
}
}
$resolvedRunRoot = [System.IO.Path]::GetFullPath($runRoot)
if (-not $resolvedRunRoot.StartsWith($artifactRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) -or (Split-Path $resolvedRunRoot -Leaf) -notmatch '^mysql-test-[a-f0-9]{32}$') {
throw 'Refusing to remove an unverified temporary path.'
}
if (Test-Path -LiteralPath $resolvedRunRoot) { Remove-Item -LiteralPath $resolvedRunRoot -Recurse -Force }
Write-Output 'Disposable MySQL stopped and its temporary data directory removed.'
}
exit $testExit
@@ -0,0 +1,70 @@
import { createServer } from '../../admin/node_modules/vite/dist/node/index.js'
import vue from '../../admin/node_modules/@vitejs/plugin-vue/dist/index.mjs'
import fs from 'node:fs'
const root = 'D:/web/zyt/admin'
const apiNames = [...fs.readFileSync(`${root}/src/api/first_visit.ts`, 'utf8').matchAll(/export (?:async )?function (wecomPromotion\w+)\s*\(/g)].map(match => match[1])
const members = [
{ id: 1, admin_id: 1, name: '测试许可', userid: 'test_xuke', enabled: 1, reception_available: true },
{ id: 2, admin_id: 2, name: '测试旧医助', userid: 'test_old', enabled: 0, reception_available: false }
].map(member => ({ ...member, dept_ids: [1], dept_names: ['测试部门'], display_dept_id: 1,
status: member.enabled, today_count: 0, total_count: 0, daily_limit: 0, is_in_remote_range: true, sync_status: 1 }))
const overview = {
automation_installed: false, meta: { scope_label: '隔离验证', generated_at: '' },
config: { configured: true, ready: true, missing: [], callback_ready: true },
summary: { configured_apps: 1, pool_count: 1, online_links: 1, today_clicks: 0 },
member_options: members, operator_options: [], department_options: [{ id: 1, name: '测试部门' }], links: [],
pools: [{ id: 99, name: '仅许可接待测试方案', public_key: 'test-only', status: 1, member_admin_ids: [1, 2],
main_url: 'https://work.weixin.qq.com/ca/test-only', can_operate: true, can_manage_access: true, can_delete: true,
operator_admin_ids: [], member_rules: members, automation_config: { backup_member_admin_ids: [] },
official_link: { id: 199, remote_link_id: 'test-link', range_userids: ['test_xuke', 'test_old'], range_department_ids: [], last_sync_time: 1788920000 },
dispatch_sync: { status: 1, desired_version: 2, applied_version: 1, last_error: '' }
}]
}
const fixture = `const overview = ${JSON.stringify(overview)};
async function invoke(name, params) {
if (name === 'wecomPromotionOverview') return structuredClone(overview);
if (name === 'wecomPromotionSyncMemberRange') {
const pool = overview.pools[0];
if (new URLSearchParams(location.search).get('mode') === 'failure') {
pool.dispatch_sync.status = 3; pool.dispatch_sync.last_error = '模拟企微网络错误';
pool.member_rules.forEach(row => {row.sync_status = 3; row.sync_error = '模拟企微网络错误';});
return {pool_id:99,sync_status:'failed',sync_error:'模拟企微网络错误',sync_queued:true};
}
pool.official_link.range_userids = ['test_xuke']; pool.official_link.last_sync_time = Math.floor(Date.now()/1000);
pool.dispatch_sync = {status:0,desired_version:2,applied_version:2,last_error:''};
pool.member_rules.forEach(row => {row.is_in_remote_range = row.enabled === 1; row.sync_status = 0;});
return {pool_id:99,sync_status:'synced',sync_error:'',sync_queued:false,range_userids:['test_xuke'],range_department_ids:[]};
}
throw new Error('隔离页面未实现该操作:' + name);
}
${apiNames.map(name => `export function ${name}(params) {return invoke('${name}',params);}`).join('\n')}`
const server = await createServer({
configFile: false, root, base: '/',
cacheDir: 'D:/web/zyt/artifacts/wecom-member-sync/vite-cache',
optimizeDeps: { entries: [], include: ['vue', 'element-plus', '@element-plus/icons-vue'] },
resolve: { alias: { '@': `${root}/src` } },
plugins: [{
name: 'member-sync-isolated-fixture', enforce: 'pre',
resolveId(id) {
if (id === '@/api/first_visit' || id.endsWith('/src/api/first_visit')) return '\0member-sync-api'
if (id === '/__member-sync-entry.js') return '\0member-sync-entry'
},
load(id) {
if (id === '\0member-sync-api') return fixture
if (id === '\0member-sync-entry') return `import {createApp} from 'vue';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import View from '/src/views/first_visit/wecom_promotion/index.vue';createApp(View).use(ElementPlus).mount('#app');`
},
configureServer(dev) {
dev.middlewares.use((req, res, next) => {
if (!req.url?.startsWith('/__member-sync-check')) return next()
res.setHeader('Content-Type', 'text/html;charset=utf-8')
res.end('<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>企微成员同步隔离验证</title></head><body><p>隔离验证:虚构数据,不连接企业微信</p><div id="app"></div><script type="module" src="/__member-sync-entry.js"></script></body></html>')
})
}
}, vue()],
server: { host: '127.0.0.1', port: 5587, strictPort: true, open: false }
})
await server.listen()
console.log('Isolated UI: http://127.0.0.1:5587/__member-sync-check')
for (const event of ['SIGINT', 'SIGTERM']) process.on(event, async () => { await server.close(); process.exit(0) })
@@ -180,6 +180,24 @@ class WecomPromotionController extends BaseAdminController
return $this->run(fn () => $this->success('获客助手 API 权限验证通过', WecomPromotionLogic::checkApiPermission()));
}
/** 推送当前方案的可用成员,不导入其他官方链接。 */
public function syncMemberRange()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
if (!$this->request->isPost()) {
return $this->fail('请使用 POST 同步成员范围');
}
$poolId = (int) $this->request->post('pool_id', 0);
return $this->run(fn () => $this->data(WecomPromotionLogic::syncMemberRange(
$poolId,
$this->adminId,
$this->adminInfo
)));
}
public function syncRemoteLinks()
{
if (!$this->hasBasePagePermission()) {
@@ -131,6 +131,18 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('保存成功', $result);
}
/** 独立授权的创建时间修正,逻辑层再次校验权限。 */
public function editTime()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('editTime');
$result = PrescriptionOrderLogic::editTime($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('创建时间已修改', $result);
}
/**
* 仅修改承运商与快递单号,不受订单履约状态或远端药房快照锁限制。
*/
@@ -234,6 +234,7 @@ class AuthMiddleware
'firstvisit.wecompromotion/savelink',
'firstvisit.wecompromotion/savemember',
'firstvisit.wecompromotion/togglemember',
'firstvisit.wecompromotion/syncmemberrange',
'firstvisit.wecompromotion/checkapipermission',
'firstvisit.wecompromotion/syncremotelinks',
'firstvisit.wecompromotion/remotelinkdetail',
@@ -2,6 +2,8 @@
namespace app\adminapi\lists\doctor;
use app\common\enum\AppointmentTypeEnum;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
@@ -335,12 +337,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
];
$item['status_desc'] = $statusMap[$item['status']] ?? '未知';
$typeMap = [
'video' => '视频问诊',
'text' => '图文问诊',
'phone' => '电话问诊',
];
$item['appointment_type_desc'] = $typeMap[$item['appointment_type']] ?? '未知';
$item['appointment_type'] = AppointmentTypeEnum::normalizeStored($item['appointment_type'] ?? null);
$item['appointment_type_desc'] = AppointmentTypeEnum::description($item['appointment_type']);
$periodRaw = (string) ($item['period'] ?? ($item['type'] ?? ''));
$periodMap = [
@@ -12,7 +12,9 @@
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\adminapi\lists\tcm;
namespace app\adminapi\lists\tcm;
use app\common\enum\AppointmentTypeEnum;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\dept\DeptLogic;
@@ -191,7 +193,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
$subQuery
->field('id, patient_id, doctor_id, appointment_date, appointment_time, status, create_time')
->field('id, patient_id, doctor_id, appointment_date, appointment_time, appointment_type, status, create_time')
->order('appointment_date', 'asc')
->order('appointment_time', 'asc')
->order('id', 'asc');
@@ -234,7 +236,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$apt = $aptList[0];
$item['has_appointment'] = 1;
$item['appointment_id'] = $apt['id'];
$item['appointment_status'] = (int) ($apt['status'] ?? 0);
$item['appointment_status'] = (int) ($apt['status'] ?? 0);
$item['appointment_type'] = AppointmentTypeEnum::normalizeStored($apt['appointment_type'] ?? null);
$item['appointment_type_desc'] = AppointmentTypeEnum::description($item['appointment_type']);
$item['appointment_doctor_id'] = $apt['doctor_id'];
$item['appointment_doctor_name'] = $doctorNames[$apt['doctor_id']] ?? '-';
$timePart = $apt['appointment_time'] ?? '';
@@ -253,14 +257,18 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
'id' => (int) ($a['id'] ?? 0),
'status' => (int) ($a['status'] ?? 0),
'doctor_id' => $doctorId,
'doctor_name' => (string) ($doctorNames[$doctorId] ?? '-'),
'doctor_name' => (string) ($doctorNames[$doctorId] ?? '-'),
'appointment_type' => AppointmentTypeEnum::normalizeStored($a['appointment_type'] ?? null),
'appointment_type_desc' => AppointmentTypeEnum::description($a['appointment_type'] ?? null),
'time_text' => trim((string) ($a['appointment_date'] ?? '') . ' ' . (string) $timePart),
];
}, $aptList);
} else {
$item['has_appointment'] = 0;
$item['appointment_id'] = null;
$item['appointment_status'] = 0;
$item['appointment_status'] = 0;
$item['appointment_type'] = AppointmentTypeEnum::VIDEO;
$item['appointment_type_desc'] = AppointmentTypeEnum::description(null);
$item['appointment_doctor_id'] = null;
$item['appointment_doctor_name'] = '';
$item['appointment_time_text'] = '';
@@ -272,7 +280,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$this->appendLatestAppointmentSummary($item, null);
$item['has_appointment'] = 0;
$item['appointment_id'] = null;
$item['appointment_status'] = 0;
$item['appointment_status'] = 0;
$item['appointment_type'] = AppointmentTypeEnum::VIDEO;
$item['appointment_type_desc'] = AppointmentTypeEnum::description(null);
$item['appointment_doctor_id'] = null;
$item['appointment_doctor_name'] = '';
$item['appointment_time_text'] = '';
@@ -806,7 +816,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
$cols = $this->appointmentTableFields();
$fields = ['id', 'patient_id', 'appointment_date', 'appointment_time', 'status'];
$fields = ['id', 'patient_id', 'appointment_date', 'appointment_time', 'appointment_type', 'status'];
foreach (['channel_source', 'channel_source_detail', 'channels'] as $col) {
if (in_array($col, $cols, true)) {
$fields[] = $col;
@@ -843,7 +853,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
*/
private function appendLatestAppointmentSummary(array &$item, ?array $appointment): void
{
$item['latest_appointment_id'] = null;
$item['latest_appointment_id'] = null;
$item['latest_appointment_type'] = AppointmentTypeEnum::VIDEO;
$item['latest_appointment_type_desc'] = AppointmentTypeEnum::description(null);
$item['latest_appointment_time_text'] = '';
$item['latest_appointment_channel_source'] = '';
$item['latest_appointment_channel_source_desc'] = '';
@@ -862,7 +874,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$rawChannel = trim((string) $appointment['channels']);
}
$item['latest_appointment_id'] = (int) ($appointment['id'] ?? 0);
$item['latest_appointment_id'] = (int) ($appointment['id'] ?? 0);
$item['latest_appointment_type'] = AppointmentTypeEnum::normalizeStored($appointment['appointment_type'] ?? null);
$item['latest_appointment_type_desc'] = AppointmentTypeEnum::description($item['latest_appointment_type']);
$item['latest_appointment_time_text'] = trim((string) ($appointment['appointment_date'] ?? '') . ' ' . $timePart);
$item['latest_appointment_channel_source'] = $rawChannel;
$item['latest_appointment_channel_source_desc'] = (string) ($appointment['channel_source_desc'] ?? '');
@@ -36,7 +36,7 @@ class PrescriptionLists extends BaseAdminDataLists implements ListsSearchInterfa
// 仅在「存在有效业务订单」的处方里做风险判定
$orderRxIds = PrescriptionOrder::whereIn('prescription_id', $candidateIds)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)
->column('prescription_id');
$orderRxIds = array_values(array_unique(array_filter(array_map('intval', $orderRxIds), static function (int $id): bool {
return $id > 0;
@@ -260,7 +260,7 @@ class PrescriptionLists extends BaseAdminDataLists implements ListsSearchInterfa
$bizRejectRows = PrescriptionOrder::whereIn('prescription_id', $rxIds)
->where('prescription_audit_status', 2)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)
->field(['prescription_id', 'prescription_audit_remark', 'id'])
->order('id', 'desc')
->select()
@@ -280,7 +280,7 @@ class PrescriptionLists extends BaseAdminDataLists implements ListsSearchInterfa
'intval',
PrescriptionOrder::whereIn('prescription_id', $rxIds)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)
->column('prescription_id')
));
$hasBizOrderRx = array_fill_keys($orderRxIds, true);
@@ -2,7 +2,8 @@
namespace app\adminapi\logic\doctor;
use app\common\logic\BaseLogic;
use app\common\logic\BaseLogic;
use app\common\enum\AppointmentTypeEnum;
use app\common\model\doctor\Appointment;
use app\common\model\doctor\Roster;
use app\common\service\doctor\RosterSegmentService;
@@ -46,7 +47,7 @@ class AppointmentLogic extends BaseLogic
/**
* @param array<int|string, mixed> $cols Db::name('doctor_appointment')->getTableFields()
*/
private static function assertAppointmentChannelWritable(array $cols, string $chSrc): ?string
private static function assertAppointmentChannelWritable(array $cols, string $chSrc): ?string
{
$hasChannelSource = in_array('channel_source', $cols, true);
$hasChannels = in_array('channels', $cols, true);
@@ -86,9 +87,12 @@ class AppointmentLogic extends BaseLogic
*
* @return array<string, mixed>
*/
private static function filterAppointmentRowByExistingColumns(array $row, array $cols): array
{
$out = [];
private static function filterAppointmentRowByExistingColumns(array $row, array $cols): array
{
if (array_key_exists('appointment_type', $row) && !in_array('appointment_type', $cols, true)) {
throw new \RuntimeException('挂号表缺少 appointment_type 字段,无法保存问诊类型');
}
$out = [];
foreach ($row as $k => $v) {
if (in_array((string) $k, $cols, true)) {
$out[$k] = $v;
@@ -273,9 +277,15 @@ class AppointmentLogic extends BaseLogic
* @param array $params
* @return array|bool
*/
public static function create(array $params, int $operatorAdminId = 0, array $operatorAdminInfo = [])
{
try {
public static function create(array $params, int $operatorAdminId = 0, array $operatorAdminInfo = [])
{
$params = AppointmentTypeEnum::withDefault($params);
if (!AppointmentTypeEnum::isWritable($params['appointment_type'])) {
self::setError('问诊类型仅支持图文问诊或视频问诊');
return false;
}
try {
Db::startTrans();
// 同一诊单患者在「所选预约日」仅允许一条「已预约」或「已过号」记录(与 appointment_date 一致,不能误用服务器当天拦其它日期)
@@ -345,7 +355,7 @@ class AppointmentLogic extends BaseLogic
'doctor_id' => (int) $params['doctor_id'],
'appointment_date' => $params['appointment_date'],
'appointment_time' => $appointmentTime,
'appointment_type' => $params['appointment_type'] ?? 'video',
'appointment_type' => $params['appointment_type'],
'remark' => $params['remark'] ?? '',
'status' => 1,
'create_time' => time(),
@@ -491,12 +501,8 @@ class AppointmentLogic extends BaseLogic
];
$appointment['status_desc'] = $statusMap[$appointment['status']] ?? '未知';
$typeMap = [
'video' => '视频问诊',
'text' => '图文问诊',
'phone' => '电话问诊',
];
$appointment['appointment_type_desc'] = $typeMap[$appointment['appointment_type']] ?? '未知';
$appointment['appointment_type'] = AppointmentTypeEnum::normalizeStored($appointment['appointment_type'] ?? null);
$appointment['appointment_type_desc'] = AppointmentTypeEnum::description($appointment['appointment_type']);
// 格式化时间戳为日期时间
if (isset($appointment['create_time']) && is_numeric($appointment['create_time'])) {
@@ -969,9 +975,14 @@ class AppointmentLogic extends BaseLogic
*
* @param array<string, mixed> $params
*/
public static function adminEdit(array $params, int $adminId, array $adminInfo): bool
{
try {
public static function adminEdit(array $params, int $adminId, array $adminInfo): bool
{
if (!AppointmentTypeEnum::isWritable($params['appointment_type'] ?? null)) {
self::setError('问诊类型仅支持图文问诊或视频问诊');
return false;
}
try {
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
self::setError('参数错误');
@@ -1023,12 +1034,7 @@ class AppointmentLogic extends BaseLogic
return false;
}
$appointmentType = trim((string) ($params['appointment_type'] ?? ''));
if (!in_array($appointmentType, ['video', 'text', 'phone'], true)) {
self::setError('预约类型无效');
return false;
}
$appointmentType = $params['appointment_type'];
$remark = isset($params['remark']) ? trim((string) $params['remark']) : '';
if (mb_strlen($remark) > 500) {
@@ -330,7 +330,8 @@ class WecomPromotionLogic
$createdRemote = true;
try {
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
if (!QywxPromotionMemberRange::same($remote['range_userids'], $eligibleUserIds)) {
if (!QywxPromotionMemberRange::same($remote['range_userids'], $eligibleUserIds)
|| $remote['range_department_ids'] !== []) {
throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致');
}
} catch (\Throwable $e) {
@@ -488,9 +489,7 @@ class WecomPromotionLogic
'range_userids' => $eligibleUserIds,
// 前端据此确认标签、欢迎语等扩展配置已和方案一并提交并完成回读校验。
'automation_saved' => $automation !== null,
'sync_error' => $syncError,
'sync_queued' => $needsQueuedSync && !$syncImmediately,
];
] + self::memberSyncResult($id, $syncError);
}
/**
@@ -679,13 +678,13 @@ class WecomPromotionLogic
$memberMatched += $poolMemberResult['matched'];
$memberUpdated += $poolMemberResult['updated'];
}
$syncError = trim((string) ($saved['sync_error'] ?? ''));
$syncResult = self::memberSyncResult($poolId, (string) ($saved['sync_error'] ?? ''));
$syncError = $syncResult['sync_error'];
$updated++;
if ($syncError !== '') {
$syncErrorCount++;
}
$syncQueued = !empty($saved['sync_queued'])
|| !empty($poolMemberResult['dispatch']['queued']);
$syncQueued = $syncResult['sync_queued'];
if ($syncQueued) {
$syncQueuedCount++;
}
@@ -693,6 +692,7 @@ class WecomPromotionLogic
'id' => $poolId,
'name' => (string) ($pool['name'] ?? ''),
'success' => true,
'sync_status' => $syncResult['sync_status'],
'sync_error' => $syncError,
'sync_queued' => $syncQueued,
'member_matched' => $poolMemberResult['matched'],
@@ -754,7 +754,8 @@ class WecomPromotionLogic
]);
}
$dispatch = $updateIds !== []
// 状态相同也需要重算:上次保存可能只入队,远端仍保留已下线成员。
$dispatch = $matchedIds !== []
? QywxPromotionMemberSchedulerService::reconcilePool($poolId)
: null;
// 下线必须能够安全收缩远端范围;上线即使尚未到生效时段,也应先保存规则。
@@ -1235,7 +1236,94 @@ class WecomPromotionLogic
}
}
return ['id' => $id, 'pool_id' => $poolId, 'dispatch' => $planned, 'sync_error' => $syncError];
return ['id' => $id, 'pool_id' => $poolId, 'dispatch' => $planned]
+ self::memberSyncResult($poolId, $syncError);
}
/** 显式重新推送当前范围;一次请求仅处理一个方案,供批量前端逐方案调用。 */
public static function syncMemberRange(
int $poolId,
int $adminId,
array $adminInfo,
?QywxPromotionRangeSyncService $syncService = null
): array {
self::assertMemberDispatchSchema();
if (!QywxPromotionOperatorAccess::hasPagePermission($adminId, $adminInfo)) {
throw new RuntimeException('权限不足');
}
self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$ready = Db::transaction(static function () use ($poolId): bool {
// 与成员保存、方案删除使用相同锁顺序,不重启删除中的同步任务。
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find();
if (!$pool) {
throw new RuntimeException('分流方案不存在或已删除');
}
$sync = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
if ((int) ($sync['status'] ?? 0) === 5
|| ((int) ($sync['status'] ?? 0) === 4
&& str_starts_with((string) ($sync['last_error'] ?? ''), '企业微信官方获客链接删除失败'))) {
return false;
}
$plan = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
if ($plan['blocked']) {
return false;
}
$linkId = (int) (Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->value('promotion_link_id') ?? 0);
// 即便快照看起来一致也重新 update/get,修复未被本地快照发现的企微端变化。
QywxPromotionMemberSchedulerService::requestPoolSync($poolId, $linkId);
return true;
});
$error = '';
if ($ready) {
try {
($syncService ?? new QywxPromotionRangeSyncService())->syncPool($poolId);
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
return self::memberSyncResult($poolId, $error);
}
/** 返回已确认的远端范围及任务状态,不能把无异常的 noop 当成同步成功。 */
private static function memberSyncResult(int $poolId, string $error = ''): array
{
$sync = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find() ?: [];
$link = Db::name('qywx_promotion_link')->where('id', (int) ($sync['promotion_link_id'] ?? 0))
->whereNull('delete_time')->find() ?: [];
$members = Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)
->whereNull('delete_time')->select()->toArray();
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), QywxPromotionConfig::forPool($poolId));
$remoteUsers = self::decodeStringList($link['range_user_json'] ?? null);
$remoteDepartments = self::decodeStringList($link['range_department_json'] ?? null);
$status = (int) ($sync['status'] ?? -1);
$syncError = trim($error ?: (string) ($sync['last_error'] ?? $link['sync_error'] ?? ''));
if ($link === [] || trim((string) ($link['remote_link_id'] ?? '')) === ''
|| (int) ($link['remote_status'] ?? 0) === 2 || $range['userids'] === [] || in_array($status, [4, 5], true)) {
$resultStatus = 'blocked';
$syncError = $syncError ?: '当前没有可同步的官方链接或可用成员,请检查方案和成员规则';
} elseif ($error !== '' || $status === 3) {
$resultStatus = 'failed';
$syncError = $syncError ?: '企业微信成员范围同步失败,请重试';
} elseif ($status === 0 && (int) ($sync['desired_version'] ?? 0) > 0
&& (int) ($sync['desired_version'] ?? 0) === (int) ($sync['applied_version'] ?? -1)
&& (int) ($link['last_sync_time'] ?? 0) > 0 && $remoteDepartments === []
&& QywxPromotionMemberRange::same($range['userids'], $remoteUsers)) {
$resultStatus = 'synced';
$syncError = '';
} else {
$resultStatus = 'pending';
}
return [
'pool_id' => $poolId,
'sync_status' => $resultStatus,
'sync_error' => $syncError,
'sync_queued' => in_array($resultStatus, ['pending', 'failed'], true),
'range_userids' => $remoteUsers,
'range_department_ids' => $remoteDepartments,
];
}
public static function toggleMember(int $id, int $status, int $adminId, array $adminInfo): array
@@ -748,16 +748,16 @@ class PrescriptionLogic
$bizPo = PrescriptionOrder::where('prescription_id', $id)
->where('prescription_audit_status', 2)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)
->order('id', 'desc')
->find();
$arr['business_prescription_audit_rejected'] = $bizPo ? 1 : 0;
$arr['business_prescription_audit_remark'] = $bizPo ? (string) ($bizPo->prescription_audit_remark ?? '') : '';
// 与 PrescriptionLists「业务订单」角标一致:未删除且非已取消(4) 即视为存在有效业务订单
// 与列表和创建校验一致:已取消 / 已退款的订单保留历史关联,但不再占用处方。
$hasBizOrder = PrescriptionOrder::where('prescription_id', $id)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)
->count() > 0;
$arr['has_prescription_order'] = $hasBizOrder ? 1 : 0;
@@ -1216,7 +1216,7 @@ class PrescriptionLogic
$rxId = (int) ($row->id ?? 0);
$bizCount = (int) PrescriptionOrder::where('prescription_id', $rxId)
->whereNull('delete_time')
->where('fulfillment_status', '<>', 4)
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)
->count();
if ($bizCount > 0) {
self::setError('该处方已存在业务订单,无法作废');
@@ -963,6 +963,31 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false
*/
public static function create(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
try {
return Db::transaction(static function () use ($params, $adminId, $adminInfo) {
// 先锁始终存在的处方行;空订单集合也能串行化两次创建。
// 首个一致性读发生在获得该锁后,等待者可见前一次创建提交的订单。
$rx = Prescription::where('id', (int) ($params['prescription_id'] ?? 0))
->whereNull('delete_time')->lock(true)->find();
if (!$rx) {
throw new \DomainException('处方不存在');
}
$result = self::createLocked($params, $adminId, $adminInfo);
if ($result === false) {
throw new \DomainException(self::$error ?: '创建业务订单失败');
}
return $result;
});
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
}
private static function createLocked(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
$rxId = (int) $params['prescription_id'];
@@ -989,8 +1014,9 @@ class PrescriptionOrderLogic
return false;
}
if (PrescriptionOrder::where('prescription_id', $rxId)->whereNull('delete_time')->where('fulfillment_status', '<>', 4)->count() > 0) {
self::$error = '该处方已存在有效业务订单(未撤回前不可重复创建)';
if (PrescriptionOrder::where('prescription_id', $rxId)->whereNull('delete_time')
->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)->count() > 0) {
self::$error = '该处方已存在有效业务订单(撤回或全额退款后可重新创建)';
return false;
}
@@ -1087,7 +1113,7 @@ class PrescriptionOrderLogic
return false;
}
self::writeLog((int) $order->id, $adminId, $adminInfo, 'create', '创建业务订单');
self::writeLog((int) $order->id, $adminId, $adminInfo, 'create', '创建业务订单', true);
if ($payOrderIds !== []) {
self::replacePayOrderLinks((int) $order->id, $payOrderIds);
@@ -1103,6 +1129,52 @@ class PrescriptionOrderLogic
return $out;
}
/** 只修正业务订单创建时间,不改变关联支付、处方、药房快照或履约状态。 */
public static function editTime(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
// 菜单尚未部署时,中间件可能放行未知权限,因此必须显式校验。
if ((int) ($adminInfo['root'] ?? 0) !== 1
&& !in_array('tcm.prescriptionOrder/editTime', AuthLogic::getAuthByAdminId($adminId), true)) {
self::$error = '无权限修改业务订单创建时间';
return false;
}
$validator = new \app\adminapi\validate\tcm\PrescriptionOrderValidate();
if (!$validator->scene('editTime')->check($params)) {
self::$error = (string) $validator->getError();
return false;
}
try {
return Db::transaction(static function () use ($params, $adminId, $adminInfo) {
$order = PrescriptionOrder::where('id', (int) $params['id'])
->whereNull('delete_time')->lock(true)->find();
if (!$order) {
throw new \DomainException('订单不存在');
}
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
throw new \DomainException('无权限操作此订单');
}
$oldValue = $order->getData('create_time');
$dateTime = (string) $params['create_time'];
$isTimestamp = is_int($oldValue) || (is_string($oldValue) && ctype_digit($oldValue));
$newValue = $isTimestamp ? (int) strtotime($dateTime) : $dateTime;
if ((string) $oldValue !== (string) $newValue) {
$order->create_time = $newValue;
$order->save();
$oldLabel = $isTimestamp ? date('Y-m-d H:i:s', (int) $oldValue) : (string) $oldValue;
self::writeLog((int) $order->id, $adminId, $adminInfo, 'edit_time',
'修改业务订单创建时间:' . $oldLabel . ' → ' . $dateTime, true);
}
return ['id' => (int) $order->id, 'create_time' => $newValue];
});
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
}
public static function detail(int $id, int $adminId, array $adminInfo): ?array
{
self::$error = '';
@@ -2,7 +2,8 @@
namespace app\adminapi\validate\doctor;
use app\common\validate\BaseValidate;
use app\common\validate\BaseValidate;
use app\common\enum\AppointmentTypeEnum;
/**
* 医生预约验证器
@@ -23,7 +24,7 @@ class AppointmentValidate extends BaseValidate
'appointment_date' => 'require|date',
'appointment_time' => 'require',
'period' => 'in:morning,afternoon,all',
'appointment_type' => 'require|in:video,text,phone',
'appointment_type' => 'require|checkAppointmentType',
'status' => 'require|integer|between:1,4',
'remark' => 'max:500',
'channel_source' => 'require',
@@ -38,7 +39,7 @@ class AppointmentValidate extends BaseValidate
* 参数描述
* @var string[]
*/
protected $field = [
protected $field = [
'id' => '预约ID',
'patient_id' => '患者ID',
'doctor_id' => '医生ID',
@@ -53,7 +54,21 @@ class AppointmentValidate extends BaseValidate
'channel_source' => '渠道来源',
'channel_source_detail' => '渠道补充说明',
'ids' => '预约ID列表',
];
];
protected function checkAppointmentType($value)
{
return AppointmentTypeEnum::isWritable($value) ? true : '问诊类型仅支持图文问诊或视频问诊';
}
public function check(array $data, array $rules = []): bool
{
if ($this->currentScene === 'create') {
$data = AppointmentTypeEnum::withDefault($data);
}
return parent::check($data, $rules);
}
/**
* @notes 创建预约场景
@@ -10,6 +10,7 @@ class PrescriptionOrderValidate extends BaseValidate
{
protected $rule = [
'id' => 'require|integer',
'create_time' => 'require|string|dateFormat:Y-m-d H:i:s',
'diagnosis_id' => 'require|integer|gt:0',
'prescription_id' => 'require|integer',
'pay_order_ids' => 'array',
@@ -62,6 +63,8 @@ class PrescriptionOrderValidate extends BaseValidate
'tracking_number.require' => '请输入快递单号',
'phone_tail.regex' => '手机后四位仅支持数字',
'reason.require' => '请填写退款原因',
'create_time.require' => '创建时间必填',
'create_time.dateFormat' => '创建时间格式不正确',
];
protected $scene = [
@@ -71,6 +74,7 @@ class PrescriptionOrderValidate extends BaseValidate
'tracking_number', 'express_company', 'ship_mode', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
],
'detail' => ['id'],
'editTime' => ['id', 'create_time'],
'edit' => [
'id', 'recipient_name', 'recipient_phone', 'shipping_address',
'is_follow_up', 'prev_staff', 'service_channel', 'service_package',
@@ -109,6 +113,11 @@ class PrescriptionOrderValidate extends BaseValidate
->append('pay_order_id', 'require|integer|gt:0');
}
public function sceneEditTime(): PrescriptionOrderValidate
{
return $this->only(['id', 'create_time'])->append('id', 'require|integer|gt:0');
}
public function updateAmount(): PrescriptionOrderValidate
{
return $this->only(['id', 'amount'])
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace app\common\enum;
/** 挂号的问诊方式,与诊单的初诊/复诊类型无关。 */
class AppointmentTypeEnum
{
public const VIDEO = 'video';
public const TEXT = 'text';
public static function isWritable($value): bool
{
return is_string($value) && in_array($value, [self::VIDEO, self::TEXT], true);
}
/** 只给未传字段的新客户端请求补默认值;显式传入空值仍需校验。 */
public static function withDefault(array $params): array
{
if (!array_key_exists('appointment_type', $params)) {
$params['appointment_type'] = self::VIDEO;
}
return $params;
}
/** 旧记录未填写类型时按视频展示,已记录的历史类型保持原值。 */
public static function normalizeStored($value): string
{
return $value === null || (is_string($value) && trim($value) === '')
? self::VIDEO
: (string) $value;
}
public static function description($value): string
{
return [
self::VIDEO => '视频问诊',
self::TEXT => '图文问诊',
'phone' => '电话问诊', // 仅兼容已存在的历史记录,不允许新写入。
][self::normalizeStored($value)] ?? '未知';
}
}
@@ -3,6 +3,7 @@
namespace app\common\model\doctor;
use app\common\model\BaseModel;
use app\common\enum\AppointmentTypeEnum;
/**
* 医生预约模型
@@ -58,12 +59,12 @@ class Appointment extends BaseModel
*/
public function getAppointmentTypeDescAttr($value, $data)
{
$typeMap = [
'video' => '视频问诊',
'text' => '图文问诊',
'phone' => '电话问诊',
];
return $typeMap[$data['appointment_type']] ?? '未知';
return AppointmentTypeEnum::description($data['appointment_type'] ?? null);
}
public function getAppointmentTypeAttr($value)
{
return AppointmentTypeEnum::normalizeStored($value);
}
/**
@@ -14,6 +14,9 @@ class PrescriptionOrder extends BaseModel
{
use SoftDelete;
/** 已取消 / 已退款的历史订单不再占用处方;部分退款仍沿用原履约状态。 */
public const RELEASED_PRESCRIPTION_STATUSES = [4, 10];
protected $name = 'tcm_prescription_order';
protected $deleteTime = 'delete_time';
@@ -261,8 +261,13 @@ class QywxPromotionMemberSchedulerService
return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true];
}
$appliedUserIds = self::linkRangeUserIds($linkId);
$changed = !QywxPromotionMemberRange::same($range['userids'], $appliedUserIds);
$alreadyPending = in_array((int) ($sync['status'] ?? 0), [1, 3], true);
$departmentJson = (string) (Db::name('qywx_promotion_link')->where('id', $linkId)->value('range_department_json') ?? '[]');
$departmentIds = json_decode($departmentJson, true);
$changed = !QywxPromotionMemberRange::same($range['userids'], $appliedUserIds)
|| !empty($departmentIds);
// 活跃或超时的租约、尚未应用的版本都不能仅凭旧快照被重算成“已同步”。
$alreadyPending = in_array((int) ($sync['status'] ?? 0), [1, 2, 3], true)
|| (int) ($sync['desired_version'] ?? 0) > (int) ($sync['applied_version'] ?? 0);
$needsSync = $changed || $alreadyPending;
self::upsertSync($poolId, $linkId, $needsSync, $sync, $now);
@@ -99,33 +99,42 @@ class QywxPromotionRangeSyncService
$response = $this->api->getLink($remoteLinkId);
$remote = QywxCustomerAcquisitionLinkService::normaliseRemoteResponse($response, $remoteLinkId);
$actualUserIds = $remote['range_userids'];
if (!QywxPromotionMemberRange::same($actualUserIds, $desiredUserIds)) {
if (!QywxPromotionMemberRange::same($actualUserIds, $desiredUserIds)
|| $remote['range_department_ids'] !== []) {
throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致');
}
$url = $remote['url'];
$snapshot = json_encode($remote['snapshot'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([
'wecom_url' => $url,
'remote_status' => 1,
'range_user_json' => json_encode($actualUserIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'range_department_json' => json_encode($remote['range_department_ids'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'remote_snapshot' => $snapshot === false ? null : $snapshot,
'last_sync_time' => time(),
'sync_error' => '',
'update_time' => time(),
]);
$freshVersion = (int) (Db::name('qywx_promotion_range_sync')
->where('pool_id', $poolId)->value('desired_version') ?? 0);
Db::name('qywx_promotion_range_sync')
->where('pool_id', $poolId)
->where('lock_token', $token)
->update([
'status' => $freshVersion === $desiredVersion ? 0 : 1,
$confirmed = Db::transaction(function () use (
$poolId, $token, $desiredVersion, $link, $url, $actualUserIds, $remote, $snapshot
): bool {
$fresh = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
if (!$fresh || (int) ($fresh['status'] ?? 0) === 5) {
return false;
}
if ((string) ($fresh['lock_token'] ?? '') !== $token
|| (int) ($fresh['lock_until'] ?? 0) <= time()) {
// 过期工作不能覆盖新工作的确认结果。它可能较晚触达企微,需再推一次最新范围。
QywxPromotionMemberSchedulerService::requestPoolSync($poolId, (int) $fresh['promotion_link_id']);
return false;
}
Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([
'wecom_url' => $url,
'remote_status' => 1,
'range_user_json' => json_encode($actualUserIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'range_department_json' => json_encode($remote['range_department_ids'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'remote_snapshot' => $snapshot === false ? null : $snapshot,
'last_sync_time' => time(),
'sync_error' => '',
'update_time' => time(),
]);
$isLatest = (int) ($fresh['desired_version'] ?? 0) === $desiredVersion;
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
'status' => $isLatest ? 0 : 1,
'desired_member_id' => 0,
'applied_member_id' => 0,
'applied_version' => $desiredVersion,
'next_retry' => $freshVersion === $desiredVersion ? 0 : time(),
'next_retry' => $isLatest ? 0 : time(),
'attempts' => 0,
'lock_token' => '',
'lock_until' => 0,
@@ -133,13 +142,19 @@ class QywxPromotionRangeSyncService
'update_time' => time(),
]);
return ['status' => 'synced', 'pool_id' => $poolId, 'member_id' => 0];
return $isLatest;
});
return ['status' => $confirmed ? 'synced' : 'pending', 'pool_id' => $poolId, 'member_id' => 0];
} catch (\Throwable $e) {
$attempts = max(1, (int) ($claim['attempts'] ?? 0) + 1);
Db::name('qywx_promotion_range_sync')
->where('pool_id', $poolId)
->where('lock_token', $token)
->update([
Db::transaction(function () use ($poolId, $token, $attempts, $claim, $e): void {
$fresh = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
if (!$fresh || (string) ($fresh['lock_token'] ?? '') !== $token
|| (int) ($fresh['status'] ?? 0) === 5) {
return;
}
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
'status' => 3,
'next_retry' => time() + min(300, 15 * $attempts),
'lock_token' => '',
@@ -147,9 +162,10 @@ class QywxPromotionRangeSyncService
'last_error' => mb_substr($e->getMessage(), 0, 500),
'update_time' => time(),
]);
Db::name('qywx_promotion_link')
->where('id', (int) ($claim['promotion_link_id'] ?? 0))
->update(['sync_error' => mb_substr($e->getMessage(), 0, 500), 'update_time' => time()]);
Db::name('qywx_promotion_link')
->where('id', (int) ($claim['promotion_link_id'] ?? 0))
->update(['sync_error' => mb_substr($e->getMessage(), 0, 500), 'update_time' => time()]);
});
throw $e;
}
@@ -0,0 +1,22 @@
-- 处方业务订单:单独授权修改创建时间。默认不授予任何角色。
-- 请在角色管理中按需勾选“修改业务订单创建时间”。表前缀如非 zyt_ 请调整。
START TRANSACTION;
SET @po_menu_id = (
SELECT id FROM zyt_system_menu
WHERE perms = 'tcm.prescriptionOrder/lists' ORDER BY id ASC LIMIT 1
);
INSERT INTO zyt_system_menu (
pid, type, name, icon, sort, perms, paths, component,
selected, params, is_cache, is_show, is_disable, create_time, update_time
)
SELECT
@po_menu_id, 'A', '修改业务订单创建时间', '', 89,
'tcm.prescriptionOrder/editTime', '', '',
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @po_menu_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.prescriptionOrder/editTime');
COMMIT;
@@ -0,0 +1,9 @@
-- 问诊类型复用 doctor_appointment.appointment_type;请先备份,在部署窗口执行。
-- 只回填未记录方式的历史挂号,不改变已有 text、video 或历史 phone 记录。
UPDATE `zyt_doctor_appointment`
SET `appointment_type` = 'video'
WHERE `appointment_type` IS NULL OR TRIM(`appointment_type`) = '';
ALTER TABLE `zyt_doctor_appointment`
MODIFY COLUMN `appointment_type` varchar(20) NOT NULL DEFAULT 'video'
COMMENT '问诊类型:video=视频问诊,text=图文问诊;phone仅保留历史记录';
+1 -1
View File
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS `zyt_doctor_appointment` (
`appointment_date` date NOT NULL COMMENT '预约日期',
`period` enum('morning','afternoon') NOT NULL COMMENT '时段:morning=上午,afternoon=下午',
`appointment_time` time NOT NULL COMMENT '预约时间',
`appointment_type` varchar(20) DEFAULT 'video' COMMENT '预约类型:video=视频问诊,text=图文问诊phone=电话问诊',
`appointment_type` varchar(20) NOT NULL DEFAULT 'video' COMMENT '问诊类型:video=视频问诊,text=图文问诊phone仅保留历史记录',
`status` tinyint(1) DEFAULT '1' COMMENT '状态:1=已预约,2=已取消,3=已完成',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`channel_source` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道来源(字典channels)',
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\lists\tcm\DiagnosisLists;
use app\adminapi\validate\doctor\AppointmentValidate;
use app\common\enum\AppointmentTypeEnum;
use app\common\model\doctor\Appointment;
$testApp = new think\App();
$testLang = new think\Lang($testApp);
think\Validate::maker(static fn (think\Validate $validator) => $validator->setLang($testLang));
function appointmentTypeExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$payload = [
'id' => 1,
'patient_id' => 101,
'doctor_id' => 202,
'appointment_date' => '2026-09-10',
'appointment_time' => '09:30',
'period' => 'morning',
'channel_source' => 'test',
'status' => 1,
];
foreach (['video' => '视频问诊', 'text' => '图文问诊'] as $type => $label) {
foreach (['create', 'adminEdit'] as $scene) {
$validator = (new AppointmentValidate())->scene($scene);
appointmentTypeExpect($validator->check($payload + ['appointment_type' => $type]), "$scene accepts $type");
}
appointmentTypeExpect(AppointmentTypeEnum::description($type) === $label, "$type label round trip");
appointmentTypeExpect(AppointmentTypeEnum::withDefault(['appointment_type' => $type])['appointment_type'] === $type, 'default does not override an explicit choice');
}
$invalidValues = ['', ' ', 'phone', 'Text', ' video ', 'unknown', 0, 1, true, false, null, [], ['text']];
foreach ($invalidValues as $value) {
foreach (['create', 'adminEdit'] as $scene) {
appointmentTypeExpect(!(new AppointmentValidate())->scene($scene)->check($payload + ['appointment_type' => $value]), "$scene rejects " . json_encode($value));
}
// Invalid requests must be rejected before touching a database, even if called outside the controller.
appointmentTypeExpect(AppointmentLogic::create(['appointment_type' => $value]) === false, 'create rejects invalid type before DB');
appointmentTypeExpect(AppointmentLogic::adminEdit(['appointment_type' => $value], 0, []) === false, 'edit rejects invalid type before DB');
}
appointmentTypeExpect((new AppointmentValidate())->scene('create')->check($payload), 'legacy create request may omit type');
appointmentTypeExpect(AppointmentTypeEnum::withDefault([])['appointment_type'] === 'video', 'omitted create type persists as video');
appointmentTypeExpect(!(new AppointmentValidate())->scene('adminEdit')->check($payload), 'edit cannot silently reset an existing text selection');
appointmentTypeExpect(AppointmentLogic::adminEdit([], 0, []) === false, 'internal edit also requires explicit type');
$model = (new ReflectionClass(Appointment::class))->newInstanceWithoutConstructor();
foreach ([null, '', ' '] as $legacyEmpty) {
appointmentTypeExpect($model->getAppointmentTypeAttr($legacyEmpty) === 'video', 'legacy empty model value uses video');
appointmentTypeExpect($model->getAppointmentTypeDescAttr(null, ['appointment_type' => $legacyEmpty]) === '视频问诊', 'legacy empty model label uses video');
}
appointmentTypeExpect(AppointmentTypeEnum::description('phone') === '电话问诊', 'historical phone records retain accurate labels');
$filter = (new ReflectionClass(AppointmentLogic::class))->getMethod('filterAppointmentRowByExistingColumns');
appointmentTypeExpect($filter->invoke(null, ['appointment_type' => 'text'], ['appointment_type']) === ['appointment_type' => 'text'], 'text is retained in database write payload');
try {
$filter->invoke(null, ['appointment_type' => 'text'], ['id']);
throw new RuntimeException('missing appointment_type column must not silently lose the selected type');
} catch (RuntimeException $exception) {
appointmentTypeExpect(str_contains($exception->getMessage(), '挂号表缺少 appointment_type'), 'missing schema yields an actionable error');
}
$summary = (new ReflectionClass(DiagnosisLists::class))->getMethod('appendLatestAppointmentSummary');
$lists = (new ReflectionClass(DiagnosisLists::class))->newInstanceWithoutConstructor();
$row = [];
$summary->invokeArgs($lists, [&$row, ['id' => 8, 'appointment_type' => 'text']]);
appointmentTypeExpect($row['latest_appointment_id'] === 8 && $row['latest_appointment_type'] === 'text' && $row['latest_appointment_type_desc'] === '图文问诊', 'latest appointment summary keeps its own type');
$summary->invokeArgs($lists, [&$row, ['id' => 9, 'appointment_type' => null]]);
appointmentTypeExpect($row['latest_appointment_type'] === 'video', 'next legacy appointment does not inherit previous text type');
echo "Appointment type validation, defaults, legacy labels and summary: OK\n";
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
/**
* Real ORM/transaction tests. Requires an explicitly selected disposable local MySQL.
* Run: ZYT_RX_ORDER_TEST_MYSQL_PORT=13379 php tests/PrescriptionOrderReleaseAndTimeTest.php
* Never initializes the application or loads its business database configuration.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\lists\tcm\PrescriptionLists;
use app\adminapi\logic\tcm\PrescriptionLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\adminapi\validate\tcm\PrescriptionOrderValidate;
use think\Container;
use think\DbManager;
use think\facade\Db;
$port = (int) getenv('ZYT_RX_ORDER_TEST_MYSQL_PORT');
if ($port <= 0) {
fwrite(STDERR, "Set ZYT_RX_ORDER_TEST_MYSQL_PORT to an isolated local MySQL instance.\n");
exit(1);
}
$isWorker = ($argv[1] ?? '') === '--worker';
$database = $isWorker ? (string) getenv('ZYT_RX_ORDER_TEST_DATABASE') : 'rx_order_test_' . bin2hex(random_bytes(6));
if (!preg_match('/^rx_order_test_[a-f0-9]{12}$/', $database)) {
throw new RuntimeException('Only this test\'s disposable databases are allowed');
}
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
if (!$isWorker) $pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
$pdo->exec("USE `{$database}`");
$testApp = new think\App();
$manager = new DbManager();
$manager->setConfig([
'default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false,
'connections' => ['mysql' => [
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
'database' => $database, 'username' => 'root', 'password' => '',
'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true,
]],
]);
Container::getInstance()->instance('think\DbManager', $manager);
Container::getInstance()->instance('config', new think\Config());
$testLang = new think\Lang($testApp);
think\Validate::maker(static fn (think\Validate $validator) => $validator->setLang($testLang));
$admin = ['root' => 1, 'admin_id' => 1, 'name' => '隔离测试管理员'];
$checks = 0;
$expect = static function (bool $ok, string $message) use (&$checks): void {
if (!$ok) throw new RuntimeException($message . ' | ' . PrescriptionOrderLogic::getError());
$checks++;
};
$createParams = static fn (int $rxId): array => [
'prescription_id' => $rxId, 'diagnosis_id' => 1, 'recipient_name' => '测试患者',
'recipient_phone' => '13000000000', 'shipping_address' => '测试地址', 'fee_type' => 3, 'amount' => 100,
];
if ($isWorker) {
echo "ready\n";
flush();
$out = PrescriptionOrderLogic::create($createParams((int) $argv[2]), 1, $admin);
echo json_encode(['success' => is_array($out), 'error' => PrescriptionOrderLogic::getError()]) . "\n";
exit(0);
}
try {
$pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_04_07_create_tcm_prescription_order.sql'));
$pdo->exec('ALTER TABLE zyt_tcm_prescription_order
ADD agency_collect_amount DECIMAL(10,2) NULL, ADD paid DECIMAL(10,2) DEFAULT 0,
ADD refund_amount DECIMAL(10,2) DEFAULT 0, ADD express_company VARCHAR(20) DEFAULT "auto",
ADD ship_mode VARCHAR(20) DEFAULT "gancao", ADD remark_assistant VARCHAR(500) DEFAULT "",
ADD gancao_reciperl_order_no VARCHAR(100) DEFAULT ""');
$pdo->exec('CREATE TABLE zyt_tcm_prescription (
id INT PRIMARY KEY AUTO_INCREMENT, diagnosis_id INT DEFAULT 1, gender INT DEFAULT 0,
creator_id INT DEFAULT 1, assistant_id INT DEFAULT 0, is_shared INT DEFAULT 0,
herbs TEXT, audit_status INT DEFAULT 1, void_status INT DEFAULT 0, visible_role_ids VARCHAR(100) DEFAULT "",
audit_time INT NULL, audit_by INT NULL, audit_by_name VARCHAR(100) DEFAULT "", audit_remark VARCHAR(500) DEFAULT "",
create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL
) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_order (
id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(50), patient_id INT DEFAULT 1,
creator_id INT DEFAULT 1, order_type INT DEFAULT 3, amount DECIMAL(10,2), status INT,
remark VARCHAR(200) DEFAULT "", is_exempt INT DEFAULT 0, payment_method VARCHAR(50) DEFAULT "",
create_type VARCHAR(50) DEFAULT "", create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL
) ENGINE=InnoDB');
$pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_04_09_prescription_order_pay_links.sql'));
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order_log (
id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, admin_id INT,
admin_name VARCHAR(64), action VARCHAR(32), summary VARCHAR(500), create_time INT
) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY, assistant_id INT DEFAULT 0, delete_time INT NULL)');
$pdo->exec('INSERT INTO zyt_tcm_diagnosis (id) VALUES (1)');
$pdo->exec('CREATE TABLE zyt_pharmacy_submission_claim (
id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, source_revision INT, status VARCHAR(50)
) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY, name VARCHAR(100), delete_time INT NULL)');
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)');
$pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)');
$pdo->exec('CREATE TABLE zyt_system_menu (
id INT PRIMARY KEY AUTO_INCREMENT, pid INT, type VARCHAR(5), name VARCHAR(100), icon VARCHAR(50),
sort INT, perms VARCHAR(100), paths VARCHAR(100), component VARCHAR(100), selected VARCHAR(100),
params VARCHAR(100), is_cache INT, is_show INT, is_disable INT DEFAULT 0, create_time INT, update_time INT
)');
$newRx = static fn (array $fields = []): int => (int) Db::name('tcm_prescription')->insertGetId(array_merge(['herbs' => '[]'], $fields));
$fixture = static fn (int $rx, array $fields = []): int => (int) Db::name('tcm_prescription_order')->insertGetId(array_merge([
'prescription_id' => $rx, 'order_no' => 'OLD-' . bin2hex(random_bytes(4)), 'diagnosis_id' => 1,
'creator_id' => 1, 'amount' => 100, 'paid' => 100, 'payment_slip_audit_status' => 1,
'create_time' => 1724300000, 'fulfillment_status' => 5,
], $fields));
$row = static fn (int $id): array => Db::name('tcm_prescription_order')->where('id', $id)->find();
$logRows = static fn (int $id): array => Db::name('tcm_prescription_order_log')->where('prescription_order_id', $id)->order('id')->select()->toArray();
$detail = static fn (int $rx): array => PrescriptionLogic::detail($rx, 1, $admin);
$listsClass = new ReflectionClass(PrescriptionLists::class);
$lists = $listsClass->newInstanceWithoutConstructor();
foreach (['adminInfo' => $admin, 'adminId' => 1, 'params' => [], 'searchWhere' => [], 'limitOffset' => 0, 'limitLength' => 1000] as $name => $value) {
$listsClass->getProperty($name)->setValue($lists, $value);
}
$listRow = static function (int $rx) use ($lists): array {
return array_values(array_filter($lists->lists(), static fn (array $r): bool => (int) $r['id'] === $rx))[0];
};
// Existing refunded/cancelled/deleted history never blocks a new order or contaminates active audit flags.
foreach ([['fulfillment_status' => 10], ['fulfillment_status' => 4], ['delete_time' => time()]] as $released) {
$rx = $newRx();
$oldId = $fixture($rx, array_merge($released, ['prescription_audit_status' => 2, 'prescription_audit_remark' => '旧驳回']));
$before = $row($oldId);
$expect($detail($rx)['has_prescription_order'] === 0 && $listRow($rx)['has_prescription_order'] === 0, 'Released history must be available in list and detail');
$expect($detail($rx)['business_prescription_audit_rejected'] === 0 && $listRow($rx)['business_prescription_audit_rejected'] === 0, 'Released history must not carry rejection badges');
$expect($listsClass->getMethod('collectRiskPrescriptionIds')->invoke($lists, [$rx]) === [], 'Released orders must not pin herb-risk rows');
$out = PrescriptionOrderLogic::create($createParams($rx), 1, $admin);
$expect(is_array($out), 'Approved prescription must support creating after released history');
$expect($row($oldId) === $before, 'Creation must preserve all historical order fields');
$expect($detail($rx)['has_prescription_order'] === 1 && $listRow($rx)['has_prescription_order'] === 1, 'New order must occupy the prescription in list and detail');
$expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'A second live order must be rejected');
}
foreach ([1, 2, 3, 5, 6, 7, 8, 9, 11, 12] as $status) {
$rx = $newRx();
$fixture($rx, ['fulfillment_status' => 10]);
$fixture($rx, ['fulfillment_status' => $status]);
$expect($detail($rx)['has_prescription_order'] === 1 && $listRow($rx)['has_prescription_order'] === 1, 'Every nonreleased live status must occupy prescription, even with refunded history');
$expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Live status must prevent duplicate creation');
}
// Exercise the actual refund endpoint logic and preserve payment/remote history and approved prescription.
$rx = $newRx();
$oldId = $fixture($rx, ['gancao_reciperl_order_no' => 'REMOTE-HISTORY']);
$payId = Db::name('order')->insertGetId(['order_no' => 'PAID-HISTORY', 'status' => 2, 'amount' => 100]);
Db::name('tcm_prescription_order_pay_order')->insert(['prescription_order_id' => $oldId, 'pay_order_id' => $payId, 'create_time' => time()]);
Db::name('tcm_prescription_order')->where('id', $oldId)->update(['linked_pay_order_id' => $payId]);
$out = PrescriptionOrderLogic::refund($oldId, '测试全退', 1, $admin);
$expect(is_array($out) && (int) $out['fulfillment_status'] === 10, 'Full refund must transition to released status');
$expect((int) Db::name('order')->where('id', $payId)->value('status') === 4, 'Original payment must stay linked and be marked refunded');
$expect(Db::name('tcm_prescription_order_pay_order')->where('prescription_order_id', $oldId)->count() === 1, 'Refund must preserve payment association history');
$expect($detail($rx)['has_prescription_order'] === 0 && (int) $detail($rx)['audit_status'] === 1, 'Refund must release an already approved prescription without resetting approval');
$expect(is_array(PrescriptionOrderLogic::create($createParams($rx), 1, $admin)), 'Actual refund must permit a new business order');
$expect($row($oldId)['gancao_reciperl_order_no'] === 'REMOTE-HISTORY' && (int) $row($oldId)['prescription_id'] === $rx, 'New order must preserve old remote and prescription associations');
$rx = $newRx();
$oldId = $fixture($rx);
$out = PrescriptionOrderLogic::refund($oldId, '测试部分退款', 1, $admin, 20);
$expect(is_array($out) && (int) $out['fulfillment_status'] === 5 && (float) $out['paid'] === 80.0, 'Partial refund must remain active while there is a balance');
$expect($detail($rx)['has_prescription_order'] === 1 && PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Partial refund must not release prescription');
$out = PrescriptionOrderLogic::refund($oldId, '剩余全退', 1, $admin);
$expect(is_array($out) && $detail($rx)['has_prescription_order'] === 0, 'Refunding remaining balance must release prescription');
$rx = $newRx();
$oldId = $fixture($rx, ['fulfillment_status' => 1]);
$expect(is_array(PrescriptionOrderLogic::withdraw($oldId, 1, $admin)), 'Existing cancellation must still work');
$expect(is_array(PrescriptionOrderLogic::create($createParams($rx), 1, $admin)), 'Withdrawn order must still release prescription');
$rx = $newRx(['void_status' => 1]);
$fixture($rx, ['fulfillment_status' => 10]);
$expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Refund must never revive a voided prescription');
$rx = $newRx(['delete_time' => time()]);
$expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Deleted prescription must not be orderable');
// Audit-log failures roll back order creation and creation-time edits.
$rx = $newRx();
$oldId = $fixture($rx, ['fulfillment_status' => 10]);
$before = $row($oldId);
$pdo->exec("CREATE TRIGGER reject_order_log BEFORE INSERT ON zyt_tcm_prescription_order_log
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced audit failure'");
$expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Create log failure must reject creation');
$expect(Db::name('tcm_prescription_order')->where('prescription_id', $rx)->count() === 1, 'Create log failure must not leave a partial active order');
$timeParams = ['id' => $oldId, 'create_time' => '2026-08-22 11:40:03'];
$expect(PrescriptionOrderLogic::editTime($timeParams, 1, $admin) === false && $row($oldId) === $before, 'Time edit must roll back if audit logging fails');
$pdo->exec('DROP TRIGGER reject_order_log');
// Dedicated permission is enforced before menu deployment, including owners and manager roles.
foreach ([[], [3]] as $roles) {
$out = PrescriptionOrderLogic::editTime($timeParams, 1, ['root' => 0, 'admin_id' => 1, 'role_id' => $roles]);
$expect($out === false && $row($oldId) === $before, 'Ownership/general manager role must not imply time-edit permission');
}
foreach ([[], ['id' => 0, 'create_time' => '2026-08-22 11:40:03'], ['id' => 1, 'create_time' => ''],
['id' => 1, 'create_time' => '2026-02-30 11:40:03'], ['id' => 1, 'create_time' => '2026-08-22'],
['id' => 1, 'create_time' => ['2026-08-22 11:40:03']], ['id' => [1], 'create_time' => '2026-08-22 11:40:03']] as $invalid) {
$expect(!(new PrescriptionOrderValidate())->scene('editTime')->check($invalid), 'Invalid time requests must be rejected');
}
$expect((new PrescriptionOrderValidate())->scene('editTime')->check($timeParams), 'Canonical creation datetime must validate');
$out = PrescriptionOrderLogic::editTime($timeParams + ['paid' => 999, 'fulfillment_status' => 1], 1, $admin);
$after = $row($oldId);
$expect(is_array($out) && (int) $after['create_time'] === strtotime($timeParams['create_time']), 'Timestamp schema must retain Unix creation time');
foreach ($before as $key => $value) {
if (!in_array($key, ['create_time', 'update_time'], true)) $expect($after[$key] === $value, 'Time edit must preserve ' . $key);
}
$log = $logRows($oldId)[0];
$expect($log['action'] === 'edit_time' && (int) $log['admin_id'] === 1
&& str_contains($log['summary'], date('Y-m-d H:i:s', (int) $before['create_time']))
&& str_contains($log['summary'], $timeParams['create_time']), 'Audit log must record operator, previous and new time');
$expect(is_array(PrescriptionOrderLogic::editTime($timeParams, 1, $admin)) && count($logRows($oldId)) === 1, 'Repeated identical edit must not duplicate audit log');
$deletedId = $fixture($newRx(), ['delete_time' => time()]);
$expect(PrescriptionOrderLogic::editTime(['id' => $deletedId, 'create_time' => $timeParams['create_time']], 1, $admin) === false, 'Deleted order time must not be editable');
$expect(PrescriptionOrderLogic::editTime(['id' => 999999, 'create_time' => $timeParams['create_time']], 1, $admin) === false, 'Missing order time must not be editable');
$pdo->exec("INSERT INTO zyt_system_menu (perms,is_disable) VALUES ('tcm.prescriptionOrder/lists',0)");
$sql = file_get_contents(dirname(__DIR__) . '/sql/1.9.20260909/add_prescription_order_edit_time_menu.sql');
$pdo->exec($sql);
$pdo->exec($sql);
$expect(Db::name('system_menu')->where('perms', 'tcm.prescriptionOrder/editTime')->count() === 1, 'Time permission migration must be idempotent');
$expect(Db::name('system_role_menu')->count() === 0, 'Migration must not grant privileges automatically');
$menuId = Db::name('system_menu')->where('perms', 'tcm.prescriptionOrder/editTime')->value('id');
Db::name('admin_role')->insert(['admin_id' => 2, 'role_id' => 2]);
Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => $menuId]);
$out = PrescriptionOrderLogic::editTime(['id' => $oldId, 'create_time' => '2026-08-23 11:40:03'], 2,
['root' => 0, 'admin_id' => 2, 'role_id' => [2], 'name' => '获授权测试员']);
$expect(is_array($out) && array_keys($out) === ['id', 'create_time'], 'Explicit time permission must work and return only safe fields');
// Two independent PHP connections race behind the prescription lock, including already-refunded history.
foreach ([false, true] as $withHistory) {
$rx = $newRx();
if ($withHistory) $fixture($rx, ['fulfillment_status' => 10]);
putenv('ZYT_RX_ORDER_TEST_DATABASE=' . $database);
Db::startTrans();
Db::name('tcm_prescription')->where('id', $rx)->lock(true)->find();
$workers = [];
try {
foreach ([1, 2] as $_) {
$process = proc_open([PHP_BINARY, __FILE__, '--worker', (string) $rx],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) throw new RuntimeException('Cannot start concurrency worker');
fclose($pipes[0]);
$workers[] = [$process, $pipes];
if (trim((string) fgets($pipes[1])) !== 'ready') throw new RuntimeException('Worker failed initialization');
}
} finally {
Db::commit();
}
$results = [];
foreach ($workers as [$process, $pipes]) {
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
if (proc_close($process) !== 0) throw new RuntimeException('Worker failed: ' . $errors . $output);
$results[] = json_decode(trim($output), true, 512, JSON_THROW_ON_ERROR);
}
$expect(count(array_filter($results, static fn (array $r): bool => $r['success'])) === 1, 'Concurrent create must succeed exactly once');
$expect(Db::name('tcm_prescription_order')->where('prescription_id', $rx)->whereNotIn('fulfillment_status', [4,10])->count() === 1, 'Concurrency must persist only one live order');
}
// Compatibility with installations that store create_time as DATETIME.
$pdo->exec('ALTER TABLE zyt_tcm_prescription_order ADD legacy_datetime DATETIME NULL');
$pdo->exec('UPDATE zyt_tcm_prescription_order SET legacy_datetime=FROM_UNIXTIME(create_time)');
$pdo->exec('ALTER TABLE zyt_tcm_prescription_order DROP create_time, CHANGE legacy_datetime create_time DATETIME NULL');
$out = PrescriptionOrderLogic::editTime(['id' => $oldId, 'create_time' => '2026-08-24 11:40:03'], 1, $admin);
$expect(is_array($out) && $row($oldId)['create_time'] === '2026-08-24 11:40:03', 'Datetime schema must preserve canonical datetime strings');
echo "PrescriptionOrderReleaseAndTimeTest: {$checks} assertions passed\n";
} finally {
$manager->connect()->close();
$pdo->exec("DROP DATABASE `{$database}`");
}
@@ -0,0 +1,385 @@
<?php
declare(strict_types=1);
/**
* Real MySQL/Think ORM regression tests; all enterprise WeChat requests are fake.
* ZYT_WECOM_MEMBER_TEST_MYSQL_PORT must point at a disposable loopback MySQL server.
* No application initialization, environment file, or business DB config is loaded.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
use app\common\service\qywx\QywxPromotionRangeSyncService;
use think\Container;
use think\DbManager;
use think\facade\Db;
final class MemberSyncFakeApi extends QywxCustomerAcquisitionApiService
{
public array $updates = [];
public array $gets = [];
public ?array $remoteUsers = null;
public array $remoteDepartments = [];
public string $failure = '';
public ?Closure $onGet = null;
// Deliberately do not create the real HTTP client or token resolver.
public function __construct() {}
public function updateLink(array $payload): array
{
$this->updates[] = $payload;
if ($this->failure !== '') {
throw new RuntimeException($this->failure);
}
return ['errcode' => 0, 'errmsg' => 'ok'];
}
public function getLink(string $linkId): array
{
$this->gets[] = $linkId;
$payload = $this->updates[count($this->updates) - 1] ?? [];
$response = [
'errcode' => 0,
'link' => ['link_id' => $linkId, 'url' => 'https://work.weixin.qq.com/ca/isolated-test'],
// Official GET shape: range is at the root, not under link.
'range' => [
'user_list' => $this->remoteUsers ?? ($payload['range']['user_list'] ?? []),
'department_list' => $this->remoteDepartments,
],
];
if ($this->onGet !== null) {
($this->onGet)();
}
return $response;
}
}
$port = (int) getenv('ZYT_WECOM_MEMBER_TEST_MYSQL_PORT');
if ($port < 1024 || $port === 3306 || $port > 65535) {
fwrite(STDERR, "Set ZYT_WECOM_MEMBER_TEST_MYSQL_PORT to an isolated local MySQL port (not 3306).\n");
exit(1);
}
$database = 'wecom_member_test_' . bin2hex(random_bytes(6));
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
$pdo->exec("USE `{$database}`");
$testApp = new think\App();
$manager = new DbManager();
$manager->setConfig([
'default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false,
'connections' => ['mysql' => [
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
'database' => $database, 'username' => 'root', 'password' => '',
'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true,
]],
]);
Container::getInstance()->instance('think\DbManager', $manager);
Container::getInstance()->instance('config', new think\Config());
$checks = 0;
$passed = [];
$failed = [];
$expect = static function (bool $ok, string $message) use (&$checks): void {
if (!$ok) {
throw new RuntimeException($message);
}
$checks++;
};
$run = static function (string $name, Closure $test) use (&$passed, &$failed): void {
try {
$test();
$passed[] = $name;
echo "PASS {$name}\n";
} catch (Throwable $error) {
$failed[$name] = $error->getMessage();
echo "FAIL {$name}: {$error->getMessage()}\n";
}
};
$admin = ['root' => 1, 'admin_id' => 1, 'name' => 'Isolated test administrator'];
try {
// Use the deployed table definitions, without executing unrelated menu/cron mutations.
$schemas = [
'1.9.20260805/add_first_visit_wecom_promotion.sql' => ['qywx_promotion_pool', 'qywx_promotion_link'],
'1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql' => [
'qywx_promotion_pool_member', 'qywx_promotion_dispatch_event', 'qywx_promotion_range_sync',
],
'1.9.20260828/add_wecom_promotion_pool_operators.sql' => ['qywx_promotion_pool_operator'],
];
foreach ($schemas as $file => $tables) {
$sql = file_get_contents(dirname(__DIR__) . '/sql/' . $file);
foreach ($tables as $table) {
if (!preg_match('/CREATE TABLE IF NOT EXISTS `zyt_' . preg_quote($table, '/') . '` \([\s\S]*?;/', $sql, $match)) {
throw new RuntimeException('Missing fixture schema: ' . $table);
}
$pdo->exec($match[0]);
}
}
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)');
$pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)');
$pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY, perms VARCHAR(100), is_disable INT DEFAULT 0)');
$fixture = static function (array $syncFields = [], array $cachedUsers = ['XuKe', 'OldAssistant'], array $cachedDepartments = []): array {
$poolId = (int) Db::name('qywx_promotion_pool')->insertGetId([
'name' => '隔离范围同步测试', 'public_key' => bin2hex(random_bytes(16)), 'owner_admin_id' => 1,
]);
$linkId = (int) Db::name('qywx_promotion_link')->insertGetId([
'pool_id' => $poolId, 'remote_link_id' => 'test-remote-' . $poolId,
'wecom_url' => 'https://work.weixin.qq.com/ca/isolated-test', 'remote_status' => 1,
'range_user_json' => json_encode($cachedUsers), 'range_department_json' => json_encode($cachedDepartments),
]);
foreach ([['XuKe', 1], ['OldAssistant', 0], ['AnotherOldAssistant', 0]] as $index => [$userId, $enabled]) {
Db::name('qywx_promotion_pool_member')->insert([
'pool_id' => $poolId, 'admin_id' => $index + 1, 'userid' => $userId,
'enabled' => $enabled, 'today_date' => date('Y-m-d'),
]);
}
Db::name('qywx_promotion_range_sync')->insert(array_replace([
'pool_id' => $poolId, 'promotion_link_id' => $linkId, 'status' => 1,
'desired_version' => 2, 'applied_version' => 1,
], $syncFields));
return [$poolId, $linkId, new MemberSyncFakeApi()];
};
$syncRow = static fn (int $poolId): array => Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find();
$linkRow = static fn (int $linkId): array => Db::name('qywx_promotion_link')->where('id', $linkId)->find();
$retry = static fn (int $poolId, MemberSyncFakeApi $api): array => WecomPromotionLogic::syncMemberRange(
$poolId, 1, $admin, new QywxPromotionRangeSyncService($api)
);
$run('only XuKe is sent and both remote range dimensions are verified', static function () use ($fixture, $expect, $syncRow, $linkRow): void {
[$poolId, $linkId, $api] = $fixture([], ['XuKe', 'OldAssistant'], ['42']);
$result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId);
$expect($result['status'] === 'synced', 'Exact confirmed range should sync');
$expect(count($api->updates) === 1 && $api->gets === ['test-remote-' . $poolId], 'Must update then GET the same official link');
$expect($api->updates[0]['range'] === ['user_list' => ['XuKe'], 'department_list' => []], 'Disabled members and departments must be removed from update');
$link = $linkRow($linkId);
$expect(json_decode($link['range_user_json'], true) === ['XuKe'] && json_decode($link['range_department_json'], true) === [], 'Save the GET-verified users and empty departments');
$row = $syncRow($poolId);
$expect((int) $row['status'] === 0 && (int) $row['applied_version'] === (int) $row['desired_version'] && $row['lock_token'] === '', 'Confirmed sync must finish its version and release the lease');
});
foreach (['old user' => [['XuKe', 'OldAssistant'], []], 'department expansion' => [['XuKe'], ['42']]] as $name => [$users, $departments]) {
$run('GET mismatch rejects ' . $name . ' and remains retryable', static function () use ($fixture, $expect, $syncRow, $linkRow, $users, $departments): void {
[$poolId, $linkId, $api] = $fixture();
$api->remoteUsers = $users;
$api->remoteDepartments = $departments;
$error = null;
try {
(new QywxPromotionRangeSyncService($api))->syncPool($poolId);
} catch (Throwable $caught) {
$error = $caught;
}
$expect($error !== null, 'Mismatched confirmed range must reject sync');
$row = $syncRow($poolId);
$expect((int) $row['status'] === 3 && (int) $row['next_retry'] > time(), 'Mismatch must leave a scheduled retry');
$expect((int) $row['applied_version'] === 1 && $row['last_error'] !== '' && $linkRow($linkId)['sync_error'] !== '', 'Failure must not advance confirmed version and must remain visible');
$expect((int) Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'OldAssistant')->value('enabled') === 0, 'Failure must preserve the saved offline switch');
});
}
$run('transport failure preserves last confirmed snapshot and retry', static function () use ($fixture, $expect, $syncRow, $linkRow): void {
[$poolId, $linkId, $api] = $fixture();
$before = $linkRow($linkId)['range_user_json'];
$api->failure = 'fake upstream timeout';
try {
(new QywxPromotionRangeSyncService($api))->syncPool($poolId);
throw new LogicException('Expected fake transport error');
} catch (RuntimeException $error) {
$expect($error->getMessage() === $api->failure, 'Surface the transport error');
}
$row = $syncRow($poolId);
$expect((int) $row['status'] === 3 && (int) $row['attempts'] === 1 && (int) $row['next_retry'] > time(), 'Transport failure must retain retry backoff');
$expect($linkRow($linkId)['range_user_json'] === $before && $api->gets === [], 'Failed update cannot replace confirmed remote snapshot');
});
$run('active sync lease cannot be reported as synced or stolen', static function () use ($fixture, $expect, $syncRow): void {
$token = str_repeat('a', 32);
[$poolId, , $api] = $fixture(['status' => 2, 'lock_token' => $token, 'lock_until' => time() + 90]);
$result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId);
$expect($result['status'] !== 'synced' && $api->updates === [] && $syncRow($poolId)['lock_token'] === $token, 'Existing worker keeps its active lease and caller stays pending');
});
$run('version change during GET reports pending and resyncs', static function () use ($fixture, $expect, $syncRow): void {
[$poolId, , $api] = $fixture();
$api->onGet = static function () use ($poolId): void {
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->inc('desired_version')->update();
};
$result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId);
$row = $syncRow($poolId);
$expect($result['status'] === 'pending' && (int) $row['status'] === 1, 'Stale confirmed version must report pending, never synced');
$expect((int) $row['applied_version'] < (int) $row['desired_version'], 'Concurrent version must remain unconfirmed');
$api->onGet = null;
$expect((new QywxPromotionRangeSyncService($api))->syncPool($poolId)['status'] === 'synced', 'Next attempt should confirm the newer version');
});
$run('lease expiry during GET cannot commit success', static function () use ($fixture, $expect, $syncRow): void {
[$poolId, , $api] = $fixture();
$api->onGet = static function () use ($poolId): void {
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update(['lock_until' => time() - 1]);
};
$result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId);
$expect($result['status'] === 'pending' && (int) $syncRow($poolId)['status'] !== 0, 'Expired worker cannot acknowledge a completed sync');
});
$run('superseded lease cannot overwrite newer worker snapshot', static function () use ($fixture, $expect, $syncRow, $linkRow): void {
[$poolId, $linkId, $api] = $fixture();
$newToken = str_repeat('b', 32);
$api->onGet = static function () use ($poolId, $linkId, $newToken): void {
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
'lock_token' => $newToken, 'lock_until' => time() + 90, 'desired_version' => 3,
]);
Db::name('qywx_promotion_link')->where('id', $linkId)->update(['range_user_json' => '["NewWorkerSnapshot"]']);
};
$result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId);
$expect($result['status'] === 'pending' && $syncRow($poolId)['lock_token'] === $newToken, 'Superseded worker must not release or acknowledge the new lease');
$expect($linkRow($linkId)['range_user_json'] === '["NewWorkerSnapshot"]', 'Superseded worker must not overwrite a newer remote snapshot');
});
foreach ([
'expired running lease' => ['status' => 2, 'lock_token' => str_repeat('c', 32), 'lock_until' => time() - 1],
'unconfirmed version' => ['status' => 0, 'desired_version' => 7, 'applied_version' => 6],
] as $name => $fields) {
$run('reconcile keeps ' . $name . ' pending despite matching cache', static function () use ($fixture, $expect, $syncRow, $fields): void {
[$poolId] = $fixture($fields, ['XuKe']);
$result = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
$expect($result['queued'] === true && (int) $syncRow($poolId)['status'] === 1, 'An unverified version or expired worker must be retried even when user cache matches');
});
}
$run('reconcile removes cached departments even when users match', static function () use ($fixture, $expect, $syncRow): void {
[$poolId] = $fixture(['status' => 0, 'desired_version' => 2, 'applied_version' => 2], ['XuKe'], ['42']);
$result = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
$expect($result['queued'] === true && (int) $syncRow($poolId)['status'] === 1, 'Residual department routes require a fresh update');
});
foreach (['pending' => ['status' => 1], 'failed backoff' => ['status' => 3, 'next_retry' => time() + 300, 'last_error' => 'previous failure']] as $name => $fields) {
$run('explicit retry repairs old ' . $name . ' without changing member switches', static function () use ($fixture, $expect, $syncRow, $retry, $fields): void {
[$poolId, , $api] = $fixture($fields);
$before = Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->column('enabled', 'userid');
$result = $retry($poolId, $api);
$expect($result['sync_status'] === 'synced' && $result['sync_error'] === '' && !$result['sync_queued'], 'Explicit retry must complete and return confirmed structured state');
$expect($result['range_userids'] === ['XuKe'] && $result['range_department_ids'] === [] && count($api->updates) === 1, 'Retry must return the GET-confirmed member range');
$expect($before === Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->column('enabled', 'userid') && (int) $syncRow($poolId)['status'] === 0, 'Retry must leave all switches unchanged');
});
}
$run('explicit retry active lease returns pending without false success', static function () use ($fixture, $expect, $retry, $syncRow): void {
$token = str_repeat('d', 32);
[$poolId, , $api] = $fixture(['status' => 2, 'lock_token' => $token, 'lock_until' => time() + 90]);
$result = $retry($poolId, $api);
$expect($result['sync_status'] === 'pending' && $result['sync_queued'] && $api->updates === [], 'In-flight retry must say pending');
$expect($syncRow($poolId)['lock_token'] === $token && (int) $syncRow($poolId)['status'] === 2, 'Retry must preserve active worker ownership');
});
$run('explicit retry API failure remains failed with saved local state', static function () use ($fixture, $expect, $retry, $syncRow): void {
[$poolId, , $api] = $fixture();
$api->failure = 'fake permission denied';
$result = $retry($poolId, $api);
$expect($result['sync_status'] === 'failed' && $result['sync_error'] !== '' && $result['sync_queued'], 'Failure should return a visible error and scheduled retry');
$expect((int) $syncRow($poolId)['status'] === 3 && $result['range_userids'] === ['XuKe', 'OldAssistant'], 'Failure must expose the last confirmed range, including pending removal');
});
$run('no eligible member is blocked and never sends empty official range', static function () use ($fixture, $expect, $retry, $syncRow): void {
[$poolId, , $api] = $fixture();
Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->update(['enabled' => 0]);
$result = $retry($poolId, $api);
$expect($result['sync_status'] === 'blocked' && $result['sync_error'] !== '' && !$result['sync_queued'], 'Empty eligible range must clearly report blocked');
$expect($api->updates === [] && (int) $syncRow($poolId)['status'] === 4, 'Blocked pool must not send an empty enterprise WeChat range');
});
$run('explicit retry rejects pools outside operator scope', static function () use ($fixture, $expect): void {
[$poolId, , $api] = $fixture();
$error = null;
try {
WecomPromotionLogic::syncMemberRange($poolId, 99, ['root' => 0, 'admin_id' => 99], new QywxPromotionRangeSyncService($api));
} catch (RuntimeException $caught) {
$error = $caught;
}
$expect($error !== null && $api->updates === [], 'Unrelated account must not mutate remote member ranges');
});
$run('batch repeated offline selection requeues the unsynced remote range', static function () use ($fixture, $expect, $syncRow, $admin): void {
[$poolId, , $api] = $fixture(['status' => 0, 'desired_version' => 2, 'applied_version' => 2]);
$result = WecomPromotionLogic::batchUpdatePools([
'pool_ids' => [$poolId],
'changes' => ['member_status' => ['member_admin_ids' => [2, 3], 'status' => 0]],
], 1, $admin);
$expect($result['failed'] === 0 && $result['member_matched'] === 2 && $result['member_updated'] === 0, 'An already-offline selection remains a valid repeat action');
$expect($result['sync_queued_count'] === 1 && $result['results'][0]['sync_queued'] && (int) $syncRow($poolId)['status'] === 1, 'No-op local switches must still queue the stale official range');
$expect((new QywxPromotionRangeSyncService($api))->syncPool($poolId)['status'] === 'synced' && $api->updates[0]['range']['user_list'] === ['XuKe'], 'The queued batch repair must leave only XuKe in the official link');
});
$run('batch disables both old assistants and queue confirms XuKe only', static function () use ($fixture, $expect, $admin): void {
[$poolId, , $api] = $fixture([], ['XuKe', 'OldAssistant', 'AnotherOldAssistant']);
Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->update(['enabled' => 1]);
$result = WecomPromotionLogic::batchUpdatePools([
'pool_ids' => [$poolId],
'changes' => ['member_status' => ['member_admin_ids' => [2, 3], 'status' => 0]],
], 1, $admin);
$expect($result['member_updated'] === 2 && $result['failed'] === 0 && $result['sync_queued_count'] === 1, 'Batch offline must save both switches and report queued remote work');
$expect((new QywxPromotionRangeSyncService($api))->syncPool($poolId)['status'] === 'synced' && $api->updates[0]['range'] === ['user_list' => ['XuKe'], 'department_list' => []], 'Actual batch queue must update/get the final exact range');
});
$run('single offline with active worker reports pending then retries successfully', static function () use ($fixture, $expect, $admin, $retry, $syncRow): void {
$token = str_repeat('e', 32);
[$poolId, , $api] = $fixture(['status' => 2, 'lock_token' => $token, 'lock_until' => time() + 90]);
Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'OldAssistant')->update(['enabled' => 1]);
$memberId = (int) Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'OldAssistant')->value('id');
// Active lease prevents any real API request from the endpoint's default service.
$result = WecomPromotionLogic::toggleMember($memberId, 0, 1, $admin);
$expect($result['sync_status'] === 'pending' && $result['sync_queued'] && $syncRow($poolId)['lock_token'] === $token, 'Single toggle must not turn a service noop into success: ' . json_encode($result, JSON_UNESCAPED_UNICODE));
$expect((int) Db::name('qywx_promotion_pool_member')->where('id', $memberId)->value('enabled') === 0, 'Single toggle must persist offline locally while waiting for its worker');
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update(['lock_until' => time() - 1]);
$expect($retry($poolId, $api)['sync_status'] === 'synced' && $api->updates[0]['range']['user_list'] === ['XuKe'], 'Explicit retry must clear the single-toggle pending removal');
});
$run('single and batch preserve at least one eligible assistant', static function () use ($fixture, $expect, $admin): void {
[$poolId] = $fixture();
$memberId = (int) Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'XuKe')->value('id');
foreach (['single', 'batch'] as $method) {
$error = null;
try {
if ($method === 'single') {
WecomPromotionLogic::toggleMember($memberId, 0, 1, $admin);
} else {
WecomPromotionLogic::batchUpdatePools([
'pool_ids' => [$poolId], 'changes' => ['member_status' => ['member_admin_ids' => [1], 'status' => 0]],
], 1, $admin);
}
} catch (RuntimeException $caught) {
$error = $caught;
}
$expect($error !== null && (int) Db::name('qywx_promotion_pool_member')->where('id', $memberId)->value('enabled') === 1, 'The final available member must remain online after rejected ' . $method . ' action');
}
});
$run('shared operator can retry its own assigned pool', static function () use ($fixture, $expect): void {
[$poolId, , $api] = $fixture();
Db::name('system_menu')->insert(['id' => 1, 'perms' => 'firstvisit.wecomPromotion/overview']);
Db::name('qywx_promotion_pool_operator')->insert(['pool_id' => $poolId, 'admin_id' => 88]);
$result = WecomPromotionLogic::syncMemberRange($poolId, 88, ['root' => 0, 'admin_id' => 88], new QywxPromotionRangeSyncService($api));
$expect($result['sync_status'] === 'synced' && count($api->updates) === 1, 'Assigned operator should be allowed the targeted range retry');
});
foreach ([
'deleting' => ['status' => 5, 'lock_token' => str_repeat('f', 32), 'lock_until' => time() + 90],
'delete failed' => ['status' => 4, 'last_error' => '企业微信官方获客链接删除失败: fake timeout'],
] as $name => $fields) {
$run('retry never revives ' . $name . ' pool', static function () use ($fixture, $expect, $retry, $syncRow, $fields): void {
[$poolId, , $api] = $fixture($fields);
$before = $syncRow($poolId);
$result = $retry($poolId, $api);
$expect($result['sync_status'] === 'blocked' && $api->updates === [] && $syncRow($poolId) === $before, 'Retry must preserve deletion ownership and leave remote API untouched');
});
}
echo json_encode(['passed' => count($passed), 'failed' => count($failed), 'checks' => $checks, 'failures' => $failed], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
} finally {
$pdo->exec("DROP DATABASE IF EXISTS `{$database}`");
echo "Disposable test database dropped.\n";
}
exit($failed === [] ? 0 : 1);