This commit is contained in:
Your Name
2026-08-31 15:17:34 +08:00
parent ed48f8be31
commit 456dd667df
439 changed files with 5720 additions and 422 deletions
+34 -3
View File
@@ -168,11 +168,12 @@ export interface FirstVisitConversionFanDetailRow {
wecom_userid: string
wecom_staff_name: string
add_time: string | null
is_deleted: boolean
delete_time: string | null
is_deleted?: boolean
delete_time?: string | null
}
export interface FirstVisitConversionFansDetailResult {
export interface FirstVisitConversionFansDetailResult {
can_view_deleted_fans?: boolean
lists?: FirstVisitConversionFanDetailRow[]
rows?: FirstVisitConversionFanDetailRow[]
total?: number
@@ -237,6 +238,36 @@ export function wecomPromotionOverview() {
export function wecomPromotionSavePool(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params, timeout: 120000 })
}
export interface WecomPromotionTagGroup {
group_id: string
group_name: string
tag: Array<{ id: string; name: string }>
}
export function wecomPromotionTagOptions() {
return request.get<{ tag_groups: WecomPromotionTagGroup[] }>({
url: '/firstvisit.wecomPromotion/tagOptions'
})
}
export function wecomPromotionCreateTag(params: { name: string }) {
return request.post<{ tag: { id: string; name: string }; group_id: string; group_name: string; reused: boolean }>({
url: '/firstvisit.wecomPromotion/createTag', params, timeout: 30000
}, { ignoreCancelToken: true })
}
export function wecomPromotionUploadWelcomeMedia(file: File, type: 'image' | 'video' | 'file') {
const data = new FormData()
data.append('file', file)
data.append('type', type)
return request.post<{ asset_id: string; name: string; type: string }>({
url: '/firstvisit.wecomPromotion/uploadWelcomeMedia',
data,
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000
}, { ignoreCancelToken: true })
}
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
+5
View File
@@ -578,6 +578,11 @@ export function prescriptionOrderAddLog(params: {
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
}
/** 移除单笔收款关联,总金额不变,同步更新已付金额和需代收 */
export function prescriptionOrderUnlinkPayOrder(params: { id: number; pay_order_id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/unlinkPayOrder', params })
}
/** 修改订单金额 */
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
@@ -541,6 +541,19 @@
{{ formatOrderTime(row.create_time) }}
</template>
</el-table-column>
<el-table-column v-if="canUnlinkPayOrder" label="操作" width="80" fixed="right" align="center">
<template #default="{ row }">
<el-button
type="danger"
size="small"
link
:loading="unlinkPayOrderId === Number(row.id)"
:disabled="unlinkPayOrderId !== null || ![2, 5].includes(Number(row.status))"
:title="Number(row.status) === 4 ? '已退款收款记录不可移除' : '解除关联并更新已付金额,总金额不变'"
@click="confirmUnlinkPayOrder(row)"
>移除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="未关联收款单" :image-size="60" />
</el-card>
@@ -1014,6 +1027,7 @@ import {
prescriptionOrderLogisticsTrace,
prescriptionOrderLogisticsJdUpdate,
prescriptionOrderPaidPayOrders,
prescriptionOrderUnlinkPayOrder,
prescriptionOrderPatchPrescriptionUsage
} from '@/api/tcm'
import { getDictData } from '@/api/app'
@@ -1107,6 +1121,38 @@ const detailLoading = ref(false)
const detailData = ref<Record<string, any> | null>(null)
const detailLogs = ref<any[]>([])
const detailUnlinkedPayOrders = ref<any[]>([])
const unlinkPayOrderId = ref<number | null>(null)
const canUnlinkPayOrder = computed(() =>
!props.readonly && canUpdateAmount(detailData.value) && hasPerm('tcm.prescriptionOrder/unlinkPayOrder')
)
async function confirmUnlinkPayOrder(row: { id: number; order_no?: string; amount: number | string; status: number | string }) {
const orderId = Number(detailData.value?.id)
const payOrderId = Number(row.id)
if (!canUnlinkPayOrder.value || !orderId || !payOrderId || unlinkPayOrderId.value !== null) return
if (![2, 5].includes(Number(row.status))) return
unlinkPayOrderId.value = payOrderId
try {
await feedback.confirm(
`确定移除收款记录「${row.order_no || '#' + payOrderId}」(¥${formatMoney(row.amount)})?` +
'仅解除关联,原收款记录保留;订单总金额不变,已付金额与需代收金额按剩余关联收款重新计算。'
)
if (!detailVisible.value || Number(detailData.value?.id) !== orderId) return
const res: any = await prescriptionOrderUnlinkPayOrder({ id: orderId, pay_order_id: payOrderId })
const updated = res?.data ?? res
if (Number(detailData.value?.id) === orderId && Number(updated?.id) === orderId) {
Object.assign(detailData.value!, updated)
}
feedback.msgSuccess('收款关联已移除,金额已同步更新')
emit('detail-changed')
await refreshIfCurrent(orderId)
} catch {
/* 取消不提交;接口错误由拦截器提示,保留当前收款记录 */
} finally {
unlinkPayOrderId.value = null
}
}
const detailPrescription = computed(() => {
const p = detailData.value?.prescription
@@ -168,6 +168,7 @@ export function logActionText(act: string) {
ship: '确认发货',
withdraw: '撤销',
link_pay_order: '关联支付单',
unlink_pay_order: '移除收款关联',
completion_request: '完单申请',
auto_complete: '自动完成',
revoke_rx_audit: '撤回处方审核',
@@ -108,7 +108,7 @@
-{{ formatNumber(dashboard.summary.deleted_fans_count) }}
</em>
</strong>
<small>{{ metric.hint }}</small>
<small>{{ metric.hint }}<template v-if="metric.key === 'add_fans_count' && canViewDeletedFans">-N为其中已删除</template></small>
</article>
</section>
@@ -154,7 +154,7 @@
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细加粉=总进线=区间新增加粉按员工+客户去重包含区间内添加后已删客户剔除继承客户扫一扫/搜手机号/名片分享添加及区间前已加过的重加-N表示加粉总数中已删除挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
<p>展开部门可查看人员明细加粉=总进线=区间新增加粉按员工+客户去重包含区间内添加后已删客户剔除继承客户扫一扫/搜手机号/名片分享添加及区间前已加过的重加<template v-if="canViewDeletedFans">-N表示加粉总数中已删除</template>挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
@@ -339,7 +339,7 @@
<el-table-column label="添加时间" min-width="180">
<template #default="{ row }">{{ fanAddTime(row) }}</template>
</el-table-column>
<el-table-column label="状态" width="150" align="center">
<el-table-column v-if="canViewDeletedFansDetail" label="状态" width="150" align="center">
<template #default="{ row }">
<div v-if="isDeletedFan(row)" class="deleted-status">
<el-tag type="danger" effect="dark" size="small">已删除</el-tag>
@@ -403,7 +403,7 @@ const emptyDashboard = () => ({
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden',
can_view_finance: false
can_view_finance: false, can_view_deleted_fans: false
},
filters: {
departments: [] as any[],
@@ -431,6 +431,7 @@ const query = reactive<FirstVisitConversionParams>({
const customDateRange = ref<string[]>([])
const fansDetailVisible = ref(false)
const fansDetailLoading = ref(false)
const fansDetailCanViewDeletedFans = ref(false)
const fansDetailRows = ref<FirstVisitConversionFanDetailRow[]>([])
const fansDetailPager = reactive({ page_no: 1, page_size: 20, total: 0 })
const fansDetailEntity = ref<{ type: 'dept' | 'member'; id: string | number; adminId?: number; name: string }>({
@@ -447,7 +448,7 @@ const timeOptions = [
{ label: '自定义', value: 'custom' }
]
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增加粉(含已删除);(-N)为其中已删除' },
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增加粉(含已删除)' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
@@ -465,6 +466,9 @@ const scopeDescription = computed(() => {
return parts.join(' · ')
})
const canViewFinance = computed(() => Boolean(dashboard.meta.can_view_finance))
// 仅服务端按登录账号精确判定为admin时放行;缺失、字符串或角色权限均不能兜底放行。
const canViewDeletedFans = computed(() => dashboard.meta.can_view_deleted_fans === true)
const canViewDeletedFansDetail = computed(() => canViewDeletedFans.value && fansDetailCanViewDeletedFans.value)
const visibleMetricCards = computed(() =>
canViewFinance.value
? metricCards
@@ -542,6 +546,7 @@ let latestFansDetailRequestId = 0
async function loadDashboard() {
const requestId = ++latestDashboardRequestId
loading.value = true
dashboard.meta.can_view_deleted_fans = false
try {
const params: FirstVisitConversionParams = { ...query }
if (params.time_type === 'custom') {
@@ -608,6 +613,7 @@ async function loadFansDetail() {
if (!fansDetailVisible.value) return
const requestId = ++latestFansDetailRequestId
fansDetailLoading.value = true
fansDetailCanViewDeletedFans.value = false
try {
const params: FirstVisitConversionFansDetailParams = {
...query,
@@ -623,6 +629,7 @@ async function loadFansDetail() {
}
const result = await firstVisitConversionFansDetail(params) as FirstVisitConversionFansDetailResult
if (requestId !== latestFansDetailRequestId) return
fansDetailCanViewDeletedFans.value = result?.can_view_deleted_fans === true
const rows = Array.isArray(result?.lists) ? result.lists : (Array.isArray(result?.rows) ? result.rows : [])
fansDetailRows.value = rows
fansDetailPager.total = Number(result?.count ?? result?.total ?? rows.length) || 0
@@ -644,6 +651,7 @@ function handleFansDetailSizeChange() {
function resetFansDetail() {
++latestFansDetailRequestId
fansDetailLoading.value = false
fansDetailCanViewDeletedFans.value = false
fansDetailRows.value = []
fansDetailPager.page_no = 1
fansDetailPager.total = 0
@@ -654,7 +662,7 @@ function hasFans(value: any) {
}
function isDeletedFan(row: FirstVisitConversionFanDetailRow) {
return Boolean(row.is_deleted)
return canViewDeletedFansDetail.value && Boolean(row.is_deleted)
}
function fanCustomerName(row: FirstVisitConversionFanDetailRow) {
@@ -685,7 +693,7 @@ function formatNumber(value: any) {
}
function hasDeletedFans(value: any) {
return Math.round(Number(value || 0)) > 0
return canViewDeletedFans.value && Math.round(Number(value || 0)) > 0
}
function formatMoney(value: any) {
@@ -0,0 +1,230 @@
<template>
<div class="automation-form">
<el-alert class="automation-note" type="info" show-icon :closable="false" title="自动化设置作用于之后新添加的客户,不会立即推送给存量客户。" description="欢迎语依赖企微回调与常驻任务消费进程在 welcome_code 的 20 秒有效期内处理;分钟任务用于接待调度及标签、备注等补偿。请确认服务端已部署这些任务。" />
<h3 class="form-section-title">接待设置</h3>
<el-form-item label="接待模式">
<el-radio-group v-model="config.reception_mode" :disabled="disabled">
<el-radio value="always">全天接待</el-radio>
<el-radio value="scheduled">按星期时段自动上下线</el-radio>
</el-radio-group>
<p class="field-help">成员仍受每日上限有效期及启用状态限制时间统一使用北京时间</p>
</el-form-item>
<div v-if="config.reception_mode === 'scheduled'" class="reception-schedules">
<div v-for="(slot, index) in config.reception_schedule" :key="index" class="schedule-card">
<div class="schedule-heading"><strong>接待时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="disabled" @click="config.reception_schedule.splice(index, 1)">删除时段</el-button></div>
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="disabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="disabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="disabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
<el-select v-model="slot.member_admin_ids" :disabled="disabled" multiple filterable clearable placeholder="从上方主接待成员中选择" style="width: 100%">
<el-option v-for="member in mainMembers" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" />
</el-select>
<p v-if="slot.member_admin_ids.some((id) => !mainMemberIds.includes(id))" class="inline-error">该时段含已从主接待移除的成员请重新选择</p>
</div>
<el-button :icon="Plus" :disabled="disabled || config.reception_schedule.length >= 30" @click="addReceptionSlot">添加接待时段</el-button>
<p class="field-help">最多 30 个时段跨午夜时段归属开始日例如星期一 22:00 02:00 包含星期二凌晨接待时段重叠时取成员并集</p>
</div>
<el-form-item label="备用成员" :required="config.reception_mode === 'scheduled'">
<el-select v-model="config.backup_member_admin_ids" :disabled="disabled" multiple filterable clearable collapse-tags collapse-tags-tooltip :max-collapse-tags="3" :multiple-limit="500" placeholder="主接待成员均不可用时由备用成员接待" style="width: 100%">
<el-option v-for="member in members" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" :disabled="mainMemberIds.includes(Number(member.id))" />
<el-option v-for="id in missingBackupIds" :key="`missing-${id}`" :value="id" :label="`成员 ${id}(当前不可选,请移除后重新选择)`" disabled />
</el-select>
<p class="field-help">备用成员不能与主接待重复按时段模式至少配置一名备用成员仅当无可用主接待时进入官方成员范围</p>
</el-form-item>
<h3 class="form-section-title">客户设置</h3>
<el-form-item label="自动添加客户标签">
<el-switch v-model="config.tags_enabled" :disabled="disabled || tagsCreating" />
<div v-if="hasMultipleTags" class="legacy-tags-warning full-width" role="alert">
<p>原方案设置了多个标签{{ selectedTagNames }}现在仅支持单选请重新选择一个标签或清空原标签</p>
<el-button size="small" :disabled="disabled || tagsCreating" @click="selectedTag = ''">清空原标签</el-button>
</div>
<div v-if="config.tags_enabled" class="full-width tags-content">
<div class="tag-select-row">
<el-select v-model="selectedTag" :disabled="disabled || tagsCreating" :loading="tagsLoading" filterable clearable placeholder="选择一个企业微信客户标签" aria-label="企业微信客户标签" class="tag-select">
<el-option-group v-for="group in tagGroups" :key="group.group_id" :label="group.group_name">
<el-option v-for="tag in group.tag" :key="tag.id" :value="tag.id" :label="tag.name" />
</el-option-group>
<el-option-group v-if="unknownTagIds.length" label="已选标签(名称暂不可用)"><el-option v-for="id in unknownTagIds" :key="id" :value="id" :label="`已选标签 · ${id}`" /></el-option-group>
</el-select>
<el-button :icon="Plus" :disabled="disabled || tagsCreating" @click="showCustomTag = !showCustomTag">自定义标签</el-button>
<el-button :icon="Refresh" :disabled="disabled || tagsCreating" :loading="tagsLoading" @click="loadTags">{{ tagsError ? '重试' : '刷新标签' }}</el-button>
</div>
<p v-if="tagsError" role="alert" class="inline-error">{{ tagsError }} 已保留原有标签点击重试重新加载</p>
<p v-else class="field-help">每个方案只选一个标签可选择已有企业微信标签也可自定义创建标签在企微后台修改后请刷新</p>
<div v-if="showCustomTag" class="custom-tag-editor">
<label for="promotion-custom-tag-name">自定义标签名称</label>
<div class="custom-tag-row">
<el-input id="promotion-custom-tag-name" v-model="customTagName" :disabled="disabled || tagsCreating" maxlength="30" show-word-limit placeholder="例如:官网咨询" @input="customTagError = ''" @keydown.enter.prevent="createCustomTag" />
<el-button type="primary" :disabled="disabled || tagsLoading" :loading="tagsCreating" @click="createCustomTag">创建并选用</el-button>
</div>
<p class="field-help">创建到企业微信推广渠道分组同组同名标签会复用创建后即保存到企微标签库取消方案编辑不会删除标签</p>
<p v-if="customTagError" role="alert" class="inline-error">{{ customTagError }} 原有选择未改变</p>
</div>
<p v-if="customTagSuccess" role="status" class="tag-success">{{ customTagSuccess }}</p>
</div>
</el-form-item>
<el-form-item label="自动设置客户备注">
<el-switch v-model="config.remark_enabled" :disabled="disabled" />
<div v-if="config.remark_enabled" class="full-width remark-content">
<div class="token-buttons"><el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="disabled" @click="insertRemark(token.value)">插入{{ token.label }}</el-button></div>
<el-input ref="remarkInput" v-model="config.remark_template" :disabled="disabled" maxlength="200" show-word-limit placeholder="例如:官网-{customer_name}" @select="rememberRemarkSelection" @keyup="rememberRemarkSelection" @click="rememberRemarkSelection" @blur="rememberRemarkSelection" />
<div class="remark-preview"><span>备注预览</span><strong>{{ remarkPreview || '—' }}</strong><small>{{ Array.from(remarkPreview).length }}/20 </small></div>
<p class="field-help">示例客户张女士员工{{ employeeName }}添加时间格式为 YYYY-MM-DD生成后的备注最多保留前 20 </p>
</div>
</el-form-item>
<el-form-item label="自动设置客户描述">
<el-switch v-model="config.description_enabled" :disabled="disabled" />
<el-input v-if="config.description_enabled" v-model="config.description" class="description-input" :disabled="disabled" type="textarea" :rows="3" maxlength="150" show-word-limit placeholder="请输入客户描述,最多 150 字" />
</el-form-item>
<h3 class="form-section-title">欢迎语设置</h3>
<el-form-item label="欢迎语模式">
<el-radio-group v-model="config.welcome_mode" :disabled="disabled || anyUploading">
<el-radio value="channel">渠道欢迎语</el-radio>
<el-radio value="default">默认欢迎语</el-radio>
<el-radio value="none">不发送欢迎语</el-radio>
</el-radio-group>
<p v-if="config.welcome_mode === 'default'" class="field-help">沿用企业微信后台配置本系统不发送欢迎语</p>
<p v-else-if="config.welcome_mode === 'none'" class="field-help warning-help">仅关闭本系统欢迎语无法覆盖或关闭企业微信后台已配置的欢迎语</p>
<p v-else class="field-help warning-help">企微后台欢迎语可能优先发送需要使用此渠道内容时请核查企微后台配置避免本系统无法发送</p>
</el-form-item>
<template v-if="config.welcome_mode === 'channel'">
<div class="welcome-block"><h4>基础渠道欢迎语</h4><p class="field-help">未开启分时欢迎语或新客户添加时间未匹配任何时段时使用以下内容</p>
<WelcomeMessageEditor v-model="config.welcome" :disabled="disabled" :employee-name="employeeName" @busy="(busy) => updateBusy('basic', busy)" />
</div>
<el-form-item class="schedule-switch" label="分时欢迎语"><el-switch v-model="config.welcome_schedule_enabled" :disabled="disabled || anyUploading" /><span class="switch-help">按客户添加时的北京时间匹配时段不能重叠</span></el-form-item>
<div v-if="config.welcome_schedule_enabled">
<div v-for="(slot, index) in config.welcome_schedule" :key="index" class="schedule-card welcome-schedule">
<div class="schedule-heading"><strong>欢迎语时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="disabled || anyUploading" @click="removeWelcomeSlot(index)">删除时段</el-button></div>
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="disabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="disabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="disabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
<WelcomeMessageEditor :model-value="slot" :disabled="disabled" :employee-name="employeeName" @update:model-value="(message) => Object.assign(slot, message)" @busy="(busy) => updateBusy(`slot-${index}`, busy)" />
</div>
<el-button :icon="Plus" :disabled="disabled || anyUploading || config.welcome_schedule.length >= 30" @click="addWelcomeSlot">添加欢迎语时段</el-button>
<p class="field-help">最多 30 个时段,支持跨午夜。时段外自动使用基础渠道欢迎语,不会随机选择内容。</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { Plus, Refresh } from '@element-plus/icons-vue'
import { wecomPromotionCreateTag, wecomPromotionTagOptions } from '@/api/first_visit'
import type { WecomPromotionTagGroup } from '@/api/first_visit'
import WelcomeMessageEditor from './WelcomeMessageEditor.vue'
import { previewTemplate, templateTokens, validateCustomTagName, weekdays } from './promotion-automation'
import type { PromotionAutomationConfig, PromotionMemberChoice } from './promotion-automation'
const props = defineProps<{ modelValue: PromotionAutomationConfig; mainMemberIds: number[]; members: PromotionMemberChoice[]; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [config: PromotionAutomationConfig]; busy: [value: boolean] }>()
const config = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
const mainMembers = computed(() => props.members.filter((member) => props.mainMemberIds.includes(Number(member.id))))
const missingBackupIds = computed(() => config.value.backup_member_admin_ids.filter((id) => !props.members.some((member) => Number(member.id) === id)))
const employeeName = computed(() => mainMembers.value[0]?.name || '小陈')
const remarkPreview = computed(() => previewTemplate(config.value.remark_template, employeeName.value, 20))
const remarkInput = ref<{ input?: HTMLInputElement }>()
const remarkSelection = ref({ start: 0, end: 0 })
const tagGroups = ref<WecomPromotionTagGroup[]>([])
const tagsLoading = ref(false)
const tagsError = ref('')
const tagsLoaded = ref(false)
const tagsCreating = ref(false)
const showCustomTag = ref(false)
const customTagName = ref('')
const customTagError = ref('')
const customTagSuccess = ref('')
const hasMultipleTags = computed(() => config.value.tag_ids.length > 1)
const selectedTag = computed({
get: () => config.value.tag_ids.length === 1 ? config.value.tag_ids[0] : '',
set: (id: string | undefined) => {
config.value.tag_ids = id ? [id] : []
customTagSuccess.value = ''
}
})
const selectedTagNames = computed(() => {
const tags = tagGroups.value.flatMap((group) => group.tag)
return config.value.tag_ids.map((id) => tags.find((tag) => tag.id === id)?.name || id).join('、')
})
const busyEditors = ref(new Set<string>())
const anyUploading = computed(() => busyEditors.value.size > 0)
watch([tagsCreating, anyUploading], ([creating, uploading]) => emit('busy', creating || uploading), { flush: 'sync' })
const unknownTagIds = computed(() => {
const ids = new Set(tagGroups.value.flatMap((group) => group.tag.map((tag) => tag.id)))
return config.value.tag_ids.filter((id) => !ids.has(id))
})
watch(() => config.value.tags_enabled, (enabled) => { if (enabled && !tagsLoaded.value && !tagsLoading.value) void loadTags() }, { immediate: true })
function memberLabel(member: PromotionMemberChoice) { return `${member.name} · ${member.dept_names?.join(' / ') || member.userid || '未分部门'}` }
function addReceptionSlot() { config.value.reception_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', member_admin_ids: [...props.mainMemberIds] }) }
function addWelcomeSlot() { config.value.welcome_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', text: '', attachments: [] }) }
function removeWelcomeSlot(index: number) { config.value.welcome_schedule.splice(index, 1); busyEditors.value.clear() }
function updateBusy(key: string, busy: boolean) { busy ? busyEditors.value.add(key) : busyEditors.value.delete(key) }
async function loadTags() {
if (tagsCreating.value || tagsLoading.value) return
tagsLoading.value = true
tagsError.value = ''
try {
const result = await wecomPromotionTagOptions()
if (!Array.isArray(result?.tag_groups)) throw new Error('标签接口未返回有效的标签列表。')
tagGroups.value = result.tag_groups.map((group) => ({ ...group, tag: Array.isArray(group.tag) ? group.tag : [] }))
tagsLoaded.value = true
} catch (error: unknown) {
tagsError.value = error instanceof Error ? error.message : typeof error === 'string' ? error : '企业微信标签加载失败。'
} finally { tagsLoading.value = false }
}
async function createCustomTag() {
if (props.disabled || tagsCreating.value || tagsLoading.value) return
customTagError.value = validateCustomTagName(customTagName.value)
customTagSuccess.value = ''
if (customTagError.value) return
tagsCreating.value = true
try {
const result = await wecomPromotionCreateTag({ name: customTagName.value.trim() })
if (!result?.tag?.id || !result.tag.name || !result.group_id) throw new Error('企业微信未返回有效的标签 ID,请刷新标签后确认。')
let group = tagGroups.value.find((item) => item.group_id === result.group_id)
if (!group) {
group = { group_id: result.group_id, group_name: result.group_name, tag: [] }
tagGroups.value.push(group)
}
const existing = group.tag.findIndex((tag) => tag.id === result.tag.id)
if (existing >= 0) group.tag[existing] = result.tag
else group.tag.push(result.tag)
selectedTag.value = result.tag.id
customTagSuccess.value = `${result.reused ? '已选用已有' : '已创建并选用'}标签“${result.tag.name}”,保存方案后生效。`
customTagName.value = ''
showCustomTag.value = false
} catch (error: unknown) {
customTagError.value = error instanceof Error ? error.message : typeof error === 'string' ? error : '自定义标签创建失败,请刷新确认或重试。'
} finally { tagsCreating.value = false }
}
function rememberRemarkSelection() {
const input = remarkInput.value?.input
if (input) remarkSelection.value = { start: input.selectionStart || 0, end: input.selectionEnd || 0 }
}
async function insertRemark(token: string) {
const { start, end } = remarkSelection.value
config.value.remark_template = config.value.remark_template.slice(0, start) + token + config.value.remark_template.slice(end)
await nextTick()
const cursor = start + token.length
remarkInput.value?.input?.focus()
remarkInput.value?.input?.setSelectionRange(cursor, cursor)
remarkSelection.value = { start: cursor, end: cursor }
}
onBeforeUnmount(() => emit('busy', false))
</script>
<style scoped>
.automation-form { width: 100%; }.automation-note { margin-top: 22px; }.automation-note :deep(.el-alert__description) { line-height: 1.7; }
.form-section-title { margin: 28px 0 18px; padding: 0 0 12px; border-bottom: 1px solid #ebeef5; font-size: 15px; font-weight: 600; color: #303133; }.field-help { width: 100%; font-size: 12px; line-height: 1.7; margin: 6px 0 0; color: #909399; }.warning-help { color: #9f6d14; }.full-width { width: 100%; }.inline-error { width: 100%; color: #d93026; font-size: 12px; line-height: 1.7; margin: 8px 0 0; }
.schedule-card { padding: 16px; border: 1px solid #e4e7ed; border-radius: 6px; background: #fafbfd; margin-bottom: 12px; }.schedule-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 13px; }.weekday-select { display: flex; flex-wrap: wrap; gap: 0 18px; }.weekday-select :deep(.el-checkbox) { margin-right: 0; }.time-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 12px 0; }.time-row :deep(.el-date-editor.el-input) { width: 150px; }.time-row > span { font-size: 12px; color: #909399; }.time-row > small { font-size: 12px; color: #b88230; }.reception-schedules { margin: 0 0 20px; }
.tags-content, .remark-content, .description-input { margin-top: 12px; }.tag-select-row { display: flex; gap: 10px; width: 100%; }.tag-select { flex: 1; min-width: 0; }.token-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }.token-buttons .el-button + .el-button { margin-left: 0; }.remark-preview { display: flex; gap: 14px; align-items: center; padding: 10px 12px; background: #f5f7fa; margin-top: 8px; border-radius: 4px; line-height: 1.7; }.remark-preview span, .remark-preview small { color: #909399; font-size: 12px; }.remark-preview strong { color: #303133; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }.remark-preview small { margin-left: auto; white-space: nowrap; }.welcome-block h4 { font-size: 13px; font-weight: 600; margin: 0 0 4px; }.welcome-block > .field-help { margin-bottom: 12px; }.schedule-switch { margin-top: 24px; }.switch-help { margin-left: 12px; color: #909399; font-size: 12px; }.welcome-schedule { background: #fff; }
.tag-select-row .el-button + .el-button { margin-left: 0; }
.custom-tag-editor { margin-top: 12px; padding: 14px; background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; }
.custom-tag-editor label { display: block; font-size: 13px; color: #606266; margin-bottom: 8px; }
.custom-tag-row { display: flex; align-items: center; gap: 10px; }
.custom-tag-row .el-input { flex: 1; min-width: 0; }
.legacy-tags-warning { margin-top: 10px; padding: 10px 12px; background: #fdf6ec; border: 1px solid #faecd8; border-radius: 4px; color: #9f6d14; }
.legacy-tags-warning p { margin: 0 0 6px; font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
.tag-success { margin: 8px 0 0; color: #27864c; font-size: 12px; line-height: 1.7; }
@media (max-width: 620px) { .tag-select-row { flex-direction: column; }.remark-preview { flex-wrap: wrap; }.automation-form :deep(.el-radio) { margin-right: 14px; }.weekday-select { gap: 0 12px; } }
</style>
@@ -0,0 +1,246 @@
<template>
<div class="welcome-editor">
<div class="welcome-editor__fields">
<div class="text-tools">
<el-popover placement="bottom-start" trigger="click" :width="240" :disabled="disabled">
<template #reference><el-button size="small" :disabled="disabled"> 插入表情</el-button></template>
<div class="emoji-grid"><button v-for="emoji in emojis" :key="emoji" type="button" @click="insertText(emoji)">{{ emoji }}</button></div>
</el-popover>
<el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="disabled" @click="insertText(token.value)">{{ token.label }}</el-button>
</div>
<el-input
ref="textInput"
:model-value="modelValue.text"
type="textarea"
:rows="6"
:disabled="disabled"
placeholder="请输入欢迎语,也可插入客户昵称、员工昵称和添加日期"
@update:model-value="updateText"
@select="rememberSelection"
@keyup="rememberSelection"
@click="rememberSelection"
@blur="rememberSelection"
/>
<div class="text-count" :class="{ 'is-error': textTooLong }">{{ Array.from(modelValue.text).length }}/1200 · {{ utf8Length(modelValue.text) }}/4000 字节</div>
<div class="attachments-heading">
<strong>附件 <span>{{ modelValue.attachments.length }}/9</span></strong>
<el-dropdown :disabled="disabled || modelValue.attachments.length >= 9 || uploading" @command="addAttachment">
<el-button size="small" :disabled="disabled || modelValue.attachments.length >= 9 || uploading" :icon="Plus">添加附件</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in attachmentTypes" :key="item.value" :command="item.value">{{ item.label }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div v-if="!modelValue.attachments.length" class="attachment-empty">可添加图片网页小程序视频或文件</div>
<div v-for="(attachment, index) in modelValue.attachments" :key="index" class="attachment-card">
<div class="attachment-card__heading">
<strong>{{ index + 1 }}. {{ attachmentLabel(attachment.msgtype) }}</strong>
<div>
<el-button text size="small" :disabled="disabled || uploading || index === 0" @click="moveAttachment(index, -1)">上移</el-button>
<el-button text size="small" :disabled="disabled || uploading || index === modelValue.attachments.length - 1" @click="moveAttachment(index, 1)">下移</el-button>
<el-button text type="danger" size="small" :disabled="disabled || uploading" @click="removeAttachment(index)">删除</el-button>
</div>
</div>
<template v-if="attachment.msgtype === 'image' || attachment.msgtype === 'video' || attachment.msgtype === 'file'">
<div class="upload-field">
<el-button size="small" :icon="Upload" :loading="uploadingIndex === index" :disabled="disabled || uploading" @click="selectFile(index, attachment.msgtype)">{{ assetId(attachment) ? '重新上传' : '上传' }}{{ attachmentLabel(attachment.msgtype) }}</el-button>
<span :class="assetId(attachment) ? 'asset-ready' : 'muted'">{{ assetName(attachment) }}</span>
</div>
<small class="field-tip">{{ attachment.msgtype === 'image' ? 'JPG / PNG,最多 10MB。' : attachment.msgtype === 'video' ? 'MP4,最多 10MB。' : '文件最多 20MB。' }}上传成功后由服务端保存素材未上传完成不能提交</small>
</template>
<template v-else-if="attachment.msgtype === 'link'">
<label class="attachment-label">网页标题 <span>{{ utf8Length(attachment.link.title) }}/128 字节</span></label>
<el-input v-model="attachment.link.title" :disabled="disabled" placeholder="必填:网页标题" />
<label class="attachment-label">网页地址</label>
<el-input v-model="attachment.link.url" :disabled="disabled" placeholder="https://" />
<label class="attachment-label">网页描述 <span>{{ utf8Length(attachment.link.desc) }}/512 字节</span></label>
<el-input v-model="attachment.link.desc" :disabled="disabled" type="textarea" :rows="2" placeholder="选填:网页摘要" />
<label class="attachment-label">网页封面地址</label>
<el-input v-model="attachment.link.picurl" :disabled="disabled" placeholder="选填:公网可访问的 HTTPS 图片地址" />
</template>
<template v-else-if="attachment.msgtype === 'miniprogram'">
<label class="attachment-label">小程序标题 <span>{{ utf8Length(attachment.miniprogram.title) }}/64 字节</span></label>
<el-input v-model="attachment.miniprogram.title" :disabled="disabled" placeholder="必填:小程序标题" />
<label class="attachment-label">AppID</label>
<el-input v-model="attachment.miniprogram.appid" :disabled="disabled" placeholder="必填:小程序 AppID" />
<label class="attachment-label">页面路径</label>
<el-input v-model="attachment.miniprogram.page" :disabled="disabled" placeholder="pages/index/index,可包含查询参数" />
<div class="upload-field mini-upload">
<el-button size="small" :icon="Upload" :loading="uploadingIndex === index" :disabled="disabled || uploading" @click="selectFile(index, 'image')">{{ attachment.miniprogram.pic_asset_id ? '更换封面' : '上传小程序封面' }}</el-button>
<span :class="assetId(attachment) ? 'asset-ready' : 'muted'">{{ assetName(attachment) }}</span>
</div>
<small class="field-tip">JPG / PNG最多 10MB</small>
</template>
<div v-if="uploadErrors[index]" role="alert" class="upload-error">{{ uploadErrors[index] }} 请重试上传原素材未被替换</div>
</div>
<input ref="fileInput" class="file-input" type="file" :accept="fileAccept" @change="uploadSelectedFile" />
</div>
<aside class="welcome-preview" aria-label="欢迎语手机预览">
<div class="phone-heading"><span></span><strong>{{ employeeName || '接待员工' }}</strong><span>···</span></div>
<div class="phone-content">
<div class="preview-time">添加成功 · 预览示例</div>
<div v-if="modelValue.text" class="chat-row"><span class="chat-avatar">{{ (employeeName || '员').slice(0, 1) }}</span><div class="chat-bubble">{{ previewTemplate(modelValue.text, employeeName) }}</div></div>
<div v-for="(attachment, index) in modelValue.attachments" :key="index" class="chat-row">
<span class="chat-avatar">{{ (employeeName || '员').slice(0, 1) }}</span>
<div class="chat-bubble attachment-preview">
<template v-if="attachment.msgtype === 'image'">
<img v-if="assetPreview(attachment)" :src="assetPreview(attachment)" alt="欢迎语图片预览" />
<div v-else class="media-placeholder"><el-icon><Picture /></el-icon><span>{{ assetId(attachment) ? '已保存的图片素材' : '待上传图片' }}</span></div>
</template>
<template v-else-if="attachment.msgtype === 'link'">
<strong>{{ attachment.link.title || '网页标题' }}</strong><p>{{ attachment.link.desc || '网页摘要' }}</p><small>网页链接</small>
</template>
<template v-else-if="attachment.msgtype === 'miniprogram'">
<small>小程序</small><strong>{{ attachment.miniprogram.title || '小程序标题' }}</strong>
<img v-if="assetPreview(attachment)" :src="assetPreview(attachment)" alt="小程序封面预览" />
<div v-else class="media-placeholder"><el-icon><Grid /></el-icon><span>小程序封面</span></div>
</template>
<template v-else><el-icon class="file-icon"><VideoPlay v-if="attachment.msgtype === 'video'" /><Document v-else /></el-icon><strong>{{ assetName(attachment) }}</strong><small>{{ attachmentLabel(attachment.msgtype) }}</small></template>
</div>
</div>
<div v-if="!modelValue.text && !modelValue.attachments.length" class="preview-empty">编辑内容后在这里预览</div>
</div>
<div class="phone-input"><span></span><span class="phone-input__blank" /><span></span></div>
<p class="preview-note">仅为排版示意昵称与时间会替换为实际值</p>
</aside>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Document, Grid, Picture, Plus, Upload, VideoPlay } from '@element-plus/icons-vue'
import { wecomPromotionUploadWelcomeMedia } from '@/api/first_visit'
import { previewTemplate, templateTokens, utf8Length } from './promotion-automation'
import type { WelcomeAttachment, WelcomeMessage } from './promotion-automation'
const props = defineProps<{ modelValue: WelcomeMessage; disabled?: boolean; employeeName?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: WelcomeMessage]; busy: [value: boolean] }>()
const textInput = ref<{ textarea?: HTMLTextAreaElement }>()
const fileInput = ref<HTMLInputElement>()
const selection = ref({ start: 0, end: 0 })
const uploadingIndex = ref(-1)
const uploading = computed(() => uploadingIndex.value >= 0)
const uploadErrors = ref<Record<number, string>>({})
const assetCache = ref<Record<string, { name: string; url?: string }>>({})
const selectedUpload = ref<{ index: number; type: 'image' | 'video' | 'file' }>({ index: 0, type: 'image' })
const fileAccept = ref('image/jpeg,image/png')
const emojis = ['😊', '😀', '👋', '🌹', '❤️', '👍', '🙏', '🎉', '☀️', '✨', '💐', '🤝', '💬', '✅', '🌿', '🍀']
const attachmentTypes: Array<{ value: WelcomeAttachment['msgtype']; label: string }> = [
{ value: 'image', label: '图片' }, { value: 'link', label: '网页' }, { value: 'miniprogram', label: '小程序' },
{ value: 'video', label: '视频' }, { value: 'file', label: '文件' }
]
const textTooLong = computed(() => Array.from(props.modelValue.text).length > 1200 || utf8Length(props.modelValue.text) > 4000)
function updateText(text: string) { emit('update:modelValue', { ...props.modelValue, text }) }
function rememberSelection() {
const textarea = textInput.value?.textarea
if (textarea) selection.value = { start: textarea.selectionStart, end: textarea.selectionEnd }
}
async function insertText(text: string) {
if (props.disabled) return
const { start, end } = selection.value
updateText(props.modelValue.text.slice(0, start) + text + props.modelValue.text.slice(end))
await nextTick()
const cursor = start + text.length
textInput.value?.textarea?.focus()
textInput.value?.textarea?.setSelectionRange(cursor, cursor)
selection.value = { start: cursor, end: cursor }
}
function attachmentLabel(type: string) { return attachmentTypes.find((item) => item.value === type)?.label || '附件' }
function addAttachment(type: WelcomeAttachment['msgtype']) {
if (props.disabled || uploading.value || props.modelValue.attachments.length >= 9) return
let attachment: WelcomeAttachment
if (type === 'image') attachment = { msgtype: 'image', image: { asset_id: '' } }
else if (type === 'video') attachment = { msgtype: 'video', video: { asset_id: '' } }
else if (type === 'file') attachment = { msgtype: 'file', file: { asset_id: '' } }
else if (type === 'link') attachment = { msgtype: 'link', link: { title: '', url: '', desc: '', picurl: '' } }
else attachment = { msgtype: 'miniprogram', miniprogram: { title: '', appid: '', page: '', pic_asset_id: '' } }
emit('update:modelValue', { ...props.modelValue, attachments: [...props.modelValue.attachments, attachment] })
}
function removeAttachment(index: number) {
uploadErrors.value = {}
emit('update:modelValue', { ...props.modelValue, attachments: props.modelValue.attachments.filter((_, position) => position !== index) })
}
function moveAttachment(index: number, direction: number) {
const attachments = [...props.modelValue.attachments]
;[attachments[index], attachments[index + direction]] = [attachments[index + direction], attachments[index]]
uploadErrors.value = {}
emit('update:modelValue', { ...props.modelValue, attachments })
}
function assetId(attachment: WelcomeAttachment): string {
if (attachment.msgtype === 'image') return attachment.image.asset_id || ''
if (attachment.msgtype === 'video') return attachment.video.asset_id
if (attachment.msgtype === 'file') return attachment.file.asset_id
if (attachment.msgtype === 'miniprogram') return attachment.miniprogram.pic_asset_id
return ''
}
function assetName(attachment: WelcomeAttachment) {
const id = assetId(attachment)
if (id) return assetCache.value[id]?.name || `已保存素材 ${id.slice(-10)}`
return attachment.msgtype === 'image' && attachment.image.pic_url ? '已保存的企微图片' : '尚未上传'
}
function assetPreview(attachment: WelcomeAttachment) { return assetCache.value[assetId(attachment)]?.url || '' }
async function selectFile(index: number, type: 'image' | 'video' | 'file') {
selectedUpload.value = { index, type }
fileAccept.value = type === 'image' ? '.jpg,.jpeg,.png' : type === 'video' ? '.mp4' : '*'
await nextTick()
fileInput.value?.click()
}
async function uploadSelectedFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const { index, type } = selectedUpload.value
const limit = type === 'file' ? 20 : 10
let error = ''
if (file.size <= 5 || file.size > limit * 1024 * 1024) error = `文件必须大于 5 字节且不超过 ${limit}MB`
else if (type === 'image' && !/\.(jpe?g|png)$/i.test(file.name)) error = '图片仅支持 JPG / PNG 格式'
else if (type === 'video' && !/\.mp4$/i.test(file.name)) error = '视频仅支持 MP4 格式'
if (error) { uploadErrors.value[index] = error; return }
const attachment = props.modelValue.attachments[index]
if (!attachment) return
uploadingIndex.value = index
emit('busy', true)
delete uploadErrors.value[index]
try {
const result = await wecomPromotionUploadWelcomeMedia(file, type)
if (!result?.asset_id) throw new Error('上传接口未返回素材 ID')
const id = String(result.asset_id)
assetCache.value[id] = { name: result.name || file.name, url: type === 'image' ? URL.createObjectURL(file) : undefined }
if (attachment.msgtype === 'image') { attachment.image = { asset_id: id } }
else if (attachment.msgtype === 'video') attachment.video.asset_id = id
else if (attachment.msgtype === 'file') attachment.file.asset_id = id
else if (attachment.msgtype === 'miniprogram') attachment.miniprogram.pic_asset_id = id
ElMessage.success('素材已上传')
} catch (error: unknown) {
uploadErrors.value[index] = error instanceof Error ? error.message : typeof error === 'string' ? error : '素材上传失败'
} finally {
uploadingIndex.value = -1
emit('busy', false)
}
}
onBeforeUnmount(() => {
Object.values(assetCache.value).forEach((asset) => { if (asset.url) URL.revokeObjectURL(asset.url) })
emit('busy', false)
})
</script>
<style scoped>
.welcome-editor { display: grid; grid-template-columns: minmax(0, 1fr) 260px; align-items: start; gap: 22px; width: 100%; }
.welcome-editor__fields { min-width: 0; }
.text-tools { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }.text-tools .el-button + .el-button { margin-left: 0; }
.emoji-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px; }.emoji-grid button { border: 0; background: none; padding: 4px; cursor: pointer; font-size: 20px; }
.text-count { text-align: right; font-size: 12px; color: #909399; margin-top: 4px; }.text-count.is-error, .upload-error { color: #d93026; }
.attachments-heading, .attachment-card__heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; }.attachments-heading { margin: 16px 0 10px; }.attachments-heading strong { font-size: 13px; }.attachments-heading strong span { color: #909399; font-weight: 400; }
.attachment-empty { padding: 18px 12px; color: #909399; background: #f7f8fa; border: 1px dashed #dcdfe6; border-radius: 4px; font-size: 12px; }
.attachment-card { border: 1px solid #e4e7ed; border-radius: 5px; padding: 12px; margin-top: 10px; }.attachment-card__heading { margin-bottom: 10px; }.attachment-card__heading strong { font-size: 13px; }.attachment-card__heading .el-button { padding: 4px; margin: 0; }
.attachment-label { display: block; font-size: 12px; color: #606266; margin: 10px 0 4px; }.attachment-label span { color: #909399; float: right; }.upload-field { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 12px; overflow-wrap: anywhere; }.mini-upload { margin-top: 12px; }.asset-ready { color: #178758; }.muted { color: #909399; }.field-tip { display: block; color: #909399; line-height: 1.6; margin-top: 6px; }.upload-error { font-size: 12px; line-height: 1.6; margin-top: 6px; }.file-input { display: none; }
.welcome-preview { width: 260px; border: 1px solid #dcdfe6; border-radius: 20px; padding: 7px; background: #fff; overflow: hidden; }
.phone-heading { display: flex; justify-content: space-between; align-items: center; padding: 13px 12px; background: #ededed; border-radius: 14px 14px 0 0; font-size: 13px; }.phone-heading > span { font-size: 19px; }
.phone-content { min-height: 330px; max-height: 520px; overflow: auto; background: #ededed; padding: 0 10px 18px; }.preview-time { font-size: 10px; text-align: center; color: #999; padding: 12px 0 18px; }.chat-row { display: flex; gap: 7px; margin-bottom: 12px; align-items: flex-start; }.chat-avatar { width: 27px; height: 27px; background: #6e92ae; color: white; flex-shrink: 0; border-radius: 4px; display: grid; place-items: center; font-size: 11px; }.chat-bubble { background: #fff; padding: 9px 10px; border-radius: 4px; font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-width: 172px; }.attachment-preview { width: 172px; }.attachment-preview strong { display: block; font-weight: 500; font-size: 12px; }.attachment-preview p { color: #909399; font-size: 10px; margin: 6px 0; }.attachment-preview small { display: block; font-size: 9px; color: #909399; margin-top: 7px; }.attachment-preview img { width: 100%; max-height: 160px; object-fit: contain; display: block; }.media-placeholder { background: #f2f5f7; height: 85px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: #909399; font-size: 10px; }.media-placeholder .el-icon, .file-icon { font-size: 28px; color: #8babc3; }.preview-empty { text-align: center; color: #aaa; font-size: 12px; margin-top: 100px; }.phone-input { display: flex; gap: 10px; padding: 9px; background: #f6f6f6; border-radius: 0 0 14px 14px; align-items: center; color: #909399; }.phone-input__blank { flex: 1; height: 24px; border-radius: 3px; background: white; }.preview-note { margin: 10px 6px 6px; font-size: 11px; color: #909399; line-height: 1.6; }
@media (max-width: 850px) { .welcome-editor { grid-template-columns: 1fr; }.welcome-preview { margin: 8px auto 0; } }
</style>
@@ -0,0 +1,195 @@
export interface PromotionMemberChoice {
id: number
name: string
userid: string
dept_names: string[]
}
export type WelcomeAttachment =
| { msgtype: 'image'; image: { asset_id?: string; pic_url?: string } }
| { msgtype: 'video'; video: { asset_id: string } }
| { msgtype: 'file'; file: { asset_id: string } }
| { msgtype: 'link'; link: { title: string; url: string; desc: string; picurl?: string } }
| { msgtype: 'miniprogram'; miniprogram: { title: string; appid: string; page: string; pic_asset_id: string } }
export interface WelcomeMessage {
text: string
attachments: WelcomeAttachment[]
}
export interface WeeklySlot {
weekdays: number[]
start: string
end: string
}
export interface ReceptionSlot extends WeeklySlot {
member_admin_ids: number[]
}
export interface WelcomeSlot extends WeeklySlot, WelcomeMessage {}
export interface PromotionAutomationConfig {
reception_mode: 'always' | 'scheduled'
reception_schedule: ReceptionSlot[]
backup_member_admin_ids: number[]
tags_enabled: boolean
tag_ids: string[]
remark_enabled: boolean
remark_template: string
description_enabled: boolean
description: string
welcome_mode: 'default' | 'channel' | 'none'
welcome: WelcomeMessage
welcome_schedule_enabled: boolean
welcome_schedule: WelcomeSlot[]
}
export const weekdays = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日']
export const templateTokens = [
{ label: '客户昵称', value: '{customer_name}' },
{ label: '员工昵称', value: '{employee_name}' },
{ label: '添加时间', value: '{add_time}' }
]
export const utf8Length = (value: string): number => new TextEncoder().encode(value).length
export function validateCustomTagName(value: string): string {
if (!value.trim()) return '请输入自定义标签名称'
if (/[\p{C}\u2028\u2029]/u.test(value)) return ''
if (Array.from(value.trim()).length > 30) return '标签名称最多 30 个字符'
return ''
}
export function defaultAutomationConfig(): PromotionAutomationConfig {
return {
reception_mode: 'always', reception_schedule: [], backup_member_admin_ids: [],
tags_enabled: false, tag_ids: [], remark_enabled: false, remark_template: '{customer_name}',
description_enabled: false, description: '', welcome_mode: 'default',
welcome: { text: '', attachments: [] }, welcome_schedule_enabled: false, welcome_schedule: []
}
}
// Only copy the editable contract. Server-resolved userids never come back in a save payload.
export function cloneAutomationConfig(source?: Partial<PromotionAutomationConfig> | null): PromotionAutomationConfig {
const defaults = defaultAutomationConfig()
if (!source) return defaults
const copy = JSON.parse(JSON.stringify(source)) as PromotionAutomationConfig
return {
reception_mode: copy.reception_mode === 'scheduled' ? 'scheduled' : 'always',
reception_schedule: (copy.reception_schedule || []).map(({ weekdays, start, end, member_admin_ids }) => ({
weekdays: [...weekdays], start, end, member_admin_ids: member_admin_ids.map(Number)
})),
backup_member_admin_ids: (copy.backup_member_admin_ids || []).map(Number),
tags_enabled: Boolean(copy.tags_enabled), tag_ids: [...(copy.tag_ids || [])],
remark_enabled: Boolean(copy.remark_enabled), remark_template: copy.remark_template ?? defaults.remark_template,
description_enabled: Boolean(copy.description_enabled), description: copy.description || '',
welcome_mode: copy.welcome_mode || 'default',
welcome: { text: copy.welcome?.text || '', attachments: copy.welcome?.attachments || [] },
welcome_schedule_enabled: Boolean(copy.welcome_schedule_enabled),
welcome_schedule: (copy.welcome_schedule || []).map(({ weekdays, start, end, text, attachments }) => ({
weekdays: [...weekdays], start, end, text: text || '', attachments: attachments || []
}))
}
}
export function previewTemplate(template: string, employee = '小陈', limit?: number): string {
const date = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit'
}).format(new Date())
const text = template.replace(/\{(customer_name|employee_name|add_time)\}/g, (token) => ({
'{customer_name}': '张女士', '{employee_name}': employee, '{add_time}': date
}[token] || token))
return limit ? Array.from(text).slice(0, limit).join('') : text
}
export function isWebUrl(value: string): boolean {
try { return ['https:', 'http:'].includes(new URL(value).protocol) } catch { return false }
}
function validateSlot(slot: WeeklySlot, label: string): string {
if (!slot.weekdays.length || slot.weekdays.some((day) => day < 1 || day > 7)) return `${label}请选择星期`
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(slot.start) || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(slot.end)) return `${label}请填写有效的起止时间`
if (slot.start === slot.end) return `${label}开始与结束时间不能相同,全天接待请选择全天模式`
return ''
}
export function validateWelcomeMessage(message: WelcomeMessage, label: string, required = true): string {
if (Array.from(message.text).length > 1200) return `${label}正文不能超过 1200 字`
if (utf8Length(message.text) > 4000) return `${label}正文不能超过 4000 UTF-8 字节,请减少表情或文字`
if (message.attachments.length > 9) return `${label}最多添加 9 个附件`
if (required && !message.text.trim() && !message.attachments.length) return `${label}请填写正文或添加附件`
for (const [index, attachment] of message.attachments.entries()) {
const prefix = `${label}${index + 1} 个附件:`
switch (attachment.msgtype) {
case 'image':
if (!attachment.image.asset_id && !isWebUrl(attachment.image.pic_url || '')) return prefix + '请上传图片或填写有效图片地址'
break
case 'video':
if (!attachment.video.asset_id) return prefix + '请先上传视频'
break
case 'file':
if (!attachment.file.asset_id) return prefix + '请先上传文件'
break
case 'link':
if (!attachment.link.title.trim() || !isWebUrl(attachment.link.url)) return prefix + '请填写网页标题和有效的 HTTP/HTTPS 链接'
if (utf8Length(attachment.link.title) > 128 || utf8Length(attachment.link.desc) > 512) return prefix + '网页标题限 128 字节,描述限 512 字节'
if (attachment.link.picurl && !isWebUrl(attachment.link.picurl)) return prefix + '网页封面地址无效'
break
case 'miniprogram':
if (!attachment.miniprogram.title.trim() || !attachment.miniprogram.appid.trim() || !attachment.miniprogram.page.trim() || !attachment.miniprogram.pic_asset_id) return prefix + '请填写小程序标题、AppID、页面路径并上传封面'
if (utf8Length(attachment.miniprogram.title) > 64) return prefix + '小程序标题不能超过 64 字节'
break
}
}
return ''
}
/** Weekly minute occupancy also catches Sunday-to-Monday and overnight overlap. */
export function welcomeScheduleOverlap(slots: WeeklySlot[]): boolean {
const minutes = new Set<number>()
for (const slot of slots) {
const [sh, sm] = slot.start.split(':').map(Number)
const [eh, em] = slot.end.split(':').map(Number)
const start = sh * 60 + sm
const duration = (eh * 60 + em - start + 1440) % 1440
for (const day of new Set(slot.weekdays)) {
for (let offset = 0; offset < duration; offset++) {
const minute = ((day - 1) * 1440 + start + offset) % 10080
if (minutes.has(minute)) return true
minutes.add(minute)
}
}
}
return false
}
export function validateAutomationConfig(config: PromotionAutomationConfig, mainMemberIds: number[]): string {
if (config.backup_member_admin_ids.some((id) => mainMemberIds.includes(id))) return '备用成员不能与主接待成员重复'
if (config.reception_schedule.length > 30 || config.welcome_schedule.length > 30) return '每类时段最多添加 30 条'
if (config.reception_mode === 'scheduled') {
if (!config.reception_schedule.length) return '按时段接待至少需要一个接待时段'
if (!config.backup_member_admin_ids.length) return '按时段接待至少需要一名备用成员,以承接非接待时段的客户'
}
for (const [index, slot] of config.reception_schedule.entries()) {
const error = validateSlot(slot, `接待时段 ${index + 1}`)
if (error) return error
if (!slot.member_admin_ids.length) return `接待时段 ${index + 1}:请至少选择一名成员`
if (slot.member_admin_ids.some((id) => !mainMemberIds.includes(id))) return `接待时段 ${index + 1}:成员必须来自主接待成员,请重新选择`
}
if (config.tags_enabled && !config.tag_ids.length) return '启用客户标签后,请选择一个企业微信标签,或创建自定义标签'
if (config.tag_ids.length > 1) return '客户标签只能选择一个,请重新选择或清空原标签'
if (config.remark_enabled && !config.remark_template.trim()) return '请填写客户备注模板'
if (Array.from(config.remark_template).length > 200) return '客户备注模板不能超过 200 字'
if (config.description_enabled && !config.description.trim()) return '请填写客户描述'
if (Array.from(config.description).length > 150) return '客户描述不能超过 150 字'
const error = validateWelcomeMessage(config.welcome, '基础渠道欢迎语:', config.welcome_mode === 'channel')
if (error) return error
if (config.welcome_mode === 'channel' && config.welcome_schedule_enabled && !config.welcome_schedule.length) return '请至少添加一个分时欢迎语时段'
for (const [index, slot] of config.welcome_schedule.entries()) {
const error = validateSlot(slot, `欢迎语时段 ${index + 1}`) || validateWelcomeMessage(slot, `欢迎语时段 ${index + 1}`)
if (error) return error
}
if (welcomeScheduleOverlap(config.welcome_schedule)) return '分时欢迎语的时间范围不能重叠(跨午夜时段归属开始日)'
return ''
}
@@ -142,17 +142,15 @@
<el-table-column label="推广成员" min-width="210" fixed="left">
<template #default="{ row }">
<div class="member-cell">
<strong>{{ row.name || row.userid }}</strong>
<strong>{{ row.name || row.userid }} <el-tag v-if="row.is_backup" size="small" type="warning" effect="plain">备用</el-tag></strong>
<small>{{ memberRuleDetail(row) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="调度" width="105" align="center">
<el-table-column label="企微范围同步" width="130" align="center">
<template #default="{ row }">
<span v-if="row.is_in_remote_range && eligibility(row).className === 'is-ok'" class="status-tag is-online">企微路由中</span>
<span v-else-if="row.is_in_remote_range" class="status-tag is-offline">待移出</span>
<span v-else-if="eligibility(row).className === 'is-ok'" class="status-tag is-offline">待同步</span>
<span v-else class="muted">不参与</span>
<span class="status-tag" :class="routeStatus(row).className">{{ routeStatus(row).label }}</span>
<div v-if="Number(row.sync_status) === 3" class="sync-retry-tip">同步失败后台重试中</div>
</template>
</el-table-column>
<el-table-column label="今日 / 上限" width="125" align="center">
@@ -367,14 +365,18 @@
<el-dialog
v-model="poolDialogVisible"
:title="poolForm.id ? '编辑分流方案' : '新建分流方案'"
width="680px"
width="1000px"
class="promotion-pool-dialog"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="!savingPool"
:close-on-press-escape="!savingPool && !automationBusy"
:show-close="!savingPool && !automationBusy"
>
<el-form label-position="top">
<div ref="poolFormScroll" class="pool-form-scroll">
<el-alert v-if="poolFormError" class="pool-form-error" :title="poolFormError" type="error" show-icon :closable="false" role="alert" />
<el-form label-position="top" :disabled="savingPool">
<el-form-item label="方案名称" required><el-input v-model="poolForm.name" :disabled="savingPool" maxlength="60" show-word-limit placeholder="例如:官网咨询分流" /></el-form-item>
<el-form-item label="获客医助" required>
<el-form-item label="主接待成员(获客医助" required>
<el-tree-select
v-model="poolForm.member_admin_ids"
:data="memberDepartmentTree"
@@ -413,8 +415,18 @@
<span class="form-tip">新生成的官方直链不经过本站跳转此项仅兼容旧安装代码</span>
</el-form-item>
<el-form-item label="运行状态"><el-switch v-model="poolForm.status" :disabled="savingPool" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" /></el-form-item>
<PromotionAutomationForm
v-if="overview.automation_installed"
v-model="poolForm.automation_config"
:main-member-ids="poolForm.member_admin_ids"
:members="overview.member_options"
:disabled="savingPool"
@busy="(busy) => automationBusy = busy"
/>
<el-alert v-else type="warning" show-icon :closable="false" title="自动化配置尚未安装" description="请先在服务端执行 add_wecom_promotion_automation.sql 并部署消费进程及定时调度,即可设置接待时段、标签、备注和欢迎语。当前仍可保存原有分流方案。" />
</el-form>
<template #footer><el-button :disabled="savingPool" @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" @click="savePool">{{ poolForm.id ? '保存方案' : '保存并生成链接' }}</el-button></template>
</div>
<template #footer><span v-if="automationBusy" class="uploading-save-tip">正在处理标签或素材,请稍候</span><el-button :disabled="savingPool || automationBusy" @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" :disabled="automationBusy" @click="savePool">{{ poolForm.id ? '保存方案' : '保存并生成链接' }}</el-button></template>
</el-dialog>
<el-dialog
@@ -524,7 +536,9 @@ import {
} from '@/api/first_visit'
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
import PromotionAutomationForm from './components/PromotionAutomationForm.vue'
import { cloneAutomationConfig, defaultAutomationConfig, isWebUrl, validateAutomationConfig } from './components/promotion-automation'
type TabName = 'links' | 'customer-stats' | 'configuration' | 'install'
@@ -573,6 +587,7 @@ const emptyOverview = () => ({
member_options: [] as PromotionMemberOption[],
operator_options: [] as PromotionOperatorOption[],
department_options: [] as PromotionDepartmentOption[],
automation_installed: false,
customer_acquisition_link_example: 'https://work.weixin.qq.com/ca/xxxxxxxx'
})
@@ -591,7 +606,10 @@ const togglingMemberId = ref(0)
const deletingPoolId = ref(0)
const accessDialogVisible = ref(false)
const savingAccess = ref(false)
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1, member_admin_ids: [] as number[], skip_verify: 0, main_url: '' })
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1, member_admin_ids: [] as number[], skip_verify: 0, main_url: '', automation_config: defaultAutomationConfig() })
const poolFormScroll = ref<HTMLElement>()
const poolFormError = ref('')
const automationBusy = ref(false)
const accessForm = reactive({ pool_ids: [] as number[], operator_admin_ids: [] as number[], action: 'grant' as 'grant' | 'revoke' })
const memberForm = reactive({ id: 0, name: '', userid: '', daily_limit: 0, status: 1, active_range: [] as string[], remark: '' })
const customerStatsLoading = ref(false)
@@ -738,6 +756,8 @@ function accessSummary(pool: any) {
function openPoolDialog(pool?: any) {
const selectableMemberIds = new Set(overview.member_options.map((member) => Number(member.id)))
poolFormError.value = ''
automationBusy.value = false
Object.assign(poolForm, pool ? {
id: Number(pool.id),
name: pool.name,
@@ -747,7 +767,8 @@ function openPoolDialog(pool?: any) {
? pool.member_admin_ids.map(Number).filter((memberId: number) => selectableMemberIds.has(memberId))
: [],
skip_verify: Number(pool.skip_verify) === 1 ? 1 : 0,
main_url: pool.main_url || ''
main_url: pool.main_url || '',
automation_config: cloneAutomationConfig(pool.automation_config)
} : {
id: 0,
name: '',
@@ -755,25 +776,48 @@ function openPoolDialog(pool?: any) {
status: 1,
member_admin_ids: [],
skip_verify: 0,
main_url: ''
main_url: '',
automation_config: defaultAutomationConfig()
})
poolDialogVisible.value = true
}
async function savePool() {
if (!poolForm.name.trim()) return ElMessage.warning('请输入分流方案名称')
if (!poolForm.member_admin_ids.length) return ElMessage.warning('请至少选择一名获客医助')
if (savingPool.value || automationBusy.value) return
poolFormError.value = ''
const automation = cloneAutomationConfig(poolForm.automation_config)
// Disabled sections do not submit unfinished draft rows or draft attachments.
if (automation.reception_mode !== 'scheduled') automation.reception_schedule = []
if (automation.welcome_mode !== 'channel') automation.welcome = { text: '', attachments: [] }
if (automation.welcome_mode !== 'channel' || !automation.welcome_schedule_enabled) automation.welcome_schedule = []
const error = !poolForm.name.trim() ? '请输入分流方案名称'
: !poolForm.member_admin_ids.length ? '请至少选择一名主接待成员'
: poolForm.fallback_url.trim() && !isWebUrl(poolForm.fallback_url.trim()) ? '兜底获客助手链接必须为有效的 HTTP/HTTPS 地址'
: overview.automation_installed ? validateAutomationConfig(automation, poolForm.member_admin_ids) : ''
if (error) {
poolFormError.value = error
poolFormScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
return ElMessage.warning(error)
}
savingPool.value = true
try {
const result: any = await wecomPromotionSavePool({ ...poolForm })
try {
const { automation_config: _draft, ...base } = poolForm
const result: any = await wecomPromotionSavePool({
...base,
name: poolForm.name.trim(),
fallback_url: poolForm.fallback_url.trim(),
...(overview.automation_installed ? { automation_config: automation } : {})
})
poolDialogVisible.value = false
await loadOverview()
if (result?.id) selectedPoolId.value = Number(result.id)
result?.sync_error
? ElMessage.warning('方案已保存,企业微信多人范围将在后台自动重试同步')
: ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建')
} catch (error: any) {
ElMessage.error(error?.message || '分流方案保存失败')
} catch (error: any) {
poolFormError.value = error?.message || (typeof error === 'string' ? error : '分流方案保存失败,请检查配置或稍后重试')
poolFormScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
ElMessage.error(poolFormError.value)
} finally {
savingPool.value = false
}
@@ -974,10 +1018,17 @@ function eligibility(row: any) {
if (Number(row.active_start) > 0 && Number(row.active_start) > now) return { label: '尚未生效', className: 'is-waiting' }
if (Number(row.active_end) > 0 && Number(row.active_end) < now) return { label: '已过期', className: 'is-error' }
if (Number(row.daily_limit) > 0 && todayCount(row) >= Number(row.daily_limit)) return { label: '今日已达上限', className: 'is-waiting' }
if (Number(row.sync_status) === 3) return { label: '企微同步重试中', className: 'is-waiting' }
if (Number(row.sync_status) === 4) return { label: '暂无可用成员', className: 'is-error' }
if (Number(selectedPool.value?.status) === 0) return { label: '方案已停用', className: 'is-muted' }
if (row.reception_available === false || row.reception_available === 0) return { label: row.is_backup ? '备用待命' : '非接待时段', className: 'is-waiting' }
if (row.is_backup) return { label: '备用接待中', className: 'is-ok' }
return { label: '可参与分流', className: 'is-ok' }
}
function routeStatus(row: any) {
const available = eligibility(row).className === 'is-ok'
if (row.is_in_remote_range) return { label: available ? '企微路由中' : '待移出', className: available ? 'is-online' : 'is-offline' }
return { label: available ? '待同步' : '未在路由范围', className: 'is-offline' }
}
function todayCount(row: any) {
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
@@ -1099,7 +1150,8 @@ function filterMemberTreeNode(keyword: string, data: any) {
function memberSummary(pool: any) {
const ids = Array.isArray(pool?.member_admin_ids) ? pool.member_admin_ids : []
return ids.length ? `${ids.length} 名医助` : '未选择医助'
const backupCount = pool?.automation_config?.backup_member_admin_ids?.length || 0
return ids.length ? `${ids.length} 名主接待${backupCount ? ` · ${backupCount} 名备用` : ''}` : '未选择医助'
}
function memberRuleDetail(row: any) {
@@ -1313,6 +1365,11 @@ h1, h2, h3, p { margin: 0; }
.access-pool-preview small, .access-pool-preview p { color: #8491a2; font-size: 10px; }
.access-pool-preview small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.access-pool-preview p { margin-top: 7px; }
.pool-form-scroll { max-height: 70vh; overflow-y: auto; overflow-x: hidden; padding: 0 10px 6px 2px; }
.pool-form-error { margin-bottom: 18px; }
.uploading-save-tip { color: #b88230; font-size: 12px; margin-right: 16px; }
.sync-retry-tip { color: #b88230; font-size: 10px; line-height: 1.6; margin-top: 4px; }
:global(.promotion-pool-dialog) { max-width: calc(100vw - 32px); margin-top: 5vh; }
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; } }
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.section-heading-actions { width: 100%; justify-content: flex-start; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid, .rule-form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-select-row { min-width: 220px; }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; }.access-pool-preview > div { grid-template-columns: 1fr; gap: 3px; } }
</style>