This commit is contained in:
Your Name
2026-08-31 15:17:34 +08:00
parent ed48f8be31
commit 456dd667df
439 changed files with 5720 additions and 422 deletions
+34 -3
View File
@@ -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 })
+5
View File
@@ -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-28Asia/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.143 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 kBPyInstaller 报告一个可选 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 包。
+1 -1
View File
@@ -3,7 +3,7 @@
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
# Single source of truth for runtime, package, installer, and executable versions.
__version__ = "1.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)
+182 -1
View File
@@ -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:
+96
View File
@@ -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故障和跨节点存储。此开发没有发出任何真实企业微信写请求。
+22
View File
@@ -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);
+3
View File
@@ -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', ''),
];
File diff suppressed because one or more lines are too long
@@ -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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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};
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 @@
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 @@
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 @@
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 @@
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 @@
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,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};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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};
@@ -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
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 @@
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};
@@ -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};
File diff suppressed because one or more lines are too long
@@ -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};
@@ -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 @@
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 _};
File diff suppressed because one or more lines are too long
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