更新
This commit is contained in:
@@ -168,11 +168,12 @@ export interface FirstVisitConversionFanDetailRow {
|
||||
wecom_userid: string
|
||||
wecom_staff_name: string
|
||||
add_time: string | null
|
||||
is_deleted: boolean
|
||||
delete_time: string | null
|
||||
is_deleted?: boolean
|
||||
delete_time?: string | null
|
||||
}
|
||||
|
||||
export interface FirstVisitConversionFansDetailResult {
|
||||
export interface FirstVisitConversionFansDetailResult {
|
||||
can_view_deleted_fans?: boolean
|
||||
lists?: FirstVisitConversionFanDetailRow[]
|
||||
rows?: FirstVisitConversionFanDetailRow[]
|
||||
total?: number
|
||||
@@ -237,6 +238,36 @@ export function wecomPromotionOverview() {
|
||||
export function wecomPromotionSavePool(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params, timeout: 120000 })
|
||||
}
|
||||
|
||||
export interface WecomPromotionTagGroup {
|
||||
group_id: string
|
||||
group_name: string
|
||||
tag: Array<{ id: string; name: string }>
|
||||
}
|
||||
|
||||
export function wecomPromotionTagOptions() {
|
||||
return request.get<{ tag_groups: WecomPromotionTagGroup[] }>({
|
||||
url: '/firstvisit.wecomPromotion/tagOptions'
|
||||
})
|
||||
}
|
||||
|
||||
export function wecomPromotionCreateTag(params: { name: string }) {
|
||||
return request.post<{ tag: { id: string; name: string }; group_id: string; group_name: string; reused: boolean }>({
|
||||
url: '/firstvisit.wecomPromotion/createTag', params, timeout: 30000
|
||||
}, { ignoreCancelToken: true })
|
||||
}
|
||||
|
||||
export function wecomPromotionUploadWelcomeMedia(file: File, type: 'image' | 'video' | 'file') {
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
data.append('type', type)
|
||||
return request.post<{ asset_id: string; name: string; type: string }>({
|
||||
url: '/firstvisit.wecomPromotion/uploadWelcomeMedia',
|
||||
data,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000
|
||||
}, { ignoreCancelToken: true })
|
||||
}
|
||||
|
||||
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
|
||||
|
||||
@@ -578,6 +578,11 @@ export function prescriptionOrderAddLog(params: {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
|
||||
}
|
||||
|
||||
/** 移除单笔收款关联,总金额不变,同步更新已付金额和需代收 */
|
||||
export function prescriptionOrderUnlinkPayOrder(params: { id: number; pay_order_id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/unlinkPayOrder', params })
|
||||
}
|
||||
|
||||
/** 修改订单金额 */
|
||||
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||
|
||||
@@ -541,6 +541,19 @@
|
||||
{{ formatOrderTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canUnlinkPayOrder" label="操作" width="80" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
:loading="unlinkPayOrderId === Number(row.id)"
|
||||
:disabled="unlinkPayOrderId !== null || ![2, 5].includes(Number(row.status))"
|
||||
:title="Number(row.status) === 4 ? '已退款收款记录不可移除' : '解除关联并更新已付金额,总金额不变'"
|
||||
@click="confirmUnlinkPayOrder(row)"
|
||||
>移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="未关联收款单" :image-size="60" />
|
||||
</el-card>
|
||||
@@ -1014,6 +1027,7 @@ import {
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderLogisticsJdUpdate,
|
||||
prescriptionOrderPaidPayOrders,
|
||||
prescriptionOrderUnlinkPayOrder,
|
||||
prescriptionOrderPatchPrescriptionUsage
|
||||
} from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
@@ -1107,6 +1121,38 @@ const detailLoading = ref(false)
|
||||
const detailData = ref<Record<string, any> | null>(null)
|
||||
const detailLogs = ref<any[]>([])
|
||||
const detailUnlinkedPayOrders = ref<any[]>([])
|
||||
const unlinkPayOrderId = ref<number | null>(null)
|
||||
const canUnlinkPayOrder = computed(() =>
|
||||
!props.readonly && canUpdateAmount(detailData.value) && hasPerm('tcm.prescriptionOrder/unlinkPayOrder')
|
||||
)
|
||||
|
||||
async function confirmUnlinkPayOrder(row: { id: number; order_no?: string; amount: number | string; status: number | string }) {
|
||||
const orderId = Number(detailData.value?.id)
|
||||
const payOrderId = Number(row.id)
|
||||
if (!canUnlinkPayOrder.value || !orderId || !payOrderId || unlinkPayOrderId.value !== null) return
|
||||
if (![2, 5].includes(Number(row.status))) return
|
||||
|
||||
unlinkPayOrderId.value = payOrderId
|
||||
try {
|
||||
await feedback.confirm(
|
||||
`确定移除收款记录「${row.order_no || '#' + payOrderId}」(¥${formatMoney(row.amount)})?` +
|
||||
'仅解除关联,原收款记录保留;订单总金额不变,已付金额与需代收金额按剩余关联收款重新计算。'
|
||||
)
|
||||
if (!detailVisible.value || Number(detailData.value?.id) !== orderId) return
|
||||
const res: any = await prescriptionOrderUnlinkPayOrder({ id: orderId, pay_order_id: payOrderId })
|
||||
const updated = res?.data ?? res
|
||||
if (Number(detailData.value?.id) === orderId && Number(updated?.id) === orderId) {
|
||||
Object.assign(detailData.value!, updated)
|
||||
}
|
||||
feedback.msgSuccess('收款关联已移除,金额已同步更新')
|
||||
emit('detail-changed')
|
||||
await refreshIfCurrent(orderId)
|
||||
} catch {
|
||||
/* 取消不提交;接口错误由拦截器提示,保留当前收款记录 */
|
||||
} finally {
|
||||
unlinkPayOrderId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const detailPrescription = computed(() => {
|
||||
const p = detailData.value?.prescription
|
||||
|
||||
@@ -168,6 +168,7 @@ export function logActionText(act: string) {
|
||||
ship: '确认发货',
|
||||
withdraw: '撤销',
|
||||
link_pay_order: '关联支付单',
|
||||
unlink_pay_order: '移除收款关联',
|
||||
completion_request: '完单申请',
|
||||
auto_complete: '自动完成',
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
(-{{ formatNumber(dashboard.summary.deleted_fans_count) }})
|
||||
</em>
|
||||
</strong>
|
||||
<small>{{ metric.hint }}</small>
|
||||
<small>{{ metric.hint }}<template v-if="metric.key === 'add_fans_count' && canViewDeletedFans">;(-N)为其中已删除</template></small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
<div class="panel-heading panel-heading--table">
|
||||
<div>
|
||||
<h2>明细数据列表</h2>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间新增加粉(按员工+客户去重,包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加),(-N)表示加粉总数中已删除;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间新增加粉(按员工+客户去重,包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加)<template v-if="canViewDeletedFans">,(-N)表示加粉总数中已删除</template>;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
</div>
|
||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||
</div>
|
||||
@@ -339,7 +339,7 @@
|
||||
<el-table-column label="添加时间" min-width="180">
|
||||
<template #default="{ row }">{{ fanAddTime(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="150" align="center">
|
||||
<el-table-column v-if="canViewDeletedFansDetail" label="状态" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<div v-if="isDeletedFan(row)" class="deleted-status">
|
||||
<el-tag type="danger" effect="dark" size="small">已删除</el-tag>
|
||||
@@ -403,7 +403,7 @@ const emptyDashboard = () => ({
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden',
|
||||
can_view_finance: false
|
||||
can_view_finance: false, can_view_deleted_fans: false
|
||||
},
|
||||
filters: {
|
||||
departments: [] as any[],
|
||||
@@ -431,6 +431,7 @@ const query = reactive<FirstVisitConversionParams>({
|
||||
const customDateRange = ref<string[]>([])
|
||||
const fansDetailVisible = ref(false)
|
||||
const fansDetailLoading = ref(false)
|
||||
const fansDetailCanViewDeletedFans = ref(false)
|
||||
const fansDetailRows = ref<FirstVisitConversionFanDetailRow[]>([])
|
||||
const fansDetailPager = reactive({ page_no: 1, page_size: 20, total: 0 })
|
||||
const fansDetailEntity = ref<{ type: 'dept' | 'member'; id: string | number; adminId?: number; name: string }>({
|
||||
@@ -447,7 +448,7 @@ const timeOptions = [
|
||||
{ label: '自定义', value: 'custom' }
|
||||
]
|
||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增加粉(含已删除);(-N)为其中已删除' },
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增加粉(含已删除)' },
|
||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||
@@ -465,6 +466,9 @@ const scopeDescription = computed(() => {
|
||||
return parts.join(' · ')
|
||||
})
|
||||
const canViewFinance = computed(() => Boolean(dashboard.meta.can_view_finance))
|
||||
// 仅服务端按登录账号精确判定为admin时放行;缺失、字符串或角色权限均不能兜底放行。
|
||||
const canViewDeletedFans = computed(() => dashboard.meta.can_view_deleted_fans === true)
|
||||
const canViewDeletedFansDetail = computed(() => canViewDeletedFans.value && fansDetailCanViewDeletedFans.value)
|
||||
const visibleMetricCards = computed(() =>
|
||||
canViewFinance.value
|
||||
? metricCards
|
||||
@@ -542,6 +546,7 @@ let latestFansDetailRequestId = 0
|
||||
async function loadDashboard() {
|
||||
const requestId = ++latestDashboardRequestId
|
||||
loading.value = true
|
||||
dashboard.meta.can_view_deleted_fans = false
|
||||
try {
|
||||
const params: FirstVisitConversionParams = { ...query }
|
||||
if (params.time_type === 'custom') {
|
||||
@@ -608,6 +613,7 @@ async function loadFansDetail() {
|
||||
if (!fansDetailVisible.value) return
|
||||
const requestId = ++latestFansDetailRequestId
|
||||
fansDetailLoading.value = true
|
||||
fansDetailCanViewDeletedFans.value = false
|
||||
try {
|
||||
const params: FirstVisitConversionFansDetailParams = {
|
||||
...query,
|
||||
@@ -623,6 +629,7 @@ async function loadFansDetail() {
|
||||
}
|
||||
const result = await firstVisitConversionFansDetail(params) as FirstVisitConversionFansDetailResult
|
||||
if (requestId !== latestFansDetailRequestId) return
|
||||
fansDetailCanViewDeletedFans.value = result?.can_view_deleted_fans === true
|
||||
const rows = Array.isArray(result?.lists) ? result.lists : (Array.isArray(result?.rows) ? result.rows : [])
|
||||
fansDetailRows.value = rows
|
||||
fansDetailPager.total = Number(result?.count ?? result?.total ?? rows.length) || 0
|
||||
@@ -644,6 +651,7 @@ function handleFansDetailSizeChange() {
|
||||
function resetFansDetail() {
|
||||
++latestFansDetailRequestId
|
||||
fansDetailLoading.value = false
|
||||
fansDetailCanViewDeletedFans.value = false
|
||||
fansDetailRows.value = []
|
||||
fansDetailPager.page_no = 1
|
||||
fansDetailPager.total = 0
|
||||
@@ -654,7 +662,7 @@ function hasFans(value: any) {
|
||||
}
|
||||
|
||||
function isDeletedFan(row: FirstVisitConversionFanDetailRow) {
|
||||
return Boolean(row.is_deleted)
|
||||
return canViewDeletedFansDetail.value && Boolean(row.is_deleted)
|
||||
}
|
||||
|
||||
function fanCustomerName(row: FirstVisitConversionFanDetailRow) {
|
||||
@@ -685,7 +693,7 @@ function formatNumber(value: any) {
|
||||
}
|
||||
|
||||
function hasDeletedFans(value: any) {
|
||||
return Math.round(Number(value || 0)) > 0
|
||||
return canViewDeletedFans.value && Math.round(Number(value || 0)) > 0
|
||||
}
|
||||
|
||||
function formatMoney(value: any) {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="automation-form">
|
||||
<el-alert class="automation-note" type="info" show-icon :closable="false" title="自动化设置作用于之后新添加的客户,不会立即推送给存量客户。" description="欢迎语依赖企微回调与常驻任务消费进程在 welcome_code 的 20 秒有效期内处理;分钟任务用于接待调度及标签、备注等补偿。请确认服务端已部署这些任务。" />
|
||||
|
||||
<h3 class="form-section-title">接待设置</h3>
|
||||
<el-form-item label="接待模式">
|
||||
<el-radio-group v-model="config.reception_mode" :disabled="disabled">
|
||||
<el-radio value="always">全天接待</el-radio>
|
||||
<el-radio value="scheduled">按星期时段自动上下线</el-radio>
|
||||
</el-radio-group>
|
||||
<p class="field-help">成员仍受每日上限、有效期及启用状态限制;时间统一使用北京时间。</p>
|
||||
</el-form-item>
|
||||
<div v-if="config.reception_mode === 'scheduled'" class="reception-schedules">
|
||||
<div v-for="(slot, index) in config.reception_schedule" :key="index" class="schedule-card">
|
||||
<div class="schedule-heading"><strong>接待时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="disabled" @click="config.reception_schedule.splice(index, 1)">删除时段</el-button></div>
|
||||
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="disabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
|
||||
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="disabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="disabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
|
||||
<el-select v-model="slot.member_admin_ids" :disabled="disabled" multiple filterable clearable placeholder="从上方主接待成员中选择" style="width: 100%">
|
||||
<el-option v-for="member in mainMembers" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" />
|
||||
</el-select>
|
||||
<p v-if="slot.member_admin_ids.some((id) => !mainMemberIds.includes(id))" class="inline-error">该时段含已从主接待移除的成员,请重新选择。</p>
|
||||
</div>
|
||||
<el-button :icon="Plus" :disabled="disabled || config.reception_schedule.length >= 30" @click="addReceptionSlot">添加接待时段</el-button>
|
||||
<p class="field-help">最多 30 个时段。跨午夜时段归属开始日,例如星期一 22:00 至 02:00 包含星期二凌晨;接待时段重叠时取成员并集。</p>
|
||||
</div>
|
||||
<el-form-item label="备用成员" :required="config.reception_mode === 'scheduled'">
|
||||
<el-select v-model="config.backup_member_admin_ids" :disabled="disabled" multiple filterable clearable collapse-tags collapse-tags-tooltip :max-collapse-tags="3" :multiple-limit="500" placeholder="主接待成员均不可用时由备用成员接待" style="width: 100%">
|
||||
<el-option v-for="member in members" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" :disabled="mainMemberIds.includes(Number(member.id))" />
|
||||
<el-option v-for="id in missingBackupIds" :key="`missing-${id}`" :value="id" :label="`成员 ${id}(当前不可选,请移除后重新选择)`" disabled />
|
||||
</el-select>
|
||||
<p class="field-help">备用成员不能与主接待重复。按时段模式至少配置一名备用成员;仅当无可用主接待时进入官方成员范围。</p>
|
||||
</el-form-item>
|
||||
|
||||
<h3 class="form-section-title">客户设置</h3>
|
||||
<el-form-item label="自动添加客户标签">
|
||||
<el-switch v-model="config.tags_enabled" :disabled="disabled || tagsCreating" />
|
||||
<div v-if="hasMultipleTags" class="legacy-tags-warning full-width" role="alert">
|
||||
<p>原方案设置了多个标签:{{ selectedTagNames }}。现在仅支持单选,请重新选择一个标签,或清空原标签。</p>
|
||||
<el-button size="small" :disabled="disabled || tagsCreating" @click="selectedTag = ''">清空原标签</el-button>
|
||||
</div>
|
||||
<div v-if="config.tags_enabled" class="full-width tags-content">
|
||||
<div class="tag-select-row">
|
||||
<el-select v-model="selectedTag" :disabled="disabled || tagsCreating" :loading="tagsLoading" filterable clearable placeholder="选择一个企业微信客户标签" aria-label="企业微信客户标签" class="tag-select">
|
||||
<el-option-group v-for="group in tagGroups" :key="group.group_id" :label="group.group_name">
|
||||
<el-option v-for="tag in group.tag" :key="tag.id" :value="tag.id" :label="tag.name" />
|
||||
</el-option-group>
|
||||
<el-option-group v-if="unknownTagIds.length" label="已选标签(名称暂不可用)"><el-option v-for="id in unknownTagIds" :key="id" :value="id" :label="`已选标签 · ${id}`" /></el-option-group>
|
||||
</el-select>
|
||||
<el-button :icon="Plus" :disabled="disabled || tagsCreating" @click="showCustomTag = !showCustomTag">自定义标签</el-button>
|
||||
<el-button :icon="Refresh" :disabled="disabled || tagsCreating" :loading="tagsLoading" @click="loadTags">{{ tagsError ? '重试' : '刷新标签' }}</el-button>
|
||||
</div>
|
||||
<p v-if="tagsError" role="alert" class="inline-error">{{ tagsError }} 已保留原有标签,点击“重试”重新加载。</p>
|
||||
<p v-else class="field-help">每个方案只选一个标签,可选择已有企业微信标签,也可自定义创建。标签在企微后台修改后请刷新。</p>
|
||||
<div v-if="showCustomTag" class="custom-tag-editor">
|
||||
<label for="promotion-custom-tag-name">自定义标签名称</label>
|
||||
<div class="custom-tag-row">
|
||||
<el-input id="promotion-custom-tag-name" v-model="customTagName" :disabled="disabled || tagsCreating" maxlength="30" show-word-limit placeholder="例如:官网咨询" @input="customTagError = ''" @keydown.enter.prevent="createCustomTag" />
|
||||
<el-button type="primary" :disabled="disabled || tagsLoading" :loading="tagsCreating" @click="createCustomTag">创建并选用</el-button>
|
||||
</div>
|
||||
<p class="field-help">创建到企业微信“推广渠道”分组,同组同名标签会复用。创建后即保存到企微标签库,取消方案编辑不会删除标签。</p>
|
||||
<p v-if="customTagError" role="alert" class="inline-error">{{ customTagError }} 原有选择未改变。</p>
|
||||
</div>
|
||||
<p v-if="customTagSuccess" role="status" class="tag-success">{{ customTagSuccess }}</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="自动设置客户备注">
|
||||
<el-switch v-model="config.remark_enabled" :disabled="disabled" />
|
||||
<div v-if="config.remark_enabled" class="full-width remark-content">
|
||||
<div class="token-buttons"><el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="disabled" @click="insertRemark(token.value)">插入{{ token.label }}</el-button></div>
|
||||
<el-input ref="remarkInput" v-model="config.remark_template" :disabled="disabled" maxlength="200" show-word-limit placeholder="例如:官网-{customer_name}" @select="rememberRemarkSelection" @keyup="rememberRemarkSelection" @click="rememberRemarkSelection" @blur="rememberRemarkSelection" />
|
||||
<div class="remark-preview"><span>备注预览</span><strong>{{ remarkPreview || '—' }}</strong><small>{{ Array.from(remarkPreview).length }}/20 字</small></div>
|
||||
<p class="field-help">示例客户:张女士;员工:{{ employeeName }}。添加时间格式为 YYYY-MM-DD,生成后的备注最多保留前 20 字。</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="自动设置客户描述">
|
||||
<el-switch v-model="config.description_enabled" :disabled="disabled" />
|
||||
<el-input v-if="config.description_enabled" v-model="config.description" class="description-input" :disabled="disabled" type="textarea" :rows="3" maxlength="150" show-word-limit placeholder="请输入客户描述,最多 150 字" />
|
||||
</el-form-item>
|
||||
|
||||
<h3 class="form-section-title">欢迎语设置</h3>
|
||||
<el-form-item label="欢迎语模式">
|
||||
<el-radio-group v-model="config.welcome_mode" :disabled="disabled || anyUploading">
|
||||
<el-radio value="channel">渠道欢迎语</el-radio>
|
||||
<el-radio value="default">默认欢迎语</el-radio>
|
||||
<el-radio value="none">不发送欢迎语</el-radio>
|
||||
</el-radio-group>
|
||||
<p v-if="config.welcome_mode === 'default'" class="field-help">沿用企业微信后台配置,本系统不发送欢迎语。</p>
|
||||
<p v-else-if="config.welcome_mode === 'none'" class="field-help warning-help">仅关闭本系统欢迎语,无法覆盖或关闭企业微信后台已配置的欢迎语。</p>
|
||||
<p v-else class="field-help warning-help">企微后台欢迎语可能优先发送;需要使用此渠道内容时,请核查企微后台配置,避免本系统无法发送。</p>
|
||||
</el-form-item>
|
||||
<template v-if="config.welcome_mode === 'channel'">
|
||||
<div class="welcome-block"><h4>基础渠道欢迎语</h4><p class="field-help">未开启分时欢迎语,或新客户添加时间未匹配任何时段时,使用以下内容。</p>
|
||||
<WelcomeMessageEditor v-model="config.welcome" :disabled="disabled" :employee-name="employeeName" @busy="(busy) => updateBusy('basic', busy)" />
|
||||
</div>
|
||||
<el-form-item class="schedule-switch" label="分时欢迎语"><el-switch v-model="config.welcome_schedule_enabled" :disabled="disabled || anyUploading" /><span class="switch-help">按客户添加时的北京时间匹配,时段不能重叠</span></el-form-item>
|
||||
<div v-if="config.welcome_schedule_enabled">
|
||||
<div v-for="(slot, index) in config.welcome_schedule" :key="index" class="schedule-card welcome-schedule">
|
||||
<div class="schedule-heading"><strong>欢迎语时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="disabled || anyUploading" @click="removeWelcomeSlot(index)">删除时段</el-button></div>
|
||||
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="disabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
|
||||
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="disabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="disabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
|
||||
<WelcomeMessageEditor :model-value="slot" :disabled="disabled" :employee-name="employeeName" @update:model-value="(message) => Object.assign(slot, message)" @busy="(busy) => updateBusy(`slot-${index}`, busy)" />
|
||||
</div>
|
||||
<el-button :icon="Plus" :disabled="disabled || anyUploading || config.welcome_schedule.length >= 30" @click="addWelcomeSlot">添加欢迎语时段</el-button>
|
||||
<p class="field-help">最多 30 个时段,支持跨午夜。时段外自动使用基础渠道欢迎语,不会随机选择内容。</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import { wecomPromotionCreateTag, wecomPromotionTagOptions } from '@/api/first_visit'
|
||||
import type { WecomPromotionTagGroup } from '@/api/first_visit'
|
||||
import WelcomeMessageEditor from './WelcomeMessageEditor.vue'
|
||||
import { previewTemplate, templateTokens, validateCustomTagName, weekdays } from './promotion-automation'
|
||||
import type { PromotionAutomationConfig, PromotionMemberChoice } from './promotion-automation'
|
||||
|
||||
const props = defineProps<{ modelValue: PromotionAutomationConfig; mainMemberIds: number[]; members: PromotionMemberChoice[]; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [config: PromotionAutomationConfig]; busy: [value: boolean] }>()
|
||||
const config = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
||||
const mainMembers = computed(() => props.members.filter((member) => props.mainMemberIds.includes(Number(member.id))))
|
||||
const missingBackupIds = computed(() => config.value.backup_member_admin_ids.filter((id) => !props.members.some((member) => Number(member.id) === id)))
|
||||
const employeeName = computed(() => mainMembers.value[0]?.name || '小陈')
|
||||
const remarkPreview = computed(() => previewTemplate(config.value.remark_template, employeeName.value, 20))
|
||||
const remarkInput = ref<{ input?: HTMLInputElement }>()
|
||||
const remarkSelection = ref({ start: 0, end: 0 })
|
||||
const tagGroups = ref<WecomPromotionTagGroup[]>([])
|
||||
const tagsLoading = ref(false)
|
||||
const tagsError = ref('')
|
||||
const tagsLoaded = ref(false)
|
||||
const tagsCreating = ref(false)
|
||||
const showCustomTag = ref(false)
|
||||
const customTagName = ref('')
|
||||
const customTagError = ref('')
|
||||
const customTagSuccess = ref('')
|
||||
const hasMultipleTags = computed(() => config.value.tag_ids.length > 1)
|
||||
const selectedTag = computed({
|
||||
get: () => config.value.tag_ids.length === 1 ? config.value.tag_ids[0] : '',
|
||||
set: (id: string | undefined) => {
|
||||
config.value.tag_ids = id ? [id] : []
|
||||
customTagSuccess.value = ''
|
||||
}
|
||||
})
|
||||
const selectedTagNames = computed(() => {
|
||||
const tags = tagGroups.value.flatMap((group) => group.tag)
|
||||
return config.value.tag_ids.map((id) => tags.find((tag) => tag.id === id)?.name || id).join('、')
|
||||
})
|
||||
const busyEditors = ref(new Set<string>())
|
||||
const anyUploading = computed(() => busyEditors.value.size > 0)
|
||||
watch([tagsCreating, anyUploading], ([creating, uploading]) => emit('busy', creating || uploading), { flush: 'sync' })
|
||||
const unknownTagIds = computed(() => {
|
||||
const ids = new Set(tagGroups.value.flatMap((group) => group.tag.map((tag) => tag.id)))
|
||||
return config.value.tag_ids.filter((id) => !ids.has(id))
|
||||
})
|
||||
watch(() => config.value.tags_enabled, (enabled) => { if (enabled && !tagsLoaded.value && !tagsLoading.value) void loadTags() }, { immediate: true })
|
||||
function memberLabel(member: PromotionMemberChoice) { return `${member.name} · ${member.dept_names?.join(' / ') || member.userid || '未分部门'}` }
|
||||
function addReceptionSlot() { config.value.reception_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', member_admin_ids: [...props.mainMemberIds] }) }
|
||||
function addWelcomeSlot() { config.value.welcome_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', text: '', attachments: [] }) }
|
||||
function removeWelcomeSlot(index: number) { config.value.welcome_schedule.splice(index, 1); busyEditors.value.clear() }
|
||||
function updateBusy(key: string, busy: boolean) { busy ? busyEditors.value.add(key) : busyEditors.value.delete(key) }
|
||||
async function loadTags() {
|
||||
if (tagsCreating.value || tagsLoading.value) return
|
||||
tagsLoading.value = true
|
||||
tagsError.value = ''
|
||||
try {
|
||||
const result = await wecomPromotionTagOptions()
|
||||
if (!Array.isArray(result?.tag_groups)) throw new Error('标签接口未返回有效的标签列表。')
|
||||
tagGroups.value = result.tag_groups.map((group) => ({ ...group, tag: Array.isArray(group.tag) ? group.tag : [] }))
|
||||
tagsLoaded.value = true
|
||||
} catch (error: unknown) {
|
||||
tagsError.value = error instanceof Error ? error.message : typeof error === 'string' ? error : '企业微信标签加载失败。'
|
||||
} finally { tagsLoading.value = false }
|
||||
}
|
||||
async function createCustomTag() {
|
||||
if (props.disabled || tagsCreating.value || tagsLoading.value) return
|
||||
customTagError.value = validateCustomTagName(customTagName.value)
|
||||
customTagSuccess.value = ''
|
||||
if (customTagError.value) return
|
||||
tagsCreating.value = true
|
||||
try {
|
||||
const result = await wecomPromotionCreateTag({ name: customTagName.value.trim() })
|
||||
if (!result?.tag?.id || !result.tag.name || !result.group_id) throw new Error('企业微信未返回有效的标签 ID,请刷新标签后确认。')
|
||||
let group = tagGroups.value.find((item) => item.group_id === result.group_id)
|
||||
if (!group) {
|
||||
group = { group_id: result.group_id, group_name: result.group_name, tag: [] }
|
||||
tagGroups.value.push(group)
|
||||
}
|
||||
const existing = group.tag.findIndex((tag) => tag.id === result.tag.id)
|
||||
if (existing >= 0) group.tag[existing] = result.tag
|
||||
else group.tag.push(result.tag)
|
||||
selectedTag.value = result.tag.id
|
||||
customTagSuccess.value = `${result.reused ? '已选用已有' : '已创建并选用'}标签“${result.tag.name}”,保存方案后生效。`
|
||||
customTagName.value = ''
|
||||
showCustomTag.value = false
|
||||
} catch (error: unknown) {
|
||||
customTagError.value = error instanceof Error ? error.message : typeof error === 'string' ? error : '自定义标签创建失败,请刷新确认或重试。'
|
||||
} finally { tagsCreating.value = false }
|
||||
}
|
||||
function rememberRemarkSelection() {
|
||||
const input = remarkInput.value?.input
|
||||
if (input) remarkSelection.value = { start: input.selectionStart || 0, end: input.selectionEnd || 0 }
|
||||
}
|
||||
async function insertRemark(token: string) {
|
||||
const { start, end } = remarkSelection.value
|
||||
config.value.remark_template = config.value.remark_template.slice(0, start) + token + config.value.remark_template.slice(end)
|
||||
await nextTick()
|
||||
const cursor = start + token.length
|
||||
remarkInput.value?.input?.focus()
|
||||
remarkInput.value?.input?.setSelectionRange(cursor, cursor)
|
||||
remarkSelection.value = { start: cursor, end: cursor }
|
||||
}
|
||||
onBeforeUnmount(() => emit('busy', false))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.automation-form { width: 100%; }.automation-note { margin-top: 22px; }.automation-note :deep(.el-alert__description) { line-height: 1.7; }
|
||||
.form-section-title { margin: 28px 0 18px; padding: 0 0 12px; border-bottom: 1px solid #ebeef5; font-size: 15px; font-weight: 600; color: #303133; }.field-help { width: 100%; font-size: 12px; line-height: 1.7; margin: 6px 0 0; color: #909399; }.warning-help { color: #9f6d14; }.full-width { width: 100%; }.inline-error { width: 100%; color: #d93026; font-size: 12px; line-height: 1.7; margin: 8px 0 0; }
|
||||
.schedule-card { padding: 16px; border: 1px solid #e4e7ed; border-radius: 6px; background: #fafbfd; margin-bottom: 12px; }.schedule-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 13px; }.weekday-select { display: flex; flex-wrap: wrap; gap: 0 18px; }.weekday-select :deep(.el-checkbox) { margin-right: 0; }.time-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 12px 0; }.time-row :deep(.el-date-editor.el-input) { width: 150px; }.time-row > span { font-size: 12px; color: #909399; }.time-row > small { font-size: 12px; color: #b88230; }.reception-schedules { margin: 0 0 20px; }
|
||||
.tags-content, .remark-content, .description-input { margin-top: 12px; }.tag-select-row { display: flex; gap: 10px; width: 100%; }.tag-select { flex: 1; min-width: 0; }.token-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }.token-buttons .el-button + .el-button { margin-left: 0; }.remark-preview { display: flex; gap: 14px; align-items: center; padding: 10px 12px; background: #f5f7fa; margin-top: 8px; border-radius: 4px; line-height: 1.7; }.remark-preview span, .remark-preview small { color: #909399; font-size: 12px; }.remark-preview strong { color: #303133; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }.remark-preview small { margin-left: auto; white-space: nowrap; }.welcome-block h4 { font-size: 13px; font-weight: 600; margin: 0 0 4px; }.welcome-block > .field-help { margin-bottom: 12px; }.schedule-switch { margin-top: 24px; }.switch-help { margin-left: 12px; color: #909399; font-size: 12px; }.welcome-schedule { background: #fff; }
|
||||
.tag-select-row .el-button + .el-button { margin-left: 0; }
|
||||
.custom-tag-editor { margin-top: 12px; padding: 14px; background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; }
|
||||
.custom-tag-editor label { display: block; font-size: 13px; color: #606266; margin-bottom: 8px; }
|
||||
.custom-tag-row { display: flex; align-items: center; gap: 10px; }
|
||||
.custom-tag-row .el-input { flex: 1; min-width: 0; }
|
||||
.legacy-tags-warning { margin-top: 10px; padding: 10px 12px; background: #fdf6ec; border: 1px solid #faecd8; border-radius: 4px; color: #9f6d14; }
|
||||
.legacy-tags-warning p { margin: 0 0 6px; font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.tag-success { margin: 8px 0 0; color: #27864c; font-size: 12px; line-height: 1.7; }
|
||||
@media (max-width: 620px) { .tag-select-row { flex-direction: column; }.remark-preview { flex-wrap: wrap; }.automation-form :deep(.el-radio) { margin-right: 14px; }.weekday-select { gap: 0 12px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="welcome-editor">
|
||||
<div class="welcome-editor__fields">
|
||||
<div class="text-tools">
|
||||
<el-popover placement="bottom-start" trigger="click" :width="240" :disabled="disabled">
|
||||
<template #reference><el-button size="small" :disabled="disabled">☺ 插入表情</el-button></template>
|
||||
<div class="emoji-grid"><button v-for="emoji in emojis" :key="emoji" type="button" @click="insertText(emoji)">{{ emoji }}</button></div>
|
||||
</el-popover>
|
||||
<el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="disabled" @click="insertText(token.value)">{{ token.label }}</el-button>
|
||||
</div>
|
||||
<el-input
|
||||
ref="textInput"
|
||||
:model-value="modelValue.text"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:disabled="disabled"
|
||||
placeholder="请输入欢迎语,也可插入客户昵称、员工昵称和添加日期"
|
||||
@update:model-value="updateText"
|
||||
@select="rememberSelection"
|
||||
@keyup="rememberSelection"
|
||||
@click="rememberSelection"
|
||||
@blur="rememberSelection"
|
||||
/>
|
||||
<div class="text-count" :class="{ 'is-error': textTooLong }">{{ Array.from(modelValue.text).length }}/1200 字 · {{ utf8Length(modelValue.text) }}/4000 字节</div>
|
||||
<div class="attachments-heading">
|
||||
<strong>附件 <span>{{ modelValue.attachments.length }}/9</span></strong>
|
||||
<el-dropdown :disabled="disabled || modelValue.attachments.length >= 9 || uploading" @command="addAttachment">
|
||||
<el-button size="small" :disabled="disabled || modelValue.attachments.length >= 9 || uploading" :icon="Plus">添加附件</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="item in attachmentTypes" :key="item.value" :command="item.value">{{ item.label }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div v-if="!modelValue.attachments.length" class="attachment-empty">可添加图片、网页、小程序、视频或文件。</div>
|
||||
<div v-for="(attachment, index) in modelValue.attachments" :key="index" class="attachment-card">
|
||||
<div class="attachment-card__heading">
|
||||
<strong>{{ index + 1 }}. {{ attachmentLabel(attachment.msgtype) }}</strong>
|
||||
<div>
|
||||
<el-button text size="small" :disabled="disabled || uploading || index === 0" @click="moveAttachment(index, -1)">上移</el-button>
|
||||
<el-button text size="small" :disabled="disabled || uploading || index === modelValue.attachments.length - 1" @click="moveAttachment(index, 1)">下移</el-button>
|
||||
<el-button text type="danger" size="small" :disabled="disabled || uploading" @click="removeAttachment(index)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="attachment.msgtype === 'image' || attachment.msgtype === 'video' || attachment.msgtype === 'file'">
|
||||
<div class="upload-field">
|
||||
<el-button size="small" :icon="Upload" :loading="uploadingIndex === index" :disabled="disabled || uploading" @click="selectFile(index, attachment.msgtype)">{{ assetId(attachment) ? '重新上传' : '上传' }}{{ attachmentLabel(attachment.msgtype) }}</el-button>
|
||||
<span :class="assetId(attachment) ? 'asset-ready' : 'muted'">{{ assetName(attachment) }}</span>
|
||||
</div>
|
||||
<small class="field-tip">{{ attachment.msgtype === 'image' ? 'JPG / PNG,最多 10MB。' : attachment.msgtype === 'video' ? 'MP4,最多 10MB。' : '文件最多 20MB。' }}上传成功后由服务端保存素材;未上传完成不能提交。</small>
|
||||
</template>
|
||||
<template v-else-if="attachment.msgtype === 'link'">
|
||||
<label class="attachment-label">网页标题 <span>{{ utf8Length(attachment.link.title) }}/128 字节</span></label>
|
||||
<el-input v-model="attachment.link.title" :disabled="disabled" placeholder="必填:网页标题" />
|
||||
<label class="attachment-label">网页地址</label>
|
||||
<el-input v-model="attachment.link.url" :disabled="disabled" placeholder="https://" />
|
||||
<label class="attachment-label">网页描述 <span>{{ utf8Length(attachment.link.desc) }}/512 字节</span></label>
|
||||
<el-input v-model="attachment.link.desc" :disabled="disabled" type="textarea" :rows="2" placeholder="选填:网页摘要" />
|
||||
<label class="attachment-label">网页封面地址</label>
|
||||
<el-input v-model="attachment.link.picurl" :disabled="disabled" placeholder="选填:公网可访问的 HTTPS 图片地址" />
|
||||
</template>
|
||||
<template v-else-if="attachment.msgtype === 'miniprogram'">
|
||||
<label class="attachment-label">小程序标题 <span>{{ utf8Length(attachment.miniprogram.title) }}/64 字节</span></label>
|
||||
<el-input v-model="attachment.miniprogram.title" :disabled="disabled" placeholder="必填:小程序标题" />
|
||||
<label class="attachment-label">AppID</label>
|
||||
<el-input v-model="attachment.miniprogram.appid" :disabled="disabled" placeholder="必填:小程序 AppID" />
|
||||
<label class="attachment-label">页面路径</label>
|
||||
<el-input v-model="attachment.miniprogram.page" :disabled="disabled" placeholder="pages/index/index,可包含查询参数" />
|
||||
<div class="upload-field mini-upload">
|
||||
<el-button size="small" :icon="Upload" :loading="uploadingIndex === index" :disabled="disabled || uploading" @click="selectFile(index, 'image')">{{ attachment.miniprogram.pic_asset_id ? '更换封面' : '上传小程序封面' }}</el-button>
|
||||
<span :class="assetId(attachment) ? 'asset-ready' : 'muted'">{{ assetName(attachment) }}</span>
|
||||
</div>
|
||||
<small class="field-tip">JPG / PNG,最多 10MB。</small>
|
||||
</template>
|
||||
<div v-if="uploadErrors[index]" role="alert" class="upload-error">{{ uploadErrors[index] }} 请重试上传,原素材未被替换。</div>
|
||||
</div>
|
||||
<input ref="fileInput" class="file-input" type="file" :accept="fileAccept" @change="uploadSelectedFile" />
|
||||
</div>
|
||||
<aside class="welcome-preview" aria-label="欢迎语手机预览">
|
||||
<div class="phone-heading"><span>‹</span><strong>{{ employeeName || '接待员工' }}</strong><span>···</span></div>
|
||||
<div class="phone-content">
|
||||
<div class="preview-time">添加成功 · 预览示例</div>
|
||||
<div v-if="modelValue.text" class="chat-row"><span class="chat-avatar">{{ (employeeName || '员').slice(0, 1) }}</span><div class="chat-bubble">{{ previewTemplate(modelValue.text, employeeName) }}</div></div>
|
||||
<div v-for="(attachment, index) in modelValue.attachments" :key="index" class="chat-row">
|
||||
<span class="chat-avatar">{{ (employeeName || '员').slice(0, 1) }}</span>
|
||||
<div class="chat-bubble attachment-preview">
|
||||
<template v-if="attachment.msgtype === 'image'">
|
||||
<img v-if="assetPreview(attachment)" :src="assetPreview(attachment)" alt="欢迎语图片预览" />
|
||||
<div v-else class="media-placeholder"><el-icon><Picture /></el-icon><span>{{ assetId(attachment) ? '已保存的图片素材' : '待上传图片' }}</span></div>
|
||||
</template>
|
||||
<template v-else-if="attachment.msgtype === 'link'">
|
||||
<strong>{{ attachment.link.title || '网页标题' }}</strong><p>{{ attachment.link.desc || '网页摘要' }}</p><small>网页链接</small>
|
||||
</template>
|
||||
<template v-else-if="attachment.msgtype === 'miniprogram'">
|
||||
<small>小程序</small><strong>{{ attachment.miniprogram.title || '小程序标题' }}</strong>
|
||||
<img v-if="assetPreview(attachment)" :src="assetPreview(attachment)" alt="小程序封面预览" />
|
||||
<div v-else class="media-placeholder"><el-icon><Grid /></el-icon><span>小程序封面</span></div>
|
||||
</template>
|
||||
<template v-else><el-icon class="file-icon"><VideoPlay v-if="attachment.msgtype === 'video'" /><Document v-else /></el-icon><strong>{{ assetName(attachment) }}</strong><small>{{ attachmentLabel(attachment.msgtype) }}</small></template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!modelValue.text && !modelValue.attachments.length" class="preview-empty">编辑内容后在这里预览</div>
|
||||
</div>
|
||||
<div class="phone-input"><span>⊕</span><span class="phone-input__blank" /><span>☺</span></div>
|
||||
<p class="preview-note">仅为排版示意,昵称与时间会替换为实际值。</p>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Document, Grid, Picture, Plus, Upload, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { wecomPromotionUploadWelcomeMedia } from '@/api/first_visit'
|
||||
import { previewTemplate, templateTokens, utf8Length } from './promotion-automation'
|
||||
import type { WelcomeAttachment, WelcomeMessage } from './promotion-automation'
|
||||
|
||||
const props = defineProps<{ modelValue: WelcomeMessage; disabled?: boolean; employeeName?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: WelcomeMessage]; busy: [value: boolean] }>()
|
||||
const textInput = ref<{ textarea?: HTMLTextAreaElement }>()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const selection = ref({ start: 0, end: 0 })
|
||||
const uploadingIndex = ref(-1)
|
||||
const uploading = computed(() => uploadingIndex.value >= 0)
|
||||
const uploadErrors = ref<Record<number, string>>({})
|
||||
const assetCache = ref<Record<string, { name: string; url?: string }>>({})
|
||||
const selectedUpload = ref<{ index: number; type: 'image' | 'video' | 'file' }>({ index: 0, type: 'image' })
|
||||
const fileAccept = ref('image/jpeg,image/png')
|
||||
const emojis = ['😊', '😀', '👋', '🌹', '❤️', '👍', '🙏', '🎉', '☀️', '✨', '💐', '🤝', '💬', '✅', '🌿', '🍀']
|
||||
const attachmentTypes: Array<{ value: WelcomeAttachment['msgtype']; label: string }> = [
|
||||
{ value: 'image', label: '图片' }, { value: 'link', label: '网页' }, { value: 'miniprogram', label: '小程序' },
|
||||
{ value: 'video', label: '视频' }, { value: 'file', label: '文件' }
|
||||
]
|
||||
const textTooLong = computed(() => Array.from(props.modelValue.text).length > 1200 || utf8Length(props.modelValue.text) > 4000)
|
||||
|
||||
function updateText(text: string) { emit('update:modelValue', { ...props.modelValue, text }) }
|
||||
function rememberSelection() {
|
||||
const textarea = textInput.value?.textarea
|
||||
if (textarea) selection.value = { start: textarea.selectionStart, end: textarea.selectionEnd }
|
||||
}
|
||||
async function insertText(text: string) {
|
||||
if (props.disabled) return
|
||||
const { start, end } = selection.value
|
||||
updateText(props.modelValue.text.slice(0, start) + text + props.modelValue.text.slice(end))
|
||||
await nextTick()
|
||||
const cursor = start + text.length
|
||||
textInput.value?.textarea?.focus()
|
||||
textInput.value?.textarea?.setSelectionRange(cursor, cursor)
|
||||
selection.value = { start: cursor, end: cursor }
|
||||
}
|
||||
function attachmentLabel(type: string) { return attachmentTypes.find((item) => item.value === type)?.label || '附件' }
|
||||
function addAttachment(type: WelcomeAttachment['msgtype']) {
|
||||
if (props.disabled || uploading.value || props.modelValue.attachments.length >= 9) return
|
||||
let attachment: WelcomeAttachment
|
||||
if (type === 'image') attachment = { msgtype: 'image', image: { asset_id: '' } }
|
||||
else if (type === 'video') attachment = { msgtype: 'video', video: { asset_id: '' } }
|
||||
else if (type === 'file') attachment = { msgtype: 'file', file: { asset_id: '' } }
|
||||
else if (type === 'link') attachment = { msgtype: 'link', link: { title: '', url: '', desc: '', picurl: '' } }
|
||||
else attachment = { msgtype: 'miniprogram', miniprogram: { title: '', appid: '', page: '', pic_asset_id: '' } }
|
||||
emit('update:modelValue', { ...props.modelValue, attachments: [...props.modelValue.attachments, attachment] })
|
||||
}
|
||||
function removeAttachment(index: number) {
|
||||
uploadErrors.value = {}
|
||||
emit('update:modelValue', { ...props.modelValue, attachments: props.modelValue.attachments.filter((_, position) => position !== index) })
|
||||
}
|
||||
function moveAttachment(index: number, direction: number) {
|
||||
const attachments = [...props.modelValue.attachments]
|
||||
;[attachments[index], attachments[index + direction]] = [attachments[index + direction], attachments[index]]
|
||||
uploadErrors.value = {}
|
||||
emit('update:modelValue', { ...props.modelValue, attachments })
|
||||
}
|
||||
function assetId(attachment: WelcomeAttachment): string {
|
||||
if (attachment.msgtype === 'image') return attachment.image.asset_id || ''
|
||||
if (attachment.msgtype === 'video') return attachment.video.asset_id
|
||||
if (attachment.msgtype === 'file') return attachment.file.asset_id
|
||||
if (attachment.msgtype === 'miniprogram') return attachment.miniprogram.pic_asset_id
|
||||
return ''
|
||||
}
|
||||
function assetName(attachment: WelcomeAttachment) {
|
||||
const id = assetId(attachment)
|
||||
if (id) return assetCache.value[id]?.name || `已保存素材 ${id.slice(-10)}`
|
||||
return attachment.msgtype === 'image' && attachment.image.pic_url ? '已保存的企微图片' : '尚未上传'
|
||||
}
|
||||
function assetPreview(attachment: WelcomeAttachment) { return assetCache.value[assetId(attachment)]?.url || '' }
|
||||
async function selectFile(index: number, type: 'image' | 'video' | 'file') {
|
||||
selectedUpload.value = { index, type }
|
||||
fileAccept.value = type === 'image' ? '.jpg,.jpeg,.png' : type === 'video' ? '.mp4' : '*'
|
||||
await nextTick()
|
||||
fileInput.value?.click()
|
||||
}
|
||||
async function uploadSelectedFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
const { index, type } = selectedUpload.value
|
||||
const limit = type === 'file' ? 20 : 10
|
||||
let error = ''
|
||||
if (file.size <= 5 || file.size > limit * 1024 * 1024) error = `文件必须大于 5 字节且不超过 ${limit}MB`
|
||||
else if (type === 'image' && !/\.(jpe?g|png)$/i.test(file.name)) error = '图片仅支持 JPG / PNG 格式'
|
||||
else if (type === 'video' && !/\.mp4$/i.test(file.name)) error = '视频仅支持 MP4 格式'
|
||||
if (error) { uploadErrors.value[index] = error; return }
|
||||
const attachment = props.modelValue.attachments[index]
|
||||
if (!attachment) return
|
||||
uploadingIndex.value = index
|
||||
emit('busy', true)
|
||||
delete uploadErrors.value[index]
|
||||
try {
|
||||
const result = await wecomPromotionUploadWelcomeMedia(file, type)
|
||||
if (!result?.asset_id) throw new Error('上传接口未返回素材 ID')
|
||||
const id = String(result.asset_id)
|
||||
assetCache.value[id] = { name: result.name || file.name, url: type === 'image' ? URL.createObjectURL(file) : undefined }
|
||||
if (attachment.msgtype === 'image') { attachment.image = { asset_id: id } }
|
||||
else if (attachment.msgtype === 'video') attachment.video.asset_id = id
|
||||
else if (attachment.msgtype === 'file') attachment.file.asset_id = id
|
||||
else if (attachment.msgtype === 'miniprogram') attachment.miniprogram.pic_asset_id = id
|
||||
ElMessage.success('素材已上传')
|
||||
} catch (error: unknown) {
|
||||
uploadErrors.value[index] = error instanceof Error ? error.message : typeof error === 'string' ? error : '素材上传失败'
|
||||
} finally {
|
||||
uploadingIndex.value = -1
|
||||
emit('busy', false)
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
Object.values(assetCache.value).forEach((asset) => { if (asset.url) URL.revokeObjectURL(asset.url) })
|
||||
emit('busy', false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.welcome-editor { display: grid; grid-template-columns: minmax(0, 1fr) 260px; align-items: start; gap: 22px; width: 100%; }
|
||||
.welcome-editor__fields { min-width: 0; }
|
||||
.text-tools { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }.text-tools .el-button + .el-button { margin-left: 0; }
|
||||
.emoji-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px; }.emoji-grid button { border: 0; background: none; padding: 4px; cursor: pointer; font-size: 20px; }
|
||||
.text-count { text-align: right; font-size: 12px; color: #909399; margin-top: 4px; }.text-count.is-error, .upload-error { color: #d93026; }
|
||||
.attachments-heading, .attachment-card__heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; }.attachments-heading { margin: 16px 0 10px; }.attachments-heading strong { font-size: 13px; }.attachments-heading strong span { color: #909399; font-weight: 400; }
|
||||
.attachment-empty { padding: 18px 12px; color: #909399; background: #f7f8fa; border: 1px dashed #dcdfe6; border-radius: 4px; font-size: 12px; }
|
||||
.attachment-card { border: 1px solid #e4e7ed; border-radius: 5px; padding: 12px; margin-top: 10px; }.attachment-card__heading { margin-bottom: 10px; }.attachment-card__heading strong { font-size: 13px; }.attachment-card__heading .el-button { padding: 4px; margin: 0; }
|
||||
.attachment-label { display: block; font-size: 12px; color: #606266; margin: 10px 0 4px; }.attachment-label span { color: #909399; float: right; }.upload-field { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 12px; overflow-wrap: anywhere; }.mini-upload { margin-top: 12px; }.asset-ready { color: #178758; }.muted { color: #909399; }.field-tip { display: block; color: #909399; line-height: 1.6; margin-top: 6px; }.upload-error { font-size: 12px; line-height: 1.6; margin-top: 6px; }.file-input { display: none; }
|
||||
.welcome-preview { width: 260px; border: 1px solid #dcdfe6; border-radius: 20px; padding: 7px; background: #fff; overflow: hidden; }
|
||||
.phone-heading { display: flex; justify-content: space-between; align-items: center; padding: 13px 12px; background: #ededed; border-radius: 14px 14px 0 0; font-size: 13px; }.phone-heading > span { font-size: 19px; }
|
||||
.phone-content { min-height: 330px; max-height: 520px; overflow: auto; background: #ededed; padding: 0 10px 18px; }.preview-time { font-size: 10px; text-align: center; color: #999; padding: 12px 0 18px; }.chat-row { display: flex; gap: 7px; margin-bottom: 12px; align-items: flex-start; }.chat-avatar { width: 27px; height: 27px; background: #6e92ae; color: white; flex-shrink: 0; border-radius: 4px; display: grid; place-items: center; font-size: 11px; }.chat-bubble { background: #fff; padding: 9px 10px; border-radius: 4px; font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-width: 172px; }.attachment-preview { width: 172px; }.attachment-preview strong { display: block; font-weight: 500; font-size: 12px; }.attachment-preview p { color: #909399; font-size: 10px; margin: 6px 0; }.attachment-preview small { display: block; font-size: 9px; color: #909399; margin-top: 7px; }.attachment-preview img { width: 100%; max-height: 160px; object-fit: contain; display: block; }.media-placeholder { background: #f2f5f7; height: 85px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: #909399; font-size: 10px; }.media-placeholder .el-icon, .file-icon { font-size: 28px; color: #8babc3; }.preview-empty { text-align: center; color: #aaa; font-size: 12px; margin-top: 100px; }.phone-input { display: flex; gap: 10px; padding: 9px; background: #f6f6f6; border-radius: 0 0 14px 14px; align-items: center; color: #909399; }.phone-input__blank { flex: 1; height: 24px; border-radius: 3px; background: white; }.preview-note { margin: 10px 6px 6px; font-size: 11px; color: #909399; line-height: 1.6; }
|
||||
@media (max-width: 850px) { .welcome-editor { grid-template-columns: 1fr; }.welcome-preview { margin: 8px auto 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,195 @@
|
||||
export interface PromotionMemberChoice {
|
||||
id: number
|
||||
name: string
|
||||
userid: string
|
||||
dept_names: string[]
|
||||
}
|
||||
|
||||
export type WelcomeAttachment =
|
||||
| { msgtype: 'image'; image: { asset_id?: string; pic_url?: string } }
|
||||
| { msgtype: 'video'; video: { asset_id: string } }
|
||||
| { msgtype: 'file'; file: { asset_id: string } }
|
||||
| { msgtype: 'link'; link: { title: string; url: string; desc: string; picurl?: string } }
|
||||
| { msgtype: 'miniprogram'; miniprogram: { title: string; appid: string; page: string; pic_asset_id: string } }
|
||||
|
||||
export interface WelcomeMessage {
|
||||
text: string
|
||||
attachments: WelcomeAttachment[]
|
||||
}
|
||||
|
||||
export interface WeeklySlot {
|
||||
weekdays: number[]
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
export interface ReceptionSlot extends WeeklySlot {
|
||||
member_admin_ids: number[]
|
||||
}
|
||||
|
||||
export interface WelcomeSlot extends WeeklySlot, WelcomeMessage {}
|
||||
|
||||
export interface PromotionAutomationConfig {
|
||||
reception_mode: 'always' | 'scheduled'
|
||||
reception_schedule: ReceptionSlot[]
|
||||
backup_member_admin_ids: number[]
|
||||
tags_enabled: boolean
|
||||
tag_ids: string[]
|
||||
remark_enabled: boolean
|
||||
remark_template: string
|
||||
description_enabled: boolean
|
||||
description: string
|
||||
welcome_mode: 'default' | 'channel' | 'none'
|
||||
welcome: WelcomeMessage
|
||||
welcome_schedule_enabled: boolean
|
||||
welcome_schedule: WelcomeSlot[]
|
||||
}
|
||||
|
||||
export const weekdays = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日']
|
||||
export const templateTokens = [
|
||||
{ label: '客户昵称', value: '{customer_name}' },
|
||||
{ label: '员工昵称', value: '{employee_name}' },
|
||||
{ label: '添加时间', value: '{add_time}' }
|
||||
]
|
||||
|
||||
export const utf8Length = (value: string): number => new TextEncoder().encode(value).length
|
||||
|
||||
export function validateCustomTagName(value: string): string {
|
||||
if (!value.trim()) return '请输入自定义标签名称'
|
||||
if (/[\p{C}\u2028\u2029]/u.test(value)) return '标签名称不能包含换行、控制字符或不可见格式字符'
|
||||
if (Array.from(value.trim()).length > 30) return '标签名称最多 30 个字符'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function defaultAutomationConfig(): PromotionAutomationConfig {
|
||||
return {
|
||||
reception_mode: 'always', reception_schedule: [], backup_member_admin_ids: [],
|
||||
tags_enabled: false, tag_ids: [], remark_enabled: false, remark_template: '{customer_name}',
|
||||
description_enabled: false, description: '', welcome_mode: 'default',
|
||||
welcome: { text: '', attachments: [] }, welcome_schedule_enabled: false, welcome_schedule: []
|
||||
}
|
||||
}
|
||||
|
||||
// Only copy the editable contract. Server-resolved userids never come back in a save payload.
|
||||
export function cloneAutomationConfig(source?: Partial<PromotionAutomationConfig> | null): PromotionAutomationConfig {
|
||||
const defaults = defaultAutomationConfig()
|
||||
if (!source) return defaults
|
||||
const copy = JSON.parse(JSON.stringify(source)) as PromotionAutomationConfig
|
||||
return {
|
||||
reception_mode: copy.reception_mode === 'scheduled' ? 'scheduled' : 'always',
|
||||
reception_schedule: (copy.reception_schedule || []).map(({ weekdays, start, end, member_admin_ids }) => ({
|
||||
weekdays: [...weekdays], start, end, member_admin_ids: member_admin_ids.map(Number)
|
||||
})),
|
||||
backup_member_admin_ids: (copy.backup_member_admin_ids || []).map(Number),
|
||||
tags_enabled: Boolean(copy.tags_enabled), tag_ids: [...(copy.tag_ids || [])],
|
||||
remark_enabled: Boolean(copy.remark_enabled), remark_template: copy.remark_template ?? defaults.remark_template,
|
||||
description_enabled: Boolean(copy.description_enabled), description: copy.description || '',
|
||||
welcome_mode: copy.welcome_mode || 'default',
|
||||
welcome: { text: copy.welcome?.text || '', attachments: copy.welcome?.attachments || [] },
|
||||
welcome_schedule_enabled: Boolean(copy.welcome_schedule_enabled),
|
||||
welcome_schedule: (copy.welcome_schedule || []).map(({ weekdays, start, end, text, attachments }) => ({
|
||||
weekdays: [...weekdays], start, end, text: text || '', attachments: attachments || []
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export function previewTemplate(template: string, employee = '小陈', limit?: number): string {
|
||||
const date = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
}).format(new Date())
|
||||
const text = template.replace(/\{(customer_name|employee_name|add_time)\}/g, (token) => ({
|
||||
'{customer_name}': '张女士', '{employee_name}': employee, '{add_time}': date
|
||||
}[token] || token))
|
||||
return limit ? Array.from(text).slice(0, limit).join('') : text
|
||||
}
|
||||
|
||||
export function isWebUrl(value: string): boolean {
|
||||
try { return ['https:', 'http:'].includes(new URL(value).protocol) } catch { return false }
|
||||
}
|
||||
|
||||
function validateSlot(slot: WeeklySlot, label: string): string {
|
||||
if (!slot.weekdays.length || slot.weekdays.some((day) => day < 1 || day > 7)) return `${label}请选择星期`
|
||||
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(slot.start) || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(slot.end)) return `${label}请填写有效的起止时间`
|
||||
if (slot.start === slot.end) return `${label}开始与结束时间不能相同,全天接待请选择全天模式`
|
||||
return ''
|
||||
}
|
||||
|
||||
export function validateWelcomeMessage(message: WelcomeMessage, label: string, required = true): string {
|
||||
if (Array.from(message.text).length > 1200) return `${label}正文不能超过 1200 字`
|
||||
if (utf8Length(message.text) > 4000) return `${label}正文不能超过 4000 UTF-8 字节,请减少表情或文字`
|
||||
if (message.attachments.length > 9) return `${label}最多添加 9 个附件`
|
||||
if (required && !message.text.trim() && !message.attachments.length) return `${label}请填写正文或添加附件`
|
||||
for (const [index, attachment] of message.attachments.entries()) {
|
||||
const prefix = `${label}第 ${index + 1} 个附件:`
|
||||
switch (attachment.msgtype) {
|
||||
case 'image':
|
||||
if (!attachment.image.asset_id && !isWebUrl(attachment.image.pic_url || '')) return prefix + '请上传图片或填写有效图片地址'
|
||||
break
|
||||
case 'video':
|
||||
if (!attachment.video.asset_id) return prefix + '请先上传视频'
|
||||
break
|
||||
case 'file':
|
||||
if (!attachment.file.asset_id) return prefix + '请先上传文件'
|
||||
break
|
||||
case 'link':
|
||||
if (!attachment.link.title.trim() || !isWebUrl(attachment.link.url)) return prefix + '请填写网页标题和有效的 HTTP/HTTPS 链接'
|
||||
if (utf8Length(attachment.link.title) > 128 || utf8Length(attachment.link.desc) > 512) return prefix + '网页标题限 128 字节,描述限 512 字节'
|
||||
if (attachment.link.picurl && !isWebUrl(attachment.link.picurl)) return prefix + '网页封面地址无效'
|
||||
break
|
||||
case 'miniprogram':
|
||||
if (!attachment.miniprogram.title.trim() || !attachment.miniprogram.appid.trim() || !attachment.miniprogram.page.trim() || !attachment.miniprogram.pic_asset_id) return prefix + '请填写小程序标题、AppID、页面路径并上传封面'
|
||||
if (utf8Length(attachment.miniprogram.title) > 64) return prefix + '小程序标题不能超过 64 字节'
|
||||
break
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Weekly minute occupancy also catches Sunday-to-Monday and overnight overlap. */
|
||||
export function welcomeScheduleOverlap(slots: WeeklySlot[]): boolean {
|
||||
const minutes = new Set<number>()
|
||||
for (const slot of slots) {
|
||||
const [sh, sm] = slot.start.split(':').map(Number)
|
||||
const [eh, em] = slot.end.split(':').map(Number)
|
||||
const start = sh * 60 + sm
|
||||
const duration = (eh * 60 + em - start + 1440) % 1440
|
||||
for (const day of new Set(slot.weekdays)) {
|
||||
for (let offset = 0; offset < duration; offset++) {
|
||||
const minute = ((day - 1) * 1440 + start + offset) % 10080
|
||||
if (minutes.has(minute)) return true
|
||||
minutes.add(minute)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function validateAutomationConfig(config: PromotionAutomationConfig, mainMemberIds: number[]): string {
|
||||
if (config.backup_member_admin_ids.some((id) => mainMemberIds.includes(id))) return '备用成员不能与主接待成员重复'
|
||||
if (config.reception_schedule.length > 30 || config.welcome_schedule.length > 30) return '每类时段最多添加 30 条'
|
||||
if (config.reception_mode === 'scheduled') {
|
||||
if (!config.reception_schedule.length) return '按时段接待至少需要一个接待时段'
|
||||
if (!config.backup_member_admin_ids.length) return '按时段接待至少需要一名备用成员,以承接非接待时段的客户'
|
||||
}
|
||||
for (const [index, slot] of config.reception_schedule.entries()) {
|
||||
const error = validateSlot(slot, `接待时段 ${index + 1}:`)
|
||||
if (error) return error
|
||||
if (!slot.member_admin_ids.length) return `接待时段 ${index + 1}:请至少选择一名成员`
|
||||
if (slot.member_admin_ids.some((id) => !mainMemberIds.includes(id))) return `接待时段 ${index + 1}:成员必须来自主接待成员,请重新选择`
|
||||
}
|
||||
if (config.tags_enabled && !config.tag_ids.length) return '启用客户标签后,请选择一个企业微信标签,或创建自定义标签'
|
||||
if (config.tag_ids.length > 1) return '客户标签只能选择一个,请重新选择或清空原标签'
|
||||
if (config.remark_enabled && !config.remark_template.trim()) return '请填写客户备注模板'
|
||||
if (Array.from(config.remark_template).length > 200) return '客户备注模板不能超过 200 字'
|
||||
if (config.description_enabled && !config.description.trim()) return '请填写客户描述'
|
||||
if (Array.from(config.description).length > 150) return '客户描述不能超过 150 字'
|
||||
const error = validateWelcomeMessage(config.welcome, '基础渠道欢迎语:', config.welcome_mode === 'channel')
|
||||
if (error) return error
|
||||
if (config.welcome_mode === 'channel' && config.welcome_schedule_enabled && !config.welcome_schedule.length) return '请至少添加一个分时欢迎语时段'
|
||||
for (const [index, slot] of config.welcome_schedule.entries()) {
|
||||
const error = validateSlot(slot, `欢迎语时段 ${index + 1}:`) || validateWelcomeMessage(slot, `欢迎语时段 ${index + 1}:`)
|
||||
if (error) return error
|
||||
}
|
||||
if (welcomeScheduleOverlap(config.welcome_schedule)) return '分时欢迎语的时间范围不能重叠(跨午夜时段归属开始日)'
|
||||
return ''
|
||||
}
|
||||
@@ -142,17 +142,15 @@
|
||||
<el-table-column label="推广成员" min-width="210" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="member-cell">
|
||||
<strong>{{ row.name || row.userid }}</strong>
|
||||
<strong>{{ row.name || row.userid }} <el-tag v-if="row.is_backup" size="small" type="warning" effect="plain">备用</el-tag></strong>
|
||||
<small>{{ memberRuleDetail(row) }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="调度" width="105" align="center">
|
||||
<el-table-column label="企微范围同步" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.is_in_remote_range && eligibility(row).className === 'is-ok'" class="status-tag is-online">企微路由中</span>
|
||||
<span v-else-if="row.is_in_remote_range" class="status-tag is-offline">待移出</span>
|
||||
<span v-else-if="eligibility(row).className === 'is-ok'" class="status-tag is-offline">待同步</span>
|
||||
<span v-else class="muted">不参与</span>
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="今日 / 上限" width="125" align="center">
|
||||
@@ -367,14 +365,18 @@
|
||||
<el-dialog
|
||||
v-model="poolDialogVisible"
|
||||
:title="poolForm.id ? '编辑分流方案' : '新建分流方案'"
|
||||
width="680px"
|
||||
width="1000px"
|
||||
class="promotion-pool-dialog"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!savingPool"
|
||||
:close-on-press-escape="!savingPool && !automationBusy"
|
||||
:show-close="!savingPool && !automationBusy"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<div ref="poolFormScroll" class="pool-form-scroll">
|
||||
<el-alert v-if="poolFormError" class="pool-form-error" :title="poolFormError" type="error" show-icon :closable="false" role="alert" />
|
||||
<el-form label-position="top" :disabled="savingPool">
|
||||
<el-form-item label="方案名称" required><el-input v-model="poolForm.name" :disabled="savingPool" maxlength="60" show-word-limit placeholder="例如:官网咨询分流" /></el-form-item>
|
||||
<el-form-item label="获客医助" required>
|
||||
<el-form-item label="主接待成员(获客医助)" required>
|
||||
<el-tree-select
|
||||
v-model="poolForm.member_admin_ids"
|
||||
:data="memberDepartmentTree"
|
||||
@@ -413,8 +415,18 @@
|
||||
<span class="form-tip">新生成的官方直链不经过本站跳转;此项仅兼容旧安装代码。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="运行状态"><el-switch v-model="poolForm.status" :disabled="savingPool" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" /></el-form-item>
|
||||
<PromotionAutomationForm
|
||||
v-if="overview.automation_installed"
|
||||
v-model="poolForm.automation_config"
|
||||
:main-member-ids="poolForm.member_admin_ids"
|
||||
:members="overview.member_options"
|
||||
:disabled="savingPool"
|
||||
@busy="(busy) => automationBusy = busy"
|
||||
/>
|
||||
<el-alert v-else type="warning" show-icon :closable="false" title="自动化配置尚未安装" description="请先在服务端执行 add_wecom_promotion_automation.sql 并部署消费进程及定时调度,即可设置接待时段、标签、备注和欢迎语。当前仍可保存原有分流方案。" />
|
||||
</el-form>
|
||||
<template #footer><el-button :disabled="savingPool" @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" @click="savePool">{{ poolForm.id ? '保存方案' : '保存并生成链接' }}</el-button></template>
|
||||
</div>
|
||||
<template #footer><span v-if="automationBusy" class="uploading-save-tip">正在处理标签或素材,请稍候</span><el-button :disabled="savingPool || automationBusy" @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" :disabled="automationBusy" @click="savePool">{{ poolForm.id ? '保存方案' : '保存并生成链接' }}</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
@@ -524,7 +536,9 @@ import {
|
||||
} from '@/api/first_visit'
|
||||
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
|
||||
|
||||
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
|
||||
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
|
||||
import PromotionAutomationForm from './components/PromotionAutomationForm.vue'
|
||||
import { cloneAutomationConfig, defaultAutomationConfig, isWebUrl, validateAutomationConfig } from './components/promotion-automation'
|
||||
|
||||
type TabName = 'links' | 'customer-stats' | 'configuration' | 'install'
|
||||
|
||||
@@ -573,6 +587,7 @@ const emptyOverview = () => ({
|
||||
member_options: [] as PromotionMemberOption[],
|
||||
operator_options: [] as PromotionOperatorOption[],
|
||||
department_options: [] as PromotionDepartmentOption[],
|
||||
automation_installed: false,
|
||||
customer_acquisition_link_example: 'https://work.weixin.qq.com/ca/xxxxxxxx'
|
||||
})
|
||||
|
||||
@@ -591,7 +606,10 @@ const togglingMemberId = ref(0)
|
||||
const deletingPoolId = ref(0)
|
||||
const accessDialogVisible = ref(false)
|
||||
const savingAccess = ref(false)
|
||||
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1, member_admin_ids: [] as number[], skip_verify: 0, main_url: '' })
|
||||
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1, member_admin_ids: [] as number[], skip_verify: 0, main_url: '', automation_config: defaultAutomationConfig() })
|
||||
const poolFormScroll = ref<HTMLElement>()
|
||||
const poolFormError = ref('')
|
||||
const automationBusy = ref(false)
|
||||
const accessForm = reactive({ pool_ids: [] as number[], operator_admin_ids: [] as number[], action: 'grant' as 'grant' | 'revoke' })
|
||||
const memberForm = reactive({ id: 0, name: '', userid: '', daily_limit: 0, status: 1, active_range: [] as string[], remark: '' })
|
||||
const customerStatsLoading = ref(false)
|
||||
@@ -738,6 +756,8 @@ function accessSummary(pool: any) {
|
||||
|
||||
function openPoolDialog(pool?: any) {
|
||||
const selectableMemberIds = new Set(overview.member_options.map((member) => Number(member.id)))
|
||||
poolFormError.value = ''
|
||||
automationBusy.value = false
|
||||
Object.assign(poolForm, pool ? {
|
||||
id: Number(pool.id),
|
||||
name: pool.name,
|
||||
@@ -747,7 +767,8 @@ function openPoolDialog(pool?: any) {
|
||||
? pool.member_admin_ids.map(Number).filter((memberId: number) => selectableMemberIds.has(memberId))
|
||||
: [],
|
||||
skip_verify: Number(pool.skip_verify) === 1 ? 1 : 0,
|
||||
main_url: pool.main_url || ''
|
||||
main_url: pool.main_url || '',
|
||||
automation_config: cloneAutomationConfig(pool.automation_config)
|
||||
} : {
|
||||
id: 0,
|
||||
name: '',
|
||||
@@ -755,25 +776,48 @@ function openPoolDialog(pool?: any) {
|
||||
status: 1,
|
||||
member_admin_ids: [],
|
||||
skip_verify: 0,
|
||||
main_url: ''
|
||||
main_url: '',
|
||||
automation_config: defaultAutomationConfig()
|
||||
})
|
||||
poolDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function savePool() {
|
||||
if (!poolForm.name.trim()) return ElMessage.warning('请输入分流方案名称')
|
||||
if (!poolForm.member_admin_ids.length) return ElMessage.warning('请至少选择一名获客医助')
|
||||
if (savingPool.value || automationBusy.value) return
|
||||
poolFormError.value = ''
|
||||
const automation = cloneAutomationConfig(poolForm.automation_config)
|
||||
// Disabled sections do not submit unfinished draft rows or draft attachments.
|
||||
if (automation.reception_mode !== 'scheduled') automation.reception_schedule = []
|
||||
if (automation.welcome_mode !== 'channel') automation.welcome = { text: '', attachments: [] }
|
||||
if (automation.welcome_mode !== 'channel' || !automation.welcome_schedule_enabled) automation.welcome_schedule = []
|
||||
const error = !poolForm.name.trim() ? '请输入分流方案名称'
|
||||
: !poolForm.member_admin_ids.length ? '请至少选择一名主接待成员'
|
||||
: poolForm.fallback_url.trim() && !isWebUrl(poolForm.fallback_url.trim()) ? '兜底获客助手链接必须为有效的 HTTP/HTTPS 地址'
|
||||
: overview.automation_installed ? validateAutomationConfig(automation, poolForm.member_admin_ids) : ''
|
||||
if (error) {
|
||||
poolFormError.value = error
|
||||
poolFormScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
return ElMessage.warning(error)
|
||||
}
|
||||
savingPool.value = true
|
||||
try {
|
||||
const result: any = await wecomPromotionSavePool({ ...poolForm })
|
||||
try {
|
||||
const { automation_config: _draft, ...base } = poolForm
|
||||
const result: any = await wecomPromotionSavePool({
|
||||
...base,
|
||||
name: poolForm.name.trim(),
|
||||
fallback_url: poolForm.fallback_url.trim(),
|
||||
...(overview.automation_installed ? { automation_config: automation } : {})
|
||||
})
|
||||
poolDialogVisible.value = false
|
||||
await loadOverview()
|
||||
if (result?.id) selectedPoolId.value = Number(result.id)
|
||||
result?.sync_error
|
||||
? ElMessage.warning('方案已保存,企业微信多人范围将在后台自动重试同步')
|
||||
: ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '分流方案保存失败')
|
||||
} catch (error: any) {
|
||||
poolFormError.value = error?.message || (typeof error === 'string' ? error : '分流方案保存失败,请检查配置或稍后重试')
|
||||
poolFormScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
ElMessage.error(poolFormError.value)
|
||||
} finally {
|
||||
savingPool.value = false
|
||||
}
|
||||
@@ -974,10 +1018,17 @@ function eligibility(row: any) {
|
||||
if (Number(row.active_start) > 0 && Number(row.active_start) > now) return { label: '尚未生效', className: 'is-waiting' }
|
||||
if (Number(row.active_end) > 0 && Number(row.active_end) < now) return { label: '已过期', className: 'is-error' }
|
||||
if (Number(row.daily_limit) > 0 && todayCount(row) >= Number(row.daily_limit)) return { label: '今日已达上限', className: 'is-waiting' }
|
||||
if (Number(row.sync_status) === 3) return { label: '企微同步重试中', className: 'is-waiting' }
|
||||
if (Number(row.sync_status) === 4) return { label: '暂无可用成员', className: 'is-error' }
|
||||
if (Number(selectedPool.value?.status) === 0) return { label: '方案已停用', className: 'is-muted' }
|
||||
if (row.reception_available === false || row.reception_available === 0) return { label: row.is_backup ? '备用待命' : '非接待时段', className: 'is-waiting' }
|
||||
if (row.is_backup) return { label: '备用接待中', className: 'is-ok' }
|
||||
return { label: '可参与分流', className: 'is-ok' }
|
||||
}
|
||||
|
||||
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' }
|
||||
}
|
||||
|
||||
function todayCount(row: any) {
|
||||
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
|
||||
@@ -1099,7 +1150,8 @@ function filterMemberTreeNode(keyword: string, data: any) {
|
||||
|
||||
function memberSummary(pool: any) {
|
||||
const ids = Array.isArray(pool?.member_admin_ids) ? pool.member_admin_ids : []
|
||||
return ids.length ? `${ids.length} 名医助` : '未选择医助'
|
||||
const backupCount = pool?.automation_config?.backup_member_admin_ids?.length || 0
|
||||
return ids.length ? `${ids.length} 名主接待${backupCount ? ` · ${backupCount} 名备用` : ''}` : '未选择医助'
|
||||
}
|
||||
|
||||
function memberRuleDetail(row: any) {
|
||||
@@ -1313,6 +1365,11 @@ h1, h2, h3, p { margin: 0; }
|
||||
.access-pool-preview small, .access-pool-preview p { color: #8491a2; font-size: 10px; }
|
||||
.access-pool-preview small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.access-pool-preview p { margin-top: 7px; }
|
||||
.pool-form-scroll { max-height: 70vh; overflow-y: auto; overflow-x: hidden; padding: 0 10px 6px 2px; }
|
||||
.pool-form-error { margin-bottom: 18px; }
|
||||
.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; }
|
||||
: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 { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.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; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Doctor Workstation 1.3.0 Windows 正式包构建与安全验证结果
|
||||
|
||||
- 执行日期:2026-08-28(Asia/Shanghai)
|
||||
- 工作目录:`D:\web\zyt\app`
|
||||
- 结论:通过。1.3.0 Windows 安装器与 ZIP 已重新构建;冻结媒体/入口门禁、隔离安装/启动/卸载冒烟和真实 Windows helper bootstrap 专项测试全部通过。
|
||||
- 安全边界:未修改生产源码或既有测试,未执行 reset/checkout,未访问现场服务端,也未用 `dist` 中旧的 1.1.0 安装器覆盖真实安装。安装器冒烟显式指定 1.3.0 文件并仅安装到随机临时目录。
|
||||
|
||||
## 最终产物
|
||||
|
||||
| 产物 | 字节数 | SHA-256 |
|
||||
| --- | ---: | --- |
|
||||
| `D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe` | 162917310 | `E1734C7B5E1619951AF81082FC4578D50BD881B5CD8C7AC7F4E658E8E28B74A7` |
|
||||
| `D:\web\zyt\app\dist\DoctorWorkstation-Windows-x64-1.3.0.zip` | 230820285 | `FAF2D4A68065C1ED528A1D887B5210B44FACBEBBC1CB89729FA3B690D7995CEF` |
|
||||
|
||||
`dist\SHA256SUMS.txt` 与重新计算的两个 SHA-256 完全一致。
|
||||
|
||||
## 命令、退出码与结果
|
||||
|
||||
以下命令均从 PowerShell 执行;未特别注明时工作目录为 `D:\web\zyt\app`。
|
||||
|
||||
### 1. 项目约束与 Trellis 检查
|
||||
|
||||
```powershell
|
||||
Get-Content -LiteralPath 'D:\web\zyt\AGENTS.md' -Raw
|
||||
if (Test-Path -LiteralPath 'D:\web\zyt\.trellis') { Get-ChildItem ... } else { 'NO_TRELLIS' }
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:根 `AGENTS.md` 已完整读取;`D:\web\zyt\.trellis` 不存在,因此没有可继续读取的 `.trellis/workflow.md` 或分层 spec。
|
||||
|
||||
### 2. 初始共享工作树与构建输入只读检查
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
Get-Content src\doctor_workstation\__init__.py
|
||||
Get-Content scripts\package_windows.ps1
|
||||
Get-Content scripts\smoke_windows_installer.ps1
|
||||
rg -n "bootstrap|ready|helper|windows" tests\test_app_update.py
|
||||
Get-ChildItem dist -File
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:开始时共享工作树已有大量未提交源码/测试变更和研究文件,均视为他人工作并保留;确认构建、安装器冒烟与 helper 测试入口存在。`dist` 内旧 1.1.0/1.2.0 包仅被列出,没有被安装或复制到真实安装位置。
|
||||
|
||||
### 3. 版本源逐行及实际导入检查
|
||||
|
||||
```powershell
|
||||
[System.IO.File]::ReadAllLines((Resolve-Path 'src\doctor_workstation\__init__.py'))
|
||||
.\.venv\Scripts\python.exe -c "import doctor_workstation; print(repr(doctor_workstation.__version__)); print(repr(doctor_workstation.DEBUG_MODE)); print(repr(doctor_workstation.ONLINE_API_BASE_URL))"
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:`__version__ == '1.3.0'`;`DEBUG_MODE is False`;线上 API 基址为 `https://admin.zhenyangtang.com.cn`。
|
||||
|
||||
### 4. 打包脚本内置冻结门禁确认
|
||||
|
||||
```powershell
|
||||
rg -n "runtime_media_smoke|smoke|entry|frozen|media|DoctorWorkstation.exe|--smoke" scripts\package_windows.ps1 scripts\build_windows.ps1 packaging\runtime_media_smoke.py
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:确认 `build_windows.ps1` 在构建后依次执行冻结 Qt 多媒体文件门禁、`--media-smoke-test` 冻结进程门禁和 `--smoke-test` 应用入口门禁。
|
||||
|
||||
### 5. 1.3.0 Windows 正式包重建
|
||||
|
||||
```powershell
|
||||
& .\scripts\package_windows.ps1
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 关键结果:
|
||||
- `uv sync --frozen --extra build` 成功;构建环境使用 Python 3.12.12、PyInstaller 6.22.0。
|
||||
- `npm ci` 成功,视频伴侣生产构建成功(Vite 6.1.1,43 modules transformed)。
|
||||
- PyInstaller onedir 冻结成功,输出 `dist\DoctorWorkstation\DoctorWorkstation.exe`。
|
||||
- `Frozen Qt multimedia file gate passed.`
|
||||
- `Frozen Qt multimedia smoke gate passed (--media-smoke-test, isolated offscreen mode).`
|
||||
- `Frozen application entry smoke gate passed (--smoke-test, isolated offscreen mode).`
|
||||
- 7-Zip 创建 1.3.0 ZIP 成功;Inno Setup 6.7.3 编译 1.3.0 安装器成功。
|
||||
- 脚本打印的最终哈希与本报告“最终产物”一致。
|
||||
- 非阻塞警告:Vite 报告单个 JS chunk 大于 500 kB;PyInstaller 报告一个可选 QML asset downloader 插件二进制不存在及若干 Windows 系统 DLL 静态解析警告。它们未阻断构建,且后续冻结媒体实际进程、应用入口及安装后启动门禁全部通过。
|
||||
|
||||
### 6. 新安装器隔离安装/启动/卸载冒烟
|
||||
|
||||
```powershell
|
||||
& .\scripts\smoke_windows_installer.ps1 -Installer (Resolve-Path -LiteralPath '.\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe').Path
|
||||
```
|
||||
|
||||
- 退出码:`0`(PowerShell 进程退出码)
|
||||
- 结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||
- 隔离目录:`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-14b72f4137814eea8ede6bc48920f5f5`
|
||||
- 覆盖项:安装器/主程序/卸载器图标一致;安装目录内主程序和卸载器存在;安装后主程序 `--smoke-test` 返回 0;卸载返回 0;卸载后主程序不再存在。
|
||||
- 安全说明:脚本使用随机 `%LOCALAPPDATA%\Temp\doctor-workstation-installer-smoke-*\install`,环境变量、配置与日志均隔离;命令显式锁定 1.3.0 安装器,没有调用 1.1.0 包或现场服务端。
|
||||
|
||||
### 7. 真实 Windows helper bootstrap 专项测试
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest 'tests\test_app_update.py::test_inno_helper_executes_bootstrap_with_production_flags' -q
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:`1 passed`(单点输出为 `.`)。该测试未 mock `subprocess.Popen`,会真实启动 PowerShell helper,并由测试生成的 helper 脚本在收到生产参数集后写入 ready 文件;最后断言 ready 文件内容为 `ready`,因此确认生产 flags 下 ready 握手实际产生。
|
||||
|
||||
### 8. 最终版本/生产 flag 复核及独立哈希核验
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -c "import doctor_workstation; assert doctor_workstation.__version__ == '1.3.0'; assert doctor_workstation.DEBUG_MODE is False"
|
||||
Get-Item .\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe
|
||||
Get-FileHash .\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe -Algorithm SHA256
|
||||
Get-Item .\dist\DoctorWorkstation-Windows-x64-1.3.0.zip
|
||||
Get-FileHash .\dist\DoctorWorkstation-Windows-x64-1.3.0.zip -Algorithm SHA256
|
||||
Compare-Object <重新计算的两行> (Get-Content .\dist\SHA256SUMS.txt)
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:版本和 `DEBUG_MODE` 断言通过;EXE/ZIP 字节数和哈希如“最终产物”所列;`Compare-Object` 无差异。
|
||||
|
||||
### 9. 完成前共享工作树/产物复查
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
Get-ChildItem .\dist -File | Where-Object { $_.Name -match '1\.3\.0|SHA256SUMS' } | Select-Object Name,Length,LastWriteTime
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:1.3.0 EXE、ZIP 和新 `SHA256SUMS.txt` 均存在;本任务未编辑生产源码或测试。完成复查时仍可见共享工作树中的既有 `src/doctor_workstation/services/app_update.py` 修改,未回退或覆盖。
|
||||
|
||||
## 验收结论
|
||||
|
||||
1. 版本/发布开关正确:`1.3.0`、`DEBUG_MODE=False`。
|
||||
2. 正式 EXE/ZIP 重建成功,内置三项冻结门禁全部通过。
|
||||
3. 1.3.0 安装器隔离安装、启动、卸载和残留检查通过。
|
||||
4. 真实 Windows helper bootstrap 在生产 flags 下成功生成 ready 文件。
|
||||
5. 最终大小与 SHA-256 已独立复核,且与 `SHA256SUMS.txt` 一致。
|
||||
6. 未触碰真实安装或现场服务端 1.1.0 包。
|
||||
@@ -3,7 +3,7 @@
|
||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||
|
||||
# Single source of truth for runtime, package, installer, and executable versions.
|
||||
__version__ = "1.3.0"
|
||||
__version__ = "1.2.0"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
|
||||
@@ -714,7 +714,10 @@ def _spawn_inno_setup_applier(
|
||||
if exit_code is not None:
|
||||
raise OSError(f"更新助手启动后提前退出(代码 {exit_code})")
|
||||
time.sleep(0.05)
|
||||
process.terminate()
|
||||
try: # noqa: SIM105 - best-effort cleanup before reporting the handshake timeout
|
||||
process.terminate()
|
||||
except OSError:
|
||||
pass
|
||||
raise OSError(f"更新助手启动超时,未生成就绪标记:{ready_file}")
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ object names and dynamic properties so adjacent pages keep their own styling.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
import weakref
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import (
|
||||
@@ -1257,7 +1258,7 @@ class DiagnosisLineEdit(QLineEdit):
|
||||
self.setText(text)
|
||||
|
||||
|
||||
class DiagnosisTextEdit(QPlainTextEdit):
|
||||
class DiagnosisTextEdit(QPlainTextEdit):
|
||||
"""Multiline editor using the same scoped field state as line inputs."""
|
||||
|
||||
def __init__(self, rows: int = 3, parent: QWidget | None = None) -> None:
|
||||
@@ -1273,11 +1274,113 @@ class DiagnosisTextEdit(QPlainTextEdit):
|
||||
# QPlainTextEdit defaults to ~80 columns; that blows past the drawer width.
|
||||
return QSize(48, self._hint_height)
|
||||
|
||||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||||
return QSize(160, self._hint_height)
|
||||
|
||||
|
||||
class DiagnosisComboBox(QComboBox):
|
||||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||||
return QSize(160, self._hint_height)
|
||||
|
||||
|
||||
class ExpandableDiagnosisTextEdit(DiagnosisTextEdit):
|
||||
"""An inline long-text editor without an inner scrollbar when expanded."""
|
||||
|
||||
def __init__(self, rows: int = 3, parent: QWidget | None = None) -> None:
|
||||
super().__init__(rows, parent)
|
||||
self._collapsed_height = self._hint_height
|
||||
self._caption = "内容"
|
||||
self._expand_host: QWidget | None = None
|
||||
self.expand_button = QPushButton("展开全部", self)
|
||||
self.expand_button.setObjectName("DiagnosisTextExpandButton")
|
||||
self.expand_button.setProperty("variant", "link")
|
||||
self.expand_button.setCheckable(True)
|
||||
self.expand_button.setAutoDefault(False)
|
||||
self.expand_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.expand_button.setToolTip("展开文本框,查看和编辑全部内容")
|
||||
self.expand_button.hide()
|
||||
self.expand_button.toggled.connect(self._toggle_expanded)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self._height_sync = QTimer(self)
|
||||
self._height_sync.setSingleShot(True)
|
||||
self._height_sync.setInterval(0)
|
||||
self._height_sync.timeout.connect(self._sync_content_height)
|
||||
self.textChanged.connect(self._height_sync.start)
|
||||
self.document().documentLayout().documentSizeChanged.connect(
|
||||
lambda _size: self._height_sync.start()
|
||||
)
|
||||
|
||||
def wrap_with_expander(self, caption: str) -> QWidget:
|
||||
"""Keep the original plain-text editor as the form's save/readonly target."""
|
||||
|
||||
if self._expand_host is None:
|
||||
self._caption = caption
|
||||
self.setAccessibleName(caption)
|
||||
self.expand_button.setAccessibleName(f"展开{caption}全部内容")
|
||||
self._expand_host = QWidget()
|
||||
self._expand_host.setMinimumWidth(0)
|
||||
self._expand_host.setFocusProxy(self)
|
||||
layout = QVBoxLayout(self._expand_host)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(2)
|
||||
layout.addWidget(self)
|
||||
layout.addWidget(self.expand_button, 0, Qt.AlignmentFlag.AlignRight)
|
||||
return self._expand_host
|
||||
|
||||
def setPlainText(self, text: str) -> None: # noqa: N802 - Qt API
|
||||
# A newly loaded record starts compact; typing/paste/undo keep its state.
|
||||
self.expand_button.setChecked(False)
|
||||
super().setPlainText(text)
|
||||
|
||||
def _toggle_expanded(self, expanded: bool) -> None:
|
||||
self.expand_button.setText("收起" if expanded else "展开全部")
|
||||
self.expand_button.setAccessibleName(
|
||||
f"收起{self._caption}" if expanded else f"展开{self._caption}全部内容"
|
||||
)
|
||||
self.expand_button.setToolTip(
|
||||
"收起为紧凑文本框,内容不会丢失" if expanded else "展开文本框,查看和编辑全部内容"
|
||||
)
|
||||
self.setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
if expanded
|
||||
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
self.verticalScrollBar().setValue(0)
|
||||
self._height_sync.start()
|
||||
|
||||
def _sync_content_height(self) -> None:
|
||||
expanded = self.expand_button.isChecked()
|
||||
document = self.document()
|
||||
document_layout = document.documentLayout()
|
||||
# QPlainTextDocumentLayout.documentSize().height() counts lines, not
|
||||
# pixels. Measure the actual wrapped blocks, including offscreen ones.
|
||||
height = self.height() - self.viewport().height() + 2 * document.documentMargin()
|
||||
block = document.begin()
|
||||
while block.isValid():
|
||||
height += document_layout.blockBoundingRect(block).height()
|
||||
if not expanded and height > self._collapsed_height:
|
||||
break
|
||||
block = block.next()
|
||||
self.expand_button.setVisible(expanded or height > self._collapsed_height)
|
||||
target = max(self._collapsed_height, ceil(height)) if expanded else self._collapsed_height
|
||||
if self._hint_height != target:
|
||||
self._hint_height = target
|
||||
self.setFixedHeight(target)
|
||||
self.updateGeometry()
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, "_height_sync"):
|
||||
self._height_sync.start()
|
||||
|
||||
def showEvent(self, event: QShowEvent) -> None: # noqa: N802 - Qt API
|
||||
super().showEvent(event)
|
||||
self._height_sync.start()
|
||||
|
||||
def changeEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
|
||||
super().changeEvent(event)
|
||||
if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange) and hasattr(
|
||||
self, "_height_sync"
|
||||
):
|
||||
self._height_sync.start()
|
||||
|
||||
|
||||
class DiagnosisComboBox(QComboBox):
|
||||
"""Choice editor with the legacy text API used by diagnosis saving."""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -46,6 +46,7 @@ from ..diagnosis_drawer import (
|
||||
DiagnosisSwitch,
|
||||
DiagnosisTabWidget,
|
||||
DiagnosisTextEdit,
|
||||
ExpandableDiagnosisTextEdit,
|
||||
LoadingOverlay,
|
||||
MessageStrip,
|
||||
NotesTimeline,
|
||||
@@ -300,7 +301,7 @@ _FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ..
|
||||
("渠道", "create_source", 12, "create_source"),
|
||||
),
|
||||
(("统计端就诊卡", "show_card", 12, "show_card"),),
|
||||
(("在用药物", "current_medications", 24, "textarea3"),),
|
||||
(("在用药物", "current_medications", 24, "expandable_textarea3"),),
|
||||
),
|
||||
),
|
||||
(
|
||||
@@ -331,7 +332,7 @@ _FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ..
|
||||
(("小便情况", "urine_condition", 24, "urine_condition_choices"),),
|
||||
(("大便情况", "stool_condition", 24, "stool_condition_choices"),),
|
||||
(("腰肾情况", "kidney_condition", 24, "kidney_condition_choices"),),
|
||||
(("其他补充", "symptoms", 24, "textarea3"),),
|
||||
(("其他补充", "symptoms", 24, "expandable_textarea3"),),
|
||||
),
|
||||
),
|
||||
("既往史", ((("既往史", "past_history", 24, "past_history_choices"),),)),
|
||||
@@ -1234,11 +1235,12 @@ class DiagnosisDialog(QDialog):
|
||||
occupied = 0
|
||||
for label, key, span, field_kind in row_fields:
|
||||
editor = self._ensure_editor(key, field_kind)
|
||||
display = (
|
||||
editor.wrap_with_unit()
|
||||
if isinstance(editor, DiagnosisNumberEdit)
|
||||
else editor
|
||||
)
|
||||
if isinstance(editor, DiagnosisNumberEdit):
|
||||
display = editor.wrap_with_unit()
|
||||
elif isinstance(editor, ExpandableDiagnosisTextEdit):
|
||||
display = editor.wrap_with_expander(label)
|
||||
else:
|
||||
display = editor
|
||||
container = self._field_container(label, display, span, editor=editor)
|
||||
row_layout.addWidget(container, span)
|
||||
fields.append(container)
|
||||
@@ -1371,9 +1373,13 @@ class DiagnosisDialog(QDialog):
|
||||
columns=4 if multiple else 5,
|
||||
)
|
||||
self._choice_fields[key] = editor
|
||||
elif field_kind.startswith("textarea"):
|
||||
elif field_kind.startswith(("textarea", "expandable_textarea")):
|
||||
rows = int(field_kind[-1])
|
||||
editor = DiagnosisTextEdit(rows)
|
||||
editor = (
|
||||
ExpandableDiagnosisTextEdit(rows)
|
||||
if field_kind.startswith("expandable_")
|
||||
else DiagnosisTextEdit(rows)
|
||||
)
|
||||
else:
|
||||
editor = DiagnosisLineEdit()
|
||||
editor.setObjectName(f"DiagnosisField_{key.removeprefix('__')}")
|
||||
|
||||
@@ -259,6 +259,7 @@ QWidget#ReceptionPage QLabel#StatusBadge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionNotifyButton,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionImButton {
|
||||
min-height: 34px;
|
||||
@@ -282,6 +283,7 @@ QWidget#ReceptionPage QPushButton#ReceptionMoreButton {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionNotifyButton:hover,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton:hover,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionImButton:hover {
|
||||
color: #5469F0;
|
||||
@@ -3832,6 +3834,10 @@ class ReceptionPage(QWidget):
|
||||
self.complete_button.clicked.connect(self._complete_appointment)
|
||||
self.complete_button.setVisible(self._can_complete)
|
||||
patient_head.addWidget(self.complete_button)
|
||||
self.notify_button = QPushButton("通知医助", hero)
|
||||
self.notify_button.setObjectName("ReceptionNotifyButton")
|
||||
self.notify_button.clicked.connect(self._notify_assistant)
|
||||
patient_head.addWidget(self.notify_button)
|
||||
self.history_button = QPushButton("查看历史", hero)
|
||||
self.history_button.setObjectName("ReceptionHistoryButton")
|
||||
self.history_button.setIcon(_painted_reception_action_icon("info", "#3F4E75"))
|
||||
@@ -3847,7 +3853,6 @@ class ReceptionPage(QWidget):
|
||||
self.more_button = QPushButton("更多", hero)
|
||||
self.more_button.setObjectName("ReceptionMoreButton")
|
||||
more_menu = QMenu(self.more_button)
|
||||
more_menu.addAction("通知医助").triggered.connect(self._notify_assistant)
|
||||
edit_action = more_menu.addAction("编辑病历")
|
||||
edit_action.setVisible(self._can_edit)
|
||||
edit_action.triggered.connect(self._edit_diagnosis)
|
||||
@@ -3865,8 +3870,6 @@ class ReceptionPage(QWidget):
|
||||
action_compat = QWidget(hero)
|
||||
action_compat.setFixedSize(0, 0)
|
||||
action_compat.move(-100, -100)
|
||||
self.notify_button = QPushButton("通知医助", action_compat)
|
||||
self.notify_button.clicked.connect(self._notify_assistant)
|
||||
self.edit_button = QPushButton("编辑病历", action_compat)
|
||||
self.edit_button.setProperty("variant", "secondary")
|
||||
self.edit_button.clicked.connect(self._edit_diagnosis)
|
||||
|
||||
@@ -9,7 +9,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtGui import QImage, QTextCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
@@ -29,6 +29,7 @@ from doctor_workstation.ui.diagnosis_drawer import (
|
||||
DiagnosisLineEdit,
|
||||
DiagnosisNumberEdit,
|
||||
DiagnosisSwitch,
|
||||
ExpandableDiagnosisTextEdit,
|
||||
SaveStateButton,
|
||||
)
|
||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
@@ -751,6 +752,186 @@ def test_semantic_form_controls_keep_desktop_grid_and_canonical_diagnosis_type(
|
||||
dialog.close()
|
||||
|
||||
|
||||
def _settle_text_layout(application: QApplication) -> None:
|
||||
# Editor measurement and the containing scroll area's layout are deferred.
|
||||
for _ in range(12):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["symptoms", "current_medications"])
|
||||
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"这是一段用于验证自动换行的测试记录。" * 40,
|
||||
"\n".join(f"第{index + 1}条:测试记录,保留原始换行。" for index in range(18)),
|
||||
"LongUnbrokenTestValue" * 80,
|
||||
],
|
||||
ids=["wrapped-chinese", "multiline", "unbroken-text"],
|
||||
)
|
||||
def test_long_diagnosis_fields_expand_inline_without_inner_scrolling(
|
||||
application: QApplication, key: str, size: tuple[int, int], text: str
|
||||
) -> None:
|
||||
repository = VisualRepository()
|
||||
repository.detail["diagnosis"][key] = text
|
||||
dialog = _open_dialog(application, size, mode="edit", repository=repository)
|
||||
try:
|
||||
_settle_text_layout(application)
|
||||
editor = dialog.edit_fields[key]
|
||||
assert isinstance(editor, ExpandableDiagnosisTextEdit)
|
||||
assert editor.height() == 72
|
||||
assert editor.expand_button.isVisibleTo(dialog)
|
||||
assert not editor.expand_button.autoDefault()
|
||||
assert editor.expand_button.text() == "展开全部"
|
||||
body = dialog.findChild(QScrollArea, "DiagnosisDrawerBody")
|
||||
footer = dialog.findChild(QFrame, "DiagnosisDrawerFooter")
|
||||
footer_before = footer.geometry()
|
||||
outer_range_before = body.verticalScrollBar().maximum()
|
||||
horizontal_range_before = body.horizontalScrollBar().maximum()
|
||||
editor.verticalScrollBar().setValue(editor.verticalScrollBar().maximum())
|
||||
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
assert editor.expand_button.text() == "收起"
|
||||
assert editor.height() > 72
|
||||
assert editor.sizeHint().height() == editor.height()
|
||||
assert not editor._height_sync.isActive()
|
||||
assert editor.verticalScrollBar().maximum() == 0
|
||||
assert editor.horizontalScrollBar().maximum() == 0
|
||||
assert not editor.verticalScrollBar().isVisibleTo(dialog)
|
||||
last_block = editor.blockBoundingGeometry(editor.document().lastBlock())
|
||||
assert last_block.translated(editor.contentOffset()).bottom() <= editor.viewport().height()
|
||||
assert body.verticalScrollBar().maximum() > outer_range_before
|
||||
# The existing narrow form has a small minimum-width overflow; expanding
|
||||
# these fields must not increase it or clip their right edge.
|
||||
assert body.horizontalScrollBar().maximum() == horizontal_range_before
|
||||
assert editor.mapTo(body.viewport(), QPoint(editor.width(), 0)).x() <= body.viewport().width()
|
||||
assert footer.geometry() == footer_before
|
||||
assert editor.toPlainText() == text
|
||||
assert not repository.updates
|
||||
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() == 72
|
||||
assert editor.toPlainText() == text
|
||||
assert editor.expand_button.text() == "展开全部"
|
||||
assert not editor._height_sync.isActive()
|
||||
finally:
|
||||
dialog.close()
|
||||
_settle_text_layout(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["symptoms", "current_medications"])
|
||||
def test_expanded_diagnosis_text_reflows_on_resize_and_keeps_edit_save_contract(
|
||||
application: QApplication, key: str
|
||||
) -> None:
|
||||
repository = VisualRepository()
|
||||
original = "自动换行测试内容,保持完整文本。" * 30
|
||||
repository.detail["diagnosis"][key] = original
|
||||
dialog = _open_dialog(application, (1440, 900), mode="edit", repository=repository)
|
||||
try:
|
||||
_settle_text_layout(application)
|
||||
editor = dialog.edit_fields[key]
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
wide_height = editor.height()
|
||||
dialog.resize(1024, 640)
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() > wide_height
|
||||
assert editor.verticalScrollBar().maximum() == 0
|
||||
dialog.resize(1440, 900)
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() == wide_height
|
||||
|
||||
editor.moveCursor(QTextCursor.MoveOperation.End)
|
||||
added = "\n新增测试记录,保存时不能截断。" * 12
|
||||
editor.insertPlainText(added)
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() > wide_height
|
||||
assert editor.verticalScrollBar().maximum() == 0
|
||||
cursor_position = editor.textCursor().position()
|
||||
editor.expand_button.click()
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
assert editor.textCursor().position() == cursor_position
|
||||
editor.undo()
|
||||
_settle_text_layout(application)
|
||||
assert editor.toPlainText() == original
|
||||
assert editor.height() == wide_height
|
||||
editor.redo()
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
dialog._save()
|
||||
assert repository.updates[-1][key] == original + added
|
||||
finally:
|
||||
dialog.close()
|
||||
_settle_text_layout(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["symptoms", "current_medications"])
|
||||
def test_short_and_cleared_diagnosis_text_keep_compact_layout(
|
||||
application: QApplication, key: str
|
||||
) -> None:
|
||||
dialog = _open_dialog(application, (1024, 640), mode="edit")
|
||||
try:
|
||||
editor = dialog.edit_fields[key]
|
||||
for text in ("", "简短测试记录"):
|
||||
editor.setPlainText(text)
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() == 72
|
||||
assert not editor.expand_button.isVisibleTo(dialog)
|
||||
editor.insertPlainText("\n很长的测试记录" * 20)
|
||||
_settle_text_layout(application)
|
||||
assert editor.expand_button.isVisibleTo(dialog)
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
editor.selectAll()
|
||||
editor.insertPlainText("")
|
||||
_settle_text_layout(application)
|
||||
assert editor.toPlainText() == ""
|
||||
assert editor.height() == 72
|
||||
assert editor.expand_button.text() == "收起"
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
assert not editor.expand_button.isVisibleTo(dialog)
|
||||
assert not isinstance(dialog.edit_fields["remark"], ExpandableDiagnosisTextEdit)
|
||||
assert not isinstance(dialog.edit_fields["present_illness"], ExpandableDiagnosisTextEdit)
|
||||
finally:
|
||||
dialog.close()
|
||||
_settle_text_layout(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["symptoms", "current_medications"])
|
||||
def test_readonly_diagnosis_text_can_expand_and_reopen_starts_collapsed(
|
||||
application: QApplication, key: str
|
||||
) -> None:
|
||||
repository = VisualRepository()
|
||||
text = "只读长文本测试\n" * 20
|
||||
repository.detail["diagnosis"][key] = text
|
||||
dialog = _open_dialog(application, (1024, 640), mode="viewOnly", repository=repository)
|
||||
try:
|
||||
_settle_text_layout(application)
|
||||
editor = dialog.edit_fields[key]
|
||||
assert editor.isReadOnly()
|
||||
assert editor.expand_button.isEnabled()
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() > 72
|
||||
assert editor.verticalScrollBar().maximum() == 0
|
||||
assert editor.isReadOnly()
|
||||
assert not dialog.save_button.isVisibleTo(dialog)
|
||||
assert not repository.updates
|
||||
dialog.close()
|
||||
dialog.open_view_only(501)
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() == 72
|
||||
assert not editor.expand_button.isChecked()
|
||||
assert editor.toPlainText() == text
|
||||
finally:
|
||||
dialog.close()
|
||||
_settle_text_layout(application)
|
||||
|
||||
|
||||
def test_hpi_choice_chips_are_visible_after_dictionary_load(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
|
||||
@@ -1436,6 +1436,102 @@ def test_im_consult_is_visible_immediately_after_history(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1280, 1494])
|
||||
@pytest.mark.parametrize("permissions", [[], ["*"]])
|
||||
def test_notify_assistant_is_a_visible_header_action_not_a_more_menu_item(
|
||||
application: QApplication,
|
||||
queued_async: list[dict[str, Any]],
|
||||
width: int,
|
||||
permissions: list[str],
|
||||
) -> None:
|
||||
page = ReceptionPage(DemoDoctorRepository(), PermissionSet(permissions))
|
||||
page.resize(width, 760)
|
||||
page.show()
|
||||
try:
|
||||
application.processEvents()
|
||||
# The first show initiates a queue reload and clears the selection.
|
||||
# Present a synthetic selected patient after that initial reset.
|
||||
page.patient_name_label.setText("测试患者")
|
||||
page.patient_meta_label.setText("女 · 42岁 · 138****8000 | 就诊号:31")
|
||||
page.detail_stack.setCurrentIndex(1)
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
hero = page.notify_button.parentWidget()
|
||||
assert hero.objectName() == "ReceptionHero"
|
||||
assert page.notify_button.isVisibleTo(page)
|
||||
assert page.notify_button.text() == "通知医助"
|
||||
assert page.notify_button.width() >= page.notify_button.sizeHint().width()
|
||||
assert page.notify_button.height() > 0
|
||||
assert "通知医助" not in [action.text() for action in page.more_button.menu().actions()]
|
||||
buttons = [
|
||||
button
|
||||
for button in (
|
||||
page.complete_button,
|
||||
page.notify_button,
|
||||
page.history_button,
|
||||
page.video_button,
|
||||
page.more_button,
|
||||
)
|
||||
if button.isVisibleTo(page)
|
||||
]
|
||||
for button in buttons:
|
||||
assert hero.rect().contains(button.geometry())
|
||||
assert button.width() >= button.sizeHint().width()
|
||||
right = button.mapTo(page.detail_scroll.viewport(), QPoint(button.width(), 0)).x()
|
||||
assert right <= page.detail_scroll.viewport().width()
|
||||
for previous, following in zip(buttons, buttons[1:], strict=False):
|
||||
assert previous.geometry().right() < following.geometry().left()
|
||||
finally:
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_notify_header_button_keeps_appointment_context_and_pending_state(
|
||||
application: QApplication,
|
||||
queued_async: list[dict[str, Any]],
|
||||
) -> None:
|
||||
sent: list[int] = []
|
||||
|
||||
class Repository:
|
||||
def notify_assistant(self, appointment_id: int) -> dict[str, bool]:
|
||||
sent.append(appointment_id)
|
||||
return {"success": True}
|
||||
|
||||
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||
try:
|
||||
page._update_action_state({}, {})
|
||||
assert not page.notify_button.isEnabled()
|
||||
page._selected_appointment_id = 31
|
||||
page._selected_record = {"id": 31, "status": 1}
|
||||
page._update_action_state(page._selected_record, {})
|
||||
assert page.notify_button.isEnabled()
|
||||
queued_async.clear()
|
||||
page.notify_button.click()
|
||||
assert not page.notify_button.isEnabled()
|
||||
page.notify_button.click()
|
||||
assert len(queued_async) == 1
|
||||
notification = queued_async.pop()
|
||||
notification["function"]()
|
||||
assert sent == [31]
|
||||
notification["on_finished"]()
|
||||
assert page.notify_button.isEnabled()
|
||||
|
||||
page.notify_button.click()
|
||||
notification = queued_async.pop()
|
||||
page._selected_appointment_id = 32
|
||||
page._selected_record = {"id": 32, "status": 1}
|
||||
page._detail_generation += 1
|
||||
# An old request still targets its original appointment and cannot
|
||||
# re-enable a pending notification for a newly selected patient.
|
||||
notification["function"]()
|
||||
notification["on_finished"]()
|
||||
assert sent == [31, 31]
|
||||
assert not page.notify_button.isEnabled()
|
||||
finally:
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_reception_ai_report_button_follows_permission(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
# 企业微信推广链接能力核验
|
||||
|
||||
核验日期:2026-08-31。范围:企业自建应用的获客助手、客户联系、客户欢迎语。本文是只读研究交付,没有修改应用代码,也没有调用任何企业的写接口。
|
||||
|
||||
来源均为企业微信开发者中心官方文档。网页搜索工具无法打开部分官方页面,实际通过 HTTPS 读取同一官方 URL 的公开 HTML,提取正文核验;没有以 SDK、博客或第三方镜像作为结论依据。以下标为“实现建议”的内容是本项目的工程设计,不是官方 API 自带能力。
|
||||
|
||||
## 1. 结论与能力边界
|
||||
|
||||
| 功能 | 官方能力 | 本地需要实现的部分 |
|
||||
| --- | --- | --- |
|
||||
| 获客成员范围 | `create_link` / `update_link` 的 `range.user_list`、`range.department_list` | 成员开关、有效期、星期时段、当日上限计算后写入范围 |
|
||||
| 老客户优先找原员工 | `priority_option`,且仅部分经营类目支持 | 校验经营类目能力;与排班、上限的冲突提示 |
|
||||
| 按星期、时段自动上下线 | 获客链接接口没有排班字段 | 常驻任务/定时调度重算,调用 `update_link` 覆盖范围 |
|
||||
| 备用员工 | 获客链接接口没有“主用/备用”字段 | 主用无人可接待时才把合格备用成员放入范围 |
|
||||
| 客户标签 | 读取企业标签库、对指定员工的客户 `mark_tag` | 配置标签 ID、回调后应用、幂等和失败补偿 |
|
||||
| 客户备注/描述 | `externalcontact/remark` | 模板变量展开、字符数校验、只更新明确配置的字段 |
|
||||
| 欢迎语文本/附件 | `send_welcome_msg` | 默认/渠道/关闭/分时策略选择、变量展开、素材准备、20 秒内发送 |
|
||||
| 通过 `LinkId` 定位添加客户渠道 | **普通 `add_external_contact` 文档不承诺有 `LinkId`** | 使用 `State` 映射本地推广方案;有 `LinkId` 的获客事件作补充 |
|
||||
|
||||
依据:[获客链接管理](https://developer.work.weixin.qq.com/document/path/97297)、[事件格式](https://developer.work.weixin.qq.com/document/path/92130)、[发送新客户欢迎语](https://developer.work.weixin.qq.com/document/path/92137)。
|
||||
|
||||
## 2. 获客链接 create / update
|
||||
|
||||
官方文档:[获客链接管理](https://developer.work.weixin.qq.com/document/path/97297),页面最后更新 2025-11-17。
|
||||
|
||||
### 2.1 请求
|
||||
|
||||
创建:`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/customer_acquisition/create_link?access_token=ACCESS_TOKEN`
|
||||
|
||||
```json
|
||||
{
|
||||
"link_name": "门诊咨询推广",
|
||||
"range": {
|
||||
"user_list": ["assistant_a", "assistant_b"],
|
||||
"department_list": [2]
|
||||
},
|
||||
"skip_verify": true,
|
||||
"priority_option": {
|
||||
"priority_type": 2,
|
||||
"priority_userid_list": ["assistant_a", "assistant_b"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
更新:`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/customer_acquisition/update_link?access_token=ACCESS_TOKEN`
|
||||
|
||||
```json
|
||||
{
|
||||
"link_id": "LINK_ID",
|
||||
"range": {
|
||||
"user_list": ["assistant_b"],
|
||||
"department_list": []
|
||||
},
|
||||
"skip_verify": true
|
||||
}
|
||||
```
|
||||
|
||||
示例中的 `priority_option` 仅在企业确实具备该能力且配置了好友优先策略时提交;它不是排班、权重或备用配置。`skip_verify` 应始终使用本地已保存的值,不能在范围同步时意外改变免验证设置。
|
||||
|
||||
### 2.2 已核实限制
|
||||
|
||||
- 创建 `link_name` 必填,更新可选,最长 **30 个字符**。
|
||||
- `range.user_list` 最多 **500 人**;部门覆盖人数也有上限,最终 `range` 覆盖总人数不得超过 **500 人**。
|
||||
- 创建时 `user_list` 与 `department_list` **不可同时为空**。
|
||||
- 更新的 `range` 是**覆盖更新**,不是增量加入或删除。若目的是精确排班,应使用明确的 `user_list`,并清掉不受排班控制的部门范围。
|
||||
- `skip_verify` 缺省值为 `true`。
|
||||
- `priority_type=1`:在全企业内优先分配给已有好友关系的成员。
|
||||
- `priority_type=2`:在 `priority_userid_list` 中优先分配给已有好友关系的成员;创建时该列表必填,最多 **1000 人**。
|
||||
- `priority_option` 也是覆盖更新;仅支持“客户与成员关系绑定”的经营类目可用,需在管理端“高级功能 → 获客助手”确认。
|
||||
- `range` / `priority_userid_list` 受应用可见范围或客户可建联成员范围约束。
|
||||
- 还有 `mark_source`,缺省 `true`,但**只对“营销获客”应用生效**;本项目自建应用不要将其误当作通用渠道标记开关。
|
||||
- 查询、更新、删除的 `link_id` 必须属于当前应用创建的链接。
|
||||
|
||||
### 2.3 权限和不确定点
|
||||
|
||||
官方明确要求使用配置到客户联系“可调用应用”列表中的自建应用 secret 获取的 token;获客链接 API **不支持客户联系系统应用调用**。不能因为客户详情接口过去可用,就推断同一 secret 一定支持获客链接。
|
||||
|
||||
文档没有明确以下行为,不能自行编造 payload:
|
||||
|
||||
1. `priority_type=0` 的含义以及取消已存在 `priority_option` 的正确方式。官方只列出 `1`、`2`;不要宣称传 `0`、空对象或省略字段能清除既有设置。
|
||||
2. 更新链接时提交全空 `range` 是否有特殊停用语义。创建明确不允许空范围,本项目应继续把“至少一名可接待成员”作为有效配置约束。
|
||||
3. `update_link` 的传播延迟以及对已经打开的成员页/已经发起的好友请求是否有追溯影响。
|
||||
4. 好友优先列表与排班范围交叉时的完整路由细节。`priority_type=1` 涵盖全企业,不能承诺严格服从本地排班/上限;需提供冲突说明并做真实企业联调。
|
||||
|
||||
## 3. 星期排班、备用员工与上限
|
||||
|
||||
以下为根据官方范围更新能力提出的实现建议,并非独立的企微“上下线 API”。
|
||||
|
||||
1. 将星期、开始/结束时间、时区、是否启用、有效期、成员角色(主用/备用)存到本地;统一用 `Asia/Shanghai`,时间区间采用左闭右开 `[start, end)`,跨午夜时段拆成两天或显式处理前一日。
|
||||
2. 先计算符合开关、有效期、班次、业务上限的主用成员;主用集合非空就只发送主用集合。主用全部不可用时才选择合格备用成员;备用成员不要日常混在同一 `range` 中,否则企微会把他们当普通候选成员。
|
||||
3. 保存后立即同步;分钟任务持续重算;在时段边界可以额外立即同步。数据库事务只认领任务/保存状态,网络请求放在事务之外。
|
||||
4. `range` 有变化才调用 `update_link`,保留版本号、租约、重试和最后成功应用范围;调用成功后用 `customer_acquisition/get` 回读核验。
|
||||
5. 企微自动跳过“暂时无法添加客户”的异常账号;若整条链接所有成员异常,会推送 `customer_acquisition/link_unavailable`。这是账号异常路由能力,**不代表按本地班次自动启用备用员工**。可以据此触发告警或经过本地规则校验的备用范围切换。[获客助手事件通知](https://developer.work.weixin.qq.com/document/path/97299)
|
||||
6. 若主用和备用都为空,不要把“本地已下线”展示成“官方链接已停用”。保留明确阻塞状态、错误提示,并由业务选择停止曝光/受控入口暂停;不能在保存排班时偷偷删除官方链接。
|
||||
7. 回调记账后更新范围是事后控制;有网络延迟和并发好友申请,不能声称“日上限绝不超发”。UI 应说明本地统计上限与官方建联并发之间的边界。
|
||||
|
||||
现有 `QywxPromotionRangeSyncService` 已有任务租约、版本号、回读范围、分钟重算和空范围阻塞,适合作为扩展点;不必另造一套链路。现有调度代码通过 `State=zyt_pool:{id}` 记账,扩展渠道参数时应保持兼容。
|
||||
|
||||
## 4. 企业标签、客户备注、客户详情
|
||||
|
||||
### 4.1 获取企业客户标签
|
||||
|
||||
文档:[管理企业标签](https://developer.work.weixin.qq.com/document/path/92117),最后更新 2023-12-01。
|
||||
|
||||
`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get_corp_tag_list?access_token=ACCESS_TOKEN`
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
`tag_id`、`group_id` 都不传即返回所有标签;如果需要按组获取:
|
||||
|
||||
```json
|
||||
{"group_id":["GROUP_ID"]}
|
||||
```
|
||||
|
||||
同时传两个筛选条件时以 `group_id` 为准,忽略 `tag_id`。返回 `tag_group[]`,组内为 `tag[]`,标签使用 `id`、`name`,有删除标记时应过滤。应用仅可编辑/删除自己创建的标签,但读取标签库和给客户打已有企业标签是另一个权限层次,不能据此把所有其他来源标签都从选择器隐藏。
|
||||
|
||||
自建应用需被列入客户联系可调用应用;企业标签库最多 10000 个标签。页面没有给 `get_corp_tag_list` 列出分页参数,不要自行增加 cursor 分页。
|
||||
|
||||
### 4.1.1 自定义企业客户标签(2026-08-31 补充核验)
|
||||
|
||||
官方接口:`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/add_corp_tag?access_token=ACCESS_TOKEN`。来源仍为 [管理企业标签](https://developer.work.weixin.qq.com/document/path/92117) 中“添加企业客户标签”一节;本次通过 HTTPS 读取官方页面公开 HTML 核实,没有调用企业 API。
|
||||
|
||||
已有“推广渠道”分组时:
|
||||
|
||||
```json
|
||||
{"group_id":"EXISTING_GROUP_ID","tag":[{"name":"直播推广"}]}
|
||||
```
|
||||
|
||||
没有该分组时,一次请求创建分组及标签:
|
||||
|
||||
```json
|
||||
{"group_name":"推广渠道","tag":[{"name":"直播推广"}]}
|
||||
```
|
||||
|
||||
- `tag.name` 必填,最长30个字符;`group_name` 同样最长30个字符,均不是字节上限。
|
||||
- 指定已有分组用 `group_id`。填写该字段后,`group_name` 和标签组 `order` 被忽略。
|
||||
- 通过 `group_name` 创建分组时,如果分组名称已存在,会在已有分组下新增标签;不能创建空分组。
|
||||
- 同组标签不能重名;单次传入多个同名标签只创建一个。官方没有承诺“名称已存在”的每种错误码及返回列表形态,因此不能靠猜测错误码返回本地假ID。
|
||||
- 返回值包含 `tag_group.group_id/group_name/tag[]`,标签真实ID为 `tag[].id`;企业标签总数上限10000。
|
||||
- `agentid` 仅旧第三方多应用套件需要,本项目自建应用不提交。
|
||||
|
||||
本项目新增 `POST firstvisit.wecomPromotion/createTag`,复用页面权限,只接受 `{name}` 并固定使用“推广渠道”分组。名称须非空、最多30字符,不得包含控制/不可见格式字符。先查询同组同名并复用,创建失败或响应无法确认时只读回确认;无法确认则提示刷新列表核对,不再次发送创建请求。每个推广方案保存的 `tag_ids` 仍是数组,但最多一项,开启时必须一项;读取旧多选数据不截断,重新保存时要求用户明确选一个。
|
||||
|
||||
### 4.2 给指定员工的客户打标签
|
||||
|
||||
文档:[编辑客户企业标签](https://developer.work.weixin.qq.com/document/path/92118),最后更新 2023-12-01。
|
||||
|
||||
`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/mark_tag?access_token=ACCESS_TOKEN`
|
||||
|
||||
```json
|
||||
{
|
||||
"userid": "assistant_a",
|
||||
"external_userid": "EXTERNAL_USER_ID",
|
||||
"add_tag": ["ENTERPRISE_TAG_ID_A", "ENTERPRISE_TAG_ID_B"]
|
||||
}
|
||||
```
|
||||
|
||||
- 可选 `remove_tag` 用于明确移除;`add_tag` 与 `remove_tag` 不能同时为空。
|
||||
- 客户必须已是该 `userid` 的外部联系人;操作面向**员工与客户的关系**,不是企业下无差别更新所有员工视角。
|
||||
- 每个成员对同一客户最多 3000 个标签;同一标签组可以选多个标签。
|
||||
- 应用只能操作可见范围内成员的客户标签;规则组标签要求同一应用创建该规则组,且成员在其管理范围。
|
||||
- 渠道自动标签建议只增添已配置标签,不能为了“同步一致”删除员工手动添加的其他标签。
|
||||
|
||||
### 4.3 客户备注和描述
|
||||
|
||||
文档:[修改客户备注信息](https://developer.work.weixin.qq.com/document/path/92115),最后更新 2025-11-17。
|
||||
|
||||
`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/remark?access_token=ACCESS_TOKEN`
|
||||
|
||||
```json
|
||||
{
|
||||
"userid": "assistant_a",
|
||||
"external_userid": "EXTERNAL_USER_ID",
|
||||
"remark": "渠道A-李女士",
|
||||
"description": "来自门诊咨询推广"
|
||||
}
|
||||
```
|
||||
|
||||
- `remark` 最多 **20 个字符**;`description` 最多 **150 个字符**,均是字符数,不是欢迎语的字节数。
|
||||
- 可选 `remark_company`(最多20字符,仅微信客户有效)、`remark_mobiles`、`remark_pic_mediaid`。
|
||||
- 不可全部为空;仅写本次用户明确启用的字段,避免覆盖人工备注/电话。
|
||||
- 电话数组会覆盖旧值;官方清除全部电话的特殊说明为给 `remark_mobiles` 填一个空字符串。当前推广需求无须触及这一功能。
|
||||
- 修改权限限制在应用可见范围内成员添加的客户。
|
||||
- 文档未对清空 `remark` / `description` 的空字符串语义作同样明确说明,不要将“未配置”自动转换成清空远端。
|
||||
|
||||
### 4.4 获取客户详情、名字与渠道
|
||||
|
||||
文档:[获取客户详情](https://developer.work.weixin.qq.com/document/path/92114),最后更新 2025-12-19。
|
||||
|
||||
`GET https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get?access_token=ACCESS_TOKEN&external_userid=EXTERNAL_USER_ID`
|
||||
|
||||
重要字段:
|
||||
|
||||
- `external_contact.name`:微信客户为微信昵称;企微联系人为其对外别名或实名。
|
||||
- `follow_user[]`:每个跟进人的 `userid`、`remark`、`description`、`tags`、`state`、`add_way`。
|
||||
- `follow_user.add_way=16` 表示获客链接添加;`state` 是本地可自定义渠道,两者不能混用。
|
||||
- 读取/应用关系级备注和标签时,要匹配回调实际 `UserID`,不能直接取第一个 `follow_user`。
|
||||
- 跟进人超过500时,使用返回的 `next_cursor` 分页;只保证获取应用有可见权限的成员信息。
|
||||
- 官方注明自 2023-12-01 起不再支持新场景使用系统应用 secret,存量企业暂不受影响。项目应以已列入可调用列表的自建应用作为正式接入方式。
|
||||
|
||||
## 5. 获客渠道与回调字段
|
||||
|
||||
### 5.1 customer_channel 与 State
|
||||
|
||||
将渠道标识放在已创建的官方链接 URL 查询参数中,而不是写进 `create_link` 的自造 `state` 字段:
|
||||
|
||||
```text
|
||||
https://work.weixin.qq.com/ca/LINK_PATH?customer_channel=zyt_pool%3A123
|
||||
```
|
||||
|
||||
如果原链接已有查询串,应以 URL 解析器安全合并;不能重复叠加 `customer_channel`。自定义字符串最长 **64 字节**,超过会截断,因此应在保存/生成时拒绝超长值,避免两个渠道被截断后碰撞。返回的客户列表与客户详情 `state` 对应这个字符串。[获取由获客链接添加的客户信息](https://developer.work.weixin.qq.com/document/path/97298)
|
||||
|
||||
建议继续采用无个人信息的短、不透明标识;若扩展为独立渠道 ID,应新增明确映射并保持 `zyt_pool:{id}` 老链接兼容。
|
||||
|
||||
### 5.2 添加客户事件
|
||||
|
||||
官方文档:[事件格式](https://developer.work.weixin.qq.com/document/path/92130)。以下是接收 XML 解密后用于本地处理的字段示意(**不是 POST API 请求体**):
|
||||
|
||||
```json
|
||||
{
|
||||
"Event": "change_external_contact",
|
||||
"ChangeType": "add_external_contact",
|
||||
"UserID": "assistant_a",
|
||||
"ExternalUserID": "EXTERNAL_USER_ID",
|
||||
"State": "zyt_pool:123",
|
||||
"WelcomeCode": "WELCOME_CODE",
|
||||
"CreateTime": 1788141600
|
||||
}
|
||||
```
|
||||
|
||||
本事件的官方字段表**没有 `LinkId`**。应以 `State` 识别渠道;不能在本事件没有 `LinkId` 时放弃欢迎语/标签,也不能把后来的首次聊天事件当成欢迎语触发条件。
|
||||
|
||||
`WelcomeCode` 不是必然存在:客户与成员已开始聊天、已经在半客户事件中发过欢迎语等情况不会继续给 code;企业微信商务伙伴自动递名片,也不回调 code。
|
||||
|
||||
`add_half_external_contact` 同样可能带 `State` 与 `WelcomeCode`。若需要支持免验证添加全流程,应让欢迎语处理器在有效 code 出现时就处理,不应被“半客户不入客户表”的早返回吞掉;但打标签和修改备注可以等关系确认后做,不能为等客户详情而消耗欢迎语窗口。
|
||||
|
||||
### 5.3 LinkId 出现在哪些事件
|
||||
|
||||
获客助手专用事件为 `Event=customer_acquisition`。例如 `link_unavailable`、`delete_link`、`open_profile`、`friend_request`、`customer_start_chat`、`message_from_customer` 等有 `LinkId`;其中 `open_profile` / `friend_request` 有 `State`,但**没有可用于发送新客户欢迎语的 `WelcomeCode`**。[获客助手事件通知](https://developer.work.weixin.qq.com/document/path/97299),最后更新 2026-07-22。
|
||||
|
||||
`message_from_customer` 的 `UserID`、`ExternalUserID`、`ChatSeq` 自 2024-12-19 起不再保证回调,须使用 30 分钟内有效的 `ChatKey` 查询。当前项目已接入 ChatKey 处理,应保留这条补偿链路,不能用旧示例假定字段永远齐全。
|
||||
|
||||
### 5.4 接收要求
|
||||
|
||||
配置了客户联系可调用应用、API 接收消息,且勾选“外部联系人变更回调”,才能收到可见范围内成员客户事件。[回调通知概述](https://developer.work.weixin.qq.com/document/path/92129)
|
||||
|
||||
企业微信要求回调在 **5 秒内响应**;连接失败或超时时会重试,官方说明总共重试三次,并明确回调并非100%可靠。接收端应验签解密、快速持久化并应答,业务由立即运行的工作进程处理;需要额外对账。[回调配置](https://developer.work.weixin.qq.com/document/path/90930)
|
||||
|
||||
## 6. 发送新客户欢迎语及附件
|
||||
|
||||
官方文档:[发送新客户欢迎语](https://developer.work.weixin.qq.com/document/path/92137),最后更新 2025-11-17。
|
||||
|
||||
`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/send_welcome_msg?access_token=ACCESS_TOKEN`
|
||||
|
||||
```json
|
||||
{
|
||||
"welcome_code": "WELCOME_CODE",
|
||||
"text": {"content": "李女士您好,我是小张医助。"},
|
||||
"attachments": [
|
||||
{
|
||||
"msgtype": "image",
|
||||
"image": {"media_id": "IMAGE_MEDIA_ID"}
|
||||
},
|
||||
{
|
||||
"msgtype": "link",
|
||||
"link": {
|
||||
"title": "就诊指南",
|
||||
"picurl": "https://example.com/guide-cover.jpg",
|
||||
"desc": "查看就诊须知",
|
||||
"url": "https://example.com/guide"
|
||||
}
|
||||
},
|
||||
{
|
||||
"msgtype": "miniprogram",
|
||||
"miniprogram": {
|
||||
"title": "预约入口",
|
||||
"pic_media_id": "COVER_MEDIA_ID",
|
||||
"appid": "ASSOCIATED_MINIPROGRAM_APPID",
|
||||
"page": "/pages/appointment/index"
|
||||
}
|
||||
},
|
||||
{"msgtype": "video", "video": {"media_id": "VIDEO_MEDIA_ID"}},
|
||||
{"msgtype": "file", "file": {"media_id": "FILE_MEDIA_ID"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
以上五类均有官方示例/对应字段。官方参数表的 `attachments.msgtype` 行漏列了 `file`,但页面说明、完整示例和 `file.media_id` 行都明确支持文件;这是文档内部不一致,应记录而不是误删文件支持。
|
||||
|
||||
### 6.1 时效、互斥、错误处理
|
||||
|
||||
- 收到相关事件后 **20 秒内**调用,`welcome_code` 有效期20秒,只能成功使用一次;不能靠分钟级任务补发过期欢迎语。
|
||||
- 管理端已为成员配置可用欢迎语时,不会返回 `welcome_code`。本地“关闭渠道欢迎语”只能控制**本应用是否发送**,无法压制企业微信管理端或其他应用自己发的欢迎语。
|
||||
- 长期未登录企业微信的成员不能发送欢迎语。
|
||||
- 已成功下发后再发返回 `41051`,无需重试。
|
||||
- 多应用竞争发送时,后来的应用可能返回 `41096`,表示正在由其他应用分发,不等于已发成功;官方允许重试,但仍受20秒窗口限制。收到 `41051` 则停止。
|
||||
- 自建应用须配置到可调用应用列表;成员须在其可见范围。获客链接可用不等于欢迎语权限和回调配置必然正确。
|
||||
|
||||
### 6.2 内容限制
|
||||
|
||||
| 字段 | 限制 |
|
||||
| --- | --- |
|
||||
| `text.content` | 最长4000字节(UTF-8 字节计算) |
|
||||
| `attachments` | 最多9个;可以同时发文本和附件 |
|
||||
| `text` / `attachments` | 不可同时为空 |
|
||||
| `link.title` | 必填,最长128字节 |
|
||||
| `link.desc` | 可选,最长512字节 |
|
||||
| `link.url` | 必填 |
|
||||
| `link.picurl` | 可选封面 URL;注意字段拼写不是 `pic_url` |
|
||||
| `image.media_id` / `image.pic_url` | 至少一个;都传时 `media_id` 优先 |
|
||||
| `image.pic_url` | 仅可用官方“上传图片”接口得到的 URL,不能直接塞任意本地/CDN图片地址 |
|
||||
| `miniprogram.title` | 必填,最长64字节 |
|
||||
| `miniprogram.pic_media_id` | 必填,封面建议520×416 |
|
||||
| `miniprogram.appid` | 必须是关联到企业的小程序 |
|
||||
| `miniprogram.page` | 必填的小程序页面路径 |
|
||||
| `video.media_id` / `file.media_id` | 对应类型必填 |
|
||||
|
||||
`msgtype` 必须与同项内的内容对象一致。不能把多个附件拼成旧版顶层 `image` / `link` / `miniprogram` 字段。
|
||||
|
||||
### 6.3 素材必须提前准备
|
||||
|
||||
临时素材:`POST https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=ACCESS_TOKEN&type=TYPE`,multipart 文件字段名为 `media`。`media_id` 有效 **3天**,同一企业内应用可共享。文件需大于5字节;图片 JPG/PNG ≤10MB,视频 MP4 ≤10MB,普通文件 ≤20MB。[上传临时素材](https://developer.work.weixin.qq.com/document/path/90253)
|
||||
|
||||
永久图片 URL:`POST https://qyapi.weixin.qq.com/cgi-bin/media/uploadimg?access_token=ACCESS_TOKEN`,得到可用于欢迎语的 URL。图片大小5B~2MB,每企业每日最多1000张、每月最多3000张;返回 URL 永久有效,但用途受企微环境限制。[上传图片](https://developer.work.weixin.qq.com/document/path/90256)
|
||||
|
||||
实现建议:配置欢迎语时保存源文件和上传状态,提前转成企微素材并按 hash 去重;临时素材在过期前刷新。发送时不应临时下载大文件再上传,否则20秒窗口很容易失效。失效附件应有明确错误/降级记录,不能假装发送完整成功。
|
||||
|
||||
## 7. 模板变量及默认/渠道/关闭/分时策略
|
||||
|
||||
以下是服务端职责,官方 `send_welcome_msg` 不会替换 `{客户昵称}`、`{员工昵称}` 等模板内容,也没有这些模式参数。
|
||||
|
||||
### 7.1 变量
|
||||
|
||||
- `{客户昵称}`:取 `external_contact.name`,而非把某个员工的 `remark` 当客户原始昵称。缓存缺失时可做有严格超时预算的详情查询;无法取得时使用事先定义的“您”等兜底,不要把未展开的占位符发送出去。
|
||||
- `{员工昵称}`:首先明确产品含义。可用本地配置的对外称呼,或企业成员 `name`;若要 `alias`,需明确优先级。`GET /cgi-bin/user/get?userid=USER_ID` 返回 `name`/`alias` 受应用类型和可见权限影响,第三方并不能普遍拿到姓名/别名。[读取成员](https://developer.work.weixin.qq.com/document/path/90196)
|
||||
- 只支持白名单变量,不运行表达式、不执行任意模板代码;API 发出的最终文本必须已完成替换。
|
||||
- 保存时校验模板结构,发送时在变量展开后再校验长度:欢迎语 UTF-8 字节上限,客户备注20字符、描述150字符。对模板变量导致的超限采用明确的截断/拒绝策略并记录,不能依赖企微静默截断。
|
||||
|
||||
### 7.2 建议的确定性策略
|
||||
|
||||
每条本地推广渠道保存 `welcome_mode = inherit | custom | disabled | scheduled`,并定义唯一优先级:
|
||||
|
||||
1. 能定位的渠道设为 `disabled` → 不发送本应用欢迎语,**不可回退默认欢迎语**。
|
||||
2. 渠道为 `custom` → 使用渠道内容。
|
||||
3. 渠道为 `scheduled` → 按事件发生时间、北京时间和星期选择规则;规则重叠要拒绝或使用明确排序。无匹配时使用该配置明确指定的兜底(默认/固定内容/不发),不能凭实现猜测。
|
||||
4. 渠道为 `inherit`,或业务明确允许未知渠道走默认 → 使用默认欢迎语。
|
||||
|
||||
分时表示“添加客户时选哪一段内容”,不是“把欢迎语延迟到某个时段再发”;延迟通常会越过20秒有效期。配置应和事件一起保存版本或内容快照,避免工作进程稍后读到另一版规则。
|
||||
|
||||
### 7.3 处理链建议
|
||||
|
||||
```text
|
||||
验签解密 → 最小事件幂等落库 → 立即工作进程领取
|
||||
├→ 欢迎语:选策略/展开变量 → 20秒内send_welcome_msg
|
||||
├→ 成员记账/范围同步(独立,可补偿)
|
||||
└→ 关系确认后标签/备注/详情同步(独立,可补偿)
|
||||
```
|
||||
|
||||
回调应答不能等全部远端请求完成;欢迎语必须使用立即消费的队列/工作进程,不能复用分钟任务。若部署没有立即消费能力,应明确补齐部署要求,不能只保存一条“待发送”记录就宣称欢迎语已经打通。
|
||||
|
||||
欢迎语状态建议包含 `pending/processing/sent/skipped/expired/failed`,并保存事件时间、首次接收时间、处理耗时、策略版本、结果码和跳过原因。幂等至少考虑企业、实际员工、客户、事件及 welcome_code;code 本身仅短期保留/加密,日志只记录摘要,不能泄露 token/code。`add_half` / `add` 以及回调重试不应造成重复发送。
|
||||
|
||||
标签、备注的结果单独记录,失败不能撤销已成功的欢迎语;人工重试仅重试失败动作,不重放全部新增客户流程。先读取现有关系可避免覆盖人工信息,但不得挡在欢迎语关键路径前。
|
||||
|
||||
## 8. 对当前代码的落地提示
|
||||
|
||||
只读查看了以下文件:
|
||||
|
||||
- `server/app/common/service/qywx/QywxCustomerAcquisitionApiService.php`
|
||||
- `server/app/api/controller/QywxExternalContactCallbackController.php`
|
||||
- `server/app/common/service/qywx/QywxPromotionRangeSyncService.php`
|
||||
- `server/app/common/service/qywx/QywxPromotionMemberSchedulerService.php`
|
||||
|
||||
观察及建议:
|
||||
|
||||
1. API service 已有 create/update/get/list 和 token 无效单次刷新;它本身没有标签、备注、欢迎语或素材封装。扩展时应区分权限、超时预算和返回错误,而非把前端配置原样塞给 `create_link`。
|
||||
2. 回调已提取 `State`、`WelcomeCode`,但目前欢迎语只记为是否存在;没有发送欢迎语。新增服务要取得真实 code,而不是只接收布尔值。
|
||||
3. `add_half_external_contact` 目前早返回;如果要支持其欢迎语,须在这个返回之前处理有效 code,客户落库的原有跳过行为可保留。
|
||||
4. 目前 `add_external_contact` 会同步触发范围 API,再拉客户详情。欢迎语不应附加在这些操作之后;其超时窗口比范围/资料同步更严格。
|
||||
5. `State` 当前正则为 `^zyt_pool:(\d+)$`。如果新前端生成别的 State 格式而不改兼容解析,现有统计和范围调度会失效。
|
||||
6. 当前范围同步只发送 `link_id/link_name/range/skip_verify`;若新增好友优先策略,要确认创建、编辑、后台定时同步、远端回读都不会意外覆盖/遗失该设置。
|
||||
7. 若排班/备用规则改变了本地“可用成员”判断,应只保留一个统一计算器供页面预览、保存校验、分钟重算和回调后同步共用,避免页面与官方实际范围不一致。
|
||||
|
||||
## 9. 联调时必须验证的项目
|
||||
|
||||
- 当前企业的自建应用已具备获客助手、客户详情、标签、备注、欢迎语权限及成员可见范围;新增这些功能不能仅沿用“获客链接列表成功”的权限检测结果。
|
||||
- 管理端欢迎语是否已关闭/让位,本应用是否实际收到含 `WelcomeCode` 的回调。
|
||||
- `priority_option` 取消语义、旧好友优先与排班范围的实际交互,官方未给明文保证的部分应以联调记录为准。
|
||||
- `range` 更新到官方生效的实际时延;全员下线/备用不可用时既有链接仍可能维持旧范围,UI需如实显示同步阻塞。
|
||||
- 20秒期限下“冷 token、冷客户缓存、素材过期、队列堆积”的处理;5秒回调应答要求。
|
||||
- Unicode昵称展开后的UTF-8字节限制、备注字符限制、小程序关联/页面可用性、每类附件真实接收效果。
|
||||
- 半客户转正式客户、重复回调、多应用竞争、人工欢迎语已发送、成员长期未登录等情况下,不把应跳过/过期误报成普通系统故障。
|
||||
|
||||
上述研究未通过真实企业写接口验收;所有不确定行为已显式列出,不能将研究示例当作企业权限或下发成功证明。
|
||||
@@ -0,0 +1,93 @@
|
||||
# 企业微信推广自动化部署与验收
|
||||
|
||||
此实现覆盖推广渠道欢迎语、企业标签、客户备注/描述及原客户同步补偿。它不会覆盖企业微信后台欢迎语设置,也不意味着企业已开通接口权限。官方能力及限制见 [核验报告](research/wecom-promotion-api-capabilities.md)。
|
||||
|
||||
## 部署顺序
|
||||
|
||||
1. 先执行 `server/sql/1.9.20260831/add_wecom_promotion_automation.sql`。脚本仅使用 `CREATE TABLE IF NOT EXISTS`,默认表前缀 `zyt_`;实际前缀不同须由部署人员调整。本开发任务没有执行业务数据库迁移。
|
||||
2. 部署 PHP 代码。为 PHP-FPM、CLI worker 使用同一项目目录 `server/runtime/qywx_promotion_private/`,授予应用运行用户读写权限。目录必须位于 Web 根目录之外,禁止静态文件映射;保留源文件,不能随意清理该目录。
|
||||
3. 配置客户联系“可调用应用”,优先使用创建获客链接的同一自建应用。代码默认使用 `qywx_customer_acquisition.secret`,无该值才使用 `pay.wechat_work.customer_contact_secret`。需要明确覆盖时设置 `WECHAT_WORK_PROMOTION_CONTACT_SECRET`。不会使用 `external_pay_secret`。新接入不要依赖已受官方限制的客户联系系统应用 Secret。检查应用可见范围、可信 IP、外部联系人变更事件订阅;欢迎语回调必须来自可调用应用的相应配置。
|
||||
4. 建议设置至少32字符的随机 `WECHAT_WORK_PROMOTION_ENCRYPTION_KEY`,所有 API/CLI 节点保持一致。未设置时会自动在项目私有目录生成 `welcome.key`(0600)。多节点使用共享源文件目录和一致密钥;更换密钥前先处理/过期并清空待处理欢迎码。
|
||||
5. 在启用渠道欢迎语之前,启动并监控下面的常驻 worker,安装分钟补偿和素材预热。仅配置分钟任务不足以发送欢迎语。
|
||||
|
||||
若旧版脚本在 `welcome_cipher` 字段的 `COMMENT=` 处报 MySQL 1064,请重新打开已修正的 SQL 文件并完整重跑。字段注释必须使用 `COMMENT '内容'`(不带等号);表级的 `COMMENT='内容'` 是合法语法,无须修改。脚本中的四张表都使用 `CREATE TABLE IF NOT EXISTS`,已成功创建的表会保留,不需要删表。四张表全部创建成功后再启动 worker。
|
||||
|
||||
## 必须运行的进程
|
||||
|
||||
欢迎码只有20秒有效。验签回调中只做数据库和加密操作,并及时返回;欢迎语由独立常驻进程秒级消费,不能等待普通客户同步或临时素材上传。
|
||||
|
||||
```sh
|
||||
cd /path/to/server
|
||||
php think qywx:work-promotion-automation
|
||||
```
|
||||
|
||||
使用 Supervisor 或 systemd 保持常驻、自动重启。建议同一数据库启动2个欢迎语worker,以免单个HTTP请求阻塞其他事件;任务租约保证同一任务不重复领取。高并发须按实际20秒延迟指标增加进程。`--once` 仅用于受控诊断/部署测试,不能替代守护进程。
|
||||
|
||||
例如 Supervisor 配置(路径和用户替换为实际值):
|
||||
|
||||
```ini
|
||||
[program:qywx-promotion-welcome]
|
||||
command=/usr/bin/php /path/to/server/think qywx:work-promotion-automation
|
||||
directory=/path/to/server
|
||||
numprocs=2
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
user=www-data
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stopwaitsecs=35
|
||||
stdout_logfile=/var/log/qywx-promotion-welcome.log
|
||||
stderr_logfile=/var/log/qywx-promotion-welcome-error.log
|
||||
```
|
||||
|
||||
每分钟运行补偿,每5分钟预热素材。以下为部署样例,不代表本任务已经安装这些定时任务:
|
||||
|
||||
```cron
|
||||
* * * * * cd /path/to/server && /usr/bin/flock -n /tmp/qywx-promotion-retry.lock /usr/bin/php think qywx:retry-promotion-automation
|
||||
*/5 * * * * cd /path/to/server && /usr/bin/flock -n /tmp/qywx-promotion-media.lock /usr/bin/php think qywx:refresh-promotion-media
|
||||
```
|
||||
|
||||
现有 `qywx:sync-promotion-ranges`、`qywx:retry-customer-acquisition-events` 也须保留。新推广配置事件的标签、备注、描述、成员记账、范围更新、客户同步由补偿任务执行;处理延迟通常不超过一分钟,繁忙时可能更长。不要把这些慢速动作塞进欢迎语worker。方案日上限是回调后的统计控制,不是企微并发建联的硬性上限。
|
||||
|
||||
## 配置与素材契约
|
||||
|
||||
- `QywxPromotionContactApiService::tagOptions(): array` → `{tag_groups:[{group_id,group_name,tag:[{id,name}]}]}`。
|
||||
- 方案客户标签为单选;接口仍使用 `tag_ids: []/[id]`,服务端拒绝多个标签。旧多选配置会显示重新选择提示,不自动截断。自定义标签通过 `POST firstvisit.wecomPromotion/createTag`(`{name}`)调用企微 `externalcontact/add_corp_tag`,固定保存到“推广渠道”分组,名称最多30字符;同组同名复用,创建成功后自动选中。创建立即写入企微标签库,取消方案编辑不会删除该标签;本次调整不新增数据库迁移。
|
||||
- `QywxPromotionMediaService::upload($file, string $type, int $adminId): array` → `{asset_id,name,type}`。仅接受 ThinkPHP 已验证的 `UploadedFile`;不接受服务器路径或网络下载地址。上传时立即预热临时素材。
|
||||
- `validateConfig(array $config, int $adminId, array $existingConfig = []): array` → 保留其他配置字段、规范化附件后的完整配置。调用方必须先校验方案编辑权;第三参数只传数据库读取的旧配置,不能传用户提供的“白名单”。其他管理员只能保留已获授权的旧方案资产,不能新引入别人的资产。
|
||||
- `validateAttachments(array $attachments, int $adminId, array $allowedAssetIds = []): array` 为底层契约;不要直接向HTTP客户端暴露第三参数。
|
||||
- 素材 ID 是随机48位十六进制字符串。图片 JPG/PNG≤10MB、视频MP4≤10MB、文件≤20MB,且大于5字节。普通文件限定PDF、Office、文本、CSV、ZIP、JPG/PNG、MP4;拒绝可执行文件、HTML/SVG等格式。后端使用实际MIME和扩展名,不信任浏览器Content-Type。
|
||||
- 附件支持 `image/video/file.{asset_id}`、`miniprogram.{title,appid,page,pic_asset_id}`、`link.{title,url,desc,picurl?}`。不接受前端任意 `media_id`。本实现不开放 `image.pic_url`:官方只支持uploadimg生成的URL,普通CDN地址不能替代。
|
||||
- 欢迎语文本最多4000 UTF-8字节(产品配置还可额外限制1200字符),最多9附件;链接标题128字节、描述512字节,小程序标题64字节。小程序需已关联企业;本地无法代替企微验证关联与页面可达性。
|
||||
- 源文件与媒体记录持久保留;缓存media_id有效3天,在到期前1小时进入刷新候选。发送只使用剩余至少5分钟的缓存。过期素材不会在欢迎语关键路径上传,不会静默丢掉附件只发文字;会记录准备失败,直至20秒期限结束。
|
||||
- 临时素材与凭证指纹绑定。更换企业/应用Secret后,先运行 `qywx:refresh-promotion-media` 并检查失败数,再恢复渠道曝光。
|
||||
|
||||
## 回调、幂等与失败策略
|
||||
|
||||
- 入口仅在 EasyWeChat 已验签/解密的 `change_external_contact` listener 中调用 `enqueueVerifiedEvent()`;不得把此服务直接作为未验签HTTP接口。
|
||||
- 优先用 `State=zyt_pool:{id}`,无State时才使用事件实际存在的 `LinkId/LinkID`。必须存在正常的推广方案、非删除成员关系、官方有效链接、新配置记录。仅有可猜测的State不是授权。普通客户事件、无新配置旧方案、迁移尚未安装时保留旧同步路径;不会让所有客户突然依赖新worker。
|
||||
- 正式客户add可执行全部动作;半客户add_half只处理欢迎语,不提前打标签、改备注或落正式客户表。事件重复通过唯一事件键去重;同一WelcomeCode在half/add之间通过唯一摘要索引只分配一次消费权。
|
||||
- 队列持久化失败会抛专用异常,listener不吞掉,HTTP返回500供企微重投。成功入队后的业务失败由持久任务补偿,不影响回调应答。
|
||||
- 每条任务保存配置快照;分时欢迎语按事件时间和Asia/Shanghai选择,未命中用基础欢迎语。`default`和`none`均不发送本系统欢迎语,不能抑制企业微信管理端或其他应用发送。
|
||||
- 欢迎码以AES-256-GCM短存;动作进入sent/skipped/expired/uncertain/failed后清除密文。既有客户事件raw字段现在移除WelcomeCode;审计中无明文code/token、上游请求或Guzzle异常链。历史已有raw数据需要另外评估清理,本次未改历史记录。
|
||||
- 发送前先持久化running。网络超时、无法解析响应、HTTP失败,或进程在发送后记录成功前崩溃,均记为uncertain,清理密文并停止自动重发,避免重复推送;需通过企微实际聊天结果人工核对。
|
||||
- 只有明确的企微拒绝响应允许在剩余窗口重试;41096可重试,41051记为已使用并停止。token明确失效可刷新一次。过期或缺少code有独立原因,不会当成成功发送。
|
||||
- 标签仅增添配置标签,不删除人工标签;备注与描述只写明确启用的字段。变量替换只支持白名单,名字接口失败使用本地员工名或“客户顾问”、客户用“您”。备注截断到20字符;欢迎语字节截断有审计原因。
|
||||
- 元数据每个动作单独记录状态。失败最多10次、指数间隔后终态failed;成功动作不重放。最终failed不是成功,应安排告警/人工修复。范围动作仅触发现有范围任务,其最终应用状态仍以原范围同步表为准。
|
||||
|
||||
## 监控与验收
|
||||
|
||||
`zyt_qywx_promotion_automation_task.actions_json` 保存分动作状态、次数、错误码、原因和重试时间;`zyt_qywx_promotion_automation_action_log` 保存每次状态转移。监控欢迎语入队延迟、`expired/uncertain/failed` 数量、待处理最早事件时间、素材 `last_error` 和worker存活。一次没有异常输出不等于客户端已收到消息。
|
||||
|
||||
安全离线验证(不会初始化现有数据库、HTTP全部Mock):
|
||||
|
||||
```sh
|
||||
cd server
|
||||
php tests/QywxPromotionContactApiServiceTest.php
|
||||
php tests/QywxPromotionMediaServiceTest.php
|
||||
php tests/QywxPromotionAutomationServiceTest.php
|
||||
php tests/QywxPromotionCodeCipherTest.php
|
||||
```
|
||||
|
||||
生产验收仍需在授权的企业测试客户/员工上验证真实权限、半客户/正式客户回调、每类附件实际接收、昵称模板、默认欢迎语互斥、worker故障和跨节点存储。此开发没有发出任何真实企业微信写请求。
|
||||
@@ -0,0 +1,22 @@
|
||||
# 企业微信获客配置验证记录
|
||||
|
||||
日期:2026-08-31。
|
||||
|
||||
## 已完成的验证
|
||||
|
||||
- 单选与自定义标签:`QywxPromotionCreateTagTest.php`、`WecomPromotionCreateTagControllerTest.php` 及配置/前端 helper 回归通过。覆盖同组复用、新建分组/已有分组参数、并发冲突和网络不确定时只读回确认、非法名称、页面权限、POST 限制及拒绝多选。隔离浏览器验证单选替换、自定义空值提示、创建期间禁止保存、失败保留原标签、创建成功选中真实响应 ID、最终仅提交一个 ID,以及旧多选配置要求重新选择。未创建真实企微标签。
|
||||
- 迁移修复复验:已修正 `welcome_cipher` 列注释中非法的 `COMMENT=`。在独立临时 MySQL 5.7.26 实例先创建前两张表并写入一条标记记录,再完整执行迁移两次;四张表均存在,字段注释正确,标记记录保持原值。未连接或修改业务数据库。
|
||||
- `QywxPromotionAutomationConfigTest.php`:接待时段起止边界、跨午夜和跨周、主接待优先、日上限和跨日重置、备用禁用、无可用成员、非法配置、分时欢迎语重叠、昵称/日期模板、备注长度、欢迎语 UTF-8 字节限制。
|
||||
- `QywxPromotionContactApiServiceTest.php`、`QywxPromotionMediaServiceTest.php`、`QywxPromotionAutomationServiceTest.php`:官方接口参数、Secret 选择、令牌失效重试、HTTP 不确定结果、素材权限/真实 MIME/私有路径/缓存刷新、欢迎码加密、重复事件、半客户、20 秒时效、分动作补偿和数据库异常触发回调重试。测试使用 HTTP mock 与内存存储,不连接业务数据库。
|
||||
- `QywxPromotionCodeCipherTest.php`:6 个隔离 PHP 进程首次启动共用完整密钥、随机密文、篡改/错误密钥拒绝、损坏密钥不自动覆盖;仅使用临时目录。
|
||||
- 原有成员范围、获客链接 URL、获客 API HTTP mock、获客事件重试、推广浮窗、删除契约及操作人权限契约测试通过。
|
||||
- 浏览器使用独立 Vite 测试入口和虚构数据,替换 API 模块,未请求真实业务接口:验证排班缺项阻止保存、备用候选排除主接待、企业标签选择、渠道欢迎语变量与手机预览、网页附件编辑、客户备注预览和描述,最终保存参数与服务端配置契约一致。浏览器控制台无运行错误。
|
||||
- 三个新增/修改 Vue 单文件组件编译通过,前端配置 helper 12 项边界断言通过。
|
||||
- 完整 Vite 生产构建成功(4057 个模块),产物输出到隔离临时目录,没有执行 `release.mjs`,没有覆盖 `server/public/admin`。现有大型 bundle 和第三方播放器 `eval` 警告仍存在。
|
||||
- 全项目 `vue-tsc --noEmit` 仍有 61 条其他文件的既存错误,本次推广页面、两个新组件、helper 和 API 文件未报错。未扩大范围修复其他模块。
|
||||
|
||||
## 验证边界
|
||||
|
||||
未向真实企业微信客户发送欢迎语、打标签、修改备注或上传测试素材;未执行生产数据库迁移。正式部署后仍须验证当前企业可调用应用的权限、成员可见范围、接收回调配置、后台欢迎语互斥、常驻进程和真实素材下发结果。
|
||||
|
||||
分钟调度存在传播时延;官方直链已打开或在途的好友请求无法由本地上限保证即时撤回。所有成员不可用时保留明确阻塞状态,不声称远端链接已停用。
|
||||
@@ -8,11 +8,48 @@ use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\firstvisit\WecomAcquisitionCustomerLogic;
|
||||
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
|
||||
use app\common\service\qywx\QywxPromotionContactApiService;
|
||||
use app\common\service\qywx\QywxPromotionMediaService;
|
||||
|
||||
class WecomPromotionController extends BaseAdminController
|
||||
{
|
||||
private const PAGE_PERMISSION = 'firstvisit.wecomPromotion/overview';
|
||||
|
||||
public function tagOptions()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
return $this->run(fn () => $this->data((new QywxPromotionContactApiService())->tagOptions()));
|
||||
}
|
||||
|
||||
public function createTag()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
if (!$this->request->isPost()) {
|
||||
return $this->fail('请使用 POST 创建标签');
|
||||
}
|
||||
$name = $this->request->post('name', '');
|
||||
if (!is_string($name)) {
|
||||
return $this->fail('标签名称格式不正确');
|
||||
}
|
||||
return $this->run(fn () => $this->data((new QywxPromotionContactApiService())->createTag($name)));
|
||||
}
|
||||
|
||||
public function uploadWelcomeMedia()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
return $this->run(fn () => $this->data((new QywxPromotionMediaService())->upload(
|
||||
$this->request->file('file'),
|
||||
(string) $this->request->post('type', ''),
|
||||
$this->adminId
|
||||
)));
|
||||
}
|
||||
|
||||
public function overview()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
|
||||
@@ -425,6 +425,18 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
return $this->success('关联支付单成功', $result);
|
||||
}
|
||||
|
||||
/** 解除单笔收款关联,总金额不变,同步更新已付金额和需代收。 */
|
||||
public function unlinkPayOrder()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('unlinkPayOrder');
|
||||
$result = PrescriptionOrderLogic::unlinkPayOrder($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('收款关联已移除,金额已同步更新', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已发货/已签收:仅提交完单申请(不新增/关联支付单),并重置支付审核为待审核
|
||||
*/
|
||||
|
||||
@@ -638,7 +638,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$allOids = array_values(array_unique($allOids));
|
||||
$amountByOid = [];
|
||||
if ($allOids !== []) {
|
||||
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')->column('amount', 'id');
|
||||
// 与详情已付总额一致:退款记录可展示,但不再计入实付。
|
||||
$amountByOid = Order::whereIn('id', $allOids)->whereNull('delete_time')
|
||||
->whereIn('status', [2, 5])->column('amount', 'id');
|
||||
}
|
||||
foreach ($poIds as $pid) {
|
||||
$s = 0.0;
|
||||
@@ -1161,6 +1163,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
->join('order o', 'l.pay_order_id = o.id')
|
||||
->whereIn('l.prescription_order_id', $poIds)
|
||||
->whereNull('o.delete_time')
|
||||
->whereIn('o.status', [2, 5])
|
||||
->sum('o.amount');
|
||||
|
||||
return round($sum, 2);
|
||||
|
||||
@@ -144,7 +144,7 @@ class FirstVisitConversionLogic
|
||||
}
|
||||
unset($row);
|
||||
}
|
||||
return [
|
||||
return self::withDeletedFansVisibility([
|
||||
'meta' => [
|
||||
'time_type' => $timeType,
|
||||
'time_label' => $timeLabel,
|
||||
@@ -178,7 +178,7 @@ class FirstVisitConversionLogic
|
||||
],
|
||||
'rows' => $rows,
|
||||
'target' => $target,
|
||||
];
|
||||
], $adminInfo);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
@@ -190,14 +190,14 @@ class FirstVisitConversionLogic
|
||||
);
|
||||
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
|
||||
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20)));
|
||||
$empty = [
|
||||
$empty = self::withDeletedFansVisibility([
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'date_range' => [$context['start_date'], $context['end_date']],
|
||||
'entity' => null,
|
||||
];
|
||||
], $adminInfo, true);
|
||||
|
||||
$entityType = strtolower(trim((string) ($params['entity_type'] ?? '')));
|
||||
if (!in_array($entityType, ['dept', 'member'], true)) {
|
||||
@@ -253,9 +253,54 @@ class FirstVisitConversionLogic
|
||||
];
|
||||
unset($result['deleted_count']);
|
||||
|
||||
return self::withDeletedFansVisibility($result, $adminInfo, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除客户统计是账号专属能力,与root、角色、财务权限和DataScope无关。
|
||||
* adminInfo由认证token缓存提供;缺失账号时拒绝,不能从HTTP参数补齐或标准化账号。
|
||||
*/
|
||||
private static function canViewDeletedFans(array $adminInfo): bool
|
||||
{
|
||||
return ($adminInfo['account'] ?? null) === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 只裁剪本页响应,不改变通用统计口径、加粉客户集合、排序或分页。
|
||||
* 对整个响应递归处理,避免嵌套成员、排名或未来新增位置泄露同一敏感指标。
|
||||
*/
|
||||
private static function withDeletedFansVisibility(array $result, array $adminInfo, bool $detail = false): array
|
||||
{
|
||||
$canView = self::canViewDeletedFans($adminInfo);
|
||||
if (!$canView) {
|
||||
$fields = $detail
|
||||
? ['deleted_fans_count', 'deleted_count', 'is_deleted', 'delete_time']
|
||||
: ['deleted_fans_count', 'deleted_count'];
|
||||
$result = self::removeDeletedFansFields($result, $fields);
|
||||
}
|
||||
if ($detail) {
|
||||
$result['can_view_deleted_fans'] = $canView;
|
||||
} else {
|
||||
$result['meta']['can_view_deleted_fans'] = $canView;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param string[] $fields */
|
||||
private static function removeDeletedFansFields(array $value, array $fields): array
|
||||
{
|
||||
foreach ($fields as $field) {
|
||||
unset($value[$field]);
|
||||
}
|
||||
foreach ($value as &$item) {
|
||||
if (is_array($item)) {
|
||||
$item = self::removeDeletedFansFields($item, $fields);
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve only the clicked entity and its authorized target range. This is
|
||||
* deliberately structural: it avoids recomputing all overview metrics,
|
||||
|
||||
@@ -9,6 +9,9 @@ use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
|
||||
use app\common\service\qywx\QywxPromotionMemberRange;
|
||||
use app\common\service\qywx\QywxPromotionConfig;
|
||||
use app\common\service\qywx\QywxPromotionContactApiService;
|
||||
use app\common\service\qywx\QywxPromotionMediaService;
|
||||
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
|
||||
use app\common\service\qywx\QywxPromotionRangeSyncService;
|
||||
use app\common\service\qywx\QywxPromotionWidgetService;
|
||||
@@ -49,6 +52,7 @@ class WecomPromotionLogic
|
||||
|
||||
$domain = self::publicDomain($domain);
|
||||
foreach ($pools as &$pool) {
|
||||
$pool['automation_config'] = QywxPromotionConfig::forPool((int) $pool['id']);
|
||||
$pool['widget_config'] = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
|
||||
unset($pool['widget_config_json']);
|
||||
$key = (string) $pool['public_key'];
|
||||
@@ -133,7 +137,11 @@ class WecomPromotionLogic
|
||||
$memberRules = $memberRulesByPool[(int) $pool['id']] ?? [];
|
||||
$sync = $syncByPool[(int) $pool['id']] ?? [];
|
||||
$remoteUserIds = array_fill_keys((array) ($officialLink['range_userids'] ?? []), true);
|
||||
$reception = QywxPromotionMemberRange::evaluate($memberRules, $today, time(), $pool['automation_config']);
|
||||
$availableUserIds = array_fill_keys($reception['userids'], true);
|
||||
foreach ($memberRules as &$memberRule) {
|
||||
$memberRule['is_backup'] = in_array((int) ($memberRule['admin_id'] ?? 0), $pool['automation_config']['backup_member_admin_ids'], true);
|
||||
$memberRule['reception_available'] = isset($availableUserIds[(string) ($memberRule['userid'] ?? '')]);
|
||||
$memberRule['is_current'] = false;
|
||||
$memberRule['is_applied'] = false;
|
||||
$memberRule['is_in_remote_range'] = isset($remoteUserIds[(string) ($memberRule['userid'] ?? '')]);
|
||||
@@ -157,6 +165,7 @@ class WecomPromotionLogic
|
||||
$pool['official_link_count'] = count($officialLinks);
|
||||
$pool['legacy_link_count'] = $legacyCount;
|
||||
$pool['member_admin_ids'] = array_values(array_unique($memberAdminIds));
|
||||
$pool['member_admin_ids'] = array_values(array_diff($pool['member_admin_ids'], $pool['automation_config']['backup_member_admin_ids']));
|
||||
$pool['member_rules'] = $memberRules;
|
||||
$pool['operators'] = $operatorsByPool[(int) $pool['id']] ?? [];
|
||||
$pool['operator_admin_ids'] = array_values(array_map(
|
||||
@@ -168,6 +177,7 @@ class WecomPromotionLogic
|
||||
$pool['can_manage_access'] = self::ownerInScope((int) ($pool['owner_admin_id'] ?? 0), $visibleIds);
|
||||
$pool['can_delete'] = $pool['can_manage_access'];
|
||||
$pool['dispatch_sync'] = $sync;
|
||||
$pool['using_backup'] = $reception['using_backup'];
|
||||
$pool['skip_verify'] = (int) ($officialLink['skip_verify'] ?? 0);
|
||||
$pool['migration_state'] = count($officialLinks) > 1
|
||||
? 'needs_resolution'
|
||||
@@ -201,6 +211,7 @@ class WecomPromotionLogic
|
||||
'member_options' => $memberOptions,
|
||||
'operator_options' => $operatorOptions,
|
||||
'department_options' => DeptLogic::getAllDataScoped($adminId, $adminInfo),
|
||||
'automation_installed' => QywxPromotionConfig::installed(),
|
||||
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
|
||||
];
|
||||
}
|
||||
@@ -220,9 +231,49 @@ class WecomPromotionLogic
|
||||
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
|
||||
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
|
||||
}
|
||||
$members = self::resolveMembers((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo, $id);
|
||||
$automation = null;
|
||||
if (array_key_exists('automation_config', $params)) {
|
||||
QywxPromotionConfig::assertInstalled();
|
||||
if (!is_array($params['automation_config'])) {
|
||||
throw new RuntimeException('获客配置格式不正确');
|
||||
}
|
||||
$automation = QywxPromotionConfig::normalize($params['automation_config']);
|
||||
$automation = (new QywxPromotionMediaService())->validateConfig(
|
||||
$automation, $adminId, $id > 0 ? QywxPromotionConfig::forPool($id) : []
|
||||
);
|
||||
if ($automation['tags_enabled']) {
|
||||
$knownTags = [];
|
||||
foreach ((new QywxPromotionContactApiService())->tagOptions()['tag_groups'] as $group) {
|
||||
foreach ($group['tag'] as $tag) {
|
||||
$knownTags[] = (string) $tag['id'];
|
||||
}
|
||||
}
|
||||
if (array_diff($automation['tag_ids'], $knownTags) !== []) {
|
||||
throw new RuntimeException('所选企业微信标签已删除或不在应用可用范围,请刷新标签后重新选择');
|
||||
}
|
||||
}
|
||||
} elseif ($id > 0 && QywxPromotionConfig::installed()) {
|
||||
$automation = QywxPromotionConfig::forPool($id);
|
||||
}
|
||||
$primaryIds = self::normalizePositiveIds((array) ($params['member_admin_ids'] ?? []));
|
||||
$backupIds = $automation['backup_member_admin_ids'] ?? [];
|
||||
if ($primaryIds === [] || array_intersect($primaryIds, $backupIds) !== []) {
|
||||
throw new RuntimeException('请选择接待成员,且备用员工不能与接待成员重复');
|
||||
}
|
||||
$members = self::resolveMembers(array_merge($primaryIds, $backupIds), $adminId, $adminInfo, $id);
|
||||
if ($automation !== null) {
|
||||
$userIdByAdmin = array_column($members, 'userid', 'id');
|
||||
$automation['backup_userids'] = array_values(array_map(static fn ($aid) => $userIdByAdmin[$aid], $backupIds));
|
||||
foreach ($automation['reception_schedule'] as &$slot) {
|
||||
if (array_diff($slot['member_admin_ids'], $primaryIds) !== []) {
|
||||
throw new RuntimeException('接待时段只能选择方案内的接待成员');
|
||||
}
|
||||
$slot['member_userids'] = array_values(array_map(static fn ($aid) => $userIdByAdmin[$aid], $slot['member_admin_ids']));
|
||||
}
|
||||
unset($slot);
|
||||
}
|
||||
$userIds = array_values(array_column($members, 'userid'));
|
||||
$eligibleUserIds = self::eligibleSelectedUserIds($id, $members);
|
||||
$eligibleUserIds = self::eligibleSelectedUserIds($id, $members, $automation ?? []);
|
||||
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
|
||||
$status = (int) ($params['status'] ?? 1) === 1 ? 1 : 0;
|
||||
$now = time();
|
||||
@@ -288,6 +339,7 @@ class WecomPromotionLogic
|
||||
$status,
|
||||
$existingPool,
|
||||
$members,
|
||||
$automation,
|
||||
$skipVerify,
|
||||
$createdRemote,
|
||||
$now,
|
||||
@@ -333,6 +385,9 @@ class WecomPromotionLogic
|
||||
]);
|
||||
}
|
||||
self::persistPoolMembers($id, $members, $now);
|
||||
if ($automation !== null) {
|
||||
QywxPromotionConfig::save($id, $automation);
|
||||
}
|
||||
if ($createdRemote) {
|
||||
QywxPromotionMemberSchedulerService::initialisePool($id, $linkId);
|
||||
}
|
||||
@@ -1061,7 +1116,7 @@ class WecomPromotionLogic
|
||||
}
|
||||
|
||||
/** @param list<array{id:int,userid:string}> $members @return list<string> */
|
||||
private static function eligibleSelectedUserIds(int $poolId, array $members): array
|
||||
private static function eligibleSelectedUserIds(int $poolId, array $members, array $config = []): array
|
||||
{
|
||||
if ($members === []) {
|
||||
throw new RuntimeException('请至少选择一名获客成员');
|
||||
@@ -1089,7 +1144,7 @@ class WecomPromotionLogic
|
||||
$rule['userid'] = $userId;
|
||||
$candidates[] = $rule;
|
||||
}
|
||||
$range = QywxPromotionMemberRange::evaluate($candidates, $today, $now);
|
||||
$range = QywxPromotionMemberRange::evaluate($candidates, $today, $now, $config);
|
||||
if ($range['userids'] === []) {
|
||||
throw new RuntimeException('至少需要一名已启用、已生效且未达到今日上限的获客医助');
|
||||
}
|
||||
@@ -1110,6 +1165,7 @@ class WecomPromotionLogic
|
||||
Db::name('qywx_promotion_pool_member')->where('id', (int) $existing['id'])->update([
|
||||
'admin_id' => (int) $member['id'],
|
||||
'userid' => $userId,
|
||||
'enabled' => $existing['delete_time'] !== null ? 1 : (int) $existing['enabled'],
|
||||
'delete_time' => null,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
@@ -799,6 +799,31 @@ class PrescriptionOrderLogic
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 收款关联增删共用订单行锁,失败时连同金额和日志一起回滚。 */
|
||||
private static function mutatePayOrderLinks(int $id, callable $mutation)
|
||||
{
|
||||
self::$error = '';
|
||||
try {
|
||||
return Db::transaction(static function () use ($id, $mutation) {
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->lock(true)->find();
|
||||
if (!$order) {
|
||||
throw new \DomainException('订单不存在');
|
||||
}
|
||||
$result = $mutation();
|
||||
if ($result === false) {
|
||||
throw new \RuntimeException(self::$error ?: '收款关联变更失败');
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
if (self::$error === '') {
|
||||
self::$error = $e->getMessage();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $payOrderIds
|
||||
*/
|
||||
@@ -2729,6 +2754,14 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function addPayOrder(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
return self::mutatePayOrderLinks(
|
||||
(int) ($params['id'] ?? 0),
|
||||
static fn () => self::addPayOrderLocked($params, $adminId, $adminInfo)
|
||||
);
|
||||
}
|
||||
|
||||
private static function addPayOrderLocked(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$id = (int) $params['id'];
|
||||
@@ -2833,6 +2866,14 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function linkPayOrder(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
return self::mutatePayOrderLinks(
|
||||
(int) ($params['id'] ?? 0),
|
||||
static fn () => self::linkPayOrderLocked($params, $adminId, $adminInfo)
|
||||
);
|
||||
}
|
||||
|
||||
private static function linkPayOrderLocked(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$id = (int) $params['id'];
|
||||
@@ -2938,6 +2979,88 @@ class PrescriptionOrderLogic
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除单笔收款关联:保留原支付单、总金额与履约/审核状态,同步已付及代收金额。
|
||||
* 普通订单 paid 按剩余有效收款重算;退款订单的 paid 保留退款后的余额口径。
|
||||
*/
|
||||
public static function unlinkPayOrder(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
// 显式校验独立权限,菜单迁移未执行时也不能绕过鉴权中间件的默认放行逻辑。
|
||||
if ((int) ($adminInfo['root'] ?? 0) !== 1
|
||||
&& !in_array('tcm.prescriptionOrder/unlinkPayOrder', AuthLogic::getAuthByAdminId($adminId), true)) {
|
||||
self::$error = '无权限移除收款关联';
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::mutatePayOrderLinks(
|
||||
(int) ($params['id'] ?? 0),
|
||||
static fn () => self::unlinkPayOrderLocked($params, $adminId, $adminInfo)
|
||||
);
|
||||
}
|
||||
|
||||
private static function unlinkPayOrderLocked(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
$id = (int) $params['id'];
|
||||
$payOrderId = (int) ($params['pay_order_id'] ?? 0);
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||
self::$error = '无权限操作此订单';
|
||||
return false;
|
||||
}
|
||||
if (in_array((int) $order->fulfillment_status, [3, 4], true)) {
|
||||
self::$error = '已完成或已取消的订单不允许移除收款关联';
|
||||
return false;
|
||||
}
|
||||
if (!in_array($payOrderId, self::linkedPayOrderIdList($id), true)) {
|
||||
self::$error = '该收款记录未关联当前订单,请刷新后重试';
|
||||
return false;
|
||||
}
|
||||
$payOrder = Order::where('id', $payOrderId)->whereNull('delete_time')->lock(true)->find();
|
||||
if (!$payOrder || !in_array((int) $payOrder->status, [2, 5], true)) {
|
||||
self::$error = '仅已支付或待审核的收款记录可移除,已退款记录不可移除';
|
||||
return false;
|
||||
}
|
||||
|
||||
$oldPaidCents = (int) round((float) $order->paid * 100);
|
||||
$removedCents = (int) round((float) $payOrder->amount * 100);
|
||||
if ($removedCents < 0) {
|
||||
self::$error = '收款金额异常,请先核对金额';
|
||||
return false;
|
||||
}
|
||||
$deleted = PrescriptionOrderPayOrder::where('prescription_order_id', $id)
|
||||
->where('pay_order_id', $payOrderId)->delete();
|
||||
if ($deleted !== 1) {
|
||||
throw new \RuntimeException('收款关联已变化,请刷新后重试');
|
||||
}
|
||||
$remainingIds = self::linkedPayOrderIdList($id);
|
||||
$remainingPaidCents = $remainingIds === [] ? 0 : (int) round((float) Order::whereIn('id', $remainingIds)
|
||||
->whereNull('delete_time')->whereIn('status', [2, 5])->sum('amount') * 100);
|
||||
$order->linked_pay_order_id = $remainingIds[0] ?? null;
|
||||
// 部分退款可能未拆分收款单金额,不能用剩余收款原额覆盖退款后的 paid 余额。
|
||||
$hasRefund = (float) ($order->refund_amount ?? 0) > 0 || (int) $order->fulfillment_status === 10;
|
||||
$newPaidCents = $hasRefund
|
||||
? min($remainingPaidCents, max(0, $oldPaidCents - $removedCents))
|
||||
: $remainingPaidCents;
|
||||
$order->paid = $newPaidCents / 100;
|
||||
// 与详情的关联已付总额口径一致;订单 amount 不变。
|
||||
$order->agency_collect_amount = round((float) $order->amount - $remainingPaidCents / 100, 2);
|
||||
$order->save();
|
||||
|
||||
self::writeLog($id, $adminId, $adminInfo, 'unlink_pay_order', sprintf(
|
||||
'移除收款关联 #%d(%s,¥%.2f),订单总金额 ¥%.2f 不变;已付金额(paid)¥%.2f → ¥%.2f;原收款记录保留',
|
||||
$payOrderId, (string) $payOrder->order_no, $removedCents / 100,
|
||||
(float) $order->amount, $oldPaidCents / 100, $newPaidCents / 100
|
||||
), true);
|
||||
|
||||
$out = PrescriptionOrder::where('id', $id)->find()->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已发货/已签收订单:不新增/关联支付单,仅提交完单申请并将支付审核置为待审核。
|
||||
*
|
||||
@@ -5484,7 +5607,7 @@ class PrescriptionOrderLogic
|
||||
$log->save();
|
||||
} catch (\Throwable $e) {
|
||||
if ($strict) {
|
||||
throw new \RuntimeException('操作日志写入失败,快递信息未保存', 0, $e);
|
||||
throw new \RuntimeException('操作日志写入失败,变更未保存', 0, $e);
|
||||
}
|
||||
// 非关键日志沿用历史容错行为
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'paidPayOrders' => ['diagnosis_id'],
|
||||
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
|
||||
'linkPayOrder' => ['id', 'pay_order_id'],
|
||||
'unlinkPayOrder' => ['id', 'pay_order_id'],
|
||||
'requestCompletion' => ['id'],
|
||||
'complete' => ['id', 'fulfillment_status'],
|
||||
'refund' => ['id', 'reason', 'refund_amount'],
|
||||
@@ -101,6 +102,13 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
|
||||
];
|
||||
|
||||
public function sceneUnlinkPayOrder(): PrescriptionOrderValidate
|
||||
{
|
||||
return $this->only(['id', 'pay_order_id'])
|
||||
->append('id', 'require|integer|gt:0')
|
||||
->append('pay_order_id', 'require|integer|gt:0');
|
||||
}
|
||||
|
||||
public function updateAmount(): PrescriptionOrderValidate
|
||||
{
|
||||
return $this->only(['id', 'amount'])
|
||||
|
||||
@@ -8,6 +8,8 @@ use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
|
||||
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
|
||||
use app\common\service\qywx\QywxPromotionRangeSyncService;
|
||||
use app\common\service\qywx\QywxPromotionAutomationService;
|
||||
use app\common\service\qywx\QywxPromotionEnqueueException;
|
||||
use EasyWeChat\Kernel\Exceptions\BadRequestException;
|
||||
use EasyWeChat\Work\Application;
|
||||
use EasyWeChat\Work\Message;
|
||||
@@ -64,6 +66,9 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
$server->addEventListener('change_external_contact', function (Message $message, \Closure $next) {
|
||||
try {
|
||||
$this->handleChangeExternalContact($message);
|
||||
} catch (QywxPromotionEnqueueException $e) {
|
||||
// 未持久化不能假应答成功:外层返回500,让企微重新投递。
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('qywx external contact callback: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
@@ -96,6 +101,11 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
}
|
||||
|
||||
return response($content, 200, $headers);
|
||||
} catch (QywxPromotionEnqueueException) {
|
||||
// 异常调用栈可能携带原始事件参数;这里只记固定信息,不记录WelcomeCode。
|
||||
Log::error('qywx external contact callback: promotion event persistence failed');
|
||||
|
||||
return response('error', 500, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
} catch (BadRequestException $e) {
|
||||
Log::warning('qywx external contact callback: bad request ' . $e->getMessage());
|
||||
|
||||
@@ -120,6 +130,13 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
$failReason = (string) ($message['FailReason'] ?? '');
|
||||
$eventTime = (int) ($message['CreateTime'] ?? 0);
|
||||
|
||||
// 只接管已保存新配置且可核验方案/成员的推广事件;这里无任何远端请求。
|
||||
// 同时处理带欢迎码的半客户,避免原来的半客户早返回吞掉20秒欢迎语窗口。
|
||||
$event = $message instanceof Message ? $message->toArray() : (array) $message;
|
||||
$queued = (new QywxPromotionAutomationService())->enqueueVerifiedEvent($event);
|
||||
$auditEvent = $event;
|
||||
unset($auditEvent['WelcomeCode']);
|
||||
|
||||
// 事件流水:一进来就落库(幂等),用于"今天进来多少人"等零误差统计;
|
||||
// 独立于业务 UPSERT,即便后续 DB 逻辑抛错也不影响计数。
|
||||
CustomerLogic::recordExternalContactEvent([
|
||||
@@ -130,9 +147,14 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
'fail_reason' => $failReason,
|
||||
'welcome_code' => $welcomeCode !== '' ? 1 : 0,
|
||||
'event_time' => $eventTime,
|
||||
'raw' => $message,
|
||||
'raw' => $auditEvent,
|
||||
]);
|
||||
|
||||
if ($queued) {
|
||||
// 常驻worker先发欢迎语,分钟补偿完成标签/备注、成员记账、范围与客户资料同步。
|
||||
return;
|
||||
}
|
||||
|
||||
if ($extId === '') {
|
||||
Log::info(sprintf('qywx external contact callback: 无 ExternalUserID type=%s user=%s', $changeType, $userId));
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\qywx\QywxPromotionMediaService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
class QywxRefreshPromotionMedia extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:refresh-promotion-media')->setDescription('预热/刷新已保存欢迎语使用的三天临时素材');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$result = (new QywxPromotionMediaService())->refreshReferenced(100);
|
||||
$output->writeln('QYWX_PROMOTION_MEDIA ' . json_encode($result));
|
||||
return $result['failed'] > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\qywx\QywxPromotionAutomationService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
class QywxRetryPromotionAutomation extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:retry-promotion-automation')
|
||||
->setDescription('补偿推广标签/备注/资料同步;过期欢迎语仅记过期,不补发');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$result = (new QywxPromotionAutomationService())->retryPending(100);
|
||||
$output->writeln('QYWX_PROMOTION_RETRY ' . json_encode($result));
|
||||
return $result['failed'] > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\qywx\QywxPromotionAutomationService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\console\input\Option;
|
||||
|
||||
/** Supervisor/systemd常驻:只消费欢迎语,不运行慢速客户同步或素材上传。 */
|
||||
class QywxWorkPromotionAutomation extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:work-promotion-automation')
|
||||
->setDescription('秒级消费推广欢迎语(需常驻;欢迎码仅20秒有效)')
|
||||
->addOption('once', null, Option::VALUE_NONE, '只消费一轮');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$running = true;
|
||||
if (function_exists('pcntl_async_signals')) {
|
||||
pcntl_async_signals(true);
|
||||
pcntl_signal(SIGTERM, static function () use (&$running): void { $running = false; });
|
||||
pcntl_signal(SIGINT, static function () use (&$running): void { $running = false; });
|
||||
}
|
||||
$service = new QywxPromotionAutomationService();
|
||||
do {
|
||||
try {
|
||||
$result = $service->processWelcomes(100);
|
||||
if ($result['selected'] > 0 || $input->getOption('once')) {
|
||||
$output->writeln('QYWX_PROMOTION_WELCOME ' . json_encode($result));
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// 不输出异常堆栈/SQL/请求,避免把短期凭证带进守护进程日志。
|
||||
$output->writeln('QYWX_PROMOTION_WELCOME worker storage unavailable');
|
||||
if ($input->getOption('once')) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (!$input->getOption('once') && $running) {
|
||||
usleep(250000);
|
||||
}
|
||||
} while (!$input->getOption('once') && $running);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** 推广客户自动化:短时欢迎语与可补偿关系动作分开消费。 */
|
||||
class QywxPromotionAutomationService
|
||||
{
|
||||
private QywxPromotionContactApiService $api;
|
||||
private QywxPromotionMediaService $media;
|
||||
private QywxPromotionAutomationStore $store;
|
||||
private QywxPromotionCodeCipher $cipher;
|
||||
private $clock;
|
||||
private const TERMINAL = ['sent', 'success', 'skipped', 'expired', 'uncertain', 'failed'];
|
||||
|
||||
public function __construct(
|
||||
?QywxPromotionContactApiService $api = null,
|
||||
?QywxPromotionMediaService $media = null,
|
||||
?QywxPromotionAutomationStore $store = null,
|
||||
?QywxPromotionCodeCipher $cipher = null,
|
||||
?callable $clock = null
|
||||
) {
|
||||
$this->api = $api ?? new QywxPromotionContactApiService();
|
||||
$this->media = $media ?? new QywxPromotionMediaService($this->api);
|
||||
$this->store = $store ?? new QywxPromotionAutomationStore();
|
||||
$this->cipher = $cipher ?? new QywxPromotionCodeCipher();
|
||||
$this->clock = $clock ?? static fn (): int => time();
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅供验签解密后的回调调用。false代表沿用旧同步流程;已接管的入队错误必须返回HTTP500。
|
||||
* 此处无网络请求,保证回调不等待客户详情、范围更新或素材上传。
|
||||
*/
|
||||
public function enqueueVerifiedEvent(array $event): bool
|
||||
{
|
||||
$change = (string) ($event['ChangeType'] ?? '');
|
||||
if (!in_array($change, ['add_external_contact', 'add_half_external_contact'], true)) {
|
||||
return false;
|
||||
}
|
||||
$state = trim((string) ($event['State'] ?? ''));
|
||||
$linkId = trim((string) ($event['LinkId'] ?? $event['LinkID'] ?? ''));
|
||||
$userid = trim((string) ($event['UserID'] ?? $event['UserId'] ?? ''));
|
||||
$external = trim((string) ($event['ExternalUserID'] ?? $event['ExternalUserId'] ?? ''));
|
||||
if (($state === '' && $linkId === '') || $userid === '' || $external === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!$this->store->installed()) {
|
||||
return false;
|
||||
}
|
||||
$attribution = $this->store->attribution($state, $linkId, $userid);
|
||||
if ($attribution === null) {
|
||||
return false;
|
||||
}
|
||||
$now = $this->now();
|
||||
$eventTime = max(0, (int) ($event['CreateTime'] ?? 0));
|
||||
$code = (string) ($event['WelcomeCode'] ?? '');
|
||||
$config = $attribution['config'];
|
||||
$half = $change === 'add_half_external_contact';
|
||||
$welcomeStatus = 'pending';
|
||||
$reason = '';
|
||||
if (($config['welcome_mode'] ?? 'default') !== 'channel') {
|
||||
$welcomeStatus = 'skipped';
|
||||
$reason = 'mode_' . ($config['welcome_mode'] ?? 'default');
|
||||
} elseif ($code === '') {
|
||||
$welcomeStatus = 'skipped';
|
||||
$reason = 'missing_welcome_code';
|
||||
} elseif (strlen($code) > 1024) {
|
||||
$welcomeStatus = 'failed';
|
||||
$reason = 'invalid_welcome_code';
|
||||
} elseif ($eventTime <= 0 || $eventTime > $now + 5 || $eventTime + 20 <= $now) {
|
||||
$welcomeStatus = 'expired';
|
||||
$reason = 'welcome_window_elapsed_or_invalid_event_time';
|
||||
}
|
||||
$actions = [
|
||||
'welcome' => self::action($welcomeStatus, $reason),
|
||||
'tags' => self::action(!$half && !empty($config['tags_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'),
|
||||
'remark' => self::action(!$half && !empty($config['remark_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'),
|
||||
'description' => self::action(!$half && !empty($config['description_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'),
|
||||
'dispatch' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
|
||||
'range' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
|
||||
'sync' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
|
||||
];
|
||||
$corp = (string) ($event['ToUserName'] ?? config('pay.wechat_work.corp_id', ''));
|
||||
$this->store->enqueue([
|
||||
'event_key' => hash('sha256', implode('|', [$corp, $change, $userid, $external, (string) $eventTime])),
|
||||
'pool_id' => $attribution['pool_id'], 'member_admin_id' => $attribution['member_admin_id'],
|
||||
'change_type' => $change, 'userid' => $userid, 'external_userid' => $external,
|
||||
'event_time' => $eventTime, 'received_at' => $now,
|
||||
'config_json' => self::json($config), 'actions_json' => self::json($actions),
|
||||
'welcome_cipher' => $welcomeStatus === 'pending' ? $this->cipher->encrypt($code) : '',
|
||||
'welcome_code_hash' => $code !== '' ? hash('sha256', $code) : '',
|
||||
'welcome_expires_at' => $eventTime > 0 ? min($eventTime + 20, $now + 20) : 0,
|
||||
'welcome_status' => $welcomeStatus, 'welcome_next_retry' => 0,
|
||||
'status' => self::allTerminal($actions) ? 'done' : 'pending', 'next_retry' => 0,
|
||||
'lock_token' => '', 'lock_until' => 0, 'create_time' => $now, 'update_time' => $now,
|
||||
]);
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
// 不附原异常,入库SQL可能包含密文和配置;回调层返回500触发企微重试。
|
||||
throw new QywxPromotionEnqueueException('推广自动化事件未能持久化,请检查数据库迁移和私有存储');
|
||||
}
|
||||
}
|
||||
|
||||
/** 常驻秒级worker仅处理欢迎语,不被范围/客户同步或大文件上传阻塞。 */
|
||||
public function processWelcomes(int $limit = 100): array
|
||||
{
|
||||
return $this->consume('welcome', $limit);
|
||||
}
|
||||
|
||||
/** 分钟补偿:过期欢迎语只记过期,绝不尝试补发。 */
|
||||
public function retryPending(int $limit = 100): array
|
||||
{
|
||||
return $this->consume('metadata', $limit);
|
||||
}
|
||||
|
||||
public static function selectWelcome(array $config, int $eventTime): array
|
||||
{
|
||||
if (!empty($config['welcome_schedule_enabled'])) {
|
||||
foreach ((array) ($config['welcome_schedule'] ?? []) as $slot) {
|
||||
if (QywxPromotionConfig::matches($slot, $eventTime)) {
|
||||
return ['text' => (string) ($slot['text'] ?? ''), 'attachments' => (array) ($slot['attachments'] ?? [])];
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['text' => (string) ($config['welcome']['text'] ?? ''), 'attachments' => (array) ($config['welcome']['attachments'] ?? [])];
|
||||
}
|
||||
|
||||
private function consume(string $lane, int $limit): array
|
||||
{
|
||||
$result = ['selected' => 0, 'processed' => 0, 'failed' => 0];
|
||||
foreach ($this->store->due($lane, $this->now(), $limit) as $id) {
|
||||
++$result['selected'];
|
||||
try {
|
||||
$row = $this->store->claim($id, $lane, $this->now());
|
||||
if ($row === null) {
|
||||
continue;
|
||||
}
|
||||
$actions = json_decode($row['actions_json'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$config = json_decode($row['config_json'], true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!self::terminal($actions['welcome']['status'])) {
|
||||
if ($lane === 'welcome') {
|
||||
$this->welcome($row, $actions, $config);
|
||||
} else {
|
||||
$running = $actions['welcome']['status'] === 'running';
|
||||
$this->transition($row, $actions, 'welcome', $running ? 'uncertain' : 'expired',
|
||||
$running ? 'worker_interrupted_after_send_started' : 'welcome_worker_not_available_in_window');
|
||||
}
|
||||
}
|
||||
if ($lane !== 'welcome') {
|
||||
$this->metadata($row, $actions, $config);
|
||||
}
|
||||
$row['lock_until'] = 0;
|
||||
$row['update_time'] = $this->now();
|
||||
$this->store->save($row);
|
||||
++$result['processed'];
|
||||
} catch (\Throwable) {
|
||||
// 失去DB/租约时保留running状态;欢迎语恢复时视为不确定,防止重复推送。
|
||||
++$result['failed'];
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function welcome(array &$row, array &$actions, array $config): void
|
||||
{
|
||||
if ($actions['welcome']['status'] === 'running') {
|
||||
$this->transition($row, $actions, 'welcome', 'uncertain', 'worker_interrupted_after_send_started');
|
||||
return;
|
||||
}
|
||||
if ((int) $row['welcome_expires_at'] <= $this->now() + 1) {
|
||||
$this->transition($row, $actions, 'welcome', 'expired', 'welcome_window_elapsed');
|
||||
return;
|
||||
}
|
||||
$sendStarted = false;
|
||||
try {
|
||||
$message = self::selectWelcome($config, (int) $row['event_time']);
|
||||
$text = $message['text'];
|
||||
if (str_contains($text, '{customer_name}') || str_contains($text, '{employee_name}') || str_contains($text, '{add_time}')) {
|
||||
$names = $this->names($row, $text, true);
|
||||
$text = QywxPromotionConfig::render($text, $names['customer'], $names['employee'], (int) $row['event_time'], 1200);
|
||||
}
|
||||
$truncated = strlen($text) > 4000;
|
||||
$text = mb_strcut($text, 0, 4000, 'UTF-8');
|
||||
$attachments = $this->media->materialize($message['attachments'], $config);
|
||||
$code = $this->cipher->decrypt($row['welcome_cipher']);
|
||||
if ((int) $row['welcome_expires_at'] <= $this->now() + 1) {
|
||||
$this->transition($row, $actions, 'welcome', 'expired', 'welcome_window_elapsed_during_prepare');
|
||||
return;
|
||||
}
|
||||
// running先持久化:如果HTTP成功后进程/DB断开,恢复时绝不再次使用同一code。
|
||||
$this->transition($row, $actions, 'welcome', 'running', 'send_started');
|
||||
$sendStarted = true;
|
||||
try {
|
||||
$this->api->sendWelcome($code, $text, $attachments);
|
||||
$this->transition($row, $actions, 'welcome', 'sent', $truncated ? 'sent_text_truncated_4000_bytes' : 'sent');
|
||||
} catch (QywxPromotionContactApiException $e) {
|
||||
if ($e->uncertain) {
|
||||
$this->transition($row, $actions, 'welcome', 'uncertain', 'network_result_unknown_do_not_resend', $e->getCode());
|
||||
} elseif ($e->getCode() === 41051) {
|
||||
$this->transition($row, $actions, 'welcome', 'skipped', 'welcome_code_already_consumed', 41051);
|
||||
} else {
|
||||
$this->welcomeRetry($row, $actions, 'explicit_api_rejection', $e->getCode());
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$this->transition($row, $actions, 'welcome', 'uncertain', 'send_or_persist_result_unknown_do_not_resend');
|
||||
} finally {
|
||||
unset($code);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 准备阶段没有执行发送,可以安全重试,且不会把错误原文/欢迎码写日志。
|
||||
if ($sendStarted || $actions['welcome']['status'] === 'running') {
|
||||
throw $e;
|
||||
}
|
||||
$this->welcomeRetry($row, $actions, 'prepare_failed_check_media_credentials_or_key', (int) $e->getCode());
|
||||
}
|
||||
}
|
||||
|
||||
private function welcomeRetry(array &$row, array &$actions, string $reason, int $code): void
|
||||
{
|
||||
$expired = (int) $row['welcome_expires_at'] <= $this->now() + 2;
|
||||
$this->transition($row, $actions, 'welcome', $expired ? 'expired' : 'retry', $reason, $code, $this->now() + 1);
|
||||
}
|
||||
|
||||
private function metadata(array &$row, array &$actions, array $config): void
|
||||
{
|
||||
$names = null;
|
||||
foreach (['tags', 'remark', 'description', 'dispatch', 'range', 'sync'] as $name) {
|
||||
if (self::terminal($actions[$name]['status']) || (int) ($actions[$name]['next_retry'] ?? 0) > $this->now()) {
|
||||
continue;
|
||||
}
|
||||
$this->transition($row, $actions, $name, 'running', 'started');
|
||||
try {
|
||||
switch ($name) {
|
||||
case 'tags':
|
||||
$this->api->markTags($row['userid'], $row['external_userid'], (array) $config['tag_ids']);
|
||||
break;
|
||||
case 'remark':
|
||||
$names = $names ?? $this->names($row, (string) $config['remark_template'], false);
|
||||
$remark = QywxPromotionConfig::render($config['remark_template'], $names['customer'], $names['employee'], (int) $row['event_time'], 20);
|
||||
$this->api->remark($row['userid'], $row['external_userid'], ['remark' => $remark]);
|
||||
break;
|
||||
case 'description':
|
||||
$this->api->remark($row['userid'], $row['external_userid'], ['description' => (string) $config['description']]);
|
||||
break;
|
||||
case 'dispatch':
|
||||
$this->store->dispatch($row);
|
||||
break;
|
||||
case 'range':
|
||||
$this->store->syncRange($row);
|
||||
break;
|
||||
case 'sync':
|
||||
$this->store->syncCustomer($row);
|
||||
break;
|
||||
}
|
||||
$this->transition($row, $actions, $name, 'success', 'completed');
|
||||
} catch (\Throwable $e) {
|
||||
$attempt = (int) $actions[$name]['attempts'];
|
||||
$failed = $attempt >= 10;
|
||||
$this->transition($row, $actions, $name, $failed ? 'failed' : 'retry',
|
||||
$failed ? 'retry_limit_reached' : 'action_failed', (int) $e->getCode(),
|
||||
$this->now() + min(3600, 15 * (2 ** min(8, $attempt))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function names(array $row, string $template, bool $welcome): array
|
||||
{
|
||||
$names = ['customer' => '', 'employee' => ''];
|
||||
try {
|
||||
$names = $this->store->localNames($row);
|
||||
} catch (\Throwable) {
|
||||
// 本地资料失败不妨碍欢迎语使用明确的文案兜底。
|
||||
}
|
||||
$budget = fn (): bool => !$welcome || (int) $row['welcome_expires_at'] > $this->now() + 7;
|
||||
if (str_contains($template, '{customer_name}') && $names['customer'] === ''
|
||||
&& $row['change_type'] !== 'add_half_external_contact' && $budget()) {
|
||||
try {
|
||||
$detail = $this->api->getExternalContact($row['external_userid']);
|
||||
$names['customer'] = (string) ($detail['external_contact']['name'] ?? '');
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
if (str_contains($template, '{employee_name}') && $budget()) {
|
||||
try {
|
||||
$user = $this->api->getUser($row['userid']);
|
||||
$names['employee'] = trim((string) ($user['name'] ?? '')) ?: $names['employee'];
|
||||
} catch (\Throwable) {
|
||||
// 通讯录姓名接口权限不足时回退后台成员称呼。
|
||||
}
|
||||
}
|
||||
$names['customer'] = $names['customer'] !== '' ? $names['customer'] : '您';
|
||||
$names['employee'] = $names['employee'] !== '' ? $names['employee'] : '客户顾问';
|
||||
return $names;
|
||||
}
|
||||
|
||||
private function transition(array &$row, array &$actions, string $name, string $status, string $reason, int $code = 0, int $retryAt = 0): void
|
||||
{
|
||||
$now = $this->now();
|
||||
$action = $actions[$name];
|
||||
if ($status === 'running' || ($name === 'welcome' && $status === 'retry' && $action['status'] !== 'running')) {
|
||||
++$action['attempts'];
|
||||
}
|
||||
$action = array_replace($action, ['status' => $status, 'reason' => $reason, 'error_code' => $code,
|
||||
'next_retry' => $retryAt, 'update_time' => $now]);
|
||||
if (self::terminal($status)) {
|
||||
$action['finished_at'] = $now;
|
||||
}
|
||||
$actions[$name] = $action;
|
||||
if ($name === 'welcome') {
|
||||
$row['welcome_status'] = $status;
|
||||
$row['welcome_next_retry'] = $retryAt;
|
||||
if (self::terminal($status)) {
|
||||
$row['welcome_cipher'] = '';
|
||||
}
|
||||
}
|
||||
$row['status'] = self::allTerminal($actions) ? 'done' : 'pending';
|
||||
$retry = [];
|
||||
foreach ($actions as $key => $value) {
|
||||
if ($key !== 'welcome' && !self::terminal($value['status'])) {
|
||||
$retry[] = (int) ($value['next_retry'] ?? 0);
|
||||
}
|
||||
}
|
||||
$row['next_retry'] = $retry === [] ? 0 : min($retry);
|
||||
$row['actions_json'] = self::json($actions);
|
||||
$row['update_time'] = $now;
|
||||
$this->store->save($row, ['action' => $name, 'status' => $status, 'attempt' => $action['attempts'],
|
||||
'reason' => $reason, 'error_code' => $code, 'create_time' => $now]);
|
||||
}
|
||||
|
||||
private static function action(string $status, string $reason = ''): array
|
||||
{
|
||||
return ['status' => $status, 'reason' => $status === 'pending' ? '' : $reason, 'attempts' => 0, 'error_code' => 0, 'next_retry' => 0];
|
||||
}
|
||||
|
||||
private static function allTerminal(array $actions): bool
|
||||
{
|
||||
foreach ($actions as $action) {
|
||||
if (!self::terminal($action['status'])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function terminal(string $status): bool
|
||||
{
|
||||
return in_array($status, self::TERMINAL, true);
|
||||
}
|
||||
|
||||
private static function json(array $value): string
|
||||
{
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function now(): int
|
||||
{
|
||||
return (int) ($this->clock)();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** DB 存储与既有同步边界;单测替换此类后不初始化业务数据库。 */
|
||||
class QywxPromotionAutomationStore
|
||||
{
|
||||
public function installed(): bool
|
||||
{
|
||||
return QywxPromotionConfig::installed();
|
||||
}
|
||||
|
||||
/** State只能定位,必须再核验真实方案、正式官方链接与实际成员关系。 */
|
||||
public function attribution(string $state, string $linkId, string $userId): ?array
|
||||
{
|
||||
if ($state !== '') {
|
||||
if (!preg_match('/^zyt_pool:([1-9][0-9]{0,9})$/', $state, $match)) {
|
||||
return null;
|
||||
}
|
||||
$poolId = (int) $match[1];
|
||||
} elseif ($linkId !== '') {
|
||||
$poolId = (int) Db::name('qywx_promotion_link')->where('remote_link_id', $linkId)
|
||||
->where('remote_status', 1)->whereNull('delete_time')->value('pool_id');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->where('status', 1)->whereNull('delete_time')->find();
|
||||
$member = Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', $userId)
|
||||
->whereNull('delete_time')->find();
|
||||
$links = Db::name('qywx_promotion_link')->where('pool_id', $poolId)->where('remote_status', 1)
|
||||
->where('remote_link_id', '<>', '')->whereNull('delete_time');
|
||||
if ($linkId !== '') {
|
||||
$links->where('remote_link_id', $linkId);
|
||||
}
|
||||
// 不用 enabled/当日额度验证:真实回调可能比排班切换晚到,不能漏掉已归属该方案的成员。
|
||||
if (!$pool || !$member || !$links->find()) {
|
||||
return null;
|
||||
}
|
||||
$configRow = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->find();
|
||||
if (!$configRow) {
|
||||
// 尚未保存新增配置的旧方案仍保持原同步链路,不强制依赖新worker。
|
||||
return null;
|
||||
}
|
||||
return ['pool_id' => $poolId, 'member_admin_id' => (int) $member['admin_id'],
|
||||
'config' => QywxPromotionConfig::decode($configRow['config_json'])];
|
||||
}
|
||||
|
||||
public function enqueue(array $row): int
|
||||
{
|
||||
$row['welcome_code_hash'] = $row['welcome_code_hash'] ?: null;
|
||||
// 同一code可能同时出现在half/add:唯一索引把欢迎语消费权固定在第一次任务。
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
if ($row['welcome_code_hash'] !== null
|
||||
&& Db::name('qywx_promotion_automation_task')->where('welcome_code_hash', $row['welcome_code_hash'])->find()) {
|
||||
$actions = json_decode($row['actions_json'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$actions['welcome']['status'] = 'skipped';
|
||||
$actions['welcome']['reason'] = 'same_welcome_code_already_queued';
|
||||
$row['actions_json'] = json_encode($actions, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
$row['welcome_status'] = 'skipped';
|
||||
$row['welcome_cipher'] = '';
|
||||
$row['welcome_code_hash'] = null;
|
||||
$pending = array_filter($actions, static fn (array $a): bool => in_array($a['status'], ['pending', 'retry', 'running'], true));
|
||||
$row['status'] = $pending === [] ? 'done' : 'pending';
|
||||
}
|
||||
try {
|
||||
return (int) Db::name('qywx_promotion_automation_task')->insertGetId($row);
|
||||
} catch (\Throwable $e) {
|
||||
$existing = Db::name('qywx_promotion_automation_task')->where('event_key', $row['event_key'])->value('id');
|
||||
if ($existing) {
|
||||
return (int) $existing;
|
||||
}
|
||||
if ($attempt === 1 || $row['welcome_code_hash'] === null) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('推广任务入队失败');
|
||||
}
|
||||
|
||||
/** 两条消费通道:常驻worker只发欢迎语,分钟任务不锁住尚有时效的欢迎语任务。 */
|
||||
public function due(string $lane, int $now, int $limit): array
|
||||
{
|
||||
$query = Db::name('qywx_promotion_automation_task')->where('status', '<>', 'done')
|
||||
->where('lock_until', '<=', $now);
|
||||
if ($lane === 'welcome') {
|
||||
$query->whereIn('welcome_status', ['pending', 'retry', 'running'])->where('welcome_next_retry', '<=', $now)
|
||||
->order('welcome_expires_at', 'asc');
|
||||
} else {
|
||||
$query->where('next_retry', '<=', $now)->where(function ($q) use ($now) {
|
||||
$q->whereNotIn('welcome_status', ['pending', 'retry', 'running'])
|
||||
->whereOr('welcome_expires_at', '<=', $now);
|
||||
})->order('id', 'asc');
|
||||
}
|
||||
return array_map('intval', $query->limit(max(1, min(500, $limit)))->column('id'));
|
||||
}
|
||||
|
||||
public function claim(int $id, string $lane, int $now): ?array
|
||||
{
|
||||
return Db::transaction(function () use ($id, $lane, $now): ?array {
|
||||
$row = Db::name('qywx_promotion_automation_task')->where('id', $id)->lock(true)->find();
|
||||
if (!$row || $row['status'] === 'done' || (int) $row['lock_until'] > $now) {
|
||||
return null;
|
||||
}
|
||||
$pendingWelcome = in_array($row['welcome_status'], ['pending', 'retry', 'running'], true);
|
||||
if (($lane === 'welcome' && (!$pendingWelcome || (int) $row['welcome_next_retry'] > $now))
|
||||
|| ($lane !== 'welcome' && (($pendingWelcome && (int) $row['welcome_expires_at'] > $now) || (int) $row['next_retry'] > $now))) {
|
||||
return null;
|
||||
}
|
||||
$row['lock_token'] = bin2hex(random_bytes(16));
|
||||
$row['lock_until'] = $now + ($lane === 'welcome' ? 30 : 600);
|
||||
Db::name('qywx_promotion_automation_task')->where('id', $id)->update([
|
||||
'lock_token' => $row['lock_token'], 'lock_until' => $row['lock_until'], 'update_time' => $now,
|
||||
]);
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
public function save(array $row, ?array $log = null): void
|
||||
{
|
||||
Db::transaction(function () use ($row, $log): void {
|
||||
$fields = array_intersect_key($row, array_flip([
|
||||
'actions_json', 'welcome_status', 'welcome_cipher', 'welcome_next_retry', 'status',
|
||||
'next_retry', 'lock_until', 'update_time',
|
||||
]));
|
||||
// 租约令牌校验不能依赖affected rows:同秒同值更新在MySQL可能返回0。
|
||||
$current = Db::name('qywx_promotion_automation_task')->where('id', $row['id'])->lock(true)->find();
|
||||
if (!$current || !hash_equals((string) $current['lock_token'], (string) $row['lock_token'])) {
|
||||
throw new RuntimeException('推广任务处理租约已失效');
|
||||
}
|
||||
Db::name('qywx_promotion_automation_task')->where('id', $row['id'])->update($fields);
|
||||
if ($log !== null) {
|
||||
Db::name('qywx_promotion_automation_action_log')->insert($log + ['task_id' => $row['id']]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function localNames(array $task): array
|
||||
{
|
||||
return [
|
||||
'customer' => (string) (Db::name('qywx_external_contact')->where('external_userid', $task['external_userid'])->value('name') ?? ''),
|
||||
'employee' => (string) (Db::name('admin')->where('id', $task['member_admin_id'])->value('name') ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatch(array $task): void
|
||||
{
|
||||
$result = QywxPromotionMemberSchedulerService::recordFromState('zyt_pool:' . $task['pool_id'],
|
||||
$task['userid'], $task['external_userid'], (int) $task['event_time'], 'external_contact');
|
||||
if (!in_array($result['status'] ?? '', ['counted', 'counted_blocked', 'counted_stale', 'duplicate'], true)) {
|
||||
throw new RuntimeException('推广成员记账未完成');
|
||||
}
|
||||
}
|
||||
|
||||
public function syncRange(array $task): void
|
||||
{
|
||||
// range服务自身有持久重试与版本保护;此调用负责触发。
|
||||
(new QywxPromotionRangeSyncService())->syncPool((int) $task['pool_id']);
|
||||
}
|
||||
|
||||
public function syncCustomer(array $task): void
|
||||
{
|
||||
$started = time();
|
||||
CustomerLogic::upsertSingleExternalContactFromApi($task['external_userid']);
|
||||
// 旧方法在API空结果时只log并返回void;必须核验本地实际更新,避免把未同步记为成功。
|
||||
$updated = (int) Db::name('qywx_external_contact')->where('external_userid', $task['external_userid'])->value('update_time');
|
||||
if ($updated < $started) {
|
||||
throw new RuntimeException('推广客户资料尚未同步到本地');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** 一次性欢迎码仅加密短存;密钥不写数据库。多节点须显式共享环境密钥。 */
|
||||
class QywxPromotionCodeCipher
|
||||
{
|
||||
private ?string $key;
|
||||
|
||||
public function __construct(?string $key = null)
|
||||
{
|
||||
$this->key = $key;
|
||||
}
|
||||
|
||||
public function encrypt(string $code): string
|
||||
{
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$encrypted = openssl_encrypt($code, 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($encrypted === false) {
|
||||
throw new RuntimeException('无法加密欢迎码');
|
||||
}
|
||||
return base64_encode($iv . $tag . $encrypted);
|
||||
}
|
||||
|
||||
public function decrypt(string $cipher): string
|
||||
{
|
||||
$value = base64_decode($cipher, true);
|
||||
if ($value === false || strlen($value) <= 28) {
|
||||
throw new RuntimeException('欢迎码密文无效');
|
||||
}
|
||||
$code = openssl_decrypt(substr($value, 28), 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, substr($value, 0, 12), substr($value, 12, 16));
|
||||
if ($code === false) {
|
||||
throw new RuntimeException('欢迎码解密失败,请核对工作进程密钥');
|
||||
}
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function key(): string
|
||||
{
|
||||
if ($this->key !== null) {
|
||||
if (strlen($this->key) < 32) {
|
||||
throw new RuntimeException('欢迎码加密密钥至少32字符');
|
||||
}
|
||||
return hash('sha256', $this->key, true);
|
||||
}
|
||||
$configured = (string) config('qywx_promotion_automation.encryption_key', '');
|
||||
if ($configured !== '') {
|
||||
$this->key = $configured;
|
||||
return $this->key();
|
||||
}
|
||||
$directory = root_path('runtime') . 'qywx_promotion_private';
|
||||
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('无法创建欢迎码私有密钥目录');
|
||||
}
|
||||
$path = $directory . DIRECTORY_SEPARATOR . 'welcome.key';
|
||||
$stream = @fopen($path, 'c+b');
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException('无法读取欢迎码私有密钥');
|
||||
}
|
||||
try {
|
||||
// 首次回调和多个worker可能同时启动;读写均持锁,避免读取尚未写完的密钥。
|
||||
if (!flock($stream, LOCK_EX)) {
|
||||
throw new RuntimeException('无法锁定欢迎码私有密钥');
|
||||
}
|
||||
@chmod($path, 0600);
|
||||
$key = trim((string) stream_get_contents($stream));
|
||||
if ($key === '') {
|
||||
$key = bin2hex(random_bytes(32));
|
||||
rewind($stream);
|
||||
if (fwrite($stream, $key) !== strlen($key) || !fflush($stream)) {
|
||||
throw new RuntimeException('无法保存欢迎码私有密钥');
|
||||
}
|
||||
}
|
||||
if (!preg_match('/^[0-9a-f]{64}$/', $key)) {
|
||||
throw new RuntimeException('欢迎码私有密钥损坏,请恢复原密钥');
|
||||
}
|
||||
$this->key = $key;
|
||||
} finally {
|
||||
flock($stream, LOCK_UN);
|
||||
fclose($stream);
|
||||
}
|
||||
return $this->key();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 获客方案配置。时间规则统一使用 Asia/Shanghai,结束时间不包含在时段内。 */
|
||||
class QywxPromotionConfig
|
||||
{
|
||||
public static function defaults(): array
|
||||
{
|
||||
return [
|
||||
'reception_mode' => 'always', 'reception_schedule' => [],
|
||||
'backup_member_admin_ids' => [], 'backup_userids' => [],
|
||||
'tags_enabled' => false, 'tag_ids' => [],
|
||||
'remark_enabled' => false, 'remark_template' => '{customer_name}',
|
||||
'description_enabled' => false, 'description' => '',
|
||||
'welcome_mode' => 'default', 'welcome' => ['text' => '', 'attachments' => []],
|
||||
'welcome_schedule_enabled' => false, 'welcome_schedule' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public static function installed(): bool
|
||||
{
|
||||
try {
|
||||
return Db::name('qywx_promotion_config')->getFields() !== [];
|
||||
} catch (\Throwable $error) {
|
||||
// 仅旧部署未建表时回退。数据库故障不能退回全天路由、忽略排班配置。
|
||||
if (str_contains($error->getMessage(), '42S02')
|
||||
|| str_contains($error->getMessage(), '1146')
|
||||
|| str_contains($error->getMessage(), 'no such table')) {
|
||||
return false;
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
|
||||
public static function assertInstalled(): void
|
||||
{
|
||||
if (!self::installed()) {
|
||||
throw new RuntimeException('请先执行 server/sql/1.9.20260831/add_wecom_promotion_automation.sql 安装获客配置与任务表');
|
||||
}
|
||||
}
|
||||
|
||||
public static function decode(mixed $json): array
|
||||
{
|
||||
$value = is_array($json) ? $json : json_decode((string) $json, true);
|
||||
return array_replace(self::defaults(), is_array($value) ? $value : []);
|
||||
}
|
||||
|
||||
public static function forPool(int $poolId): array
|
||||
{
|
||||
if (!self::installed()) {
|
||||
return self::defaults();
|
||||
}
|
||||
return self::decode(Db::name('qywx_promotion_config')->where('pool_id', $poolId)->value('config_json'));
|
||||
}
|
||||
|
||||
public static function save(int $poolId, array $config): void
|
||||
{
|
||||
self::assertInstalled();
|
||||
$row = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->find();
|
||||
$data = ['config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), 'update_time' => time()];
|
||||
if ($row) {
|
||||
Db::name('qywx_promotion_config')->where('pool_id', $poolId)->update($data);
|
||||
} else {
|
||||
Db::name('qywx_promotion_config')->insert($data + ['pool_id' => $poolId, 'create_time' => time()]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 不接受浏览器提供的 userid;成员归属必须经过现有后台数据权限校验后再绑定。 */
|
||||
public static function normalize(array $input): array
|
||||
{
|
||||
$config = self::defaults();
|
||||
foreach (['tags_enabled', 'remark_enabled', 'description_enabled', 'welcome_schedule_enabled'] as $key) {
|
||||
$value = $input[$key] ?? false;
|
||||
if (!in_array($value, [true, false, 0, 1, '0', '1'], true)) {
|
||||
throw new RuntimeException('配置开关格式不正确');
|
||||
}
|
||||
$config[$key] = in_array($value, [true, 1, '1'], true);
|
||||
}
|
||||
$config['reception_mode'] = self::choice($input['reception_mode'] ?? 'always', ['always', 'scheduled']);
|
||||
$config['welcome_mode'] = self::choice($input['welcome_mode'] ?? 'default', ['default', 'channel', 'none']);
|
||||
$config['backup_member_admin_ids'] = self::ids($input['backup_member_admin_ids'] ?? []);
|
||||
$config['reception_schedule'] = self::schedule($input['reception_schedule'] ?? [], true);
|
||||
if ($config['reception_mode'] === 'scheduled' && $config['reception_schedule'] === []) {
|
||||
throw new RuntimeException('自动上下线模式至少需要一个接待时段');
|
||||
}
|
||||
if ($config['reception_mode'] === 'scheduled' && $config['backup_member_admin_ids'] === []) {
|
||||
throw new RuntimeException('自动上下线须配置备用员工,避免非接待时段官方链接仍路由给原成员');
|
||||
}
|
||||
if (!is_array($input['tag_ids'] ?? [])) {
|
||||
throw new RuntimeException('客户标签格式不正确');
|
||||
}
|
||||
if (count($input['tag_ids'] ?? []) > 1) {
|
||||
// 兼容旧数组字段,但不能默默截断旧方案多选;编辑时须由用户重新确认单个标签。
|
||||
throw new RuntimeException('推广方案仅支持单个客户标签,请重新选择一个标签');
|
||||
}
|
||||
$tags = [];
|
||||
foreach ($input['tag_ids'] ?? [] as $tag) {
|
||||
if (!is_string($tag) || trim($tag) === '' || strlen($tag) > 128) {
|
||||
throw new RuntimeException('企业微信标签 ID 不正确');
|
||||
}
|
||||
$tags[] = trim($tag);
|
||||
}
|
||||
$config['tag_ids'] = array_values(array_unique($tags));
|
||||
if ($config['tags_enabled'] && count($config['tag_ids']) !== 1) {
|
||||
throw new RuntimeException('启用客户标签时请选择一个企业微信标签');
|
||||
}
|
||||
$config['remark_template'] = self::text($input['remark_template'] ?? '{customer_name}', 200, '客户备注模板');
|
||||
$config['description'] = self::text($input['description'] ?? '', 150, '客户描述');
|
||||
if ($config['remark_enabled'] && $config['remark_template'] === '') {
|
||||
throw new RuntimeException('请填写客户备注模板');
|
||||
}
|
||||
if ($config['description_enabled'] && $config['description'] === '') {
|
||||
throw new RuntimeException('请填写客户描述');
|
||||
}
|
||||
$config['welcome'] = self::message($input['welcome'] ?? []);
|
||||
$config['welcome_schedule'] = self::schedule($input['welcome_schedule'] ?? [], false);
|
||||
if ($config['welcome_mode'] === 'channel') {
|
||||
self::assertMessage($config['welcome']);
|
||||
if ($config['welcome_schedule_enabled'] && $config['welcome_schedule'] === []) {
|
||||
throw new RuntimeException('请添加分时段欢迎语');
|
||||
}
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
public static function matches(array $slot, int $timestamp): bool
|
||||
{
|
||||
$date = (new DateTimeImmutable('@' . $timestamp))->setTimezone(new DateTimeZone('Asia/Shanghai'));
|
||||
$minute = $date->format('H:i');
|
||||
$start = (string) ($slot['start'] ?? '');
|
||||
$end = (string) ($slot['end'] ?? '');
|
||||
$weekdays = array_map('intval', (array) ($slot['weekdays'] ?? []));
|
||||
$day = (int) $date->format('N');
|
||||
if ($start < $end) {
|
||||
return in_array($day, $weekdays, true) && $minute >= $start && $minute < $end;
|
||||
}
|
||||
// 跨午夜时段归属于开始日期,例如周一 22:00—02:00 包含周二凌晨。
|
||||
return ($minute >= $start && in_array($day, $weekdays, true))
|
||||
|| ($minute < $end && in_array($day === 1 ? 7 : $day - 1, $weekdays, true));
|
||||
}
|
||||
|
||||
public static function render(string $template, string $customer, string $employee, int $timestamp, int $limit): string
|
||||
{
|
||||
$date = (new DateTimeImmutable('@' . $timestamp))->setTimezone(new DateTimeZone('Asia/Shanghai'));
|
||||
return mb_substr(strtr($template, [
|
||||
'{customer_name}' => $customer, '{employee_name}' => $employee,
|
||||
'{add_time}' => $date->format('Y-m-d'),
|
||||
]), 0, $limit);
|
||||
}
|
||||
|
||||
private static function schedule(mixed $value, bool $reception): array
|
||||
{
|
||||
if (!is_array($value) || count($value) > 30) {
|
||||
throw new RuntimeException('每类时间规则最多配置 30 条');
|
||||
}
|
||||
$rows = [];
|
||||
foreach ($value as $row) {
|
||||
if (!is_array($row)) {
|
||||
throw new RuntimeException('时间规则格式不正确');
|
||||
}
|
||||
$days = self::ids($row['weekdays'] ?? []);
|
||||
if ($days === [] || max($days) > 7) {
|
||||
throw new RuntimeException('请选择星期一至星期日');
|
||||
}
|
||||
$start = (string) ($row['start'] ?? '');
|
||||
$end = (string) ($row['end'] ?? '');
|
||||
if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $start)
|
||||
|| !preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $end) || $start === $end) {
|
||||
throw new RuntimeException('时段起止时间必须不同,格式为 HH:mm;全天在线请使用全天模式');
|
||||
}
|
||||
$clean = ['weekdays' => $days, 'start' => $start, 'end' => $end];
|
||||
if ($reception) {
|
||||
$clean['member_admin_ids'] = self::ids($row['member_admin_ids'] ?? []);
|
||||
if ($clean['member_admin_ids'] === []) {
|
||||
throw new RuntimeException('每个接待时段至少选择一名接待成员');
|
||||
}
|
||||
} else {
|
||||
$clean += self::message($row);
|
||||
self::assertMessage($clean);
|
||||
}
|
||||
$rows[] = $clean;
|
||||
}
|
||||
if (!$reception) {
|
||||
// 分时欢迎语不可重叠,避免靠数组顺序决定发送内容。
|
||||
$occupied = [];
|
||||
foreach ($rows as $row) {
|
||||
[$sh, $sm] = array_map('intval', explode(':', $row['start']));
|
||||
[$eh, $em] = array_map('intval', explode(':', $row['end']));
|
||||
$from = $sh * 60 + $sm;
|
||||
$to = $eh * 60 + $em;
|
||||
$duration = ($to - $from + 1440) % 1440;
|
||||
foreach ($row['weekdays'] as $day) {
|
||||
for ($i = 0; $i < $duration; $i++) {
|
||||
$key = (($day - 1) * 1440 + $from + $i) % 10080;
|
||||
if (isset($occupied[$key])) {
|
||||
throw new RuntimeException('分时段欢迎语的时间范围不能重叠');
|
||||
}
|
||||
$occupied[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public static function message(mixed $value): array
|
||||
{
|
||||
if (!is_array($value) || !is_array($value['attachments'] ?? [])) {
|
||||
throw new RuntimeException('欢迎语格式不正确');
|
||||
}
|
||||
$attachments = array_values($value['attachments'] ?? []);
|
||||
if (count($attachments) > 9) {
|
||||
throw new RuntimeException('欢迎语最多添加 9 个附件');
|
||||
}
|
||||
// 附件的详细格式与素材权限由 API/素材服务进一步验证。
|
||||
foreach ($attachments as $attachment) {
|
||||
if (!is_array($attachment) || !in_array($attachment['msgtype'] ?? '', ['image', 'link', 'miniprogram', 'video', 'file'], true)) {
|
||||
throw new RuntimeException('不支持的欢迎语附件类型');
|
||||
}
|
||||
}
|
||||
$text = self::text($value['text'] ?? '', 1200, '欢迎语');
|
||||
if (strlen($text) > 4000) {
|
||||
throw new RuntimeException('欢迎语不能超过 4000 个 UTF-8 字节(表情通常占 4 字节)');
|
||||
}
|
||||
return ['text' => $text, 'attachments' => $attachments];
|
||||
}
|
||||
|
||||
private static function assertMessage(array $message): void
|
||||
{
|
||||
if (trim($message['text']) === '' && $message['attachments'] === []) {
|
||||
throw new RuntimeException('渠道欢迎语必须包含文字或附件');
|
||||
}
|
||||
}
|
||||
|
||||
private static function choice(mixed $value, array $choices): string
|
||||
{
|
||||
if (!is_string($value) || !in_array($value, $choices, true)) {
|
||||
throw new RuntimeException('不支持的配置模式');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function ids(mixed $value): array
|
||||
{
|
||||
if (!is_array($value) || count($value) > 500) {
|
||||
throw new RuntimeException('成员或星期列表格式不正确');
|
||||
}
|
||||
$result = [];
|
||||
foreach ($value as $id) {
|
||||
if ((!is_int($id) && !(is_string($id) && ctype_digit($id))) || (int) $id <= 0) {
|
||||
throw new RuntimeException('成员或星期 ID 必须是正整数');
|
||||
}
|
||||
$result[] = (int) $id;
|
||||
}
|
||||
return array_values(array_unique($result));
|
||||
}
|
||||
|
||||
private static function text(mixed $value, int $limit, string $label): string
|
||||
{
|
||||
if (!is_string($value) || mb_strlen($value) > $limit) {
|
||||
throw new RuntimeException($label . '不能超过 ' . $limit . ' 个字符');
|
||||
}
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** 不保存 Guzzle 原异常,避免请求 URL / token / welcome_code 进入日志。 */
|
||||
class QywxPromotionContactApiException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, int $code = 0, public bool $uncertain = false)
|
||||
{
|
||||
parent::__construct($message, $code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use RuntimeException;
|
||||
use think\facade\Cache;
|
||||
|
||||
/** 客户联系可调用自建应用;不使用对外收款应用 Secret。 */
|
||||
class QywxPromotionContactApiService
|
||||
{
|
||||
private const PROMOTION_TAG_GROUP = '推广渠道';
|
||||
|
||||
private Client $client;
|
||||
private string $corpId;
|
||||
private string $secret;
|
||||
private $tokenResolver;
|
||||
|
||||
public function __construct(?Client $client = null, ?callable $tokenResolver = null)
|
||||
{
|
||||
$this->corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''))
|
||||
?: trim((string) config('pay.wechat_work.corp_id', ''));
|
||||
// 获客回调的 WelcomeCode 应交由相同的可调用应用发送。专用覆盖仅用于明确配置的同应用。
|
||||
$this->secret = trim((string) config('qywx_promotion_automation.contact_secret', ''))
|
||||
?: (trim((string) config('qywx_customer_acquisition.secret', ''))
|
||||
?: trim((string) config('pay.wechat_work.customer_contact_secret', '')));
|
||||
$caPath = dirname(__DIR__, 4) . '/cacert.pem';
|
||||
$this->client = $client ?? new Client([
|
||||
'base_uri' => 'https://qyapi.weixin.qq.com/',
|
||||
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
|
||||
'verify' => is_file($caPath) ? $caPath : true, 'allow_redirects' => false,
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
$this->tokenResolver = $tokenResolver;
|
||||
}
|
||||
|
||||
public function credentialFingerprint(): string
|
||||
{
|
||||
return hash('sha256', $this->corpId . '|' . $this->secret);
|
||||
}
|
||||
|
||||
public function tagOptions(): array
|
||||
{
|
||||
$result = $this->request('POST', 'externalcontact/get_corp_tag_list', []);
|
||||
$groups = [];
|
||||
foreach ((array) ($result['tag_group'] ?? []) as $group) {
|
||||
if (!is_array($group) || !empty($group['deleted'])) {
|
||||
continue;
|
||||
}
|
||||
$tags = [];
|
||||
foreach ((array) ($group['tag'] ?? []) as $tag) {
|
||||
if (is_array($tag) && empty($tag['deleted']) && !empty($tag['id'])) {
|
||||
$tags[] = ['id' => (string) $tag['id'], 'name' => (string) ($tag['name'] ?? '')];
|
||||
}
|
||||
}
|
||||
$groups[] = ['group_id' => (string) ($group['group_id'] ?? ''),
|
||||
'group_name' => (string) ($group['group_name'] ?? ''), 'tag' => $tags];
|
||||
}
|
||||
return ['tag_groups' => $groups];
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义企业客户标签:只写固定分组,先查重;创建结果不确定时只读回,不再次创建。
|
||||
* @return array{tag:array{id:string,name:string},group_id:string,group_name:string,reused:bool}
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92117
|
||||
*/
|
||||
public function createTag(string $name): array
|
||||
{
|
||||
if (!mb_check_encoding($name, 'UTF-8') || preg_match('/[\p{C}\x{2028}\x{2029}]/u', $name)) {
|
||||
throw new RuntimeException('标签名称不能包含控制字符或不可见格式字符');
|
||||
}
|
||||
$name = preg_replace('/^\p{Z}+|\p{Z}+$/u', '', trim($name)) ?? '';
|
||||
if ($name === '' || mb_strlen($name, 'UTF-8') > 30) {
|
||||
throw new RuntimeException('标签名称须为 1-30 个字符');
|
||||
}
|
||||
$groups = $this->tagOptions()['tag_groups'];
|
||||
$existing = $this->findPromotionTag($groups, $name, true);
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
$body = ['tag' => [['name' => $name]]];
|
||||
foreach ($groups as $group) {
|
||||
if (($group['group_name'] ?? '') === self::PROMOTION_TAG_GROUP && ($group['group_id'] ?? '') !== '') {
|
||||
$body['group_id'] = $group['group_id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isset($body['group_id'])) {
|
||||
// 官方保证同名分组存在时向该组添加,不额外创建同名分组;空分组不受支持。
|
||||
$body['group_name'] = self::PROMOTION_TAG_GROUP;
|
||||
}
|
||||
$failure = null;
|
||||
try {
|
||||
$response = $this->request('POST', 'externalcontact/add_corp_tag', $body, true);
|
||||
$created = $this->findPromotionTag([(array) ($response['tag_group'] ?? [])], $name, false);
|
||||
if ($created !== null) {
|
||||
return $created;
|
||||
}
|
||||
} catch (QywxPromotionContactApiException $error) {
|
||||
$failure = $error;
|
||||
}
|
||||
// 同名并发、上游缺失返回ID或网络中断,均只读回一次。永不构造本地伪标签ID。
|
||||
try {
|
||||
$confirmed = $this->findPromotionTag($this->tagOptions()['tag_groups'], $name, true);
|
||||
if ($confirmed !== null) {
|
||||
return $confirmed;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
throw new RuntimeException('标签创建结果无法确认,请刷新标签列表核对,勿重复提交', (int) ($failure?->getCode() ?? 0));
|
||||
}
|
||||
if ($failure !== null && !$failure->uncertain) {
|
||||
throw new RuntimeException('企业微信标签创建失败[' . $failure->getCode() . '],请检查客户联系应用权限或标签额度', $failure->getCode());
|
||||
}
|
||||
throw new RuntimeException('标签创建结果无法确认,请刷新标签列表核对,勿重复提交', (int) ($failure?->getCode() ?? 0));
|
||||
}
|
||||
|
||||
private function findPromotionTag(array $groups, string $name, bool $reused): ?array
|
||||
{
|
||||
foreach ($groups as $group) {
|
||||
if (!is_array($group) || !empty($group['deleted'])
|
||||
|| ($group['group_name'] ?? '') !== self::PROMOTION_TAG_GROUP
|
||||
|| !is_string($group['group_id'] ?? null) || $group['group_id'] === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ((array) ($group['tag'] ?? []) as $tag) {
|
||||
if (is_array($tag) && empty($tag['deleted']) && ($tag['name'] ?? '') === $name
|
||||
&& is_string($tag['id'] ?? null) && $tag['id'] !== '') {
|
||||
return ['tag' => ['id' => $tag['id'], 'name' => $name],
|
||||
'group_id' => $group['group_id'], 'group_name' => self::PROMOTION_TAG_GROUP, 'reused' => $reused];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getExternalContact(string $externalUserId, string $cursor = ''): array
|
||||
{
|
||||
$query = ['external_userid' => $externalUserId];
|
||||
if ($cursor !== '') {
|
||||
$query['cursor'] = $cursor;
|
||||
}
|
||||
return $this->request('GET', 'externalcontact/get', $query);
|
||||
}
|
||||
|
||||
public function getUser(string $userId): array
|
||||
{
|
||||
return $this->request('GET', 'user/get', ['userid' => $userId]);
|
||||
}
|
||||
|
||||
public function markTags(string $userId, string $externalUserId, array $tagIds): void
|
||||
{
|
||||
if ($tagIds === []) {
|
||||
throw new RuntimeException('企业标签不能为空');
|
||||
}
|
||||
$this->request('POST', 'externalcontact/mark_tag', [
|
||||
'userid' => $userId, 'external_userid' => $externalUserId,
|
||||
'add_tag' => array_values(array_unique($tagIds)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function remark(string $userId, string $externalUserId, array $fields): void
|
||||
{
|
||||
$body = ['userid' => $userId, 'external_userid' => $externalUserId];
|
||||
foreach (['remark' => 20, 'description' => 150] as $field => $limit) {
|
||||
if (isset($fields[$field]) && $fields[$field] !== '') {
|
||||
if (!is_string($fields[$field]) || mb_strlen($fields[$field]) > $limit) {
|
||||
throw new RuntimeException('客户备注或描述长度不正确');
|
||||
}
|
||||
$body[$field] = $fields[$field];
|
||||
}
|
||||
}
|
||||
if (count($body) === 2) {
|
||||
throw new RuntimeException('没有启用需要修改的备注字段');
|
||||
}
|
||||
$this->request('POST', 'externalcontact/remark', $body);
|
||||
}
|
||||
|
||||
public function sendWelcome(string $code, string $text, array $attachments): void
|
||||
{
|
||||
if ($code === '' || strlen($code) > 1024 || strlen($text) > 4000
|
||||
|| count($attachments) > 9 || ($text === '' && $attachments === [])) {
|
||||
throw new RuntimeException('欢迎语内容或欢迎码格式不正确');
|
||||
}
|
||||
$body = ['welcome_code' => $code];
|
||||
if ($text !== '') {
|
||||
$body['text'] = ['content' => $text];
|
||||
}
|
||||
if ($attachments !== []) {
|
||||
$body['attachments'] = array_values($attachments);
|
||||
}
|
||||
$this->request('POST', 'externalcontact/send_welcome_msg', $body, true);
|
||||
}
|
||||
|
||||
/** 仅由私有素材服务传入受控文件流,不接受 URL 或请求提供的任意路径。 */
|
||||
public function uploadMedia($stream, string $type, string $filename): array
|
||||
{
|
||||
if (!is_resource($stream) || !in_array($type, ['image', 'video', 'file'], true)) {
|
||||
throw new RuntimeException('临时素材类型或文件流不正确');
|
||||
}
|
||||
return $this->request('POST', 'media/upload', ['type' => $type], false, [
|
||||
'multipart' => [['name' => 'media', 'contents' => Utils::streamFor($stream), 'filename' => $filename]],
|
||||
'timeout' => 45,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 仅明确的 token 失效响应允许重取一次;欢迎语/标签创建的网络异常不能直接重发。 */
|
||||
private function request(string $method, string $path, array $body, bool $nonIdempotent = false, array $extra = [], bool $retried = false): array
|
||||
{
|
||||
$token = $this->accessToken();
|
||||
$options = $extra + ['query' => ['access_token' => $token]];
|
||||
if ($method === 'GET' || isset($extra['multipart'])) {
|
||||
$options['query'] += $body;
|
||||
} else {
|
||||
$options['json'] = $body === [] ? (object) [] : $body;
|
||||
}
|
||||
try {
|
||||
$response = $this->client->request($method, 'cgi-bin/' . $path, $options);
|
||||
} catch (GuzzleException) {
|
||||
throw new QywxPromotionContactApiException('企业微信客户联系接口网络异常', 0, $nonIdempotent);
|
||||
}
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300
|
||||
|| !is_array($decoded) || !array_key_exists('errcode', $decoded)) {
|
||||
// media/upload 成功返回可没有 errcode。
|
||||
if ($path === 'media/upload' && $response->getStatusCode() === 200 && is_array($decoded) && !empty($decoded['media_id'])) {
|
||||
return $decoded;
|
||||
}
|
||||
throw new QywxPromotionContactApiException('企业微信客户联系接口响应无法确认', 0, $nonIdempotent);
|
||||
}
|
||||
$code = (int) $decoded['errcode'];
|
||||
if ($code === 0) {
|
||||
return $decoded;
|
||||
}
|
||||
if (!$retried && in_array($code, [40001, 40014, 42001], true)) {
|
||||
if ($this->tokenResolver === null) {
|
||||
Cache::delete('qywx_promotion_contact_token:' . $this->credentialFingerprint());
|
||||
}
|
||||
if (isset($extra['multipart'])) {
|
||||
$extra['multipart'][0]['contents']->rewind();
|
||||
}
|
||||
return $this->request($method, $path, $body, $nonIdempotent, $extra, true);
|
||||
}
|
||||
// 不回显上游 errmsg;部分错误会包含请求参数与一次性凭证。
|
||||
throw new QywxPromotionContactApiException('企业微信客户联系接口失败[' . $code . ']', $code);
|
||||
}
|
||||
|
||||
private function accessToken(): string
|
||||
{
|
||||
if ($this->tokenResolver !== null) {
|
||||
$token = (string) ($this->tokenResolver)();
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('客户联系托管 token 为空');
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
if ($this->corpId === '' || $this->secret === '') {
|
||||
throw new RuntimeException('请配置客户联系可调用自建应用的 corp_id 和 Secret');
|
||||
}
|
||||
$key = 'qywx_promotion_contact_token:' . $this->credentialFingerprint();
|
||||
$token = (string) Cache::get($key, '');
|
||||
if ($token !== '') {
|
||||
return $token;
|
||||
}
|
||||
try {
|
||||
$response = $this->client->request('GET', 'cgi-bin/gettoken', [
|
||||
'query' => ['corpid' => $this->corpId, 'corpsecret' => $this->secret],
|
||||
]);
|
||||
} catch (GuzzleException) {
|
||||
throw new QywxPromotionContactApiException('获取客户联系 token 网络异常');
|
||||
}
|
||||
$data = json_decode((string) $response->getBody(), true);
|
||||
if ($response->getStatusCode() !== 200 || !is_array($data)
|
||||
|| (int) ($data['errcode'] ?? 0) !== 0 || empty($data['access_token'])) {
|
||||
throw new QywxPromotionContactApiException('获取客户联系 token 失败', (int) ($data['errcode'] ?? 0));
|
||||
}
|
||||
$token = (string) $data['access_token'];
|
||||
Cache::set($key, $token, max(60, (int) ($data['expires_in'] ?? 7200) - 300));
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
class QywxPromotionEnqueueException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
use think\file\UploadedFile;
|
||||
|
||||
/** 私有源文件 + 可刷新三天临时素材。欢迎语关键路径仅使用缓存,不下载/上传文件。 */
|
||||
class QywxPromotionMediaService
|
||||
{
|
||||
private QywxPromotionContactApiService $api;
|
||||
private QywxPromotionMediaStore $store;
|
||||
private string $root;
|
||||
|
||||
public function __construct(?QywxPromotionContactApiService $api = null, ?QywxPromotionMediaStore $store = null, ?string $root = null)
|
||||
{
|
||||
$this->api = $api ?? new QywxPromotionContactApiService();
|
||||
$this->store = $store ?? new QywxPromotionMediaStore();
|
||||
// runtime_path()在adminapi/api/CLI间不同;使用项目级私有目录保证上传与worker共享。
|
||||
$this->root = rtrim($root ?? (root_path('runtime') . 'qywx_promotion_private' . DIRECTORY_SEPARATOR . 'media'), '/\\');
|
||||
}
|
||||
|
||||
/** @return array{asset_id:string,name:string,type:string} */
|
||||
public function upload($file, string $type, int $adminId): array
|
||||
{
|
||||
if ($adminId <= 0 || !$file instanceof UploadedFile || !$file->isValid()) {
|
||||
throw new RuntimeException('请上传有效文件');
|
||||
}
|
||||
if (!in_array($type, ['image', 'video', 'file'], true)) {
|
||||
throw new RuntimeException('素材类型仅支持 image、video、file');
|
||||
}
|
||||
$size = (int) $file->getSize();
|
||||
$limit = ($type === 'file' ? 20 : 10) * 1024 * 1024;
|
||||
if ($size <= 5 || $size > $limit) {
|
||||
throw new RuntimeException($type === 'file' ? '文件须大于5字节且不超过20MB' : '图片/视频须大于5字节且不超过10MB');
|
||||
}
|
||||
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($file->getPathname());
|
||||
$name = str_replace('\\', '/', $file->getOriginalName());
|
||||
$name = mb_substr(preg_replace('/[\x00-\x1f\x7f]/u', '', basename($name)) ?? '', 0, 180);
|
||||
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
if ($type === 'image') {
|
||||
$info = @getimagesize($file->getPathname());
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png'], true) || $info === false
|
||||
|| !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG], true)) {
|
||||
throw new RuntimeException('图片仅支持真实 JPG/PNG 文件');
|
||||
}
|
||||
$extension = $mime === 'image/png' ? 'png' : 'jpg';
|
||||
} elseif ($type === 'video') {
|
||||
if ($mime !== 'video/mp4' || $extension !== 'mp4') {
|
||||
throw new RuntimeException('视频仅支持 MP4');
|
||||
}
|
||||
} else {
|
||||
// 私有存储也拒绝可执行内容/HTML/SVG;按实际 MIME 与扩展名双重检查。
|
||||
$allowed = [
|
||||
'pdf' => ['application/pdf'], 'txt' => ['text/plain'], 'csv' => ['text/plain', 'text/csv', 'application/csv'],
|
||||
'doc' => ['application/msword', 'application/x-ole-storage', 'application/CDFV2'],
|
||||
'xls' => ['application/vnd.ms-excel', 'application/x-ole-storage', 'application/CDFV2'],
|
||||
'ppt' => ['application/vnd.ms-powerpoint', 'application/x-ole-storage', 'application/CDFV2'],
|
||||
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip'],
|
||||
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip'],
|
||||
'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/zip'],
|
||||
'zip' => ['application/zip'], 'jpg' => ['image/jpeg'], 'jpeg' => ['image/jpeg'], 'png' => ['image/png'],
|
||||
'mp4' => ['video/mp4'],
|
||||
];
|
||||
if (!isset($allowed[$extension]) || !in_array($mime, $allowed[$extension], true)) {
|
||||
throw new RuntimeException('不支持该文件格式,请上传PDF、Office、文本、ZIP、JPG/PNG或MP4');
|
||||
}
|
||||
}
|
||||
if ($name === '') {
|
||||
$name = '素材.' . $extension;
|
||||
}
|
||||
$this->ensureRoot();
|
||||
$assetId = bin2hex(random_bytes(24));
|
||||
$storageName = $assetId . '.' . $extension;
|
||||
$hash = hash_file('sha256', $file->getPathname());
|
||||
$file->move($this->root, $storageName);
|
||||
@chmod($this->root . DIRECTORY_SEPARATOR . $storageName, 0600);
|
||||
try {
|
||||
$this->store->insert([
|
||||
'asset_id' => $assetId, 'admin_id' => $adminId, 'name' => $name, 'type' => $type,
|
||||
'mime' => $mime, 'size' => $size, 'sha256' => $hash, 'storage_name' => $storageName,
|
||||
'media_id' => '', 'media_expires_at' => 0, 'credential_hash' => '',
|
||||
'last_error' => '', 'create_time' => time(), 'update_time' => time(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($this->root . DIRECTORY_SEPARATOR . $storageName);
|
||||
throw new RuntimeException('素材入库失败,请确认已安装推广自动化数据表', 0, $e);
|
||||
}
|
||||
// 配置阶段就上传企微素材。失败保留私有文件供后续排障,不对外提供文件路径。
|
||||
$this->mediaId($assetId, $type, true);
|
||||
return ['asset_id' => $assetId, 'name' => $name, 'type' => $type];
|
||||
}
|
||||
|
||||
/** 旧方案授权由上层完成;只白名单旧配置实际已有资产,不接受请求单独声明的白名单。 */
|
||||
public function validateConfig(array $config, int $adminId, array $existingConfig = []): array
|
||||
{
|
||||
$allowed = self::assetIds($existingConfig);
|
||||
$config['welcome']['attachments'] = $this->validateAttachments((array) ($config['welcome']['attachments'] ?? []), $adminId, $allowed);
|
||||
foreach ((array) ($config['welcome_schedule'] ?? []) as $index => $slot) {
|
||||
$config['welcome_schedule'][$index]['attachments'] = $this->validateAttachments((array) ($slot['attachments'] ?? []), $adminId, $allowed);
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
public function validateAttachments(array $attachments, int $adminId, array $allowedAssetIds = []): array
|
||||
{
|
||||
if (count($attachments) > 9) {
|
||||
throw new RuntimeException('欢迎语最多9个附件');
|
||||
}
|
||||
$clean = [];
|
||||
foreach ($attachments as $attachment) {
|
||||
if (!is_array($attachment)) {
|
||||
throw new RuntimeException('附件格式不正确');
|
||||
}
|
||||
$type = (string) ($attachment['msgtype'] ?? '');
|
||||
$body = $attachment[$type] ?? null;
|
||||
if (!is_array($body)) {
|
||||
throw new RuntimeException('附件内容类型不匹配');
|
||||
}
|
||||
if (in_array($type, ['image', 'video', 'file'], true)) {
|
||||
// image.pic_url 限企微 uploadimg URL;本服务仅接受私有资产,避免伪装任意外部地址。
|
||||
$asset = $this->authorizedAsset((string) ($body['asset_id'] ?? ''), $type, $adminId, $allowedAssetIds);
|
||||
$body = ['asset_id' => $asset['asset_id']];
|
||||
} elseif ($type === 'link') {
|
||||
$body = [
|
||||
'title' => self::bytes($body['title'] ?? '', 128, '链接标题', true),
|
||||
'url' => self::url($body['url'] ?? ''),
|
||||
'desc' => self::bytes($body['desc'] ?? '', 512, '链接描述'),
|
||||
] + (!empty($body['picurl']) ? ['picurl' => self::url($body['picurl'])] : []);
|
||||
} elseif ($type === 'miniprogram') {
|
||||
$asset = $this->authorizedAsset((string) ($body['pic_asset_id'] ?? ''), 'image', $adminId, $allowedAssetIds);
|
||||
$appid = (string) ($body['appid'] ?? '');
|
||||
$page = self::bytes($body['page'] ?? '', 1024, '小程序页面', true);
|
||||
if (!preg_match('/^wx[0-9a-fA-F]{16}$/', $appid) || str_contains($page, '://')
|
||||
|| str_contains($page, '..') || preg_match('/[\x00-\x1f]/', $page)) {
|
||||
throw new RuntimeException('小程序 appid 或页面路径不正确');
|
||||
}
|
||||
$body = ['title' => self::bytes($body['title'] ?? '', 64, '小程序标题', true),
|
||||
'appid' => $appid, 'page' => $page, 'pic_asset_id' => $asset['asset_id']];
|
||||
} else {
|
||||
throw new RuntimeException('不支持的附件类型');
|
||||
}
|
||||
$clean[] = ['msgtype' => $type, $type => $body];
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/** 仅处理已授权并持久化的配置快照;绝不在欢迎语发送时进行网络文件上传。 */
|
||||
public function materialize(array $attachments, array $config): array
|
||||
{
|
||||
$attachments = $this->validateAttachments($attachments, 0, self::assetIds($config));
|
||||
foreach ($attachments as &$attachment) {
|
||||
$type = $attachment['msgtype'];
|
||||
if (in_array($type, ['image', 'video', 'file'], true)) {
|
||||
$attachment[$type] = ['media_id' => $this->mediaId($attachment[$type]['asset_id'], $type, false)];
|
||||
} elseif ($type === 'miniprogram') {
|
||||
$attachment[$type]['pic_media_id'] = $this->mediaId($attachment[$type]['pic_asset_id'], 'image', false);
|
||||
unset($attachment[$type]['pic_asset_id']);
|
||||
}
|
||||
}
|
||||
unset($attachment);
|
||||
return $attachments;
|
||||
}
|
||||
|
||||
public function refreshReferenced(int $limit = 100): array
|
||||
{
|
||||
$result = ['selected' => 0, 'refreshed' => 0, 'failed' => 0];
|
||||
foreach ($this->store->referencedAssetIds() as $id) {
|
||||
$asset = $this->store->find($id);
|
||||
if (!$asset || ($this->cacheValid($asset, 3600))) {
|
||||
continue;
|
||||
}
|
||||
if ($result['selected'] >= max(1, $limit)) {
|
||||
break;
|
||||
}
|
||||
++$result['selected'];
|
||||
try {
|
||||
$this->mediaId($id, $asset['type'], true, 3600);
|
||||
++$result['refreshed'];
|
||||
} catch (\Throwable) {
|
||||
++$result['failed'];
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function assetIds(array $config): array
|
||||
{
|
||||
$ids = [];
|
||||
$messages = array_merge([(array) ($config['welcome'] ?? [])], (array) ($config['welcome_schedule'] ?? []));
|
||||
foreach ($messages as $message) {
|
||||
foreach ((array) ($message['attachments'] ?? []) as $attachment) {
|
||||
$type = $attachment['msgtype'] ?? '';
|
||||
$key = $type === 'miniprogram' ? 'pic_asset_id' : 'asset_id';
|
||||
$id = (string) ($attachment[$type][$key] ?? '');
|
||||
if (preg_match('/^[0-9a-f]{48}$/', $id)) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
private function authorizedAsset(string $id, string $type, int $adminId, array $allowed): array
|
||||
{
|
||||
if (!preg_match('/^[0-9a-f]{48}$/', $id)) {
|
||||
throw new RuntimeException('请先上传欢迎语素材');
|
||||
}
|
||||
$asset = $this->store->find($id);
|
||||
if (!$asset || $asset['type'] !== $type || ((int) $asset['admin_id'] !== $adminId && !in_array($id, $allowed, true))) {
|
||||
throw new RuntimeException('素材不存在、类型不匹配或无权使用');
|
||||
}
|
||||
return $asset;
|
||||
}
|
||||
|
||||
private function mediaId(string $id, string $type, bool $allowUpload, int $margin = 300): string
|
||||
{
|
||||
$asset = $this->store->find($id);
|
||||
if (!$asset || $asset['type'] !== $type) {
|
||||
throw new RuntimeException('欢迎语素材不存在');
|
||||
}
|
||||
if ($this->cacheValid($asset, $margin)) {
|
||||
return (string) $asset['media_id'];
|
||||
}
|
||||
if (!$allowUpload) {
|
||||
throw new RuntimeException('欢迎语素材未预热或已过期,请检查素材刷新任务');
|
||||
}
|
||||
$stream = null;
|
||||
try {
|
||||
$path = $this->privatePath((string) $asset['storage_name']);
|
||||
if (!is_file($path) || hash_file('sha256', $path) !== $asset['sha256']) {
|
||||
throw new RuntimeException('欢迎语源文件缺失或完整性检查失败');
|
||||
}
|
||||
$stream = fopen($path, 'rb');
|
||||
$result = $this->api->uploadMedia($stream, $type, (string) $asset['name']);
|
||||
if (empty($result['media_id'])) {
|
||||
throw new RuntimeException('企微素材接口未返回 media_id');
|
||||
}
|
||||
$created = min(time(), (int) ($result['created_at'] ?? time()));
|
||||
$this->store->update($id, ['media_id' => (string) $result['media_id'],
|
||||
'media_expires_at' => $created + 3 * 86400, 'credential_hash' => $this->api->credentialFingerprint(),
|
||||
'last_error' => '', 'update_time' => time()]);
|
||||
return (string) $result['media_id'];
|
||||
} catch (\Throwable $e) {
|
||||
$this->store->update($id, ['last_error' => '素材预热失败[' . (int) $e->getCode() . ']', 'update_time' => time()]);
|
||||
throw $e;
|
||||
} finally {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function cacheValid(array $asset, int $margin): bool
|
||||
{
|
||||
return !empty($asset['media_id']) && (int) $asset['media_expires_at'] > time() + $margin
|
||||
&& hash_equals((string) $asset['credential_hash'], $this->api->credentialFingerprint());
|
||||
}
|
||||
|
||||
private function privatePath(string $name): string
|
||||
{
|
||||
if (!preg_match('/^[0-9a-f]{48}\.[a-z0-9]{1,8}$/', $name)) {
|
||||
throw new RuntimeException('素材存储标识不正确');
|
||||
}
|
||||
$root = realpath($this->root);
|
||||
$path = realpath($this->root . DIRECTORY_SEPARATOR . $name);
|
||||
if ($root === false || $path === false || !str_starts_with($path, $root . DIRECTORY_SEPARATOR)) {
|
||||
throw new RuntimeException('素材文件不在私有存储目录');
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function ensureRoot(): void
|
||||
{
|
||||
if (!is_dir($this->root) && !mkdir($this->root, 0700, true) && !is_dir($this->root)) {
|
||||
throw new RuntimeException('无法创建私有素材目录');
|
||||
}
|
||||
}
|
||||
|
||||
private static function bytes(mixed $value, int $limit, string $label, bool $required = false): string
|
||||
{
|
||||
if (!is_string($value) || strlen($value) > $limit || ($required && trim($value) === '')) {
|
||||
throw new RuntimeException($label . '须' . ($required ? '非空且' : '') . '不超过' . $limit . '字节');
|
||||
}
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
private static function url(mixed $value): string
|
||||
{
|
||||
if (!is_string($value) || strlen($value) > 2048 || filter_var($value, FILTER_VALIDATE_URL) === false) {
|
||||
throw new RuntimeException('链接地址不正确');
|
||||
}
|
||||
$parts = parse_url($value);
|
||||
if (!in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)
|
||||
|| isset($parts['user']) || isset($parts['pass'])) {
|
||||
throw new RuntimeException('链接仅支持不含账号密码的HTTP(S)地址');
|
||||
}
|
||||
// 仅向企微传递链接;服务端永远不会抓取这些URL。
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/** 独立存储边界,测试可使用内存替身,禁止连接业务数据库。 */
|
||||
class QywxPromotionMediaStore
|
||||
{
|
||||
public function find(string $assetId): ?array
|
||||
{
|
||||
return Db::name('qywx_promotion_media')->where('asset_id', $assetId)->find() ?: null;
|
||||
}
|
||||
|
||||
public function insert(array $row): void
|
||||
{
|
||||
Db::name('qywx_promotion_media')->insert($row);
|
||||
}
|
||||
|
||||
public function update(string $assetId, array $fields): void
|
||||
{
|
||||
Db::name('qywx_promotion_media')->where('asset_id', $assetId)->update($fields);
|
||||
}
|
||||
|
||||
/** 只预热已保存方案引用的素材;未使用上传不永久续期。 */
|
||||
public function referencedAssetIds(): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach (Db::name('qywx_promotion_config')->alias('cfg')
|
||||
->join('qywx_promotion_pool pool', 'pool.id = cfg.pool_id')
|
||||
->whereNull('pool.delete_time')->column('cfg.config_json') as $json) {
|
||||
$ids = array_merge($ids, QywxPromotionMediaService::assetIds(QywxPromotionConfig::decode($json)));
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,22 @@ class QywxPromotionMemberRange
|
||||
* @param list<array<string,mixed>> $members
|
||||
* @return array{userids:list<string>,members:list<array<string,mixed>>,eligible_count:int}
|
||||
*/
|
||||
public static function evaluate(array $members, string $today, int $now): array
|
||||
public static function evaluate(array $members, string $today, int $now, array $config = []): array
|
||||
{
|
||||
$userIds = [];
|
||||
$backups = array_fill_keys((array) ($config['backup_userids'] ?? []), true);
|
||||
$backupIds = [];
|
||||
$scheduled = ($config['reception_mode'] ?? 'always') === 'scheduled';
|
||||
$scheduledUsers = [];
|
||||
if ($scheduled) {
|
||||
foreach ((array) ($config['reception_schedule'] ?? []) as $slot) {
|
||||
if (QywxPromotionConfig::matches($slot, $now)) {
|
||||
foreach ((array) ($slot['member_userids'] ?? []) as $userId) {
|
||||
$scheduledUsers[$userId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($members as &$member) {
|
||||
if ((string) ($member['today_date'] ?? '') !== $today) {
|
||||
$member['today_date'] = $today;
|
||||
@@ -25,15 +38,24 @@ class QywxPromotionMemberRange
|
||||
}
|
||||
$userId = trim((string) ($member['userid'] ?? ''));
|
||||
if ($userId !== '') {
|
||||
$userIds[$userId] = true;
|
||||
if (isset($backups[$userId])) {
|
||||
$backupIds[$userId] = true;
|
||||
} elseif (!$scheduled || isset($scheduledUsers[$userId])) {
|
||||
$userIds[$userId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($member);
|
||||
|
||||
$usingBackup = $userIds === [] && $backupIds !== [];
|
||||
if ($usingBackup) {
|
||||
$userIds = $backupIds;
|
||||
}
|
||||
return [
|
||||
'userids' => array_keys($userIds),
|
||||
'members' => array_values($members),
|
||||
'eligible_count' => count($userIds),
|
||||
'using_backup' => $usingBackup,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ class QywxPromotionMemberSchedulerService
|
||||
string $today,
|
||||
int $now
|
||||
): array {
|
||||
$range = QywxPromotionMemberRange::evaluate($members, $today, $now);
|
||||
$range = QywxPromotionMemberRange::evaluate($members, $today, $now, QywxPromotionConfig::forPool($poolId));
|
||||
self::persistMemberCursors($range['members'], $now);
|
||||
if ($range['userids'] === []) {
|
||||
self::upsertSync($poolId, $linkId, false, $sync, $now, '所有成员均已禁用、未生效或达到今日上限');
|
||||
|
||||
@@ -66,7 +66,7 @@ class QywxPromotionRangeSyncService
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('官方链接 ID 为空');
|
||||
}
|
||||
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time());
|
||||
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), QywxPromotionConfig::forPool($poolId));
|
||||
$desiredUserIds = $range['userids'];
|
||||
if ($desiredUserIds === []) {
|
||||
$message = '所有成员均已禁用、未生效或达到今日上限;企业微信官方链接至少需要保留一名成员';
|
||||
@@ -93,7 +93,7 @@ class QywxPromotionRangeSyncService
|
||||
$this->api->updateLink([
|
||||
'link_id' => $remoteLinkId,
|
||||
'link_name' => mb_substr((string) ($pool['name'] ?? '获客分流方案'), 0, 30),
|
||||
'range' => ['user_list' => $desiredUserIds],
|
||||
'range' => ['user_list' => $desiredUserIds, 'department_list' => []],
|
||||
'skip_verify' => (int) ($link['skip_verify'] ?? 0) === 1,
|
||||
]);
|
||||
$response = $this->api->getLink($remoteLinkId);
|
||||
|
||||
@@ -34,6 +34,9 @@ return [
|
||||
'qywx:retry-customer-acquisition-events' => 'app\\command\\QywxRetryCustomerAcquisitionEvents',
|
||||
// 回调确认实际承接成员后,按权重/上限切换同一条官方获客链接的成员范围
|
||||
'qywx:sync-promotion-ranges' => 'app\\command\\QywxSyncPromotionRanges',
|
||||
'qywx:work-promotion-automation' => 'app\\command\\QywxWorkPromotionAutomation',
|
||||
'qywx:retry-promotion-automation' => 'app\\command\\QywxRetryPromotionAutomation',
|
||||
'qywx:refresh-promotion-media' => 'app\\command\\QywxRefreshPromotionMedia',
|
||||
// 甘草订单物流路由同步(GET_TASK_ROUTE_LIST)
|
||||
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
|
||||
'ej-pharmacy:sync-catalog' => 'app\\command\\EjPharmacySyncCatalog',
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// 缺省使用获客助手相同的可调用自建应用,绝不回退对外收款 Secret。
|
||||
'contact_secret' => env('WECHAT_WORK_PROMOTION_CONTACT_SECRET', ''),
|
||||
// 至少32字符的随机值;多节点必须使用同一密钥。缺省在私有runtime目录生成0600密钥。
|
||||
'encryption_key' => env('WECHAT_WORK_PROMOTION_ENCRYPTION_KEY', ''),
|
||||
];
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import t from"./error-D-uPyFBJ.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-Dwh6tNxD.js";import"./index-a_ZxLOOo.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
import t from"./error-abZoCXdu.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-BSw4l71J.js";import"./index-BWlhxa68.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import e from"./error-D-uPyFBJ.js";import{o,q as r,r as t,v as s}from"./.pnpm-Dwh6tNxD.js";import"./index-a_ZxLOOo.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
import e from"./error-abZoCXdu.js";import{o,q as r,r as t,v as s}from"./.pnpm-BSw4l71J.js";import"./index-BWlhxa68.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-Dwh6tNxD.js";import{a as V}from"./doctor-DQJF3mgr.js";import{m as A,_ as M}from"./index-a_ZxLOOo.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-BSw4l71J.js";import{a as V}from"./doctor-DBWxvtwh.js";import{m as A,_ as M}from"./index-BWlhxa68.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-Dwh6tNxD.js";import{ae as V}from"./tcm--WHZCSMq.js";import{_ as q}from"./index-a_ZxLOOo.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-BSw4l71J.js";import{af as V}from"./tcm-Bv_Ly0A0.js";import{_ as q}from"./index-BWlhxa68.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,dg as c}from"./.pnpm-Dwh6tNxD.js";import{af as Y}from"./tcm--WHZCSMq.js";import{_ as q}from"./index-a_ZxLOOo.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function M(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:M},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,di as c}from"./.pnpm-BSw4l71J.js";import{ag as Y}from"./tcm-Bv_Ly0A0.js";import{_ as q}from"./index-BWlhxa68.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function M(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:M},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as N,di as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as j,T as u,s as y,bi as z,M as v}from"./.pnpm-Dwh6tNxD.js";import M from"./RecordingPlaybackBlock-B2R-2s7-.js";import{U as k}from"./index-B8LZ-HCp.js";import{i as c,_ as q}from"./index-a_ZxLOOo.js";import{aj as K,ak as x,al as A}from"./tcm--WHZCSMq.js";import"./RecordingVideoPlayer-Xhx3gz5-.js";import"./file-DWoFbTjb.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await x({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await x({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=j,I=L,B=z;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(k,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(M,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(k,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
import{o as N,dk as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as z,T as u,s as y,bi as M,M as v}from"./.pnpm-BSw4l71J.js";import j from"./RecordingPlaybackBlock-Ci19TbAl.js";import{U as k}from"./index-TisaJaAB.js";import{i as c,_ as q}from"./index-BWlhxa68.js";import{ak as K,al as x,am as A}from"./tcm-Bv_Ly0A0.js";import"./RecordingVideoPlayer-fgB7SxV4.js";import"./file-BXk5F0Ys.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await x({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await x({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(k,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(k,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-Dwh6tNxD.js";import{am as q}from"./tcm--WHZCSMq.js";import{_ as H}from"./index-a_ZxLOOo.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-BSw4l71J.js";import{an as q}from"./tcm-Bv_Ly0A0.js";import{_ as H}from"./index-BWlhxa68.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-DH8EYYzR.js";import"./.pnpm-Dwh6tNxD.js";import"./tcm--WHZCSMq.js";import"./index-a_ZxLOOo.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-Dkug3Ge4.js";import"./.pnpm-BSw4l71J.js";import"./tcm-Bv_Ly0A0.js";import"./index-BWlhxa68.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-Dwh6tNxD.js";import{p as j}from"./tcm--WHZCSMq.js";import{i as C}from"./index-a_ZxLOOo.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-BSw4l71J.js";import{p as j}from"./tcm-Bv_Ly0A0.js";import{i as C}from"./index-BWlhxa68.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cV as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as se,a8 as ae,E as C}from"./.pnpm-Dwh6tNxD.js";import{d as te}from"./dayjs-Cbxn44tS.js";import{ar as ne,as as oe}from"./tcm--WHZCSMq.js";import{p as re}from"./im-business-message-parse-CVnz1EnV.js";import{_ as le}from"./index-a_ZxLOOo.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,a)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=se,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),a[1]||(a[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),a[2]||(a[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,s=>(t(),n("div",{key:s.raw.msg_id,class:X(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(V,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(Y,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cX as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as X,M as m,p as Q,ae as U,T as r,br as Z,aa as ee,bq as se,a8 as ae,E as C}from"./.pnpm-BSw4l71J.js";import{d as te}from"./dayjs-CVa8MSSA.js";import{as as ne,at as oe}from"./tcm-Bv_Ly0A0.js";import{p as re}from"./im-business-message-parse-oYIP1khU.js";import{_ as le}from"./index-BWlhxa68.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=Q(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,a)=>{const l=G,k=H,I=j,T=Z,Y=ee,V=se,z=W,A=X;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),a[1]||(a[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),a[2]||(a[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,s=>(t(),n("div",{key:s.raw.msg_id,class:U(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(Y,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(V,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-B_HCfW5z.js";import"./.pnpm-Dwh6tNxD.js";export{m as default};
|
||||
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-CsbRGLkf.js";import"./.pnpm-BSw4l71J.js";export{m as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-Dwh6tNxD.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
|
||||
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-BSw4l71J.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-Dwh6tNxD.js";import{t as j,_ as J}from"./index-a_ZxLOOo.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-BSw4l71J.js";import{t as j,_ as J}from"./index-BWlhxa68.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d7 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-Dwh6tNxD.js";import{_ as fe}from"./picker-tazliPuC.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-a_ZxLOOo.js";import{a as T,d as he}from"./patient-DiY8uj2P.js";import{h as ke}from"./perm-tSma3sWL.js";import"./index-CJn0zdCA.js";import"./index-DFvgqKH8.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./index-BSwVDYZd.js";import"./index-B8LZ-HCp.js";import"./file-DWoFbTjb.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./usePaging-DOuAwzL9.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d9 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-BSw4l71J.js";import{_ as fe}from"./picker-C_3iViNJ.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-BWlhxa68.js";import{a as T,d as he}from"./patient-SnE6JXh9.js";import{h as ke}from"./perm-BdlAVcmi.js";import"./index-IBEgpZdk.js";import"./index-PArzJ7v1.js";import"./index.vue_vue_type_script_setup_true_lang-BOJpu9c9.js";import"./index-Du0eYB29.js";import"./index-TisaJaAB.js";import"./file-BXk5F0Ys.js";import"./index.vue_vue_type_script_setup_true_lang-D3ZtXZaD.js";import"./usePaging-BeGcb2kN.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
`).filter(Boolean):[],Q=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const i=e.slice(y.value);y.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,tongue_images:i}).then(()=>{f.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const i=e.slice(h.value);h.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,report_files:i}).then(()=>{f.msgSuccess("检查报告已添加"),I("refresh")})};oe(()=>c.notes,()=>{S.value=[],D.value=[],y.value=0,h.value=0});const ee=async()=>{if(!c.diagnosisId){f.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){f.msgWarning("请输入备注内容");return}N.value=!0;try{await T({diagnosis_id:c.diagnosisId,content:t}),f.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){f.msgError((e==null?void 0:e.message)||"保存失败")}finally{N.value=!1}},A=async(t,e,i)=>{try{await ge.confirm("确认删除?","提示",{type:"warning"})}catch{return}await he({note_id:t,image_type:e,image_path:i}),f.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const i=le,b=_e,F=fe,L=me,x=ce,te=ae,se=re,ne=de;return n(),o("div",we,[!u.readonly&&u.diagnosisId?(n(),o("div",Ie,[X.value?(n(),k(i,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=s=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[P(" 添加备注 ",-1)])]),_:1})):m("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=s=>S.value=s),limit:99,type:"image","exclude-domain":!0,onChange:Q},{upload:r(()=>[d("div",Ce,[l(b,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=d("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:D.value,"onUpdate:modelValue":e[2]||(e[2]=s=>D.value=s),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[d("div",be,[l(b,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=d("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):m("",!0),u.notes.length?(n(),o("div",xe,[(n(!0),o(w,null,E(u.notes,s=>{var R,G;return n(),o("div",{key:s.id,class:"timeline-node"},[e[11]||(e[11]=d("div",{class:"timeline-dot"},null,-1)),d("div",Ee,U(s.note_date),1),d("div",Ve,[s.content?(n(),o("div",Ne,[(n(!0),o(w,null,E(J(s.content),(a,p)=>(n(),o("div",{key:p,class:"content-line"},U(a),1))),128))])):m("",!0),(R=s.tongue_images)!=null&&R.length?(n(),o("div",Se,[e[9]||(e[9]=d("span",{class:"images-label"},"舌苔照片",-1)),(n(!0),o(w,null,E(s.tongue_images,(a,p)=>(n(),o("div",{key:p,class:"thumb-wrap"},[l(L,{src:_(a),"preview-src-list":s.tongue_images.map(_),"initial-index":p,"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"tongue_images",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))),128))])):m("",!0),(G=s.report_files)!=null&&G.length?(n(),o("div",De,[e[10]||(e[10]=d("span",{class:"images-label"},"检查报告",-1)),(n(!0),o(w,null,E(s.report_files,(a,p)=>(n(),o(w,{key:p},[B(a)?(n(),o("div",Be,[l(L,{src:_(a),"preview-src-list":K(s.report_files),"initial-index":Z(s.report_files,p),"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))])):(n(),o("div",Ae,[d("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(x,{size:20},{default:r(()=>[l(V(pe))]),_:1}),d("span",Ue,U($(a)),1)],8,Pe),u.readonly?m("",!0):(n(),k(x,{key:0,class:"file-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))],64))),128))])):m("",!0)])])}),128))])):m("",!0),!u.notes.length&&u.readonly?(n(),k(te,{key:2,description:"暂无备注","image-size":48})):m("",!0),l(ne,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=s=>v.value=s),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(i,{onClick:e[4]||(e[4]=s=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[P("取消",-1)])]),_:1}),l(i,{type:"primary",loading:N.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[P("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(se,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=s=>C.value=s),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Ke=ye(ze,[["__scopeId","data-v-530ad386"]]);export{Ke as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-Dwh6tNxD.js";import{_ as V}from"./index-a_ZxLOOo.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
|
||||
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-BSw4l71J.js";import{_ as V}from"./index-BWlhxa68.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
@charset "UTF-8";.po-detail-drawer[data-v-3fe6d04f] .el-drawer__header{margin-bottom:0;padding:16px 24px;border-bottom:1px solid var(--el-border-color-lighter)}.stat-card[data-v-3fe6d04f]{border-radius:8px}.stat-title[data-v-3fe6d04f]{font-weight:500}.po-panel[data-v-3fe6d04f]{border-radius:8px;transition:all .3s}.po-panel[data-v-3fe6d04f] .el-card__header{padding:14px 16px;background-color:var(--el-bg-color-page);border-bottom:1px solid var(--el-border-color-lighter)}.po-panel[data-v-3fe6d04f] .el-card__body{padding:16px}.po-desc[data-v-3fe6d04f] .el-descriptions__label{width:120px;color:var(--el-text-color-regular)}.po-audit-remark[data-v-3fe6d04f]{color:var(--el-color-danger);font-weight:600;white-space:pre-wrap;word-break:break-word}.audit-stamp[data-v-3fe6d04f]{position:absolute;top:18px;right:-14px;width:72px;height:72px;border:3px solid currentColor;border-radius:50%;display:flex;align-items:center;justify-content:center;transform:rotate(20deg);opacity:.8;pointer-events:none;z-index:10;-webkit-user-select:none;-moz-user-select:none;user-select:none;font-weight:700;font-size:13px;letter-spacing:1px;box-shadow:inset 0 0 0 1px #ffffff80}.audit-stamp[data-v-3fe6d04f]:after{content:"";position:absolute;top:4px;left:4px;right:4px;bottom:4px;border:1px double currentColor;border-radius:50%;opacity:.6}.audit-stamp .stamp-inner[data-v-3fe6d04f]{text-align:center;line-height:1.1}.stamp-pass[data-v-3fe6d04f]{color:var(--el-color-success)}.stamp-reject[data-v-3fe6d04f]{color:var(--el-color-danger)}.po-diagnosis-creator-dept-breadcrumb[data-v-3fe6d04f] .el-breadcrumb__item{display:inline-flex;float:none}.po-diagnosis-creator-dept-breadcrumb[data-v-3fe6d04f] .el-breadcrumb__separator{margin:0 2px 0 4px}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
@charset "UTF-8";.po-detail-drawer[data-v-04a9fc6d] .el-drawer__header{margin-bottom:0;padding:16px 24px;border-bottom:1px solid var(--el-border-color-lighter)}.stat-card[data-v-04a9fc6d]{border-radius:8px}.stat-title[data-v-04a9fc6d]{font-weight:500}.po-panel[data-v-04a9fc6d]{border-radius:8px;transition:all .3s}.po-panel[data-v-04a9fc6d] .el-card__header{padding:14px 16px;background-color:var(--el-bg-color-page);border-bottom:1px solid var(--el-border-color-lighter)}.po-panel[data-v-04a9fc6d] .el-card__body{padding:16px}.po-desc[data-v-04a9fc6d] .el-descriptions__label{width:120px;color:var(--el-text-color-regular)}.po-audit-remark[data-v-04a9fc6d]{color:var(--el-color-danger);font-weight:600;white-space:pre-wrap;word-break:break-word}.audit-stamp[data-v-04a9fc6d]{position:absolute;top:18px;right:-14px;width:72px;height:72px;border:3px solid currentColor;border-radius:50%;display:flex;align-items:center;justify-content:center;transform:rotate(20deg);opacity:.8;pointer-events:none;z-index:10;-webkit-user-select:none;-moz-user-select:none;user-select:none;font-weight:700;font-size:13px;letter-spacing:1px;box-shadow:inset 0 0 0 1px #ffffff80}.audit-stamp[data-v-04a9fc6d]:after{content:"";position:absolute;top:4px;left:4px;right:4px;bottom:4px;border:1px double currentColor;border-radius:50%;opacity:.6}.audit-stamp .stamp-inner[data-v-04a9fc6d]{text-align:center;line-height:1.1}.stamp-pass[data-v-04a9fc6d]{color:var(--el-color-success)}.stamp-reject[data-v-04a9fc6d]{color:var(--el-color-danger)}.po-diagnosis-creator-dept-breadcrumb[data-v-04a9fc6d] .el-breadcrumb__item{display:inline-flex;float:none}.po-diagnosis-creator-dept-breadcrumb[data-v-04a9fc6d] .el-breadcrumb__separator{margin:0 2px 0 4px}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.automation-form[data-v-aad1420d]{width:100%}.automation-note[data-v-aad1420d]{margin-top:22px}.automation-note[data-v-aad1420d] .el-alert__description{line-height:1.7}.form-section-title[data-v-aad1420d]{margin:28px 0 18px;padding:0 0 12px;border-bottom:1px solid #ebeef5;font-size:15px;font-weight:600;color:#303133}.field-help[data-v-aad1420d]{width:100%;font-size:12px;line-height:1.7;margin:6px 0 0;color:#909399}.warning-help[data-v-aad1420d]{color:#9f6d14}.full-width[data-v-aad1420d]{width:100%}.inline-error[data-v-aad1420d]{width:100%;color:#d93026;font-size:12px;line-height:1.7;margin:8px 0 0}.schedule-card[data-v-aad1420d]{padding:16px;border:1px solid #e4e7ed;border-radius:6px;background:#fafbfd;margin-bottom:12px}.schedule-heading[data-v-aad1420d]{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;font-size:13px}.weekday-select[data-v-aad1420d]{display:flex;flex-wrap:wrap;gap:0 18px}.weekday-select[data-v-aad1420d] .el-checkbox{margin-right:0}.time-row[data-v-aad1420d]{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:12px 0}.time-row[data-v-aad1420d] .el-date-editor.el-input{width:150px}.time-row>span[data-v-aad1420d]{font-size:12px;color:#909399}.time-row>small[data-v-aad1420d]{font-size:12px;color:#b88230}.reception-schedules[data-v-aad1420d]{margin:0 0 20px}.tags-content[data-v-aad1420d],.remark-content[data-v-aad1420d],.description-input[data-v-aad1420d]{margin-top:12px}.tag-select-row[data-v-aad1420d]{display:flex;gap:10px;width:100%}.tag-select[data-v-aad1420d]{flex:1;min-width:0}.token-buttons[data-v-aad1420d]{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px}.token-buttons .el-button+.el-button[data-v-aad1420d]{margin-left:0}.remark-preview[data-v-aad1420d]{display:flex;gap:14px;align-items:center;padding:10px 12px;background:#f5f7fa;margin-top:8px;border-radius:4px;line-height:1.7}.remark-preview span[data-v-aad1420d],.remark-preview small[data-v-aad1420d]{color:#909399;font-size:12px}.remark-preview strong[data-v-aad1420d]{color:#303133;font-size:13px;font-weight:500;overflow-wrap:anywhere}.remark-preview small[data-v-aad1420d]{margin-left:auto;white-space:nowrap}.welcome-block h4[data-v-aad1420d]{font-size:13px;font-weight:600;margin:0 0 4px}.welcome-block>.field-help[data-v-aad1420d]{margin-bottom:12px}.schedule-switch[data-v-aad1420d]{margin-top:24px}.switch-help[data-v-aad1420d]{margin-left:12px;color:#909399;font-size:12px}.welcome-schedule[data-v-aad1420d]{background:#fff}.tag-select-row .el-button+.el-button[data-v-aad1420d]{margin-left:0}.custom-tag-editor[data-v-aad1420d]{margin-top:12px;padding:14px;background:#f5f7fa;border:1px solid #e4e7ed;border-radius:4px}.custom-tag-editor label[data-v-aad1420d]{display:block;font-size:13px;color:#606266;margin-bottom:8px}.custom-tag-row[data-v-aad1420d]{display:flex;align-items:center;gap:10px}.custom-tag-row .el-input[data-v-aad1420d]{flex:1;min-width:0}.legacy-tags-warning[data-v-aad1420d]{margin-top:10px;padding:10px 12px;background:#fdf6ec;border:1px solid #faecd8;border-radius:4px;color:#9f6d14}.legacy-tags-warning p[data-v-aad1420d]{margin:0 0 6px;font-size:12px;line-height:1.7;overflow-wrap:anywhere}.tag-success[data-v-aad1420d]{margin:8px 0 0;color:#27864c;font-size:12px;line-height:1.7}@media (max-width: 620px){.tag-select-row[data-v-aad1420d]{flex-direction:column}.remark-preview[data-v-aad1420d]{flex-wrap:wrap}.automation-form[data-v-aad1420d] .el-radio{margin-right:14px}.weekday-select[data-v-aad1420d]{gap:0 12px}}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-Dwh6tNxD.js";import H from"./RecordingVideoPlayer-Xhx3gz5-.js";import{e as I,_ as P}from"./index-a_ZxLOOo.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
|
||||
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-BSw4l71J.js";import H from"./RecordingVideoPlayer-fgB7SxV4.js";import{e as I,_ as P}from"./index-BWlhxa68.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-Dwh6tNxD.js","assets/.pnpm-B3v8nGpq.css"])))=>i.map(i=>d[i]);
|
||||
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-Dwh6tNxD.js";import{e as ae,_ as ne}from"./index-a_ZxLOOo.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?N(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function N(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function U(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-Dwh6tNxD.js").then(M=>M.dN),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function C(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{C()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:U},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-BSw4l71J.js","assets/.pnpm-B3v8nGpq.css"])))=>i.map(i=>d[i]);
|
||||
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-BSw4l71J.js";import{e as ae,_ as ne}from"./index-BWlhxa68.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?U(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function U(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function C(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-BSw4l71J.js").then(M=>M.dP),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function N(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{N()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:C},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,2 +1,2 @@
|
||||
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-Dwh6tNxD.js";import{a4 as L}from"./tcm--WHZCSMq.js";import{i as M,_ as S}from"./index-a_ZxLOOo.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
|
||||
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-BSw4l71J.js";import{a5 as L}from"./tcm-Bv_Ly0A0.js";import{i as M,_ as S}from"./index-BWlhxa68.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
|
||||
`).filter(Boolean):[],k=async()=>{if(!c.diagnosisId)return;const s=o.value.trim();if(s){l.value=!0;try{await L({diagnosis_id:c.diagnosisId,tracking_content:s}),M.msgSuccess("已添加"),o.value="",y("refresh")}finally{l.value=!1}}};return(s,i)=>{const b=V,h=E,B=z;return e(),t("div",D,[!n.readonly&&n.diagnosisId?(e(),t("div",F,[m(b,{modelValue:o.value,"onUpdate:modelValue":i[0]||(i[0]=a=>o.value=a),type:"textarea",rows:2,placeholder:"输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录",maxlength:"1000","show-word-limit":"",resize:"none",disabled:l.value},null,8,["modelValue","disabled"]),d("div",H,[m(h,{type:"primary",size:"small",loading:l.value,disabled:!o.value.trim(),onClick:k},{default:w(()=>[...i[1]||(i[1]=[I(" 添加 ",-1)])]),_:1},8,["loading","disabled"])])])):r("",!0),n.notes.length?(e(),t("div",q,[(e(!0),t(u,null,v(n.notes,a=>(e(),t("div",{key:a.id,class:"timeline-node"},[i[2]||(i[2]=d("div",{class:"timeline-dot"},null,-1)),d("div",A,g(a.note_date),1),d("div",G,[a.content?(e(),t("div",K,[(e(!0),t(u,null,v(_(a.content),(x,N)=>(e(),t("div",{key:N,class:"content-line"},g(x),1))),128))])):r("",!0)])]))),128))])):r("",!0),!n.notes.length&&n.readonly?(e(),C(B,{key:2,description:"暂无跟踪备注","image-size":48})):r("",!0)])}}}),Q=S(O,[["__scopeId","data-v-82b635bd"]]);export{Q as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.welcome-editor[data-v-1e5d0e03]{display:grid;grid-template-columns:minmax(0,1fr) 260px;align-items:start;gap:22px;width:100%}.welcome-editor__fields[data-v-1e5d0e03]{min-width:0}.text-tools[data-v-1e5d0e03]{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}.text-tools .el-button+.el-button[data-v-1e5d0e03]{margin-left:0}.emoji-grid[data-v-1e5d0e03]{display:grid;grid-template-columns:repeat(8,1fr);gap:4px}.emoji-grid button[data-v-1e5d0e03]{border:0;background:none;padding:4px;cursor:pointer;font-size:20px}.text-count[data-v-1e5d0e03]{text-align:right;font-size:12px;color:#909399;margin-top:4px}.text-count.is-error[data-v-1e5d0e03],.upload-error[data-v-1e5d0e03]{color:#d93026}.attachments-heading[data-v-1e5d0e03],.attachment-card__heading[data-v-1e5d0e03]{display:flex;justify-content:space-between;align-items:center;gap:8px}.attachments-heading[data-v-1e5d0e03]{margin:16px 0 10px}.attachments-heading strong[data-v-1e5d0e03]{font-size:13px}.attachments-heading strong span[data-v-1e5d0e03]{color:#909399;font-weight:400}.attachment-empty[data-v-1e5d0e03]{padding:18px 12px;color:#909399;background:#f7f8fa;border:1px dashed #dcdfe6;border-radius:4px;font-size:12px}.attachment-card[data-v-1e5d0e03]{border:1px solid #e4e7ed;border-radius:5px;padding:12px;margin-top:10px}.attachment-card__heading[data-v-1e5d0e03]{margin-bottom:10px}.attachment-card__heading strong[data-v-1e5d0e03]{font-size:13px}.attachment-card__heading .el-button[data-v-1e5d0e03]{padding:4px;margin:0}.attachment-label[data-v-1e5d0e03]{display:block;font-size:12px;color:#606266;margin:10px 0 4px}.attachment-label span[data-v-1e5d0e03]{color:#909399;float:right}.upload-field[data-v-1e5d0e03]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:12px;overflow-wrap:anywhere}.mini-upload[data-v-1e5d0e03]{margin-top:12px}.asset-ready[data-v-1e5d0e03]{color:#178758}.muted[data-v-1e5d0e03]{color:#909399}.field-tip[data-v-1e5d0e03]{display:block;color:#909399;line-height:1.6;margin-top:6px}.upload-error[data-v-1e5d0e03]{font-size:12px;line-height:1.6;margin-top:6px}.file-input[data-v-1e5d0e03]{display:none}.welcome-preview[data-v-1e5d0e03]{width:260px;border:1px solid #dcdfe6;border-radius:20px;padding:7px;background:#fff;overflow:hidden}.phone-heading[data-v-1e5d0e03]{display:flex;justify-content:space-between;align-items:center;padding:13px 12px;background:#ededed;border-radius:14px 14px 0 0;font-size:13px}.phone-heading>span[data-v-1e5d0e03]{font-size:19px}.phone-content[data-v-1e5d0e03]{min-height:330px;max-height:520px;overflow:auto;background:#ededed;padding:0 10px 18px}.preview-time[data-v-1e5d0e03]{font-size:10px;text-align:center;color:#999;padding:12px 0 18px}.chat-row[data-v-1e5d0e03]{display:flex;gap:7px;margin-bottom:12px;align-items:flex-start}.chat-avatar[data-v-1e5d0e03]{width:27px;height:27px;background:#6e92ae;color:#fff;flex-shrink:0;border-radius:4px;display:grid;place-items:center;font-size:11px}.chat-bubble[data-v-1e5d0e03]{background:#fff;padding:9px 10px;border-radius:4px;font-size:12px;line-height:1.65;white-space:pre-wrap;overflow-wrap:anywhere;min-width:0;max-width:172px}.attachment-preview[data-v-1e5d0e03]{width:172px}.attachment-preview strong[data-v-1e5d0e03]{display:block;font-weight:500;font-size:12px}.attachment-preview p[data-v-1e5d0e03]{color:#909399;font-size:10px;margin:6px 0}.attachment-preview small[data-v-1e5d0e03]{display:block;font-size:9px;color:#909399;margin-top:7px}.attachment-preview img[data-v-1e5d0e03]{width:100%;max-height:160px;-o-object-fit:contain;object-fit:contain;display:block}.media-placeholder[data-v-1e5d0e03]{background:#f2f5f7;height:85px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;color:#909399;font-size:10px}.media-placeholder .el-icon[data-v-1e5d0e03],.file-icon[data-v-1e5d0e03]{font-size:28px;color:#8babc3}.preview-empty[data-v-1e5d0e03]{text-align:center;color:#aaa;font-size:12px;margin-top:100px}.phone-input[data-v-1e5d0e03]{display:flex;gap:10px;padding:9px;background:#f6f6f6;border-radius:0 0 14px 14px;align-items:center;color:#909399}.phone-input__blank[data-v-1e5d0e03]{flex:1;height:24px;border-radius:3px;background:#fff}.preview-note[data-v-1e5d0e03]{margin:10px 6px 6px;font-size:11px;color:#909399;line-height:1.6}@media (max-width: 850px){.welcome-editor[data-v-1e5d0e03]{grid-template-columns:1fr}.welcome-preview[data-v-1e5d0e03]{margin:8px auto 0}}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-Bqj5kT9K.js";import"./.pnpm-Dwh6tNxD.js";import"./index-CJn0zdCA.js";import"./index-a_ZxLOOo.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-CrbSIkWk.js";import"./.pnpm-BSw4l71J.js";import"./index-IBEgpZdk.js";import"./index-BWlhxa68.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-Dwh6tNxD.js";import{_ as L}from"./index-CJn0zdCA.js";import{i as V}from"./index-a_ZxLOOo.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
|
||||
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-BSw4l71J.js";import{_ as L}from"./index-IBEgpZdk.js";import{i as V}from"./index-BWlhxa68.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./add-nav.vue_vue_type_script_setup_true_lang-B_JUXGnm.js";import"./.pnpm-BSw4l71J.js";import"./index-Du0eYB29.js";import"./index-BWlhxa68.js";import"./picker-B4EVDozl.js";import"./index-IBEgpZdk.js";import"./index.vue_vue_type_script_setup_true_lang-BOJpu9c9.js";import"./article-_SYv-ini.js";import"./usePaging-BeGcb2kN.js";import"./picker-C_3iViNJ.js";import"./index-PArzJ7v1.js";import"./index-TisaJaAB.js";import"./file-BXk5F0Ys.js";import"./index.vue_vue_type_script_setup_true_lang-D3ZtXZaD.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./add-nav.vue_vue_type_script_setup_true_lang-BHQKdn5u.js";import"./.pnpm-Dwh6tNxD.js";import"./index-BSwVDYZd.js";import"./index-a_ZxLOOo.js";import"./picker-RZLRR_G5.js";import"./index-CJn0zdCA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Dsf3Q7Pd.js";import"./usePaging-DOuAwzL9.js";import"./picker-tazliPuC.js";import"./index-DFvgqKH8.js";import"./index-B8LZ-HCp.js";import"./file-DWoFbTjb.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";export{o as default};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user