This commit is contained in:
Your Name
2026-09-09 12:18:17 +08:00
parent 0cea43b027
commit 40319eea41
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