更新
This commit is contained in:
@@ -293,6 +293,42 @@ export function wecomPromotionBatchSetOperators(params: WecomPromotionBatchSetOp
|
||||
})
|
||||
}
|
||||
|
||||
export interface WecomPromotionBatchUpdatePoolsParams {
|
||||
pool_ids: number[]
|
||||
changes: {
|
||||
skip_verify?: 0 | 1
|
||||
fallback_url?: string
|
||||
status?: 0 | 1
|
||||
automation_config?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface WecomPromotionBatchUpdatePoolResult {
|
||||
id: number
|
||||
name: string
|
||||
success: boolean
|
||||
sync_error?: string
|
||||
sync_queued?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WecomPromotionBatchUpdatePoolsResult {
|
||||
pool_ids: number[]
|
||||
updated: number
|
||||
failed: number
|
||||
sync_error_count: number
|
||||
sync_queued_count: number
|
||||
results: WecomPromotionBatchUpdatePoolResult[]
|
||||
}
|
||||
|
||||
export function wecomPromotionBatchUpdatePools(params: WecomPromotionBatchUpdatePoolsParams) {
|
||||
return request.post<WecomPromotionBatchUpdatePoolsResult>({
|
||||
url: '/firstvisit.wecomPromotion/batchUpdatePools',
|
||||
params,
|
||||
timeout: 120000
|
||||
}, { ignoreCancelToken: true })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeletePool(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params, timeout: 120000 })
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ export function qywxCustomerLists(params: any) {
|
||||
return request.get({ url: '/qywx.customer/lists', params })
|
||||
}
|
||||
|
||||
// 删除一条本地企业微信客户同步记录
|
||||
export function qywxCustomerDelete(params: { id: number }) {
|
||||
return request.post({ url: '/qywx.customer/delete', params })
|
||||
}
|
||||
|
||||
// 同步企业微信客户
|
||||
export function qywxCustomerSync() {
|
||||
return request.post({ url: '/qywx.customer/sync' })
|
||||
|
||||
+299
-21
@@ -94,15 +94,32 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟进人">
|
||||
<el-input
|
||||
v-model="queryParams.follow_user"
|
||||
<el-form-item label="跟进人">
|
||||
<el-input
|
||||
v-model="queryParams.follow_user"
|
||||
placeholder="跟进人姓名(后台姓名或企微账号)"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加时间">
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道">
|
||||
<el-select
|
||||
v-model="queryParams.add_way"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="选择或搜索添加渠道"
|
||||
style="width: 240px"
|
||||
@change="resetPage"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in ADD_WAY_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加时间">
|
||||
<el-date-picker
|
||||
v-model="addTimeRange"
|
||||
type="daterange"
|
||||
@@ -235,6 +252,32 @@
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="添加渠道" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div v-if="customerAddSources(row).length" class="flex items-center gap-1">
|
||||
<el-tooltip
|
||||
v-for="source in customerAddSources(row).slice(0, 1)"
|
||||
:key="source.key"
|
||||
:content="addSourceTooltip(source)"
|
||||
placement="top"
|
||||
>
|
||||
<span class="inline-block max-w-[150px] truncate align-middle">
|
||||
{{ source.label }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
v-if="customerAddSources(row).length > 1"
|
||||
:content="remainingAddSourcesTooltip(row)"
|
||||
placement="top"
|
||||
>
|
||||
<span class="text-primary whitespace-nowrap cursor-help">
|
||||
另 {{ customerAddSources(row).length - 1 }} 条
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">未记录</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="添加时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(firstExternalAddTime(row)) }}
|
||||
@@ -245,9 +288,19 @@
|
||||
{{ formatTime(row.update_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
|
||||
<el-button
|
||||
v-perms="['qywx.customer/delete']"
|
||||
type="danger"
|
||||
link
|
||||
:loading="deletingCustomerId === Number(row.id)"
|
||||
:disabled="deletingCustomerId !== null"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -500,6 +553,21 @@
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="添加时间" :span="2">
|
||||
{{ formatTime(firstExternalAddTime(currentCustomer)) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="添加渠道" :span="2">
|
||||
<div v-if="customerAddSources(currentCustomer).length" class="flex flex-wrap gap-1">
|
||||
<el-tooltip
|
||||
v-for="source in customerAddSources(currentCustomer)"
|
||||
:key="source.key"
|
||||
:content="addSourceTooltip(source)"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag size="small" type="info" effect="plain">
|
||||
{{ source.label }}
|
||||
</el-tag>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">未记录</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间" :span="2">
|
||||
{{ formatTime(currentCustomer.update_time) }}
|
||||
@@ -549,8 +617,9 @@ import { Refresh, Setting, DataLine, CollectionTag } from '@element-plus/icons-v
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
qywxCustomerLists,
|
||||
qywxCustomerSync,
|
||||
qywxCustomerLists,
|
||||
qywxCustomerDelete,
|
||||
qywxCustomerSync,
|
||||
qywxCustomerStats,
|
||||
qywxSyncSettingsGet,
|
||||
qywxSyncSettingsSave,
|
||||
@@ -563,6 +632,7 @@ const syncing = ref(false)
|
||||
const showSyncSettings = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const currentCustomer = ref<any>(null)
|
||||
const deletingCustomerId = ref<number | null>(null)
|
||||
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
@@ -591,17 +661,19 @@ const syncSettings = reactive({
|
||||
interval: 3600
|
||||
})
|
||||
|
||||
const queryParams = reactive<{
|
||||
name: string
|
||||
follow_user: string
|
||||
tag_ids: string[]
|
||||
const queryParams = reactive<{
|
||||
name: string
|
||||
follow_user: string
|
||||
add_way: number | ''
|
||||
tag_ids: string[]
|
||||
add_time_start: string
|
||||
add_time_end: string
|
||||
dedupe_mode: 'first' | 'any'
|
||||
}>({
|
||||
name: '',
|
||||
follow_user: '',
|
||||
tag_ids: [],
|
||||
name: '',
|
||||
follow_user: '',
|
||||
add_way: '',
|
||||
tag_ids: [],
|
||||
add_time_start: '',
|
||||
add_time_end: '',
|
||||
dedupe_mode: 'first'
|
||||
@@ -639,6 +711,22 @@ interface TagStatsPayload {
|
||||
groups: TagGroup[]
|
||||
}
|
||||
|
||||
interface AddChannel {
|
||||
state: string
|
||||
label: string
|
||||
source_type: 'promotion_pool' | 'state'
|
||||
pool_id: number
|
||||
user_id: string
|
||||
event_time: number
|
||||
}
|
||||
|
||||
interface AddSource extends AddChannel {
|
||||
key: string
|
||||
add_way: number | null
|
||||
channel_label: string
|
||||
staff_name: string
|
||||
}
|
||||
|
||||
const tagStats = reactive<TagStatsPayload>({
|
||||
total_tags: 0,
|
||||
total_relations: 0,
|
||||
@@ -847,10 +935,11 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
function handleReset() {
|
||||
queryParams.name = ''
|
||||
queryParams.follow_user = ''
|
||||
queryParams.tag_ids = []
|
||||
function handleReset() {
|
||||
queryParams.name = ''
|
||||
queryParams.follow_user = ''
|
||||
queryParams.add_way = ''
|
||||
queryParams.tag_ids = []
|
||||
queryParams.add_time_start = ''
|
||||
queryParams.add_time_end = ''
|
||||
queryParams.dedupe_mode = 'first'
|
||||
@@ -964,6 +1053,34 @@ function viewDetail(row: any) {
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(row: Record<string, any>) {
|
||||
const id = Number(row.id)
|
||||
if (!Number.isInteger(id) || id <= 0 || deletingCustomerId.value !== null) return
|
||||
|
||||
const customerName = String(row.name || row.external_userid || '该客户')
|
||||
try {
|
||||
await feedback.confirm(
|
||||
`确定删除企业微信客户“${customerName}”吗?此操作仅删除系统内的同步记录,不会删除企业微信中的客户关系;后续重新同步时可能再次出现。`
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
deletingCustomerId.value = id
|
||||
try {
|
||||
await qywxCustomerDelete({ id })
|
||||
if (pager.page > 1 && pager.lists.length === 1) {
|
||||
pager.page -= 1
|
||||
}
|
||||
await Promise.all([getLists(), loadStats(), loadTagStats()])
|
||||
feedback.msgSuccess('删除成功')
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || e?.msg || '删除失败')
|
||||
} finally {
|
||||
deletingCustomerId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 列表接口会写入 admin_name(admin.work_wechat_userid = userid) */
|
||||
function formatFollowUser(user: Record<string, any>) {
|
||||
const adminName = String(user?.admin_name ?? '').trim()
|
||||
@@ -985,6 +1102,167 @@ function followStaffTooltip(user: Record<string, any>) {
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
function customerAddChannels(row: Record<string, any> | null | undefined): AddChannel[] {
|
||||
if (!row) return []
|
||||
if (Array.isArray(row.add_channels)) {
|
||||
return row.add_channels
|
||||
.map((channel: Record<string, any>): AddChannel => ({
|
||||
state: String(channel?.state ?? '').trim(),
|
||||
label: String(channel?.label ?? channel?.state ?? '').trim(),
|
||||
source_type: channel?.source_type === 'promotion_pool' ? 'promotion_pool' : 'state',
|
||||
pool_id: Number(channel?.pool_id ?? 0),
|
||||
user_id: String(channel?.user_id ?? '').trim(),
|
||||
event_time: Number(channel?.event_time ?? 0)
|
||||
}))
|
||||
.filter((channel: AddChannel) => channel.state !== '')
|
||||
}
|
||||
|
||||
// 兼容仅返回原始渠道数组的旧接口/灰度节点。
|
||||
if (!Array.isArray(row.add_channel_states)) return []
|
||||
return row.add_channel_states
|
||||
.map((state: unknown) => String(state ?? '').trim())
|
||||
.filter((state: string) => state !== '')
|
||||
.map((state: string) => ({
|
||||
state,
|
||||
label: state,
|
||||
source_type: 'state' as const,
|
||||
pool_id: 0,
|
||||
user_id: '',
|
||||
event_time: 0
|
||||
}))
|
||||
}
|
||||
|
||||
const ADD_WAY_LABELS: Record<number, string> = {
|
||||
0: '未知添加方式',
|
||||
1: '通过扫描二维码添加',
|
||||
2: '通过搜索手机号添加',
|
||||
3: '通过名片分享添加',
|
||||
4: '通过群聊添加',
|
||||
5: '通过手机通讯录添加',
|
||||
6: '通过微信联系人添加',
|
||||
8: '安装第三方应用时自动添加',
|
||||
9: '通过搜索邮箱添加',
|
||||
10: '通过视频号添加',
|
||||
11: '通过日程参与人添加',
|
||||
12: '通过会议参与人添加',
|
||||
13: '通过微信好友添加',
|
||||
14: '通过智慧硬件专属客服添加',
|
||||
15: '通过上门服务客服添加',
|
||||
16: '通过获客链接添加',
|
||||
17: '通过定制开发添加',
|
||||
18: '通过需求回复添加',
|
||||
21: '通过第三方售前客服添加',
|
||||
22: '通过可能的商务伙伴添加',
|
||||
24: '通过接受微信好友申请添加',
|
||||
201: '通过内部成员共享添加',
|
||||
202: '通过管理员或负责人分配添加'
|
||||
}
|
||||
|
||||
const ADD_WAY_OPTIONS = Object.entries(ADD_WAY_LABELS).map(([value, label]) => ({
|
||||
value: Number(value),
|
||||
label
|
||||
}))
|
||||
|
||||
function normalizeAddWay(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) return null
|
||||
return Number(value.trim())
|
||||
}
|
||||
|
||||
function addWayLabel(addWay: number) {
|
||||
return ADD_WAY_LABELS[addWay] || `其他添加方式(${addWay})`
|
||||
}
|
||||
|
||||
function customerAddSources(row: Record<string, any> | null | undefined): AddSource[] {
|
||||
if (!row) return []
|
||||
|
||||
const channels = customerAddChannels(row)
|
||||
const usedChannelIndexes = new Set<number>()
|
||||
const sources: AddSource[] = []
|
||||
const followUsers = Array.isArray(row.follow_users) ? row.follow_users : []
|
||||
|
||||
followUsers.forEach((user: Record<string, any>, index: number) => {
|
||||
const userId = String(user?.userid ?? user?.UserId ?? '').trim()
|
||||
const state = String(user?.state ?? user?.State ?? '').trim()
|
||||
const addWay = normalizeAddWay(user?.add_way ?? user?.AddWay)
|
||||
|
||||
let channelIndex = channels.findIndex(
|
||||
(channel, i) =>
|
||||
!usedChannelIndexes.has(i) &&
|
||||
userId !== '' &&
|
||||
state !== '' &&
|
||||
channel.user_id === userId &&
|
||||
channel.state === state
|
||||
)
|
||||
if (channelIndex < 0 && state !== '') {
|
||||
channelIndex = channels.findIndex(
|
||||
(channel, i) => !usedChannelIndexes.has(i) && channel.state === state
|
||||
)
|
||||
}
|
||||
if (channelIndex < 0 && userId !== '') {
|
||||
channelIndex = channels.findIndex(
|
||||
(channel, i) => !usedChannelIndexes.has(i) && channel.user_id === userId
|
||||
)
|
||||
}
|
||||
|
||||
const channel = channelIndex >= 0 ? channels[channelIndex] : undefined
|
||||
if (channelIndex >= 0) usedChannelIndexes.add(channelIndex)
|
||||
if (addWay === null && state === '' && !channel) return
|
||||
|
||||
const labelFromApi = String(user?.add_way_label ?? '').trim()
|
||||
const sourceType = channel?.source_type ?? (/^zyt_pool:[1-9]\d*$/.test(state) ? 'promotion_pool' : 'state')
|
||||
const label = labelFromApi || (addWay !== null
|
||||
? addWayLabel(addWay)
|
||||
: sourceType === 'promotion_pool'
|
||||
? '通过获客链接添加'
|
||||
: '通过其他渠道添加')
|
||||
|
||||
sources.push({
|
||||
key: `follow:${index}:${userId}:${addWay ?? 'unknown'}:${state}`,
|
||||
add_way: addWay,
|
||||
label,
|
||||
state: state || channel?.state || '',
|
||||
channel_label: channel?.label || '',
|
||||
source_type: sourceType,
|
||||
pool_id: channel?.pool_id || 0,
|
||||
user_id: userId || channel?.user_id || '',
|
||||
staff_name: formatFollowUser(user),
|
||||
event_time: channel?.event_time || Number(user?.createtime ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
// 兼容事件日志中仍有记录、但当前 follow_users 已不存在或旧接口未返回 add_way 的客户。
|
||||
channels.forEach((channel, index) => {
|
||||
if (usedChannelIndexes.has(index)) return
|
||||
sources.push({
|
||||
...channel,
|
||||
key: `channel:${index}:${channel.user_id}:${channel.state}`,
|
||||
add_way: channel.source_type === 'promotion_pool' ? 16 : null,
|
||||
label: channel.source_type === 'promotion_pool' ? '通过获客链接添加' : '通过其他渠道添加',
|
||||
channel_label: channel.label,
|
||||
staff_name: channel.user_id || '—'
|
||||
})
|
||||
})
|
||||
|
||||
return sources.sort((a, b) => b.event_time - a.event_time)
|
||||
}
|
||||
|
||||
function addSourceTooltip(source: AddSource) {
|
||||
const parts: string[] = []
|
||||
parts.push(`添加方式:${source.label}`)
|
||||
if (source.source_type === 'promotion_pool' && source.channel_label) {
|
||||
parts.push(`获客助手方案:${source.channel_label}`)
|
||||
}
|
||||
if (source.staff_name && source.staff_name !== '—') parts.push(`跟进人:${source.staff_name}`)
|
||||
if (source.event_time > 0) parts.push(`添加时间:${formatTime(source.event_time)}`)
|
||||
if (source.state) parts.push(`渠道参数:${source.state}`)
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
function remainingAddSourcesTooltip(row: Record<string, any>) {
|
||||
return customerAddSources(row).slice(1).map(addSourceTooltip).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加时间:优先接口字段 external_first_add_time(同步写入 + 列表对未回填行按 JSON 兜底);
|
||||
* 再解析 follow_users;最后退回 create_time
|
||||
|
||||
+53
-31
@@ -2,9 +2,10 @@
|
||||
<div class="automation-form">
|
||||
<el-alert class="automation-note" type="info" show-icon :closable="false" title="自动化设置只作用于之后新添加的客户,不会写入企微获客链接详情中的“欢迎语/客户标签”配置。" description="系统会在客户添加回调中立即发送渠道欢迎语并添加标签,后台任务负责失败重试及其他补偿。测试时请使用系统复制的、带渠道参数的链接。" />
|
||||
|
||||
<section class="automation-section" :class="{ 'is-disabled': receptionDisabled }">
|
||||
<h3 class="form-section-title">接待设置</h3>
|
||||
<el-form-item label="接待模式">
|
||||
<el-radio-group v-model="config.reception_mode" :disabled="disabled">
|
||||
<el-radio-group v-model="config.reception_mode" :disabled="receptionDisabled">
|
||||
<el-radio value="always">全天接待</el-radio>
|
||||
<el-radio value="scheduled">按星期时段自动上下线</el-radio>
|
||||
</el-radio-group>
|
||||
@@ -12,50 +13,52 @@
|
||||
</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%">
|
||||
<div class="schedule-heading"><strong>接待时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="receptionDisabled" @click="config.reception_schedule.splice(index, 1)">删除时段</el-button></div>
|
||||
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="receptionDisabled"><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="receptionDisabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="receptionDisabled" 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="receptionDisabled" 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>
|
||||
<el-button :icon="Plus" :disabled="receptionDisabled || 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-select v-model="config.backup_member_admin_ids" :disabled="receptionDisabled" 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="backupExcludedIds.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>
|
||||
</section>
|
||||
|
||||
<section class="automation-section" :class="{ 'is-disabled': customerDisabled }">
|
||||
<h3 class="form-section-title">客户设置</h3>
|
||||
<el-form-item label="自动添加客户标签">
|
||||
<el-switch v-model="config.tags_enabled" :disabled="disabled || tagsCreating" />
|
||||
<el-switch v-model="config.tags_enabled" :disabled="customerDisabled || 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>
|
||||
<el-button size="small" :disabled="customerDisabled || 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-select v-model="selectedTag" :disabled="customerDisabled || 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>
|
||||
<el-button :icon="Plus" :disabled="customerDisabled || tagsCreating" @click="showCustomTag = !showCustomTag">自定义标签</el-button>
|
||||
<el-button :icon="Refresh" :disabled="customerDisabled || 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>
|
||||
<el-input id="promotion-custom-tag-name" v-model="customTagName" :disabled="customerDisabled || tagsCreating" maxlength="30" show-word-limit placeholder="例如:官网咨询" @input="customTagError = ''" @keydown.enter.prevent="createCustomTag" />
|
||||
<el-button type="primary" :disabled="customerDisabled || tagsLoading" :loading="tagsCreating" @click="createCustomTag">创建并选用</el-button>
|
||||
</div>
|
||||
<p class="field-help">创建到企业微信“推广渠道”分组,同组同名标签会复用。创建后即保存到企微标签库,取消方案编辑不会删除标签。</p>
|
||||
<p v-if="customTagError" role="alert" class="inline-error">{{ customTagError }} 原有选择未改变。</p>
|
||||
@@ -64,22 +67,24 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="自动设置客户备注">
|
||||
<el-switch v-model="config.remark_enabled" :disabled="disabled" />
|
||||
<el-switch v-model="config.remark_enabled" :disabled="customerDisabled" />
|
||||
<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="token-buttons"><el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="customerDisabled" @click="insertRemark(token.value)">插入{{ token.label }}</el-button></div>
|
||||
<el-input ref="remarkInput" v-model="config.remark_template" :disabled="customerDisabled" 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-switch v-model="config.description_enabled" :disabled="customerDisabled" />
|
||||
<el-input v-if="config.description_enabled" v-model="config.description" class="description-input" :disabled="customerDisabled" type="textarea" :rows="3" maxlength="150" show-word-limit placeholder="请输入客户描述,最多 150 字" />
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<section class="automation-section" :class="{ 'is-disabled': welcomeDisabled }">
|
||||
<h3 class="form-section-title">欢迎语设置</h3>
|
||||
<el-form-item label="欢迎语模式">
|
||||
<el-radio-group v-model="config.welcome_mode" :disabled="disabled || anyUploading">
|
||||
<el-radio-group v-model="config.welcome_mode" :disabled="welcomeDisabled || anyUploading">
|
||||
<el-radio value="channel">渠道欢迎语</el-radio>
|
||||
<el-radio value="default">默认欢迎语</el-radio>
|
||||
<el-radio value="none">不发送欢迎语</el-radio>
|
||||
@@ -90,20 +95,21 @@
|
||||
</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)" />
|
||||
<WelcomeMessageEditor v-model="config.welcome" :disabled="welcomeDisabled" :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>
|
||||
<el-form-item class="schedule-switch" label="分时欢迎语"><el-switch v-model="config.welcome_schedule_enabled" :disabled="welcomeDisabled || 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 class="schedule-heading"><strong>欢迎语时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="welcomeDisabled || anyUploading" @click="removeWelcomeSlot(index)">删除时段</el-button></div>
|
||||
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="welcomeDisabled"><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="welcomeDisabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="welcomeDisabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
|
||||
<WelcomeMessageEditor :model-value="slot" :disabled="welcomeDisabled" :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>
|
||||
<el-button :icon="Plus" :disabled="welcomeDisabled || anyUploading || config.welcome_schedule.length >= 30" @click="addWelcomeSlot">添加欢迎语时段</el-button>
|
||||
<p class="field-help">最多 30 个时段,支持跨午夜。时段外自动使用基础渠道欢迎语,不会随机选择内容。</p>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -116,9 +122,22 @@ 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 }>()
|
||||
type AutomationSection = 'reception' | 'customer' | 'welcome'
|
||||
const props = defineProps<{
|
||||
modelValue: PromotionAutomationConfig
|
||||
mainMemberIds: number[]
|
||||
members: PromotionMemberChoice[]
|
||||
disabled?: boolean
|
||||
disabledSections?: AutomationSection[]
|
||||
backupExcludedMemberIds?: number[]
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [config: PromotionAutomationConfig]; busy: [value: boolean] }>()
|
||||
const config = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
||||
const sectionDisabled = (section: AutomationSection) => Boolean(props.disabled || props.disabledSections?.includes(section))
|
||||
const receptionDisabled = computed(() => sectionDisabled('reception'))
|
||||
const customerDisabled = computed(() => sectionDisabled('customer'))
|
||||
const welcomeDisabled = computed(() => sectionDisabled('welcome'))
|
||||
const backupExcludedIds = computed(() => props.backupExcludedMemberIds || props.mainMemberIds)
|
||||
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 || '小陈')
|
||||
@@ -153,7 +172,9 @@ 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 })
|
||||
watch([() => config.value.tags_enabled, customerDisabled], ([enabled, sectionIsDisabled]) => {
|
||||
if (enabled && !sectionIsDisabled && !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: [] }) }
|
||||
@@ -173,7 +194,7 @@ async function loadTags() {
|
||||
} finally { tagsLoading.value = false }
|
||||
}
|
||||
async function createCustomTag() {
|
||||
if (props.disabled || tagsCreating.value || tagsLoading.value) return
|
||||
if (customerDisabled.value || tagsCreating.value || tagsLoading.value) return
|
||||
customTagError.value = validateCustomTagName(customTagName.value)
|
||||
customTagSuccess.value = ''
|
||||
if (customTagError.value) return
|
||||
@@ -215,6 +236,7 @@ onBeforeUnmount(() => emit('busy', false))
|
||||
|
||||
<style scoped>
|
||||
.automation-form { width: 100%; }.automation-note { margin-top: 22px; }.automation-note :deep(.el-alert__description) { line-height: 1.7; }
|
||||
.automation-section { min-width: 0; transition: opacity .2s ease; }.automation-section.is-disabled { opacity: .58; }
|
||||
.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; }
|
||||
|
||||
@@ -58,6 +58,11 @@
|
||||
</div>
|
||||
<div class="section-heading-actions">
|
||||
<span v-if="selectedPoolIds.length" class="selection-count">已选 {{ selectedPoolIds.length }} 个方案</span>
|
||||
<el-button
|
||||
:icon="Edit"
|
||||
:disabled="!selectedPoolIds.length"
|
||||
@click="openBatchConfigDialog()"
|
||||
>批量修改方案</el-button>
|
||||
<el-button
|
||||
:icon="User"
|
||||
:disabled="!selectedPoolIds.length"
|
||||
@@ -138,7 +143,8 @@
|
||||
description="当前全部可用医助会同时写入官方链接的成员范围,由企业微信在打开和添加阶段直接进行多人路由。回调只用于统计实际承接结果,并在禁用、过期或达到上限后更新成员范围。"
|
||||
/>
|
||||
|
||||
<el-table :data="selectedMemberRules" class="link-table" stripe>
|
||||
<div class="member-table-area">
|
||||
<el-table :data="selectedMemberRules" class="link-table" height="100%" stripe>
|
||||
<el-table-column label="推广成员" min-width="210" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="member-cell">
|
||||
@@ -174,7 +180,8 @@
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :image-size="72" description="编辑方案并选择获客医助" /></template>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="创建方案并选择多个医助,保存后自动生成一个企业微信官方获客链接">
|
||||
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">创建第一个方案</el-button>
|
||||
@@ -429,6 +436,94 @@
|
||||
<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
|
||||
v-model="batchConfigDialogVisible"
|
||||
title="批量修改分流方案"
|
||||
width="1000px"
|
||||
class="promotion-pool-dialog"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!savingBatchConfig && !batchConfigBusy"
|
||||
:show-close="!savingBatchConfig && !batchConfigBusy"
|
||||
>
|
||||
<div ref="batchConfigScroll" class="pool-form-scroll batch-config-scroll">
|
||||
<el-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
:title="`将统一修改 ${batchConfigForm.pool_ids.length} 个分流方案`"
|
||||
description="仅勾选的项目会覆盖到所选方案;未勾选项目保留各方案原值。表单初始值取第一个所选方案。"
|
||||
/>
|
||||
<el-alert v-if="batchConfigError" class="pool-form-error batch-config-error" :title="batchConfigError" type="error" show-icon :closable="false" role="alert" />
|
||||
|
||||
<div v-if="batchConfigPools.length" class="batch-pool-summary">
|
||||
<strong>已选方案</strong>
|
||||
<span v-for="pool in batchConfigPools.slice(0, 8)" :key="pool.id">{{ pool.name }}</span>
|
||||
<small v-if="batchConfigPools.length > 8">另有 {{ batchConfigPools.length - 8 }} 个</small>
|
||||
</div>
|
||||
|
||||
<el-form class="batch-config-form" label-position="top" :disabled="savingBatchConfig">
|
||||
<section class="batch-config-section">
|
||||
<h3>基础设置</h3>
|
||||
<div class="batch-field-grid">
|
||||
<div class="batch-field" :class="{ 'is-disabled': !batchConfigApply.skip_verify }">
|
||||
<el-checkbox v-model="batchConfigApply.skip_verify" :disabled="savingBatchConfig">批量修改验证方式</el-checkbox>
|
||||
<el-form-item label="添加客户时跳过验证">
|
||||
<el-switch v-model="batchConfigForm.skip_verify" :disabled="!batchConfigApply.skip_verify || savingBatchConfig" :active-value="1" :inactive-value="0" active-text="跳过验证" inactive-text="需要验证" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="batch-field" :class="{ 'is-disabled': !batchConfigApply.status }">
|
||||
<el-checkbox v-model="batchConfigApply.status" :disabled="savingBatchConfig">批量修改运行状态</el-checkbox>
|
||||
<el-form-item label="运行状态">
|
||||
<el-switch v-model="batchConfigForm.status" :disabled="!batchConfigApply.status || savingBatchConfig" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-field" :class="{ 'is-disabled': !batchConfigApply.fallback_url }">
|
||||
<el-checkbox v-model="batchConfigApply.fallback_url" :disabled="savingBatchConfig">批量修改兜底获客助手链接</el-checkbox>
|
||||
<el-form-item label="兜底获客助手链接">
|
||||
<el-input v-model="batchConfigForm.fallback_url" :disabled="!batchConfigApply.fallback_url || savingBatchConfig" placeholder="留空表示清除;仅供旧版兼容跳转使用" />
|
||||
<span class="form-tip">新生成的官方直链不经过本站跳转;此项仅兼容旧安装代码。</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="overview.automation_installed" class="batch-config-section automation-batch-section">
|
||||
<h3>自动化设置</h3>
|
||||
<p class="batch-section-tip">先选择要覆盖的分组。未选择的接待、客户或欢迎语设置不会随本次批量操作改变。</p>
|
||||
<div class="batch-section-selectors">
|
||||
<el-checkbox v-model="batchConfigApply.reception" :disabled="savingBatchConfig" border>批量修改接待设置</el-checkbox>
|
||||
<el-checkbox v-model="batchConfigApply.customer" :disabled="savingBatchConfig" border>批量修改客户设置</el-checkbox>
|
||||
<el-checkbox v-model="batchConfigApply.welcome" :disabled="savingBatchConfig" border>批量修改欢迎语设置</el-checkbox>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="batchConfigApply.reception && !batchSharedPrimaryMemberIds.length"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="所选方案没有共同主接待成员"
|
||||
description="仍可统一改为全天接待;若使用按时段接待,时段成员必须同时属于全部所选方案。"
|
||||
/>
|
||||
<PromotionAutomationForm
|
||||
v-model="batchConfigForm.automation_config"
|
||||
:main-member-ids="batchSharedPrimaryMemberIds"
|
||||
:backup-excluded-member-ids="batchPrimaryMemberUnion"
|
||||
:members="overview.member_options"
|
||||
:disabled="savingBatchConfig"
|
||||
:disabled-sections="batchDisabledAutomationSections"
|
||||
@busy="(busy) => batchConfigBusy = busy"
|
||||
/>
|
||||
</section>
|
||||
<el-alert v-else type="warning" show-icon :closable="false" title="自动化配置尚未安装" description="本次仍可批量修改验证方式、兜底链接和运行状态;安装自动化数据表后即可批量修改接待、客户和欢迎语设置。" />
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span v-if="batchConfigBusy" class="uploading-save-tip">正在处理标签或素材,请稍候</span>
|
||||
<el-button :disabled="savingBatchConfig || batchConfigBusy" @click="batchConfigDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingBatchConfig" :disabled="batchConfigBusy" @click="saveBatchConfig">批量保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="accessDialogVisible"
|
||||
title="批量设置他人访问操作"
|
||||
@@ -525,6 +620,7 @@ import {
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
wecomPromotionBatchSetOperators,
|
||||
wecomPromotionBatchUpdatePools,
|
||||
wecomPromotionCheckApiPermission,
|
||||
wecomPromotionCustomerStats,
|
||||
wecomPromotionDeletePool,
|
||||
@@ -539,8 +635,10 @@ import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
|
||||
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
|
||||
import PromotionAutomationForm from './components/PromotionAutomationForm.vue'
|
||||
import { cloneAutomationConfig, defaultAutomationConfig, isWebUrl, serializeAutomationConfig, validateAutomationConfig } from './components/promotion-automation'
|
||||
|
||||
import type { PromotionAutomationConfig } from './components/promotion-automation'
|
||||
|
||||
type TabName = 'links' | 'customer-stats' | 'configuration' | 'install'
|
||||
type BatchAutomationSection = 'reception' | 'customer' | 'welcome'
|
||||
|
||||
interface PromotionDepartmentOption {
|
||||
id: number | string
|
||||
@@ -606,11 +704,18 @@ const togglingMemberId = ref(0)
|
||||
const deletingPoolId = ref(0)
|
||||
const accessDialogVisible = ref(false)
|
||||
const savingAccess = ref(false)
|
||||
const batchConfigDialogVisible = ref(false)
|
||||
const savingBatchConfig = ref(false)
|
||||
const batchConfigBusy = ref(false)
|
||||
const batchConfigScroll = ref<HTMLElement>()
|
||||
const batchConfigError = ref('')
|
||||
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 batchConfigApply = reactive({ skip_verify: false, fallback_url: false, status: false, reception: false, customer: false, welcome: false })
|
||||
const batchConfigForm = reactive({ pool_ids: [] as number[], skip_verify: 0, fallback_url: '', status: 1, automation_config: defaultAutomationConfig() })
|
||||
const memberForm = reactive({ id: 0, name: '', userid: '', daily_limit: 0, status: 1, active_range: [] as string[], remark: '' })
|
||||
const customerStatsLoading = ref(false)
|
||||
const customerStatsLoaded = ref(false)
|
||||
@@ -633,6 +738,20 @@ const selectedPool = computed(() => overview.pools.find((item: any) => Number(it
|
||||
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
|
||||
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
|
||||
const accessDialogPools = computed(() => overview.pools.filter((item: any) => accessForm.pool_ids.includes(Number(item.id))))
|
||||
const batchConfigPools = computed(() => overview.pools.filter((item: any) => batchConfigForm.pool_ids.includes(Number(item.id))))
|
||||
const batchPrimaryMemberSets = computed(() => batchConfigPools.value.map((pool: any) => new Set<number>(
|
||||
(Array.isArray(pool.member_admin_ids) ? pool.member_admin_ids : []).map(Number)
|
||||
)))
|
||||
const batchPrimaryMemberUnion = computed(() => [...new Set(batchPrimaryMemberSets.value.flatMap((ids) => [...ids]))])
|
||||
const batchSharedPrimaryMemberIds = computed(() => {
|
||||
const sets = batchPrimaryMemberSets.value
|
||||
if (!sets.length) return []
|
||||
return [...sets[0]].filter((id) => sets.slice(1).every((ids) => ids.has(id)))
|
||||
})
|
||||
const batchDisabledAutomationSections = computed<BatchAutomationSection[]>(() => (
|
||||
(['reception', 'customer', 'welcome'] as BatchAutomationSection[])
|
||||
.filter((section) => !batchConfigApply[section])
|
||||
))
|
||||
const memberTreeProps = { value: 'value', label: 'label', children: 'children', disabled: 'disabled' }
|
||||
const memberDepartmentTree = computed(() => buildMemberDepartmentTree(overview.department_options, overview.member_options))
|
||||
const memberTreeDefaultExpandedKeys = computed(() => memberDepartmentTree.value.map((node) => node.value))
|
||||
@@ -692,6 +811,135 @@ function togglePoolSelection(pool: any, checked: unknown) {
|
||||
selectedPoolIds.value = [...next]
|
||||
}
|
||||
|
||||
function openBatchConfigDialog(poolIds: number[] = selectedPoolIds.value) {
|
||||
const manageableIds = new Set(overview.pools
|
||||
.filter((pool: any) => pool.can_manage_access)
|
||||
.map((pool: any) => Number(pool.id)))
|
||||
const ids = [...new Set(poolIds.map(Number).filter((id) => manageableIds.has(id)))]
|
||||
if (!ids.length) return ElMessage.warning('请先选择可管理的分流方案')
|
||||
if (ids.length > 100) return ElMessage.warning('单次最多设置 100 个分流方案')
|
||||
const reference = overview.pools.find((pool: any) => Number(pool.id) === ids[0])
|
||||
Object.assign(batchConfigApply, {
|
||||
skip_verify: false,
|
||||
fallback_url: false,
|
||||
status: false,
|
||||
reception: false,
|
||||
customer: false,
|
||||
welcome: false
|
||||
})
|
||||
Object.assign(batchConfigForm, {
|
||||
pool_ids: ids,
|
||||
skip_verify: Number(reference?.skip_verify) === 1 ? 1 : 0,
|
||||
fallback_url: String(reference?.fallback_url || ''),
|
||||
status: Number(reference?.status) === 1 ? 1 : 0,
|
||||
automation_config: cloneAutomationConfig(reference?.automation_config)
|
||||
})
|
||||
batchConfigError.value = ''
|
||||
batchConfigBusy.value = false
|
||||
batchConfigDialogVisible.value = true
|
||||
}
|
||||
|
||||
function copyAutomationSection(
|
||||
target: PromotionAutomationConfig,
|
||||
source: PromotionAutomationConfig,
|
||||
keys: Array<keyof PromotionAutomationConfig>
|
||||
) {
|
||||
const targetRecord = target as unknown as Record<string, unknown>
|
||||
const sourceRecord = source as unknown as Record<string, unknown>
|
||||
keys.forEach((key) => { targetRecord[key] = JSON.parse(JSON.stringify(sourceRecord[key])) })
|
||||
}
|
||||
|
||||
function setBatchConfigError(message: string) {
|
||||
batchConfigError.value = message
|
||||
batchConfigScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
ElMessage.warning(message)
|
||||
}
|
||||
|
||||
async function saveBatchConfig() {
|
||||
if (savingBatchConfig.value || batchConfigBusy.value) return
|
||||
batchConfigError.value = ''
|
||||
const hasAutomationChange = overview.automation_installed
|
||||
&& (batchConfigApply.reception || batchConfigApply.customer || batchConfigApply.welcome)
|
||||
if (!batchConfigApply.skip_verify && !batchConfigApply.fallback_url && !batchConfigApply.status && !hasAutomationChange) {
|
||||
return setBatchConfigError('请至少勾选一项需要批量修改的配置')
|
||||
}
|
||||
if (batchConfigApply.fallback_url && batchConfigForm.fallback_url.trim() && !isWebUrl(batchConfigForm.fallback_url.trim())) {
|
||||
return setBatchConfigError('兜底获客助手链接必须为有效的 HTTP/HTTPS 地址')
|
||||
}
|
||||
|
||||
const automation = cloneAutomationConfig(batchConfigForm.automation_config)
|
||||
if (batchConfigApply.reception && automation.reception_mode !== 'scheduled') automation.reception_schedule = []
|
||||
if (batchConfigApply.welcome && automation.welcome_mode !== 'channel') automation.welcome = { text: '', attachments: [] }
|
||||
if (batchConfigApply.welcome && (automation.welcome_mode !== 'channel' || !automation.welcome_schedule_enabled)) automation.welcome_schedule = []
|
||||
if (hasAutomationChange) {
|
||||
const validationConfig = defaultAutomationConfig()
|
||||
if (batchConfigApply.reception) copyAutomationSection(validationConfig, automation, ['reception_mode', 'reception_schedule', 'backup_member_admin_ids'])
|
||||
if (batchConfigApply.customer) copyAutomationSection(validationConfig, automation, ['tags_enabled', 'tag_ids', 'remark_enabled', 'remark_template', 'description_enabled', 'description'])
|
||||
if (batchConfigApply.welcome) copyAutomationSection(validationConfig, automation, ['welcome_mode', 'welcome', 'welcome_schedule_enabled', 'welcome_schedule'])
|
||||
const backupConflict = batchConfigApply.reception
|
||||
&& automation.backup_member_admin_ids.some((id) => batchPrimaryMemberUnion.value.includes(Number(id)))
|
||||
const validationError = backupConflict
|
||||
? '备用成员不能是任一所选方案的主接待成员'
|
||||
: validateAutomationConfig(validationConfig, batchSharedPrimaryMemberIds.value)
|
||||
if (validationError) return setBatchConfigError(validationError)
|
||||
}
|
||||
|
||||
const changes: {
|
||||
skip_verify?: 0 | 1
|
||||
fallback_url?: string
|
||||
status?: 0 | 1
|
||||
automation_config?: Record<string, unknown>
|
||||
} = {}
|
||||
if (batchConfigApply.skip_verify) changes.skip_verify = batchConfigForm.skip_verify === 1 ? 1 : 0
|
||||
if (batchConfigApply.fallback_url) changes.fallback_url = batchConfigForm.fallback_url.trim()
|
||||
if (batchConfigApply.status) changes.status = batchConfigForm.status === 1 ? 1 : 0
|
||||
if (hasAutomationChange) {
|
||||
const serialized = serializeAutomationConfig(automation) as unknown as Record<string, unknown>
|
||||
const automationPatch: Record<string, unknown> = {}
|
||||
const assignKeys = (keys: string[]) => keys.forEach((key) => { automationPatch[key] = serialized[key] })
|
||||
if (batchConfigApply.reception) assignKeys(['reception_mode', 'reception_schedule', 'backup_member_admin_ids'])
|
||||
if (batchConfigApply.customer) assignKeys(['tags_enabled', 'tag_ids', 'remark_enabled', 'remark_template', 'description_enabled', 'description'])
|
||||
if (batchConfigApply.welcome) assignKeys(['welcome_mode', 'welcome', 'welcome_schedule_enabled', 'welcome_schedule'])
|
||||
changes.automation_config = automationPatch
|
||||
}
|
||||
|
||||
savingBatchConfig.value = true
|
||||
try {
|
||||
const result = await wecomPromotionBatchUpdatePools({
|
||||
pool_ids: [...batchConfigForm.pool_ids],
|
||||
changes
|
||||
})
|
||||
if (result.updated === 0) {
|
||||
const detail = result.results
|
||||
.slice(0, 2)
|
||||
.map((item) => `${item.name || `方案 ${item.id}`}:${item.error || '保存失败'}`)
|
||||
.join(';')
|
||||
return setBatchConfigError(detail || '所选方案均未能保存,请检查配置后重试')
|
||||
}
|
||||
batchConfigDialogVisible.value = false
|
||||
selectedPoolIds.value = []
|
||||
await loadOverview()
|
||||
if (result.failed > 0) {
|
||||
const detail = result.results
|
||||
.filter((item) => !item.success)
|
||||
.slice(0, 2)
|
||||
.map((item) => `${item.name || `方案 ${item.id}`}:${item.error || '保存失败'}`)
|
||||
.join(';')
|
||||
ElMessage.warning(`已更新 ${result.updated} 个方案,${result.failed} 个失败${detail ? `。${detail}` : ''}`)
|
||||
} else if (result.sync_error_count > 0) {
|
||||
ElMessage.warning(`已更新 ${result.updated} 个方案,其中 ${result.sync_error_count} 个企微范围将在后台自动重试同步`)
|
||||
} else if (result.sync_queued_count > 0) {
|
||||
ElMessage.success(`已批量更新 ${result.updated} 个分流方案,${result.sync_queued_count} 个企微链接配置将在后台同步`)
|
||||
} else {
|
||||
ElMessage.success(`已批量更新 ${result.updated} 个分流方案`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
setBatchConfigError(error?.message || '批量修改分流方案失败')
|
||||
} finally {
|
||||
savingBatchConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAccessDialog(poolIds: number[] = selectedPoolIds.value) {
|
||||
const manageableIds = new Set(overview.pools
|
||||
.filter((pool: any) => pool.can_manage_access)
|
||||
@@ -1319,18 +1567,18 @@ h1, h2, h3, p { margin: 0; }
|
||||
.section-heading-actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.section-heading-actions .el-button + .el-button { margin-left: 0; }
|
||||
.selection-count { color: #117f75; font-size: 11px; font-weight: 600; }
|
||||
.pool-layout { display: grid; grid-template-columns: 252px minmax(0, 1fr); min-height: 430px; border: 1px solid var(--line); border-radius: 11px; overflow: hidden; }
|
||||
.pool-sidebar { padding: 8px; border-right: 1px solid var(--line); background: #f7f9fa; }
|
||||
.pool-layout { display: grid; grid-template-columns: 252px minmax(0, 1fr); height: clamp(430px, calc(100vh - 330px), 720px); min-height: 430px; border: 1px solid var(--line); border-radius: 11px; overflow: hidden; }
|
||||
.pool-sidebar { min-height: 0; padding: 8px; border-right: 1px solid var(--line); overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; background: #f7f9fa; }
|
||||
.pool-select-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: 4px; margin-bottom: 5px; }
|
||||
.pool-select-row :deep(.el-checkbox) { justify-content: center; margin-right: 0; }
|
||||
.pool-item { display: grid; grid-template-columns: 9px minmax(0, 1fr) 16px; align-items: center; gap: 9px; width: 100%; min-height: 62px; padding: 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; background: transparent; cursor: pointer; }
|
||||
.pool-item:hover { background: #fff; }.pool-select-row.active .pool-item { border-color: #bfe1dc; background: #fff; box-shadow: 0 5px 16px rgba(25,69,70,.05); }
|
||||
.pool-item strong, .pool-item small { display: block; }.pool-item strong { overflow: hidden; color: #253348; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.pool-item small { margin-top: 4px; color: #8c98a7; font-size: 10px; }
|
||||
.pool-status { width: 8px; height: 8px; border-radius: 50%; }.pool-status.online { background: #18a277; }.pool-status.offline { background: #aab3bf; }.pool-item > .el-icon { color: #9ca7b4; }
|
||||
.pool-main { min-width: 0; }.pool-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 78px; padding: 12px 15px; border-bottom: 1px solid var(--line); }
|
||||
.pool-main { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; }.pool-toolbar { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 16px; min-height: 78px; padding: 12px 15px; border-bottom: 1px solid var(--line); }
|
||||
.pool-title-row { gap: 8px; }.pool-title-row h3 { font-size: 15px; }.pool-toolbar p { margin-top: 6px; color: #8b97a6; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; }.toolbar-actions { flex-wrap: wrap; justify-content: flex-end; gap: 7px; }.toolbar-actions .el-button + .el-button { margin-left: 0; }
|
||||
.status-tag, .owner-tag, .availability { display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border-radius: 11px; font-size: 10px; }.status-tag.is-online, .availability.is-ok { color: #16895f; background: #eaf8ef; }.status-tag.is-offline, .availability.is-muted { color: #788696; background: #eef2f5; }.owner-tag { color: #50728c; background: #edf4f8; }.availability.is-error { color: #d94b4b; background: #ffeded; }.availability.is-waiting { color: #b86c1f; background: #fff1dd; }
|
||||
.link-table, .account-table { --el-table-header-bg-color: #f7f9fb; }.link-table :deep(th.el-table__cell), .account-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.member-cell strong, .member-cell small { display: block; }.member-cell small { margin-top: 3px; color: #8d98a7; font-size: 10px; }.muted { color: #9aa4b0; }
|
||||
.legacy-sync-alert { flex: 0 0 auto; }.member-table-area { min-height: 0; flex: 1 1 auto; overflow: hidden; }.link-table, .account-table { --el-table-header-bg-color: #f7f9fb; }.link-table :deep(th.el-table__cell), .account-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.member-cell strong, .member-cell small { display: block; }.member-cell small { margin-top: 3px; color: #8d98a7; font-size: 10px; }.muted { color: #9aa4b0; }
|
||||
.remote-id { display: block; overflow: hidden; color: #68778b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.link-id-cell { display: grid; gap: 4px; min-width: 0; }
|
||||
.wecom-url { display: block; overflow: hidden; color: #148f83; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.4; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -1368,9 +1616,15 @@ h1, h2, h3, p { margin: 0; }
|
||||
.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; }
|
||||
.batch-config-error { margin-top: 14px; }
|
||||
.batch-pool-summary { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-top: 14px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; background: #f8fafb; }
|
||||
.batch-pool-summary strong { margin-right: 3px; font-size: 12px; }.batch-pool-summary span { padding: 4px 8px; border-radius: 12px; color: #426078; background: #eaf1f5; font-size: 10px; }.batch-pool-summary small { color: #8491a2; font-size: 10px; }
|
||||
.batch-config-form { margin-top: 16px; }.batch-config-section { margin-bottom: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 9px; background: #fff; }.batch-config-section > h3 { margin: 0 0 14px; color: #303b4d; font-size: 14px; }
|
||||
.batch-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }.batch-field { padding: 12px; border: 1px solid #dfe6ec; border-radius: 8px; transition: opacity .2s ease, background .2s ease; }.batch-field.is-disabled { opacity: .58; background: #f7f8fa; }.batch-field :deep(.el-form-item) { margin: 10px 0 0; }
|
||||
.batch-section-tip { margin: -6px 0 12px; color: #8491a2; font-size: 11px; line-height: 1.6; }.batch-section-selectors { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }.batch-section-selectors :deep(.el-checkbox) { margin-right: 0; }.automation-batch-section :deep(.automation-note) { margin-top: 14px; }
|
||||
.uploading-save-tip { color: #b88230; font-size: 12px; margin-right: 16px; }
|
||||
.sync-retry-tip { color: #b88230; font-size: 10px; line-height: 1.6; margin-top: 4px; }
|
||||
: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; } }
|
||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.section-heading-actions { width: 100%; justify-content: flex-start; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid, .rule-form-grid, .batch-field-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { height: auto; min-height: 0; grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--line); scrollbar-gutter: auto; }.pool-main { overflow: visible; }.member-table-area { height: 420px; min-height: 320px; flex: none; }.pool-select-row { min-width: 220px; }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; }.access-pool-preview > div { grid-template-columns: 1fr; gap: 3px; } }
|
||||
</style>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||
|
||||
# Single source of truth for runtime, package, installer, and executable versions.
|
||||
__version__ = "1.4.0"
|
||||
__version__ = "1.4.1"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
DEBUG_MODE = True
|
||||
DEBUG_MODE = False
|
||||
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||
|
||||
@@ -758,9 +758,11 @@ class DiagnosisDialog(QDialog):
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
permissions: Any = None,
|
||||
embedded: bool = False,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.repository = repository
|
||||
self._embedded = bool(embedded)
|
||||
self.permissions = (
|
||||
permissions
|
||||
if permissions is not None
|
||||
@@ -882,11 +884,17 @@ class DiagnosisDialog(QDialog):
|
||||
)
|
||||
|
||||
self.setObjectName("DiagnosisDialogRoot")
|
||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
if self._embedded:
|
||||
self.setWindowFlags(Qt.WindowType.Widget)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False)
|
||||
self.setMinimumSize(0, 0)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||
else:
|
||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setMinimumSize(760, 520)
|
||||
self.resize(1024, 640)
|
||||
self.setWindowTitle("患者信息详情")
|
||||
self.setMinimumSize(760, 520)
|
||||
self.resize(1024, 640)
|
||||
self.setStyleSheet(DIAGNOSIS_QSS)
|
||||
|
||||
self.view_stack = QStackedLayout(self)
|
||||
@@ -911,16 +919,25 @@ class DiagnosisDialog(QDialog):
|
||||
root = QVBoxLayout(page)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
self.readonly_header = QWidget(page)
|
||||
self.readonly_header.setObjectName("DiagnosisReadonlyHeader")
|
||||
header_layout = QVBoxLayout(self.readonly_header)
|
||||
header_layout.setContentsMargins(16, 16, 16, 0)
|
||||
header_layout.setSpacing(0)
|
||||
self.readonly_hero = self._build_readonly_hero()
|
||||
header_layout.addWidget(self.readonly_hero)
|
||||
root.addWidget(self.readonly_header)
|
||||
self.readonly_scroll = QScrollArea()
|
||||
self.readonly_scroll.setObjectName("DiagnosisReadonlyScroll")
|
||||
self.readonly_scroll.setWidgetResizable(True)
|
||||
self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.readonly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.readonly_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.readonly_scroll.verticalScrollBar().setSingleStep(28)
|
||||
content = QWidget()
|
||||
self.readonly_content_layout = QVBoxLayout(content)
|
||||
self.readonly_content_layout.setContentsMargins(16, 16, 16, 16)
|
||||
self.readonly_content_layout.setSpacing(16)
|
||||
self.readonly_hero = self._build_readonly_hero()
|
||||
self.readonly_content_layout.addWidget(self.readonly_hero)
|
||||
self.readonly_error = QFrame()
|
||||
self.readonly_error.setObjectName("DiagnosisReadonlyErrorCard")
|
||||
self.readonly_error.setProperty("diagnosisReadonlyCard", True)
|
||||
@@ -981,16 +998,16 @@ class DiagnosisDialog(QDialog):
|
||||
left_layout = QHBoxLayout(self.readonly_hero_left)
|
||||
left_layout.setContentsMargins(0, 0, 0, 0)
|
||||
left_layout.setSpacing(12)
|
||||
back = QPushButton("← 返回")
|
||||
back.setObjectName("DiagnosisReadonlyBack")
|
||||
back.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
back.setStyleSheet(
|
||||
self.readonly_back_button = QPushButton("← 收起资料" if self._embedded else "← 返回")
|
||||
self.readonly_back_button.setObjectName("DiagnosisReadonlyBack")
|
||||
self.readonly_back_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.readonly_back_button.setStyleSheet(
|
||||
"QPushButton{height:32px;padding:0 8px;border:0;background:transparent;"
|
||||
"color:#5265F6;font-size:13px;font-weight:500;}"
|
||||
"QPushButton:hover,QPushButton:focus{background:#F0F2FF;border-radius:6px;}"
|
||||
)
|
||||
back.clicked.connect(self.reject)
|
||||
left_layout.addWidget(back)
|
||||
self.readonly_back_button.clicked.connect(self.reject)
|
||||
left_layout.addWidget(self.readonly_back_button)
|
||||
title = QLabel("患者信息详情")
|
||||
title.setObjectName("DiagnosisReadonlyTitle")
|
||||
left_layout.addWidget(title)
|
||||
@@ -1009,6 +1026,13 @@ class DiagnosisDialog(QDialog):
|
||||
self.readonly_status.setObjectName("DiagnosisReadonlyStatus")
|
||||
self.readonly_status.setProperty("severity", "neutral")
|
||||
right_layout.addWidget(self.readonly_status)
|
||||
self.readonly_close_button = QPushButton("×")
|
||||
self.readonly_close_button.setObjectName("DiagnosisCloseButton")
|
||||
self.readonly_close_button.setToolTip("关闭诊单详情")
|
||||
self.readonly_close_button.setAccessibleName("关闭诊单详情")
|
||||
self.readonly_close_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.readonly_close_button.clicked.connect(self.reject)
|
||||
right_layout.addWidget(self.readonly_close_button)
|
||||
layout.addWidget(self.readonly_hero_left, 0, 0)
|
||||
layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight)
|
||||
layout.setColumnStretch(0, 1)
|
||||
@@ -1790,6 +1814,8 @@ class DiagnosisDialog(QDialog):
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
def _sync_host_geometry(self) -> None:
|
||||
if self._embedded:
|
||||
return
|
||||
owner = self._owner
|
||||
if owner is None:
|
||||
if self.width() < 760 or self.height() < 520:
|
||||
@@ -1823,6 +1849,8 @@ class DiagnosisDialog(QDialog):
|
||||
layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
def _install_owner_filter(self) -> None:
|
||||
if self._embedded:
|
||||
return
|
||||
if self._owner is not None and not self._owner_filter_installed:
|
||||
self._owner.installEventFilter(self)
|
||||
self._owner_filter_installed = True
|
||||
@@ -1830,6 +1858,10 @@ class DiagnosisDialog(QDialog):
|
||||
def _rebind_owner(self) -> None:
|
||||
"""Resolve the live Shell window for every open/show cycle."""
|
||||
|
||||
if self._embedded:
|
||||
self._owner = None
|
||||
self._owner_filter_installed = False
|
||||
return
|
||||
parent = self.parentWidget()
|
||||
candidate = parent.window() if parent is not None else None
|
||||
if candidate is self:
|
||||
@@ -1852,8 +1884,9 @@ class DiagnosisDialog(QDialog):
|
||||
super().resizeEvent(event)
|
||||
|
||||
def showEvent(self, event: Any) -> None:
|
||||
self._rebind_owner()
|
||||
self._sync_host_geometry()
|
||||
if not self._embedded:
|
||||
self._rebind_owner()
|
||||
self._sync_host_geometry()
|
||||
self._update_drawer_geometry()
|
||||
self._reflow_readonly_hero()
|
||||
super().showEvent(event)
|
||||
@@ -1938,10 +1971,12 @@ class DiagnosisDialog(QDialog):
|
||||
*,
|
||||
editable: bool = False,
|
||||
seed: Any = None,
|
||||
authoritative_detail: Any = None,
|
||||
view_only: bool = False,
|
||||
modeless: bool = False,
|
||||
auto_show: bool = True,
|
||||
) -> None:
|
||||
"""Open immediately, then replace the seed with authoritative server data."""
|
||||
"""Prepare a diagnosis view, optionally showing it immediately."""
|
||||
|
||||
self._rebind_owner()
|
||||
for player in list(self._recording_players):
|
||||
@@ -1986,13 +2021,13 @@ class DiagnosisDialog(QDialog):
|
||||
self._daily_todo_status = None
|
||||
self._orders_page = 1
|
||||
self._orders_total = 0
|
||||
self._detail = seed
|
||||
self._detail = authoritative_detail if authoritative_detail is not None else seed
|
||||
self.save_button.set_state("idle")
|
||||
self.refresh_permissions()
|
||||
self.view_stack.setCurrentWidget(
|
||||
self.readonly_page if self._standalone_readonly else self.drawer_overlay
|
||||
)
|
||||
self.setModal(not modeless and not self._standalone_readonly)
|
||||
self.setModal(False if self._embedded else not modeless and not self._standalone_readonly)
|
||||
self.setWindowTitle(
|
||||
"患者信息详情"
|
||||
if self._standalone_readonly
|
||||
@@ -2018,15 +2053,38 @@ class DiagnosisDialog(QDialog):
|
||||
)
|
||||
self._clear_tables()
|
||||
self._clear_message()
|
||||
if seed is not None:
|
||||
self._render(seed, [], [])
|
||||
if self._detail is not None:
|
||||
self._render(self._detail, [], [])
|
||||
self._sync_form_interactivity()
|
||||
self._sync_save_button()
|
||||
self._sync_host_geometry()
|
||||
if auto_show:
|
||||
self.show()
|
||||
if not self._embedded:
|
||||
self.raise_()
|
||||
if authoritative_detail is not None:
|
||||
diagnosis = get_value(authoritative_detail, "diagnosis", None) or authoritative_detail
|
||||
patient = get_value(authoritative_detail, "patient", None) or {}
|
||||
self._patient_id = _int(
|
||||
first_value(
|
||||
diagnosis,
|
||||
"patient_id",
|
||||
"source_patient_id",
|
||||
default=first_value(patient, "patient_id", "id", default=0),
|
||||
),
|
||||
0,
|
||||
)
|
||||
self._authoritative_detail_loaded = True
|
||||
self._show_authoritative_content(True)
|
||||
self._clear_message()
|
||||
self._set_loading(False)
|
||||
if self._standalone_readonly:
|
||||
self._load_visible_readonly_sections()
|
||||
else:
|
||||
self._ensure_tab_loaded(self._current_tab_key())
|
||||
return
|
||||
self._show_message("正在加载权威诊单详情…", "info")
|
||||
self._set_loading(True)
|
||||
self._sync_host_geometry()
|
||||
self.show()
|
||||
self.raise_()
|
||||
self._start_detail_load()
|
||||
|
||||
def _start_detail_load(self) -> None:
|
||||
|
||||
@@ -16,7 +16,7 @@ import re
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from datetime import date, datetime
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
@@ -80,6 +80,7 @@ from PySide6.QtWidgets import (
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QSplitter,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QTabWidget,
|
||||
@@ -104,6 +105,7 @@ from ..widgets import (
|
||||
run_async,
|
||||
show_toast,
|
||||
)
|
||||
from .diagnosis import DiagnosisDialog as _StructuredDiagnosisDialog
|
||||
|
||||
PRESCRIPTION_DRAWER_QSS = r"""
|
||||
QFrame#PrescriptionDrawerSurface {
|
||||
@@ -2697,6 +2699,7 @@ class PrescriptionEditorDialog(QDialog):
|
||||
self._source = _mapping(prescription)
|
||||
self._loading_data = False
|
||||
self._linked_order_generation = 0
|
||||
self._diagnosis_view: _StructuredDiagnosisDialog | None = None
|
||||
self._prescribing_creator_id = _int(
|
||||
first_value(
|
||||
prescription,
|
||||
@@ -2709,17 +2712,37 @@ class PrescriptionEditorDialog(QDialog):
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(420, 600)
|
||||
self.resize(self.DRAWER_WIDTH, 900)
|
||||
root = QVBoxLayout(self)
|
||||
root = QHBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
self.workspace_splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||
self.workspace_splitter.setObjectName("PrescriptionWorkspaceSplitter")
|
||||
self.workspace_splitter.setChildrenCollapsible(False)
|
||||
self.workspace_splitter.setHandleWidth(1)
|
||||
root.addWidget(self.workspace_splitter)
|
||||
|
||||
self.diagnosis_host = QFrame(self.workspace_splitter)
|
||||
self.diagnosis_host.setObjectName("PrescriptionDiagnosisPane")
|
||||
self.diagnosis_host.setMinimumWidth(300)
|
||||
diagnosis_layout = QVBoxLayout(self.diagnosis_host)
|
||||
diagnosis_layout.setContentsMargins(0, 0, 0, 0)
|
||||
diagnosis_layout.setSpacing(0)
|
||||
self.diagnosis_layout = diagnosis_layout
|
||||
self.workspace_splitter.addWidget(self.diagnosis_host)
|
||||
self.diagnosis_host.hide()
|
||||
|
||||
self.drawer_surface = QFrame()
|
||||
self.drawer_surface.setObjectName("PrescriptionDrawerSurface")
|
||||
self.drawer_surface.setStyleSheet(_prescription_drawer_qss())
|
||||
self.drawer_surface.setMinimumWidth(self.minimumWidth())
|
||||
self.drawer_surface.setMaximumWidth(self.DRAWER_WIDTH)
|
||||
surface_layout = QVBoxLayout(self.drawer_surface)
|
||||
surface_layout.setContentsMargins(0, 0, 0, 0)
|
||||
surface_layout.setSpacing(0)
|
||||
root.addWidget(self.drawer_surface)
|
||||
self.workspace_splitter.addWidget(self.drawer_surface)
|
||||
self.workspace_splitter.setStretchFactor(0, 1)
|
||||
self.workspace_splitter.setStretchFactor(1, 0)
|
||||
|
||||
self.header = self._build_header()
|
||||
surface_layout.addWidget(self.header)
|
||||
@@ -2920,7 +2943,7 @@ class PrescriptionEditorDialog(QDialog):
|
||||
self.diagnosis_button.setProperty("size", "small")
|
||||
self.diagnosis_button.setVisible(diagnosis_id > 0)
|
||||
self.diagnosis_button.clicked.connect(
|
||||
lambda _checked=False, value=diagnosis_id: self.diagnosis_requested.emit(value)
|
||||
lambda _checked=False, value=diagnosis_id: self._toggle_diagnosis_view(value)
|
||||
)
|
||||
diagnosis_layout.addWidget(self.diagnosis_button)
|
||||
self.diagnosis_id_hint = QLabel(f"关联诊单 #{diagnosis_id}" if diagnosis_id > 0 else "")
|
||||
@@ -3237,15 +3260,58 @@ class PrescriptionEditorDialog(QDialog):
|
||||
target = targets[max(0, min(len(targets) - 1, index))]
|
||||
self.body_scroll.ensureWidgetVisible(target, 0, 24)
|
||||
|
||||
def _toggle_diagnosis_view(self, diagnosis_id: int) -> None:
|
||||
if self.diagnosis_host.isVisible():
|
||||
if self._diagnosis_view is not None:
|
||||
self._diagnosis_view.reject()
|
||||
return
|
||||
if diagnosis_id <= 0:
|
||||
return
|
||||
if not has_permission(self.permissions, "tcm.diagnosis/readonlyDetail", default=True):
|
||||
self.context_banner.show_message("无权查看诊单详情。", "danger")
|
||||
return
|
||||
if self._diagnosis_view is None:
|
||||
self._diagnosis_view = _StructuredDiagnosisDialog(
|
||||
self.repository,
|
||||
self.diagnosis_host,
|
||||
permissions=self.permissions,
|
||||
embedded=True,
|
||||
)
|
||||
self._diagnosis_view.finished.connect(
|
||||
lambda _result, view=self._diagnosis_view: self._diagnosis_view_finished(view)
|
||||
)
|
||||
self.diagnosis_layout.addWidget(self._diagnosis_view)
|
||||
self.diagnosis_host.show()
|
||||
self.diagnosis_button.setText("收起患者诊单")
|
||||
self._fit_drawer_geometry()
|
||||
self._diagnosis_view.open_for(diagnosis_id, editable=False)
|
||||
|
||||
def _diagnosis_view_finished(self, view: _StructuredDiagnosisDialog) -> None:
|
||||
if view is not self._diagnosis_view:
|
||||
return
|
||||
self.diagnosis_host.hide()
|
||||
self.diagnosis_button.setText("查看患者诊单详情")
|
||||
self._fit_drawer_geometry()
|
||||
|
||||
def _fit_drawer_geometry(self) -> None:
|
||||
parent = self.parentWidget()
|
||||
if parent is None:
|
||||
return
|
||||
anchor = parent.window()
|
||||
origin = anchor.mapToGlobal(QPoint(0, 0))
|
||||
width = min(self.DRAWER_WIDTH, max(self.minimumWidth(), anchor.width()))
|
||||
expanded = self.diagnosis_host.isVisible()
|
||||
width = (
|
||||
anchor.width()
|
||||
if expanded
|
||||
else min(self.DRAWER_WIDTH, max(self.minimumWidth(), anchor.width()))
|
||||
)
|
||||
height = max(self.minimumHeight(), anchor.height())
|
||||
self.setGeometry(origin.x() + anchor.width() - width, origin.y(), width, height)
|
||||
if expanded:
|
||||
editor_width = min(self.DRAWER_WIDTH, max(520, round(width * 0.55)))
|
||||
self.workspace_splitter.setSizes([max(300, width - editor_width - 1), editor_width])
|
||||
else:
|
||||
self.workspace_splitter.setSizes([0, width])
|
||||
|
||||
def showEvent(self, event: Any) -> None:
|
||||
super().showEvent(event)
|
||||
@@ -3836,6 +3902,11 @@ class PrescriptionEditorDialog(QDialog):
|
||||
self.validation.clear()
|
||||
super().accept()
|
||||
|
||||
def done(self, result: int) -> None:
|
||||
if self._diagnosis_view is not None and self._diagnosis_view.isVisible():
|
||||
self._diagnosis_view.reject()
|
||||
super().done(result)
|
||||
|
||||
|
||||
class PatchPatientDialog(QDialog):
|
||||
"""Narrow patient identity correction that preserves audit state."""
|
||||
@@ -5102,8 +5173,8 @@ class PrescriptionDetailDialog(QDialog):
|
||||
painter.end()
|
||||
|
||||
|
||||
class DiagnosisDetailDialog(QDialog):
|
||||
"""Read-only diagnosis view preserving the important admin tab boundaries."""
|
||||
class DiagnosisDetailDialog(_StructuredDiagnosisDialog):
|
||||
"""Compatibility entry that renders prescription-linked diagnoses with the shared UI."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -5113,180 +5184,29 @@ class DiagnosisDetailDialog(QDialog):
|
||||
repository: Any = None,
|
||||
permissions: Any = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
source = _mapping(diagnosis)
|
||||
self.repository = repository
|
||||
self.permissions = permissions
|
||||
self._order_detail_generation = 0
|
||||
self._order_detail_order_id = 0
|
||||
self._order_detail_table: QTableWidget | None = None
|
||||
self._order_detail_button: QPushButton | None = None
|
||||
if repository is None:
|
||||
raise ValueError("repository is required to show diagnosis details")
|
||||
super().__init__(repository, parent, permissions=permissions)
|
||||
self.setWindowTitle("诊单详情(只读)")
|
||||
self.resize(880, 700)
|
||||
root = QVBoxLayout(self)
|
||||
tabs = QTabWidget()
|
||||
groups = (
|
||||
(
|
||||
"病历",
|
||||
(
|
||||
"id",
|
||||
"patient_id",
|
||||
"patient_name",
|
||||
"gender",
|
||||
"age",
|
||||
"phone",
|
||||
"chief_complaint",
|
||||
"present_illness",
|
||||
"past_history",
|
||||
"diagnosis",
|
||||
"syndrome",
|
||||
"treatment",
|
||||
),
|
||||
source = _mapping(diagnosis)
|
||||
nested = _mapping(source.get("diagnosis"))
|
||||
diagnosis_id = _int(
|
||||
first_value(
|
||||
nested,
|
||||
"id",
|
||||
"diagnosis_id",
|
||||
default=first_value(source, "id", "diagnosis_id"),
|
||||
),
|
||||
("医生备注", ("doctor_notes", "doctor_note", "notes")),
|
||||
("日常记录", ("daily_records", "blood_records")),
|
||||
("处方", ("prescriptions", "case_records")),
|
||||
("沟通与指派", ("call_records", "chat_records", "assign_logs", "appointments")),
|
||||
0,
|
||||
)
|
||||
for title, keys in groups:
|
||||
browser = QTextBrowser()
|
||||
rows = []
|
||||
for key in keys:
|
||||
value = source.get(key)
|
||||
if value in (None, "", [], {}):
|
||||
continue
|
||||
rendered = (
|
||||
json.dumps(value, ensure_ascii=False, indent=2, default=str)
|
||||
if isinstance(value, (Mapping, list, tuple))
|
||||
else str(value)
|
||||
)
|
||||
rows.append(f"<h3>{html.escape(key)}</h3><pre>{html.escape(rendered)}</pre>")
|
||||
browser.setHtml("".join(rows) or "<p>暂无数据</p>")
|
||||
tabs.addTab(browser, title)
|
||||
tabs.addTab(self._build_orders_tab(source), "业务订单")
|
||||
root.addWidget(tabs, 1)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
root.addWidget(buttons)
|
||||
|
||||
def _build_orders_tab(self, source: Mapping[str, Any]) -> QWidget:
|
||||
host = QWidget()
|
||||
layout = QVBoxLayout(host)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
layout.setSpacing(8)
|
||||
rows: list[Any] = []
|
||||
for key in ("prescription_orders", "orders"):
|
||||
value = source.get(key)
|
||||
if isinstance(value, list):
|
||||
rows.extend(value)
|
||||
latest = source.get("latest_prescription_order")
|
||||
if isinstance(latest, Mapping) and latest:
|
||||
latest_id = _int(first_value(latest, "id", "order_id"), 0)
|
||||
if latest_id and not any(
|
||||
_int(first_value(row, "id", "order_id"), 0) == latest_id for row in rows
|
||||
):
|
||||
rows.insert(0, latest)
|
||||
if not rows:
|
||||
empty = QLabel("暂无关联业务订单")
|
||||
empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(empty, 1)
|
||||
return host
|
||||
table = QTableWidget(len(rows), 6)
|
||||
table.setHorizontalHeaderLabels(
|
||||
["订单号", "金额", "履约状态", "收货人", "手机", "创建时间"]
|
||||
)
|
||||
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
table.verticalHeader().hide()
|
||||
table.horizontalHeader().setStretchLastSection(True)
|
||||
self._order_detail_table = table
|
||||
for row_index, row in enumerate(rows):
|
||||
values = (
|
||||
first_value(row, "order_no", "sn", "id"),
|
||||
first_value(row, "amount", "effective_amount"),
|
||||
first_value(row, "fulfillment_status_text", "status_text", "status"),
|
||||
first_value(row, "recipient_name", "patient_name"),
|
||||
first_value(row, "recipient_phone", "phone"),
|
||||
first_value(row, "create_time_text", "create_time"),
|
||||
)
|
||||
for column, value in enumerate(values):
|
||||
item = QTableWidgetItem(display_text(value))
|
||||
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||
table.setItem(row_index, column, item)
|
||||
layout.addWidget(table, 1)
|
||||
actions = QHBoxLayout()
|
||||
view = QPushButton("查看订单详情")
|
||||
view.setProperty("variant", "primary")
|
||||
self._order_detail_button = view
|
||||
view.clicked.connect(self._open_selected_order)
|
||||
table.itemDoubleClicked.connect(lambda _item: self._open_selected_order())
|
||||
actions.addWidget(view)
|
||||
actions.addStretch(1)
|
||||
layout.addLayout(actions)
|
||||
return host
|
||||
|
||||
def _set_order_detail_loading(self, loading: bool) -> None:
|
||||
if self._order_detail_table is not None:
|
||||
self._order_detail_table.setEnabled(not loading)
|
||||
if self._order_detail_button is not None:
|
||||
self._order_detail_button.setEnabled(not loading)
|
||||
|
||||
def _open_selected_order(self) -> None:
|
||||
table = self._order_detail_table
|
||||
if table is None:
|
||||
return
|
||||
row = table.currentRow()
|
||||
item = table.item(row, 0) if row >= 0 else None
|
||||
order = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
if order is None:
|
||||
return
|
||||
order_id = _int(first_value(order, "id", "order_id"), 0)
|
||||
self._order_detail_generation += 1
|
||||
generation = self._order_detail_generation
|
||||
self._order_detail_order_id = order_id
|
||||
getter = getattr(self.repository, "get_prescription_order", None)
|
||||
if order_id <= 0 or not callable(getter):
|
||||
self._set_order_detail_loading(False)
|
||||
self._present_order_detail(order, order_id)
|
||||
return
|
||||
|
||||
self._set_order_detail_loading(True)
|
||||
run_async(
|
||||
lambda: getter(order_id),
|
||||
on_success=lambda result: self._order_detail_success(result, order_id, generation),
|
||||
on_error=lambda error: self._order_detail_error(error, order, order_id, generation),
|
||||
on_finished=lambda: self._order_detail_finished(order_id, generation),
|
||||
)
|
||||
|
||||
def _order_detail_success(self, order: Any, order_id: int, generation: int) -> None:
|
||||
if generation != self._order_detail_generation or order_id != self._order_detail_order_id:
|
||||
return
|
||||
self._present_order_detail(order, order_id)
|
||||
|
||||
def _order_detail_error(
|
||||
self,
|
||||
_error: Exception,
|
||||
fallback_order: Any,
|
||||
order_id: int,
|
||||
generation: int,
|
||||
) -> None:
|
||||
if generation != self._order_detail_generation or order_id != self._order_detail_order_id:
|
||||
return
|
||||
self._present_order_detail(fallback_order, order_id)
|
||||
|
||||
def _order_detail_finished(self, order_id: int, generation: int) -> None:
|
||||
if generation == self._order_detail_generation and order_id == self._order_detail_order_id:
|
||||
self._set_order_detail_loading(False)
|
||||
|
||||
def _present_order_detail(self, order: Any, order_id: int) -> None:
|
||||
from .diagnosis import present_order_detail
|
||||
|
||||
present_order_detail(
|
||||
self.window() if self.window() is not None else self,
|
||||
order,
|
||||
order_id=order_id,
|
||||
permissions=self.permissions,
|
||||
exec_=True,
|
||||
if diagnosis_id <= 0:
|
||||
raise ValueError("diagnosis detail is missing a valid diagnosis id")
|
||||
self.open_for(
|
||||
diagnosis_id,
|
||||
editable=False,
|
||||
seed=source,
|
||||
authoritative_detail=source,
|
||||
auto_show=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -639,6 +639,23 @@ def test_readonly_is_an_independent_vertical_page_flow(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_readonly_scroll_keeps_close_controls_reachable(application: QApplication) -> None:
|
||||
dialog = _open_dialog(application, (760, 520), mode="readonly")
|
||||
header_before = dialog.readonly_header.geometry()
|
||||
scroll_bar = dialog.readonly_scroll.verticalScrollBar()
|
||||
|
||||
assert scroll_bar.maximum() > 0
|
||||
assert dialog.readonly_close_button.isVisibleTo(dialog)
|
||||
scroll_bar.setValue(scroll_bar.maximum())
|
||||
application.processEvents()
|
||||
|
||||
assert dialog.readonly_header.geometry() == header_before
|
||||
assert dialog.readonly_close_button.isVisibleTo(dialog)
|
||||
dialog.readonly_close_button.click()
|
||||
application.processEvents()
|
||||
assert not dialog.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
|
||||
@pytest.mark.parametrize("mode", ["edit", "viewOnly"])
|
||||
def test_drawer_is_full_height_rtl_and_sixty_percent_wide(
|
||||
|
||||
@@ -288,122 +288,121 @@ def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_order_detail_lookup_is_queued_before_repository_call(
|
||||
def test_prescription_diagnosis_detail_uses_shared_structured_readonly_ui(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
queued: list[tuple[Any, dict[str, Any]]] = []
|
||||
requested: list[int] = []
|
||||
shown: list[tuple[int, str]] = []
|
||||
|
||||
class Repository:
|
||||
def get_prescription_order(self, order_id: int) -> dict[str, Any]:
|
||||
requested.append(order_id)
|
||||
return {"id": order_id, "order_no": f"DETAIL-{order_id}"}
|
||||
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
||||
assert diagnosis_id == 745
|
||||
return []
|
||||
|
||||
def queue_async(function: Any, **options: Any) -> object:
|
||||
queued.append((function, options))
|
||||
return object()
|
||||
|
||||
def present_order_detail(
|
||||
_host: Any,
|
||||
order: dict[str, Any],
|
||||
*,
|
||||
order_id: int,
|
||||
permissions: Any,
|
||||
exec_: bool,
|
||||
) -> None:
|
||||
del permissions, exec_
|
||||
shown.append((order_id, order["order_no"]))
|
||||
|
||||
monkeypatch.setattr(dialog_module, "run_async", queue_async)
|
||||
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
|
||||
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
|
||||
dialog = DiagnosisDetailDialog(
|
||||
{"orders": [{"id": 17, "order_no": "ROW-17"}]},
|
||||
{
|
||||
"diagnosis": {
|
||||
"id": 745,
|
||||
"patient_id": 745,
|
||||
"patient_name": "庄志芳",
|
||||
"phone": "13823549442",
|
||||
"id_card": "440305196701011234",
|
||||
"gender": 0,
|
||||
"age": 59,
|
||||
"chief_complaint": "睡眠不好、出汗多",
|
||||
"past_history": ["高血压", "高脂血症"],
|
||||
},
|
||||
"patient": {"id": 745},
|
||||
},
|
||||
repository=Repository(),
|
||||
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]),
|
||||
)
|
||||
table = dialog._order_detail_table
|
||||
button = dialog._order_detail_button
|
||||
assert table is not None
|
||||
assert button is not None
|
||||
table.setCurrentCell(0, 0)
|
||||
|
||||
button.click()
|
||||
|
||||
assert len(queued) == 1
|
||||
assert requested == []
|
||||
assert shown == []
|
||||
assert not table.isEnabled()
|
||||
assert not button.isEnabled()
|
||||
|
||||
function, options = queued[0]
|
||||
options["on_success"](function())
|
||||
options["on_finished"]()
|
||||
assert requested == [17]
|
||||
assert shown == [(17, "DETAIL-17")]
|
||||
assert table.isEnabled()
|
||||
assert button.isEnabled()
|
||||
assert isinstance(dialog, DiagnosisDialog)
|
||||
assert dialog.view_stack.currentWidget() is dialog.readonly_page
|
||||
assert dialog.edit_fields["chief_complaint"].toPlainText() == "睡眠不好、出汗多"
|
||||
assert dialog.summary_fields["phone"].text() == "138****9442"
|
||||
assert dialog.case_grid.isVisibleTo(dialog)
|
||||
assert not dialog.findChildren(dialog_module.QTextBrowser)
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_order_detail_ignores_stale_result_and_keeps_row_fallback(
|
||||
@pytest.mark.parametrize("owner_width", [1024, 1440, 1710])
|
||||
def test_prescription_editor_embeds_scrollable_diagnosis_beside_editable_form(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
owner_width: int,
|
||||
) -> None:
|
||||
queued: list[dict[str, Any]] = []
|
||||
shown: list[tuple[int, str]] = []
|
||||
class Repository:
|
||||
def get_diagnosis_detail(
|
||||
self, diagnosis_id: int, *, readonly: bool = False
|
||||
) -> dict[str, Any]:
|
||||
assert readonly
|
||||
return {
|
||||
"diagnosis": {
|
||||
"id": diagnosis_id,
|
||||
"patient_id": 745,
|
||||
"patient_name": "庄志芳",
|
||||
"chief_complaint": "睡眠不好、出汗多",
|
||||
}
|
||||
}
|
||||
|
||||
def queue_async(_function: Any, **options: Any) -> object:
|
||||
queued.append(options)
|
||||
return object()
|
||||
def get_doctor_notes(self, _diagnosis_id: int) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def present_order_detail(
|
||||
_host: Any,
|
||||
order: dict[str, Any],
|
||||
*,
|
||||
order_id: int,
|
||||
permissions: Any,
|
||||
exec_: bool,
|
||||
) -> None:
|
||||
del permissions, exec_
|
||||
shown.append((order_id, order["order_no"]))
|
||||
|
||||
repository = SimpleNamespace(get_prescription_order=lambda order_id: {"id": order_id})
|
||||
monkeypatch.setattr(dialog_module, "run_async", queue_async)
|
||||
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
|
||||
dialog = DiagnosisDetailDialog(
|
||||
{
|
||||
"orders": [
|
||||
{"id": 21, "order_no": "ROW-21"},
|
||||
{"id": 22, "order_no": "ROW-22"},
|
||||
]
|
||||
},
|
||||
repository=repository,
|
||||
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
|
||||
owner = QDialog()
|
||||
owner.resize(owner_width, 720)
|
||||
owner.show()
|
||||
repository = Repository()
|
||||
editor = PrescriptionEditorDialog(
|
||||
repository,
|
||||
{"diagnosis_id": 745, "patient_name": "庄志芳"},
|
||||
mode="edit",
|
||||
current_user=SimpleNamespace(id=9, name="周医生"),
|
||||
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]),
|
||||
parent=owner,
|
||||
)
|
||||
table = dialog._order_detail_table
|
||||
button = dialog._order_detail_button
|
||||
assert table is not None
|
||||
assert button is not None
|
||||
editor.show()
|
||||
application.processEvents()
|
||||
assert QApplication.activeModalWidget() is editor
|
||||
assert editor.width() == editor.DRAWER_WIDTH
|
||||
|
||||
table.setCurrentCell(0, 0)
|
||||
dialog._open_selected_order()
|
||||
table.setCurrentCell(1, 0)
|
||||
dialog._open_selected_order()
|
||||
assert len(queued) == 2
|
||||
editor.diagnosis_button.click()
|
||||
application.processEvents()
|
||||
|
||||
queued[0]["on_success"]({"id": 21, "order_no": "STALE-21"})
|
||||
queued[0]["on_finished"]()
|
||||
assert shown == []
|
||||
assert not table.isEnabled()
|
||||
assert not button.isEnabled()
|
||||
detail = editor._diagnosis_view
|
||||
assert detail is not None
|
||||
assert QApplication.activeModalWidget() is editor
|
||||
assert not detail.isWindow()
|
||||
assert detail.parentWidget() is editor.diagnosis_host
|
||||
assert editor.diagnosis_host.isVisibleTo(editor)
|
||||
assert editor.drawer_surface.isVisibleTo(editor)
|
||||
assert editor.width() == owner.width()
|
||||
assert editor.diagnosis_host.geometry().right() < editor.drawer_surface.geometry().left()
|
||||
scroll_bar = detail.readonly_scroll.verticalScrollBar()
|
||||
assert scroll_bar.maximum() > 0
|
||||
scroll_bar.setValue(scroll_bar.maximum())
|
||||
assert scroll_bar.value() == scroll_bar.maximum()
|
||||
|
||||
queued[1]["on_error"](RuntimeError("detail unavailable"))
|
||||
queued[1]["on_finished"]()
|
||||
assert shown == [(22, "ROW-22")]
|
||||
assert table.isEnabled()
|
||||
assert button.isEnabled()
|
||||
dialog.close()
|
||||
editor.patient_name.setText("庄志芳(已核对)")
|
||||
assert editor.patient_name.isEnabled()
|
||||
assert editor.payload()["patient_name"] == "庄志芳(已核对)"
|
||||
|
||||
detail.readonly_close_button.click()
|
||||
application.processEvents()
|
||||
assert editor.isVisible()
|
||||
assert not editor.diagnosis_host.isVisible()
|
||||
assert editor.width() == editor.DRAWER_WIDTH
|
||||
assert editor.patient_name.text() == "庄志芳(已核对)"
|
||||
editor.diagnosis_button.click()
|
||||
application.processEvents()
|
||||
assert editor.diagnosis_host.isVisibleTo(editor)
|
||||
editor.reject()
|
||||
application.processEvents()
|
||||
assert not editor.isVisible()
|
||||
assert not detail.isVisible()
|
||||
owner.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,19 @@ class WecomPromotionController extends BaseAdminController
|
||||
)));
|
||||
}
|
||||
|
||||
public function batchUpdatePools()
|
||||
{
|
||||
if (!$this->hasBasePagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('分流方案配置已批量更新', WecomPromotionLogic::batchUpdatePools(
|
||||
$this->request->post(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function saveWidget()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
|
||||
@@ -4,16 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\qywx;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\qywx\CustomerLists;
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use app\adminapi\validate\qywx\CustomerValidate;
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\qywx\CustomerLists;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use app\adminapi\validate\qywx\CustomerValidate;
|
||||
|
||||
/**
|
||||
* 企业微信客户管理控制器
|
||||
*/
|
||||
class CustomerController extends BaseAdminController
|
||||
{
|
||||
class CustomerController extends BaseAdminController
|
||||
{
|
||||
private const DELETE_PERMISSION = 'qywx.customer/delete';
|
||||
|
||||
/**
|
||||
* @notes 客户列表
|
||||
*/
|
||||
@@ -25,16 +28,34 @@ class CustomerController extends BaseAdminController
|
||||
/**
|
||||
* @notes 同步企业微信客户
|
||||
*/
|
||||
public function sync()
|
||||
{
|
||||
public function sync()
|
||||
{
|
||||
$result = CustomerLogic::triggerBackgroundSync();
|
||||
if ($result === false) {
|
||||
return $this->fail(CustomerLogic::getError());
|
||||
}
|
||||
$msg = is_array($result) && isset($result['message']) ? (string) $result['message'] : '已提交同步';
|
||||
|
||||
return $this->success($msg, $result);
|
||||
}
|
||||
return $this->success($msg, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 删除一条本地企业微信客户同步记录
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
// 显式鉴权,避免权限菜单迁移漏执行时被通用中间件当成“未受控 URI”放行。
|
||||
if (!$this->canDeleteCustomer()) {
|
||||
return $this->fail('权限不足,无法删除企业微信客户');
|
||||
}
|
||||
|
||||
$params = (new CustomerValidate())->post()->goCheck('delete');
|
||||
if (!CustomerLogic::deleteCustomer((int) $params['id'])) {
|
||||
return $this->fail(CustomerLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取统计信息
|
||||
@@ -84,13 +105,22 @@ class CustomerController extends BaseAdminController
|
||||
/**
|
||||
* @notes 保存同步设置
|
||||
*/
|
||||
public function saveSyncSettings()
|
||||
public function saveSyncSettings()
|
||||
{
|
||||
$params = (new CustomerValidate())->post()->goCheck('syncSettings');
|
||||
$result = CustomerLogic::saveSyncSettings($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(CustomerLogic::getError());
|
||||
}
|
||||
return $this->success('保存成功');
|
||||
}
|
||||
}
|
||||
return $this->success('保存成功');
|
||||
}
|
||||
|
||||
private function canDeleteCustomer(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::DELETE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,7 @@ class AuthMiddleware
|
||||
'firstvisit.wecompromotion/uploadwelcomemedia',
|
||||
'firstvisit.wecompromotion/overview',
|
||||
'firstvisit.wecompromotion/savepool',
|
||||
'firstvisit.wecompromotion/batchupdatepools',
|
||||
'firstvisit.wecompromotion/savewidget',
|
||||
'firstvisit.wecompromotion/batchsetoperators',
|
||||
'firstvisit.wecompromotion/deletepool',
|
||||
|
||||
@@ -230,20 +230,49 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$endTs]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function baseQuery()
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按客户当前跟进关系中的添加方式筛选。
|
||||
*
|
||||
* follow_users 由同步逻辑使用 json_encode 写入,匹配数字值及历史字符串值;
|
||||
* 数字后必须紧跟逗号或对象结束符,避免 add_way=1 误命中 16。
|
||||
*/
|
||||
private function applyAddWayFilter($query): void
|
||||
{
|
||||
if (!array_key_exists('add_way', $this->params) || $this->params['add_way'] === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$addWay = self::normalizeAddWay($this->params['add_way']);
|
||||
if ($addWay === null) {
|
||||
$query->whereRaw('1=0');
|
||||
return;
|
||||
}
|
||||
|
||||
$numberPrefix = '%"add_way":' . $addWay;
|
||||
$stringPrefix = '%"add_way":"' . $addWay;
|
||||
$query->where(function ($q) use ($numberPrefix, $stringPrefix) {
|
||||
$q->where('follow_users', 'like', $numberPrefix . ',%')
|
||||
->whereOr('follow_users', 'like', $numberPrefix . '}%')
|
||||
->whereOr('follow_users', 'like', $stringPrefix . '",%')
|
||||
->whereOr('follow_users', 'like', $stringPrefix . '"}%');
|
||||
});
|
||||
}
|
||||
|
||||
private function baseQuery()
|
||||
{
|
||||
$query = QywxExternalContact::where($this->searchWhere);
|
||||
|
||||
// 添加时间可能已按「跟进人+事件流水」收窄;此时不必再 LIKE follow_users
|
||||
$followAlreadyScoped = $this->applyAddTimeFilter($query);
|
||||
if (!$followAlreadyScoped) {
|
||||
$this->applyFollowUserFilter($query);
|
||||
}
|
||||
|
||||
// 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。
|
||||
if (!$followAlreadyScoped) {
|
||||
$this->applyFollowUserFilter($query);
|
||||
}
|
||||
$this->applyAddWayFilter($query);
|
||||
|
||||
// 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。
|
||||
// 走 zyt_qywx_external_contact_tag.idx_tag 索引,比 LIKE follow_users 快得多
|
||||
$tagIds = $this->normalizeTagIds();
|
||||
if ($tagIds !== []) {
|
||||
@@ -262,6 +291,154 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量读取当前页客户的加客渠道流水。
|
||||
*
|
||||
* 事件表是一对多关系,不能直接 JOIN 到分页主查询,否则会放大列表行数与 count。
|
||||
* 同一客户可能被不同员工重复添加,因此保留所有不同的非空 state,并按最近事件排序。
|
||||
*
|
||||
* @param string[] $externalUserids
|
||||
* @return array<string, array<int, array<string, mixed>>>
|
||||
*/
|
||||
private function loadAddChannelsByExternalUserid(array $externalUserids): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($externalUserids as $externalUserid) {
|
||||
$externalUserid = trim((string) $externalUserid);
|
||||
if ($externalUserid !== '') {
|
||||
$ids[$externalUserid] = true;
|
||||
}
|
||||
}
|
||||
$ids = array_keys($ids);
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$events = Db::name('qywx_external_contact_event')
|
||||
->where('change_type', 'add_external_contact')
|
||||
->where('state', '<>', '')
|
||||
->whereIn('external_userid', $ids)
|
||||
->field(['id', 'external_userid', 'user_id', 'state', 'event_time'])
|
||||
->order('event_time', 'desc')
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$poolIds = [];
|
||||
foreach ($events as $event) {
|
||||
$state = trim((string) ($event['state'] ?? ''));
|
||||
if (preg_match('/^zyt_pool:([1-9]\d*)$/D', $state, $matches) === 1) {
|
||||
$poolIds[(int) $matches[1]] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$poolNamesById = [];
|
||||
if ($poolIds !== []) {
|
||||
// 历史渠道仍应显示已删除方案原来的名称,因此这里不限制 delete_time。
|
||||
$poolNamesById = Db::name('qywx_promotion_pool')
|
||||
->whereIn('id', array_keys($poolIds))
|
||||
->column('name', 'id');
|
||||
}
|
||||
|
||||
return self::projectAddChannelEvents($events, $poolNamesById);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $events 已按 event_time DESC, id DESC 排序
|
||||
* @param array<int|string, string> $poolNamesById
|
||||
* @return array<string, array<int, array<string, mixed>>>
|
||||
*/
|
||||
private static function projectAddChannelEvents(array $events, array $poolNamesById): array
|
||||
{
|
||||
$channelsByExternalUserid = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
$externalUserid = trim((string) ($event['external_userid'] ?? ''));
|
||||
$state = trim((string) ($event['state'] ?? ''));
|
||||
if ($externalUserid === '' || $state === '' || isset($seen[$externalUserid][$state])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$externalUserid][$state] = true;
|
||||
|
||||
$poolId = 0;
|
||||
if (preg_match('/^zyt_pool:([1-9]\d*)$/D', $state, $matches) === 1) {
|
||||
$poolId = (int) $matches[1];
|
||||
}
|
||||
$poolName = $poolId > 0 ? trim((string) ($poolNamesById[$poolId] ?? '')) : '';
|
||||
|
||||
$channelsByExternalUserid[$externalUserid][] = [
|
||||
'state' => $state,
|
||||
'label' => $poolName !== ''
|
||||
? $poolName
|
||||
: ($poolId > 0 ? '获客助手方案 #' . $poolId : $state),
|
||||
'source_type' => $poolId > 0 ? 'promotion_pool' : 'state',
|
||||
'pool_id' => $poolId,
|
||||
'user_id' => trim((string) ($event['user_id'] ?? '')),
|
||||
'event_time' => (int) ($event['event_time'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $channelsByExternalUserid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业微信客户详情 follow_user.add_way 的可读文案。
|
||||
*
|
||||
* add_way 是固定添加方式,state 是企业自定义渠道参数,两者不能混用。
|
||||
* 未识别的新枚举保留原值,避免后续企微扩展时页面退化成“未记录”。
|
||||
*/
|
||||
private static function addWayLabel(int $addWay): string
|
||||
{
|
||||
$labels = [
|
||||
0 => '未知添加方式',
|
||||
1 => '通过扫描二维码添加',
|
||||
2 => '通过搜索手机号添加',
|
||||
3 => '通过名片分享添加',
|
||||
4 => '通过群聊添加',
|
||||
5 => '通过手机通讯录添加',
|
||||
6 => '通过微信联系人添加',
|
||||
8 => '安装第三方应用时自动添加',
|
||||
9 => '通过搜索邮箱添加',
|
||||
10 => '通过视频号添加',
|
||||
11 => '通过日程参与人添加',
|
||||
12 => '通过会议参与人添加',
|
||||
13 => '通过微信好友添加',
|
||||
14 => '通过智慧硬件专属客服添加',
|
||||
15 => '通过上门服务客服添加',
|
||||
16 => '通过获客链接添加',
|
||||
17 => '通过定制开发添加',
|
||||
18 => '通过需求回复添加',
|
||||
21 => '通过第三方售前客服添加',
|
||||
22 => '通过可能的商务伙伴添加',
|
||||
24 => '通过接受微信好友申请添加',
|
||||
201 => '通过内部成员共享添加',
|
||||
202 => '通过管理员或负责人分配添加',
|
||||
];
|
||||
|
||||
return $labels[$addWay] ?? '其他添加方式(' . $addWay . ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
private static function normalizeAddWay($value): ?int
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return $value >= 0 ? $value : null;
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
if ($value === '' || preg_match('/^\d+$/D', $value) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取列表
|
||||
*/
|
||||
@@ -278,7 +455,12 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
->toArray();
|
||||
|
||||
$wxUserids = [];
|
||||
$externalUserids = [];
|
||||
foreach ($lists as $item) {
|
||||
$externalUserid = trim((string) ($item['external_userid'] ?? ''));
|
||||
if ($externalUserid !== '') {
|
||||
$externalUserids[$externalUserid] = true;
|
||||
}
|
||||
$raw = json_decode($item['follow_users'] ?? '[]', true);
|
||||
if (!is_array($raw)) {
|
||||
continue;
|
||||
@@ -298,19 +480,25 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
if ($wxUserids !== []) {
|
||||
$adminNameByWx = Admin::whereIn('work_wechat_userid', $wxUserids)->column('name', 'work_wechat_userid');
|
||||
}
|
||||
$addChannelsByExternalUserid = $this->loadAddChannelsByExternalUserid(array_keys($externalUserids));
|
||||
|
||||
foreach ($lists as &$item) {
|
||||
$followUsers = json_decode($item['follow_users'] ?? '[]', true);
|
||||
$followUsers = is_array($followUsers) ? $followUsers : [];
|
||||
foreach ($followUsers as &$fu) {
|
||||
if (!is_array($fu)) {
|
||||
continue;
|
||||
}
|
||||
$wx = trim((string) ($fu['userid'] ?? ''));
|
||||
if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') {
|
||||
$fu['admin_name'] = $adminNameByWx[$wx];
|
||||
}
|
||||
}
|
||||
foreach ($followUsers as &$fu) {
|
||||
if (!is_array($fu)) {
|
||||
continue;
|
||||
}
|
||||
$wx = trim((string) ($fu['userid'] ?? ''));
|
||||
if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') {
|
||||
$fu['admin_name'] = $adminNameByWx[$wx];
|
||||
}
|
||||
$addWay = self::normalizeAddWay($fu['add_way'] ?? $fu['AddWay'] ?? null);
|
||||
if ($addWay !== null) {
|
||||
$fu['add_way'] = $addWay;
|
||||
$fu['add_way_label'] = self::addWayLabel($addWay);
|
||||
}
|
||||
}
|
||||
unset($fu);
|
||||
$item['follow_users'] = $followUsers;
|
||||
$followAdminIds = json_decode($item['follow_admin_ids'] ?? '[]', true);
|
||||
@@ -320,6 +508,10 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
$tags = json_decode((string) ($item['tags'] ?? '[]'), true);
|
||||
$item['tags'] = is_array($tags) ? $tags : [];
|
||||
|
||||
$externalUserid = trim((string) ($item['external_userid'] ?? ''));
|
||||
$item['add_channels'] = $addChannelsByExternalUserid[$externalUserid] ?? [];
|
||||
$item['add_channel_states'] = array_column($item['add_channels'], 'state');
|
||||
|
||||
$fromDb = (int) ($item['external_first_add_time'] ?? 0);
|
||||
$fromJson = CustomerLogic::minFollowCreatetime($followUsers);
|
||||
$item['external_first_add_time'] = $fromDb > 0 ? $fromDb : $fromJson;
|
||||
|
||||
@@ -225,7 +225,12 @@ class WecomPromotionLogic
|
||||
];
|
||||
}
|
||||
|
||||
public static function savePool(array $params, int $adminId, array $adminInfo): array
|
||||
public static function savePool(
|
||||
array $params,
|
||||
int $adminId,
|
||||
array $adminInfo,
|
||||
bool $syncImmediately = true
|
||||
): array
|
||||
{
|
||||
self::assertMemberDispatchSchema();
|
||||
$id = max(0, (int) ($params['id'] ?? 0));
|
||||
@@ -425,11 +430,13 @@ class WecomPromotionLogic
|
||||
$syncError = '';
|
||||
if (!$createdRemote) {
|
||||
QywxPromotionMemberSchedulerService::requestPoolSync($id, $linkId);
|
||||
try {
|
||||
(new QywxPromotionRangeSyncService())->syncPool($id);
|
||||
} catch (\Throwable $e) {
|
||||
// 本地方案和成员规则已保存;后台分钟任务会继续重试最新完整范围。
|
||||
$syncError = $e->getMessage();
|
||||
if ($syncImmediately) {
|
||||
try {
|
||||
(new QywxPromotionRangeSyncService())->syncPool($id);
|
||||
} catch (\Throwable $e) {
|
||||
// 本地方案和成员规则已保存;后台分钟任务会继续重试最新完整范围。
|
||||
$syncError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
$savedLink = Db::name('qywx_promotion_link')->where('id', $linkId)->find() ?: [];
|
||||
@@ -448,6 +455,152 @@ class WecomPromotionLogic
|
||||
// 前端据此确认标签、欢迎语等扩展配置已和方案一并提交并完成回读校验。
|
||||
'automation_saved' => $automation !== null,
|
||||
'sync_error' => $syncError,
|
||||
'sync_queued' => !$createdRemote && !$syncImmediately,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量局部更新分流方案。changes 只覆盖显式传入的字段;每个方案仍复用
|
||||
* savePool 的成员、自动化、素材和企业微信同步校验。
|
||||
*
|
||||
* @return array{pool_ids:list<int>,updated:int,failed:int,sync_error_count:int,sync_queued_count:int,results:list<array<string,mixed>>}
|
||||
*/
|
||||
public static function batchUpdatePools(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
self::assertMemberDispatchSchema();
|
||||
self::assertBasePagePermission($adminId, $adminInfo);
|
||||
$poolIds = self::normalizePositiveIds((array) ($params['pool_ids'] ?? []));
|
||||
if ($poolIds === []) {
|
||||
throw new RuntimeException('请至少选择一个分流方案');
|
||||
}
|
||||
if (count($poolIds) > 100) {
|
||||
throw new RuntimeException('单次最多设置 100 个分流方案');
|
||||
}
|
||||
|
||||
$changes = $params['changes'] ?? null;
|
||||
if (!is_array($changes)) {
|
||||
throw new RuntimeException('批量修改内容格式不正确');
|
||||
}
|
||||
$allowedFields = ['skip_verify', 'fallback_url', 'status', 'automation_config'];
|
||||
$unknownFields = array_diff(array_keys($changes), $allowedFields);
|
||||
if ($unknownFields !== []) {
|
||||
throw new RuntimeException('批量修改包含不支持的字段');
|
||||
}
|
||||
if ($changes === []) {
|
||||
throw new RuntimeException('请至少选择一项需要批量修改的配置');
|
||||
}
|
||||
if (array_key_exists('fallback_url', $changes) && !is_string($changes['fallback_url'])) {
|
||||
throw new RuntimeException('兜底获客助手链接格式不正确');
|
||||
}
|
||||
|
||||
$automationPatch = null;
|
||||
if (array_key_exists('automation_config', $changes)) {
|
||||
if (!is_array($changes['automation_config']) || $changes['automation_config'] === []) {
|
||||
throw new RuntimeException('自动化配置格式不正确');
|
||||
}
|
||||
$allowedAutomationFields = [
|
||||
'reception_mode', 'reception_schedule', 'backup_member_admin_ids',
|
||||
'tags_enabled', 'tag_ids', 'remark_enabled', 'remark_template',
|
||||
'description_enabled', 'description', 'welcome_mode', 'welcome',
|
||||
'welcome_schedule_enabled', 'welcome_schedule',
|
||||
];
|
||||
if (array_diff(array_keys($changes['automation_config']), $allowedAutomationFields) !== []) {
|
||||
throw new RuntimeException('自动化配置包含不支持的字段');
|
||||
}
|
||||
QywxPromotionConfig::assertInstalled();
|
||||
$automationPatch = $changes['automation_config'];
|
||||
}
|
||||
|
||||
// 必须在任何方案写入前完成整批权限校验,避免越权请求产生部分更新。
|
||||
$pools = [];
|
||||
foreach ($poolIds as $poolId) {
|
||||
$pools[$poolId] = self::assertScopedRow(
|
||||
'qywx_promotion_pool',
|
||||
$poolId,
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$updated = 0;
|
||||
$failed = 0;
|
||||
$syncErrorCount = 0;
|
||||
$syncQueuedCount = 0;
|
||||
foreach ($poolIds as $poolId) {
|
||||
$pool = $pools[$poolId];
|
||||
$currentAutomation = QywxPromotionConfig::forPool($poolId);
|
||||
$memberAdminIds = self::normalizePositiveIds(Db::name('qywx_promotion_pool_member')
|
||||
->where('pool_id', $poolId)
|
||||
->whereNull('delete_time')
|
||||
->column('admin_id'));
|
||||
$primaryMemberAdminIds = array_values(array_diff(
|
||||
$memberAdminIds,
|
||||
self::normalizePositiveIds((array) ($currentAutomation['backup_member_admin_ids'] ?? []))
|
||||
));
|
||||
$officialLink = Db::name('qywx_promotion_link')
|
||||
->where('pool_id', $poolId)
|
||||
->whereNull('delete_time')
|
||||
->where('remote_link_id', '<>', '')
|
||||
->where('remote_status', '<>', 2)
|
||||
->order('id', 'desc')
|
||||
->find() ?: [];
|
||||
$saveParams = [
|
||||
'id' => $poolId,
|
||||
'name' => (string) ($pool['name'] ?? ''),
|
||||
'fallback_url' => array_key_exists('fallback_url', $changes)
|
||||
? trim($changes['fallback_url'])
|
||||
: (string) ($pool['fallback_url'] ?? ''),
|
||||
'status' => array_key_exists('status', $changes)
|
||||
? ((int) $changes['status'] === 1 ? 1 : 0)
|
||||
: (int) ($pool['status'] ?? 0),
|
||||
'member_admin_ids' => $primaryMemberAdminIds,
|
||||
'skip_verify' => array_key_exists('skip_verify', $changes)
|
||||
? ((int) $changes['skip_verify'] === 1 ? 1 : 0)
|
||||
: (int) ($officialLink['skip_verify'] ?? 0),
|
||||
];
|
||||
if ($automationPatch !== null) {
|
||||
$saveParams['automation_config'] = array_replace($currentAutomation, $automationPatch);
|
||||
}
|
||||
|
||||
try {
|
||||
// 批量操作只落本地并入同步队列,避免大量企微请求阻塞管理端 HTTP 请求。
|
||||
$saved = self::savePool($saveParams, $adminId, $adminInfo, false);
|
||||
$syncError = trim((string) ($saved['sync_error'] ?? ''));
|
||||
$updated++;
|
||||
if ($syncError !== '') {
|
||||
$syncErrorCount++;
|
||||
}
|
||||
$syncQueued = !empty($saved['sync_queued']);
|
||||
if ($syncQueued) {
|
||||
$syncQueuedCount++;
|
||||
}
|
||||
$results[] = [
|
||||
'id' => $poolId,
|
||||
'name' => (string) ($pool['name'] ?? ''),
|
||||
'success' => true,
|
||||
'sync_error' => $syncError,
|
||||
'sync_queued' => $syncQueued,
|
||||
];
|
||||
} catch (\Throwable $error) {
|
||||
$failed++;
|
||||
$results[] = [
|
||||
'id' => $poolId,
|
||||
'name' => (string) ($pool['name'] ?? ''),
|
||||
'success' => false,
|
||||
'error' => $error->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'pool_ids' => $poolIds,
|
||||
'updated' => $updated,
|
||||
'failed' => $failed,
|
||||
'sync_error_count' => $syncErrorCount,
|
||||
'sync_queued_count' => $syncQueuedCount,
|
||||
'results' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -722,6 +722,73 @@ class CustomerLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台手工删除一条本地同步记录。
|
||||
*
|
||||
* 仅按列表行主键软删除,不调用企业微信删除客户关系;兼容历史库中可能存在的重复
|
||||
* external_userid。只有该客户已无其他有效行时才清理共享的标签关系。
|
||||
*/
|
||||
public static function deleteCustomer(int $id): bool
|
||||
{
|
||||
if ($id <= 0) {
|
||||
self::$error = '客户参数错误';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$externalUserId = Db::transaction(static function () use ($id): string {
|
||||
$row = Db::name('qywx_external_contact')
|
||||
->where('id', $id)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$row) {
|
||||
throw new \DomainException('客户不存在或已删除');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
Db::name('qywx_external_contact')
|
||||
->where('id', $id)
|
||||
->whereNull('delete_time')
|
||||
->update([
|
||||
'delete_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
|
||||
if ($externalUserId !== '') {
|
||||
$activeRows = (int) Db::name('qywx_external_contact')
|
||||
->where('external_userid', $externalUserId)
|
||||
->whereNull('delete_time')
|
||||
->count();
|
||||
if ($activeRows === 0) {
|
||||
Db::name('qywx_external_contact_tag')
|
||||
->where('external_userid', $externalUserId)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
||||
return $externalUserId;
|
||||
});
|
||||
|
||||
if ($externalUserId !== '') {
|
||||
MediaChannelService::forgetCurrentTagCatalogCache();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\DomainException $e) {
|
||||
self::$error = $e->getMessage();
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('后台删除企业微信客户同步记录失败: ' . $e->getMessage());
|
||||
self::$error = '删除失败,请稍后重试';
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户联系「删除企业客户」等事件:本地软删除一行。
|
||||
*/
|
||||
|
||||
@@ -12,11 +12,15 @@ use app\common\validate\BaseValidate;
|
||||
class CustomerValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'id' => 'require|integer|gt:0',
|
||||
'auto_sync' => 'require|boolean',
|
||||
'interval' => 'require|integer|between:3600,86400',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'id.require' => '请选择要删除的客户',
|
||||
'id.integer' => '客户参数格式错误',
|
||||
'id.gt' => '客户参数格式错误',
|
||||
'auto_sync.require' => '请选择是否自动同步',
|
||||
'auto_sync.boolean' => '自动同步参数格式错误',
|
||||
'interval.require' => '请选择同步间隔',
|
||||
@@ -31,4 +35,12 @@ class CustomerValidate extends BaseValidate
|
||||
{
|
||||
return $this->only(['auto_sync', 'interval']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 删除客户场景
|
||||
*/
|
||||
public function sceneDelete()
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `zyt_qywx_external_contact_event` (
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_change_user_ext_time` (`change_type`, `user_id`, `external_userid`, `event_time`),
|
||||
KEY `idx_change_time` (`change_type`, `event_time`),
|
||||
KEY `idx_change_ext_time` (`change_type`, `external_userid`, `event_time`, `id`),
|
||||
KEY `idx_event_time` (`event_time`),
|
||||
KEY `idx_state` (`state`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业微信外部联系人事件流水(用于进入计数)';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 客户列表按当前页 external_userid 批量读取加客渠道流水。
|
||||
-- 覆盖 change_type + external_userid + event_time,避免事件量增长后反复扫描全量 add 事件。
|
||||
SET @idx_change_ext_time_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'zyt_qywx_external_contact_event'
|
||||
AND INDEX_NAME = 'idx_change_ext_time'
|
||||
);
|
||||
|
||||
SET @add_idx_change_ext_time_sql := IF(
|
||||
@idx_change_ext_time_exists = 0,
|
||||
'ALTER TABLE `zyt_qywx_external_contact_event` ADD INDEX `idx_change_ext_time` (`change_type`, `external_userid`, `event_time`, `id`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
|
||||
PREPARE add_idx_change_ext_time_stmt FROM @add_idx_change_ext_time_sql;
|
||||
EXECUTE add_idx_change_ext_time_stmt;
|
||||
DEALLOCATE PREPARE add_idx_change_ext_time_stmt;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 企业微信客户管理:新增独立“删除”操作权限。
|
||||
-- 仅创建权限节点,不自动授予已有角色;请在角色权限中按需勾选。
|
||||
|
||||
SET @qywx_customer_menu_id := (
|
||||
SELECT `id`
|
||||
FROM `zyt_system_menu`
|
||||
WHERE `component` = 'fans/qywx'
|
||||
OR `perms` = 'qywx.customer/lists'
|
||||
ORDER BY CASE WHEN `component` = 'fans/qywx' THEN 0 ELSE 1 END, `id`
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
INSERT INTO `zyt_system_menu`
|
||||
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||
SELECT
|
||||
@qywx_customer_menu_id,
|
||||
'A',
|
||||
'删除',
|
||||
'',
|
||||
1,
|
||||
'qywx.customer/delete',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
1,
|
||||
0,
|
||||
UNIX_TIMESTAMP(),
|
||||
UNIX_TIMESTAMP()
|
||||
FROM DUAL
|
||||
WHERE @qywx_customer_menu_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `zyt_system_menu`
|
||||
WHERE `perms` = 'qywx.customer/delete'
|
||||
);
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\adminapi\lists\qywx\CustomerLists;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
function qywxChannelExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
final class QywxChannelFilterQueryFake
|
||||
{
|
||||
/** @var array<int, array{field:string,operator:string,value:string,logic:string}> */
|
||||
public array $likes = [];
|
||||
/** @var string[] */
|
||||
public array $raw = [];
|
||||
|
||||
public function where($field, $operator = null, $value = null): self
|
||||
{
|
||||
if ($field instanceof Closure) {
|
||||
$field($this);
|
||||
return $this;
|
||||
}
|
||||
$this->likes[] = [
|
||||
'field' => (string) $field,
|
||||
'operator' => (string) $operator,
|
||||
'value' => (string) $value,
|
||||
'logic' => 'and',
|
||||
];
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function whereOr($field, $operator = null, $value = null): self
|
||||
{
|
||||
$this->likes[] = [
|
||||
'field' => (string) $field,
|
||||
'operator' => (string) $operator,
|
||||
'value' => (string) $value,
|
||||
'logic' => 'or',
|
||||
];
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function whereRaw(string $condition): self
|
||||
{
|
||||
$this->raw[] = $condition;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
$method = new ReflectionMethod(CustomerLists::class, 'projectAddChannelEvents');
|
||||
$method->setAccessible(true);
|
||||
$addWayLabelMethod = new ReflectionMethod(CustomerLists::class, 'addWayLabel');
|
||||
$addWayLabelMethod->setAccessible(true);
|
||||
$normalizeAddWayMethod = new ReflectionMethod(CustomerLists::class, 'normalizeAddWay');
|
||||
$normalizeAddWayMethod->setAccessible(true);
|
||||
|
||||
$events = [
|
||||
['id' => 12, 'external_userid' => 'ext-a', 'user_id' => 'staff-2', 'state' => 'channel-b', 'event_time' => 300],
|
||||
['id' => 11, 'external_userid' => 'ext-a', 'user_id' => 'staff-1', 'state' => ' ', 'event_time' => 300],
|
||||
['id' => 10, 'external_userid' => 'ext-a', 'user_id' => 'staff-1', 'state' => 'zyt_pool:2', 'event_time' => 250],
|
||||
['id' => 9, 'external_userid' => 'ext-a', 'user_id' => 'staff-1', 'state' => 'channel-b', 'event_time' => 200],
|
||||
['id' => 8, 'external_userid' => 'ext-a', 'user_id' => 'staff-1', 'state' => 'channel-a', 'event_time' => 100],
|
||||
['id' => 7, 'external_userid' => 'ext-zero', 'user_id' => 'staff-1', 'state' => '0', 'event_time' => 100],
|
||||
['id' => 6, 'external_userid' => '', 'user_id' => 'staff-1', 'state' => 'ignored', 'event_time' => 100],
|
||||
];
|
||||
|
||||
/** @var array<string, array<int, array<string, mixed>>> $projected */
|
||||
$projected = $method->invoke(null, $events, [2 => '九月投放方案']);
|
||||
|
||||
qywxChannelExpect(
|
||||
array_column($projected['ext-a'] ?? [], 'state') === ['channel-b', 'zyt_pool:2', 'channel-a'],
|
||||
'渠道必须按最近事件排序、排除空值并按 state 去重'
|
||||
);
|
||||
qywxChannelExpect(($projected['ext-a'][0]['user_id'] ?? '') === 'staff-2', '重复渠道必须保留最近事件的员工');
|
||||
qywxChannelExpect(($projected['ext-a'][0]['event_time'] ?? 0) === 300, '重复渠道必须保留最近事件时间');
|
||||
qywxChannelExpect(($projected['ext-a'][1]['label'] ?? '') === '九月投放方案', '获客助手 state 必须映射方案名称');
|
||||
qywxChannelExpect(($projected['ext-a'][1]['source_type'] ?? '') === 'promotion_pool', '获客助手渠道类型错误');
|
||||
qywxChannelExpect(($projected['ext-a'][1]['pool_id'] ?? 0) === 2, '获客助手方案 ID 解析错误');
|
||||
qywxChannelExpect(array_column($projected['ext-zero'] ?? [], 'state') === ['0'], '字符串 0 是有效渠道,不能被 empty/filter 丢弃');
|
||||
qywxChannelExpect(!isset($projected['']), '空 external_userid 不得生成渠道投影');
|
||||
|
||||
$fallback = $method->invoke(null, [
|
||||
['id' => 1, 'external_userid' => 'ext-b', 'user_id' => '', 'state' => 'zyt_pool:99', 'event_time' => 1],
|
||||
], []);
|
||||
qywxChannelExpect(
|
||||
($fallback['ext-b'][0]['label'] ?? '') === '获客助手方案 #99',
|
||||
'已删除或缺失的获客助手方案应保留可读兜底名称'
|
||||
);
|
||||
|
||||
$knownAddWays = [
|
||||
0 => '未知添加方式',
|
||||
1 => '通过扫描二维码添加',
|
||||
2 => '通过搜索手机号添加',
|
||||
3 => '通过名片分享添加',
|
||||
4 => '通过群聊添加',
|
||||
5 => '通过手机通讯录添加',
|
||||
6 => '通过微信联系人添加',
|
||||
8 => '安装第三方应用时自动添加',
|
||||
9 => '通过搜索邮箱添加',
|
||||
10 => '通过视频号添加',
|
||||
11 => '通过日程参与人添加',
|
||||
12 => '通过会议参与人添加',
|
||||
13 => '通过微信好友添加',
|
||||
14 => '通过智慧硬件专属客服添加',
|
||||
15 => '通过上门服务客服添加',
|
||||
16 => '通过获客链接添加',
|
||||
17 => '通过定制开发添加',
|
||||
18 => '通过需求回复添加',
|
||||
21 => '通过第三方售前客服添加',
|
||||
22 => '通过可能的商务伙伴添加',
|
||||
24 => '通过接受微信好友申请添加',
|
||||
201 => '通过内部成员共享添加',
|
||||
202 => '通过管理员或负责人分配添加',
|
||||
];
|
||||
foreach ($knownAddWays as $addWay => $expectedLabel) {
|
||||
qywxChannelExpect(
|
||||
$addWayLabelMethod->invoke(null, $addWay) === $expectedLabel,
|
||||
"add_way={$addWay} 缺少正确的可读文案"
|
||||
);
|
||||
}
|
||||
qywxChannelExpect(
|
||||
$addWayLabelMethod->invoke(null, 999) === '其他添加方式(999)',
|
||||
'未知的新 add_way 必须保留编号作为可读兜底'
|
||||
);
|
||||
qywxChannelExpect($normalizeAddWayMethod->invoke(null, '16') === 16, '数字字符串 add_way 应被规范化');
|
||||
qywxChannelExpect($normalizeAddWayMethod->invoke(null, '1future') === null, '异常 add_way 不得被强转成错误来源');
|
||||
qywxChannelExpect($normalizeAddWayMethod->invoke(null, null) === null, '缺失 add_way 应保持未记录');
|
||||
|
||||
$filterMethod = new ReflectionMethod(CustomerLists::class, 'applyAddWayFilter');
|
||||
$filterMethod->setAccessible(true);
|
||||
$list = (new ReflectionClass(CustomerLists::class))->newInstanceWithoutConstructor();
|
||||
$paramsProperty = new ReflectionProperty(app\common\lists\BaseDataLists::class, 'params');
|
||||
$paramsProperty->setAccessible(true);
|
||||
|
||||
$paramsProperty->setValue($list, ['add_way' => 1]);
|
||||
$filterQuery = new QywxChannelFilterQueryFake();
|
||||
$filterMethod->invoke($list, $filterQuery);
|
||||
qywxChannelExpect(
|
||||
array_column($filterQuery->likes, 'value') === [
|
||||
'%"add_way":1,%',
|
||||
'%"add_way":1}%',
|
||||
'%"add_way":"1",%',
|
||||
'%"add_way":"1"}%',
|
||||
],
|
||||
'渠道筛选必须精确匹配数字或字符串 add_way,不能让 1 误命中 16'
|
||||
);
|
||||
|
||||
$paramsProperty->setValue($list, ['add_way' => 0]);
|
||||
$zeroFilterQuery = new QywxChannelFilterQueryFake();
|
||||
$filterMethod->invoke($list, $zeroFilterQuery);
|
||||
qywxChannelExpect(count($zeroFilterQuery->likes) === 4, 'add_way=0 是有效渠道筛选,不能按空值忽略');
|
||||
|
||||
$paramsProperty->setValue($list, ['add_way' => 'invalid']);
|
||||
$invalidFilterQuery = new QywxChannelFilterQueryFake();
|
||||
$filterMethod->invoke($list, $invalidFilterQuery);
|
||||
qywxChannelExpect($invalidFilterQuery->raw === ['1=0'], '非法渠道参数必须返回空结果,不能泄露全量客户');
|
||||
|
||||
$paramsProperty->setValue($list, ['add_way' => '']);
|
||||
$emptyFilterQuery = new QywxChannelFilterQueryFake();
|
||||
$filterMethod->invoke($list, $emptyFilterQuery);
|
||||
qywxChannelExpect($emptyFilterQuery->likes === [] && $emptyFilterQuery->raw === [], '空渠道参数应表示不限');
|
||||
|
||||
$source = file_get_contents(__DIR__ . '/../app/adminapi/lists/qywx/CustomerLists.php');
|
||||
qywxChannelExpect(is_string($source), '无法读取 CustomerLists.php');
|
||||
foreach ([
|
||||
"->where('change_type', 'add_external_contact')",
|
||||
"->where('state', '<>', '')",
|
||||
"->whereIn('external_userid', \$ids)",
|
||||
"->order('event_time', 'desc')",
|
||||
"->order('id', 'desc')",
|
||||
"\$item['add_channels']",
|
||||
"\$item['add_channel_states']",
|
||||
"\$fu['add_way_label'] = self::addWayLabel(\$addWay)",
|
||||
'$this->applyAddWayFilter($query)',
|
||||
] as $needle) {
|
||||
qywxChannelExpect(str_contains($source, $needle), "客户渠道投影缺少契约:{$needle}");
|
||||
}
|
||||
|
||||
echo "QYWX_CUSTOMER_CHANNEL_PROJECTION_OK\n";
|
||||
@@ -0,0 +1,60 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pagePath = path.resolve(currentDir, '../../admin/src/views/fans/qywx.vue')
|
||||
const source = fs.readFileSync(pagePath, 'utf8')
|
||||
|
||||
function expect(condition, message) {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
expect(source.includes('<el-table-column label="添加渠道"'), '客户主表缺少添加渠道列')
|
||||
expect(source.includes('<el-descriptions-item label="添加渠道"'), '客户详情缺少添加渠道')
|
||||
expect(source.includes('<el-form-item label="渠道"'), '客户检索区缺少渠道筛选')
|
||||
expect(source.includes('v-model="queryParams.add_way"'), '渠道筛选未绑定 add_way 参数')
|
||||
expect(source.includes('v-for="option in ADD_WAY_OPTIONS"'), '渠道筛选未复用完整添加方式枚举')
|
||||
expect(source.includes('placeholder="选择或搜索添加渠道"'), '渠道筛选必须支持按可读文案检索')
|
||||
expect(source.includes('queryParams.add_way ='), '重置操作未清空渠道筛选')
|
||||
expect(source.includes('customerAddSources(row)'), '客户主表未读取可读添加方式')
|
||||
expect(source.includes('customerAddSources(currentCustomer)'), '客户详情未读取可读添加方式')
|
||||
expect(source.includes(':key="source.key"'), '添加方式循环必须使用成员关系级稳定 key')
|
||||
expect(source.includes('add_channel_states'), '前端缺少原始渠道数组兼容逻辑')
|
||||
expect(source.includes('未记录'), '无渠道客户必须明确显示未记录')
|
||||
for (const [addWay, label] of [
|
||||
[0, '未知添加方式'],
|
||||
[1, '通过扫描二维码添加'],
|
||||
[2, '通过搜索手机号添加'],
|
||||
[3, '通过名片分享添加'],
|
||||
[4, '通过群聊添加'],
|
||||
[5, '通过手机通讯录添加'],
|
||||
[6, '通过微信联系人添加'],
|
||||
[8, '安装第三方应用时自动添加'],
|
||||
[9, '通过搜索邮箱添加'],
|
||||
[10, '通过视频号添加'],
|
||||
[11, '通过日程参与人添加'],
|
||||
[12, '通过会议参与人添加'],
|
||||
[13, '通过微信好友添加'],
|
||||
[14, '通过智慧硬件专属客服添加'],
|
||||
[15, '通过上门服务客服添加'],
|
||||
[16, '通过获客链接添加'],
|
||||
[17, '通过定制开发添加'],
|
||||
[18, '通过需求回复添加'],
|
||||
[21, '通过第三方售前客服添加'],
|
||||
[22, '通过可能的商务伙伴添加'],
|
||||
[24, '通过接受微信好友申请添加'],
|
||||
[201, '通过内部成员共享添加'],
|
||||
[202, '通过管理员或负责人分配添加']
|
||||
]) {
|
||||
expect(source.includes(`${addWay}: '${label}'`), `add_way=${addWay} 未显示可读来源`)
|
||||
}
|
||||
expect(source.includes('add_way_label'), '前端未优先使用后端返回的添加方式文案')
|
||||
expect(source.includes('添加方式:${source.label}'), '悬浮说明缺少直观添加方式')
|
||||
expect(source.includes('获客助手方案:${source.channel_label}'), '悬浮说明缺少获客助手方案')
|
||||
expect(source.includes('跟进人:${source.staff_name}'), '悬浮说明缺少对应跟进人')
|
||||
expect(source.includes('添加时间:${formatTime(source.event_time)}'), '悬浮说明缺少添加时间')
|
||||
expect(source.includes('渠道参数:${source.state}'), '原始 state 必须仅保留在悬浮说明')
|
||||
expect(!source.includes('{{ addChannelText(channel) }}'), '列表不得继续直接展示原始渠道参数')
|
||||
|
||||
console.log('QYWX_CUSTOMER_CHANNEL_UI_CONTRACT_OK')
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__, 2);
|
||||
$paths = [
|
||||
'controller' => __DIR__ . '/../app/adminapi/controller/qywx/CustomerController.php',
|
||||
'logic' => __DIR__ . '/../app/adminapi/logic/qywx/CustomerLogic.php',
|
||||
'validate' => __DIR__ . '/../app/adminapi/validate/qywx/CustomerValidate.php',
|
||||
'api' => $root . '/admin/src/api/qywx.ts',
|
||||
'page' => $root . '/admin/src/views/fans/qywx.vue',
|
||||
'migration' => __DIR__ . '/../sql/1.9.20260902/add_qywx_customer_delete_menu.sql',
|
||||
];
|
||||
|
||||
$sources = [];
|
||||
foreach ($paths as $name => $path) {
|
||||
$source = file_get_contents($path);
|
||||
if (!is_string($source)) {
|
||||
throw new RuntimeException("无法读取 {$name}: {$path}");
|
||||
}
|
||||
$sources[$name] = $source;
|
||||
}
|
||||
|
||||
function qywxDeleteExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
$controller = $sources['controller'];
|
||||
$permissionCheck = strpos($controller, 'if (!$this->canDeleteCustomer())');
|
||||
$deleteCall = strpos($controller, 'CustomerLogic::deleteCustomer(');
|
||||
qywxDeleteExpect(str_contains($controller, "private const DELETE_PERMISSION = 'qywx.customer/delete';"), '控制器缺少独立删除权限');
|
||||
qywxDeleteExpect($permissionCheck !== false && $deleteCall !== false && $permissionCheck < $deleteCall, '控制器必须在删除前显式鉴权');
|
||||
qywxDeleteExpect(str_contains($controller, "(int) (\$this->adminInfo['root'] ?? 0) === 1")
|
||||
&& str_contains($controller, 'AuthLogic::getAuthByAdminId($this->adminId)')
|
||||
&& str_contains($controller, 'in_array(self::DELETE_PERMISSION,'), '控制器删除权限必须仅放行 root 或显式授权账号');
|
||||
|
||||
qywxDeleteExpect(str_contains($sources['validate'], "'id' => 'require|integer|gt:0'")
|
||||
&& str_contains($sources['validate'], 'public function sceneDelete()'), '删除请求缺少正整数 ID 校验');
|
||||
|
||||
$logic = $sources['logic'];
|
||||
$logicStart = strpos($logic, 'public static function deleteCustomer(int $id): bool');
|
||||
$logicEnd = strpos($logic, 'public static function softDeleteExternalContactRow(', $logicStart === false ? 0 : $logicStart);
|
||||
qywxDeleteExpect($logicStart !== false && $logicEnd !== false, '无法定位客户删除逻辑');
|
||||
$method = substr($logic, $logicStart, $logicEnd - $logicStart);
|
||||
foreach ([
|
||||
"->where('id', \$id)",
|
||||
"->whereNull('delete_time')",
|
||||
"'delete_time' => \$now",
|
||||
"->where('external_userid', \$externalUserId)",
|
||||
'if ($activeRows === 0)',
|
||||
"Db::name('qywx_external_contact_tag')",
|
||||
'MediaChannelService::forgetCurrentTagCatalogCache()',
|
||||
] as $needle) {
|
||||
qywxDeleteExpect(str_contains($method, $needle), "客户删除逻辑缺少契约:{$needle}");
|
||||
}
|
||||
qywxDeleteExpect(!str_contains($method, 'WechatWorkService'), '后台删除不得调用企微接口删除外部客户关系');
|
||||
|
||||
qywxDeleteExpect(str_contains($sources['api'], '/qywx.customer/delete')
|
||||
&& str_contains($sources['api'], 'qywxCustomerDelete'), '前端缺少客户删除 API');
|
||||
foreach ([
|
||||
"v-perms=\"['qywx.customer/delete']\"",
|
||||
'handleDelete(row)',
|
||||
'仅删除系统内的同步记录',
|
||||
'qywxCustomerDelete({ id })',
|
||||
'Promise.all([getLists(), loadStats(), loadTagStats()])',
|
||||
] as $needle) {
|
||||
qywxDeleteExpect(str_contains($sources['page'], $needle), "客户列表删除交互缺少:{$needle}");
|
||||
}
|
||||
|
||||
$migration = $sources['migration'];
|
||||
qywxDeleteExpect(str_contains($migration, "`component` = 'fans/qywx'")
|
||||
&& str_contains($migration, "'qywx.customer/delete'")
|
||||
&& str_contains($migration, "'A'")
|
||||
&& str_contains($migration, 'NOT EXISTS'), '删除权限迁移必须按页面定位并可重复执行');
|
||||
qywxDeleteExpect(!str_contains(strtolower($migration), 'system_role_menu'), '删除权限不得自动授予已有角色');
|
||||
|
||||
echo "QYWX_CUSTOMER_DELETE_PERMISSION_CONTRACT_OK\n";
|
||||
@@ -62,5 +62,9 @@ assert.match(previewTemplate('{customer_name}-{employee_name}-{add_time}', '小
|
||||
assert.equal(Array.from(previewTemplate('王'.repeat(30), '小陈', 20)).length, 20)
|
||||
assert.match(formSource, /不会写入企微获客链接详情中的“欢迎语\/客户标签”配置/)
|
||||
assert.match(formSource, /客户添加回调中立即发送渠道欢迎语并添加标签/)
|
||||
assert.match(formSource, /disabledSections\?: AutomationSection\[\]/)
|
||||
assert.match(formSource, /backupExcludedMemberIds\?: number\[\]/)
|
||||
assert.match(pageSource, /result\?\.automation_saved !== true/)
|
||||
assert.match(pageSource, /仅勾选的项目会覆盖到所选方案/)
|
||||
assert.match(pageSource, /batchSharedPrimaryMemberIds/)
|
||||
console.log('WECOM_PROMOTION_AUTOMATION_UI_OK')
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__, 2);
|
||||
$paths = [
|
||||
'logic' => __DIR__ . '/../app/adminapi/logic/firstvisit/WecomPromotionLogic.php',
|
||||
'controller' => __DIR__ . '/../app/adminapi/controller/firstvisit/WecomPromotionController.php',
|
||||
'middleware' => __DIR__ . '/../app/adminapi/http/middleware/AuthMiddleware.php',
|
||||
'api' => $root . '/admin/src/api/first_visit.ts',
|
||||
'page' => $root . '/admin/src/views/first_visit/wecom_promotion/index.vue',
|
||||
'automationForm' => $root . '/admin/src/views/first_visit/wecom_promotion/components/PromotionAutomationForm.vue',
|
||||
];
|
||||
$sources = [];
|
||||
foreach ($paths as $name => $path) {
|
||||
$source = file_get_contents($path);
|
||||
if (!is_string($source)) {
|
||||
throw new RuntimeException("无法读取 {$name}: {$path}");
|
||||
}
|
||||
$sources[$name] = $source;
|
||||
}
|
||||
|
||||
if (!str_contains($sources['controller'], 'public function batchUpdatePools()')
|
||||
|| !str_contains($sources['controller'], 'WecomPromotionLogic::batchUpdatePools(')) {
|
||||
throw new RuntimeException('控制器缺少批量修改方案配置接口');
|
||||
}
|
||||
if (!str_contains($sources['middleware'], "'firstvisit.wecompromotion/batchupdatepools'")) {
|
||||
throw new RuntimeException('批量修改方案接口未加入获客助手权限白名单');
|
||||
}
|
||||
if (!str_contains($sources['api'], '/firstvisit.wecomPromotion/batchUpdatePools')
|
||||
|| !str_contains($sources['api'], 'WecomPromotionBatchUpdatePoolsParams')) {
|
||||
throw new RuntimeException('前端缺少类型化批量修改 API');
|
||||
}
|
||||
|
||||
$logic = $sources['logic'];
|
||||
$start = strpos($logic, 'public static function batchUpdatePools(');
|
||||
$end = strpos($logic, 'public static function saveWidget(', $start === false ? 0 : $start);
|
||||
if ($start === false || $end === false) {
|
||||
throw new RuntimeException('无法定位 batchUpdatePools 方法');
|
||||
}
|
||||
$method = substr($logic, $start, $end - $start);
|
||||
foreach ([
|
||||
'assertBasePagePermission($adminId, $adminInfo)',
|
||||
"array_key_exists('fallback_url', \$changes)",
|
||||
"array_key_exists('status', \$changes)",
|
||||
"array_key_exists('skip_verify', \$changes)",
|
||||
"array_key_exists('automation_config', \$changes)",
|
||||
'array_replace($currentAutomation, $automationPatch)',
|
||||
'self::savePool($saveParams, $adminId, $adminInfo, false)',
|
||||
"'sync_error_count' => \$syncErrorCount",
|
||||
"'sync_queued_count' => \$syncQueuedCount",
|
||||
"'results' => \$results",
|
||||
] as $needle) {
|
||||
if (!str_contains($method, $needle)) {
|
||||
throw new RuntimeException("批量修改方案逻辑缺少契约:{$needle}");
|
||||
}
|
||||
}
|
||||
$permissionValidation = strpos($method, "self::assertScopedRow(");
|
||||
$writeLoop = strpos($method, '$results = []');
|
||||
if ($permissionValidation === false || $writeLoop === false || $permissionValidation > $writeLoop) {
|
||||
throw new RuntimeException('批量修改必须在任何方案保存前完成全部方案权限校验');
|
||||
}
|
||||
if (!str_contains($method, "false\n );")) {
|
||||
throw new RuntimeException('批量修改不得允许共享操作人绕过原管理范围批量编辑');
|
||||
}
|
||||
|
||||
$page = $sources['page'];
|
||||
foreach ([
|
||||
'批量修改方案',
|
||||
'仅勾选的项目会覆盖到所选方案',
|
||||
'batchConfigApply.skip_verify',
|
||||
'batchConfigApply.fallback_url',
|
||||
'batchConfigApply.status',
|
||||
'batchConfigApply.reception',
|
||||
'batchConfigApply.customer',
|
||||
'batchConfigApply.welcome',
|
||||
'batchSharedPrimaryMemberIds',
|
||||
'batchPrimaryMemberUnion',
|
||||
'wecomPromotionBatchUpdatePools',
|
||||
] as $needle) {
|
||||
if (!str_contains($page, $needle)) {
|
||||
throw new RuntimeException("前端批量修改交互缺少 {$needle}");
|
||||
}
|
||||
}
|
||||
if (!str_contains($sources['automationForm'], 'disabledSections?: AutomationSection[]')
|
||||
|| !str_contains($sources['automationForm'], 'backupExcludedMemberIds?: number[]')) {
|
||||
throw new RuntimeException('自动化表单未支持批量场景的分组禁用或主成员冲突约束');
|
||||
}
|
||||
|
||||
echo "WECOM_PROMOTION_BATCH_UPDATE_CONTRACT_OK\n";
|
||||
Reference in New Issue
Block a user