更新
This commit is contained in:
@@ -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 })
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user