Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5b14516a1 | ||
|
|
381fe65367 | ||
|
|
af603a4e9a | ||
|
|
f24afa116f | ||
|
|
b8ccbaf567 | ||
|
|
af1db59c07 | ||
|
|
47094cc617 | ||
|
|
01c38d8c5b | ||
|
|
1f3e580cf8 | ||
|
|
562fe0ea0e | ||
|
|
d9bb94cd3f | ||
|
|
9646ccd3f6 |
@@ -154,6 +154,50 @@ export function firstVisitConversionOverview(params: FirstVisitConversionParams)
|
||||
)
|
||||
}
|
||||
|
||||
export interface FirstVisitConversionFansDetailParams extends FirstVisitConversionParams {
|
||||
entity_type: 'dept' | 'member'
|
||||
entity_id: string | number
|
||||
admin_id?: number
|
||||
page_no: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface FirstVisitConversionFanDetailRow {
|
||||
external_userid: string
|
||||
customer_name: string
|
||||
wecom_userid: string
|
||||
wecom_staff_name: string
|
||||
add_time: string | null
|
||||
is_deleted: boolean
|
||||
delete_time: string | null
|
||||
}
|
||||
|
||||
export interface FirstVisitConversionFansDetailResult {
|
||||
lists?: FirstVisitConversionFanDetailRow[]
|
||||
rows?: FirstVisitConversionFanDetailRow[]
|
||||
total?: number
|
||||
count?: number
|
||||
page_no?: number
|
||||
page_size?: number
|
||||
entity?: {
|
||||
type?: 'dept' | 'member'
|
||||
id?: string | number
|
||||
admin_id?: number
|
||||
name?: string
|
||||
add_fans_count?: number
|
||||
deleted_fans_count?: number
|
||||
}
|
||||
date_range?: [string, string]
|
||||
}
|
||||
|
||||
/** 一诊加粉明细:时间、渠道及行实体均由调用方显式透传,服务端仍需按 DataScope 收窄。 */
|
||||
export function firstVisitConversionFansDetail(params: FirstVisitConversionFansDetailParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.conversion/fansDetail', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export interface FirstVisitRegistrationStatsParams {
|
||||
time_type: 'today' | 'yesterday' | 'week' | 'month'
|
||||
dept_id?: number
|
||||
@@ -190,21 +234,29 @@ export function wecomPromotionOverview() {
|
||||
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
|
||||
}
|
||||
|
||||
export function wecomPromotionSavePool(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
|
||||
}
|
||||
export function wecomPromotionSavePool(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params, timeout: 120000 })
|
||||
}
|
||||
|
||||
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeletePool(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
|
||||
}
|
||||
export function wecomPromotionDeletePool(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params, timeout: 120000 })
|
||||
}
|
||||
|
||||
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
|
||||
}
|
||||
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionSaveMember(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveMember', params, timeout: 120000 })
|
||||
}
|
||||
|
||||
export function wecomPromotionToggleMember(params: { id: number; status: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/toggleMember', params, timeout: 120000 })
|
||||
}
|
||||
|
||||
export function wecomPromotionCheckApiPermission() {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
|
||||
|
||||
@@ -98,7 +98,16 @@
|
||||
<section class="metric-grid" aria-label="综合转化指标">
|
||||
<article v-for="metric in visibleMetricCards" :key="metric.key" class="metric-card">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
|
||||
<strong class="metric-value">
|
||||
{{ formatMetric(metric.key, metric.type) }}
|
||||
<em
|
||||
v-if="metric.key === 'add_fans_count' && hasDeletedFans(dashboard.summary.deleted_fans_count)"
|
||||
class="deleted-fans-marker"
|
||||
title="加粉总数中已删除"
|
||||
>
|
||||
(-{{ formatNumber(dashboard.summary.deleted_fans_count) }})
|
||||
</em>
|
||||
</strong>
|
||||
<small>{{ metric.hint }}</small>
|
||||
</article>
|
||||
</section>
|
||||
@@ -145,7 +154,7 @@
|
||||
<div class="panel-heading panel-heading--table">
|
||||
<div>
|
||||
<h2>明细数据列表</h2>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间有效加粉(按员工+客户去重,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间新增加粉(按员工+客户去重,包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加),(-N)表示加粉总数中已删除;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
</div>
|
||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||
</div>
|
||||
@@ -178,7 +187,27 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="add_fans_count" label="加粉" min-width="72" align="right" />
|
||||
<el-table-column prop="add_fans_count" label="加粉" min-width="92" align="right">
|
||||
<template #default="{ row }">
|
||||
<button
|
||||
v-if="hasFans(row.add_fans_count)"
|
||||
type="button"
|
||||
class="fan-count-value fan-detail-trigger fan-detail-trigger--table"
|
||||
title="查看该行加粉客户明细"
|
||||
@click.stop="openFansDetail(row)"
|
||||
>
|
||||
{{ formatNumber(row.add_fans_count) }}
|
||||
<em
|
||||
v-if="hasDeletedFans(row.deleted_fans_count)"
|
||||
class="deleted-fans-marker"
|
||||
title="加粉总数中已删除"
|
||||
>
|
||||
(-{{ formatNumber(row.deleted_fans_count) }})
|
||||
</em>
|
||||
</button>
|
||||
<span v-else class="fan-count-value is-empty">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
|
||||
<el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
|
||||
<el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
|
||||
@@ -270,6 +299,75 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="fansDetailVisible"
|
||||
:title="fansDetailTitle"
|
||||
width="920px"
|
||||
top="7vh"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
class="fans-detail-dialog"
|
||||
@closed="resetFansDetail"
|
||||
>
|
||||
<div class="fans-detail-context">
|
||||
<span><b>统计区间</b>{{ detailRangeText }}</span>
|
||||
<span><b>媒体渠道</b>{{ detailChannelText }}</span>
|
||||
<span><b>数据范围</b>{{ dashboard.meta.scope_label || '当前权限范围' }}</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="fansDetailLoading"
|
||||
:data="fansDetailRows"
|
||||
:row-class-name="fansDetailRowClassName"
|
||||
height="430"
|
||||
class="fans-detail-table"
|
||||
>
|
||||
<el-table-column label="客户" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<div class="customer-cell">
|
||||
<strong class="customer-name" :class="{ 'is-deleted': isDeletedFan(row) }">
|
||||
{{ fanCustomerName(row) }}
|
||||
</strong>
|
||||
<small v-if="row.customer_name && row.external_userid">{{ row.external_userid }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="归属员工" min-width="160">
|
||||
<template #default="{ row }">{{ fanOwnerName(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="添加时间" min-width="180">
|
||||
<template #default="{ row }">{{ fanAddTime(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column 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>
|
||||
<small v-if="row.delete_time">{{ row.delete_time }}</small>
|
||||
</div>
|
||||
<el-tag v-else type="success" effect="plain" size="small">正常</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :image-size="68" description="当前条件下暂无加粉客户明细" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div class="fans-detail-footer">
|
||||
<span>共 {{ formatNumber(fansDetailPager.total) }} 位客户</span>
|
||||
<el-pagination
|
||||
v-if="fansDetailPager.total > 0"
|
||||
v-model:current-page="fansDetailPager.page_no"
|
||||
v-model:page-size="fansDetailPager.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="fansDetailPager.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@current-change="loadFansDetail"
|
||||
@size-change="handleFansDetailSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -278,7 +376,14 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Lock, Refresh } from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
import { firstVisitConversionOverview, type FirstVisitConversionParams } from '@/api/first_visit'
|
||||
import {
|
||||
firstVisitConversionFansDetail,
|
||||
firstVisitConversionOverview,
|
||||
type FirstVisitConversionFanDetailRow,
|
||||
type FirstVisitConversionFansDetailParams,
|
||||
type FirstVisitConversionFansDetailResult,
|
||||
type FirstVisitConversionParams
|
||||
} from '@/api/first_visit'
|
||||
|
||||
type MetricType = 'count' | 'money' | 'ratio'
|
||||
type MediaChannelOption = {
|
||||
@@ -324,6 +429,13 @@ const query = reactive<FirstVisitConversionParams>({
|
||||
media_channel_code: ''
|
||||
})
|
||||
const customDateRange = ref<string[]>([])
|
||||
const fansDetailVisible = ref(false)
|
||||
const fansDetailLoading = 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 }>({
|
||||
type: 'dept', id: 0, name: '当前行'
|
||||
})
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
const timeOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
@@ -335,7 +447,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: '区间有效加粉:去重,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加' },
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增加粉(含已删除);(-N)为其中已删除' },
|
||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||
@@ -402,6 +514,13 @@ const rankingKind = computed(() => dashboard.meta.ranking_kind || (
|
||||
))
|
||||
const showRankings = computed(() => rankingKind.value !== 'hidden')
|
||||
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
|
||||
const fansDetailTitle = computed(() => `${fansDetailEntity.value.name} · 加粉客户明细`)
|
||||
const detailRangeText = computed(() => {
|
||||
const startDate = dashboard.meta.start_date || query.start_date || ''
|
||||
const endDate = dashboard.meta.end_date || query.end_date || ''
|
||||
return startDate && endDate ? `${startDate} 至 ${endDate}` : (dashboard.meta.time_label || '当前区间')
|
||||
})
|
||||
const detailChannelText = computed(() => dashboard.meta.selected_media_channel_name || '全部渠道')
|
||||
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||
const targetChartOption = computed(() => ({
|
||||
@@ -418,6 +537,7 @@ const targetChartOption = computed(() => ({
|
||||
}))
|
||||
|
||||
let latestDashboardRequestId = 0
|
||||
let latestFansDetailRequestId = 0
|
||||
|
||||
async function loadDashboard() {
|
||||
const requestId = ++latestDashboardRequestId
|
||||
@@ -470,6 +590,89 @@ function handleDeptChange() {
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function openFansDetail(row: any) {
|
||||
fansDetailEntity.value = {
|
||||
type: row.type ? 'member' : 'dept',
|
||||
id: row.id,
|
||||
adminId: Number(row.admin_id || 0) || undefined,
|
||||
name: row.name || '当前行'
|
||||
}
|
||||
fansDetailPager.page_no = 1
|
||||
fansDetailRows.value = []
|
||||
fansDetailPager.total = 0
|
||||
fansDetailVisible.value = true
|
||||
loadFansDetail()
|
||||
}
|
||||
|
||||
async function loadFansDetail() {
|
||||
if (!fansDetailVisible.value) return
|
||||
const requestId = ++latestFansDetailRequestId
|
||||
fansDetailLoading.value = true
|
||||
try {
|
||||
const params: FirstVisitConversionFansDetailParams = {
|
||||
...query,
|
||||
time_type: query.time_type,
|
||||
start_date: dashboard.meta.start_date || query.start_date,
|
||||
end_date: dashboard.meta.end_date || query.end_date,
|
||||
media_channel_code: dashboard.meta.selected_media_channel_code || query.media_channel_code || '',
|
||||
entity_type: fansDetailEntity.value.type,
|
||||
entity_id: fansDetailEntity.value.id,
|
||||
admin_id: fansDetailEntity.value.adminId,
|
||||
page_no: fansDetailPager.page_no,
|
||||
page_size: fansDetailPager.page_size
|
||||
}
|
||||
const result = await firstVisitConversionFansDetail(params) as FirstVisitConversionFansDetailResult
|
||||
if (requestId !== latestFansDetailRequestId) return
|
||||
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
|
||||
} catch (error: any) {
|
||||
if (requestId !== latestFansDetailRequestId) return
|
||||
fansDetailRows.value = []
|
||||
fansDetailPager.total = 0
|
||||
ElMessage.error(error?.message || '加粉客户明细加载失败')
|
||||
} finally {
|
||||
if (requestId === latestFansDetailRequestId) fansDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFansDetailSizeChange() {
|
||||
fansDetailPager.page_no = 1
|
||||
loadFansDetail()
|
||||
}
|
||||
|
||||
function resetFansDetail() {
|
||||
++latestFansDetailRequestId
|
||||
fansDetailLoading.value = false
|
||||
fansDetailRows.value = []
|
||||
fansDetailPager.page_no = 1
|
||||
fansDetailPager.total = 0
|
||||
}
|
||||
|
||||
function hasFans(value: any) {
|
||||
return Math.round(Number(value || 0)) > 0
|
||||
}
|
||||
|
||||
function isDeletedFan(row: FirstVisitConversionFanDetailRow) {
|
||||
return Boolean(row.is_deleted)
|
||||
}
|
||||
|
||||
function fanCustomerName(row: FirstVisitConversionFanDetailRow) {
|
||||
return row.customer_name || row.external_userid || '未知客户'
|
||||
}
|
||||
|
||||
function fanOwnerName(row: FirstVisitConversionFanDetailRow) {
|
||||
return row.wecom_staff_name || row.wecom_userid || '未绑定员工'
|
||||
}
|
||||
|
||||
function fanAddTime(row: FirstVisitConversionFanDetailRow) {
|
||||
return row.add_time || '-'
|
||||
}
|
||||
|
||||
function fansDetailRowClassName({ row }: { row: FirstVisitConversionFanDetailRow }) {
|
||||
return isDeletedFan(row) ? 'is-deleted-fan-row' : ''
|
||||
}
|
||||
|
||||
function formatMetric(key: string, type: MetricType) {
|
||||
const value = dashboard.summary[key]
|
||||
if (type === 'money') return formatMoney(value)
|
||||
@@ -481,6 +684,10 @@ function formatNumber(value: any) {
|
||||
return Math.round(Number(value || 0)).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function hasDeletedFans(value: any) {
|
||||
return Math.round(Number(value || 0)) > 0
|
||||
}
|
||||
|
||||
function formatMoney(value: any) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
@@ -621,6 +828,41 @@ onMounted(loadDashboard)
|
||||
small { color: #a0a9b6; font-size: 11px; }
|
||||
}
|
||||
|
||||
.metric-value,
|
||||
.fan-count-value {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
gap: 3px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-value { display: flex; justify-content: flex-start; }
|
||||
.fan-detail-trigger {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible { outline: 2px solid #2f78df; outline-offset: 3px; border-radius: 3px; }
|
||||
&:disabled { cursor: default; }
|
||||
}
|
||||
.deleted-fans-marker {
|
||||
color: #c65f26;
|
||||
font-size: .58em;
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.fan-count-value .deleted-fans-marker { font-size: 11px; }
|
||||
.fan-detail-trigger--table { color: #1769aa; font-weight: 650; }
|
||||
.fan-detail-trigger--table:hover { color: #0f4f82; }
|
||||
.fan-count-value.is-empty { color: #98a2b3; }
|
||||
|
||||
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
|
||||
|
||||
@@ -683,6 +925,47 @@ onMounted(loadDashboard)
|
||||
.legend-line.is-target { border-color: #2f78df; border-top-style: dashed; }
|
||||
.target-chart { width: 100%; height: 260px; }
|
||||
|
||||
.fans-detail-context {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
margin: -4px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e4e9ef;
|
||||
border-radius: 7px;
|
||||
color: #5d6b7e;
|
||||
background: #f7f9fb;
|
||||
font-size: 12px;
|
||||
|
||||
span { display: inline-flex; gap: 7px; }
|
||||
b { color: #8a95a5; font-weight: 500; }
|
||||
}
|
||||
.fans-detail-table {
|
||||
width: 100%;
|
||||
|
||||
.customer-cell { display: grid; gap: 3px; }
|
||||
.customer-name { color: #273347; font-weight: 650; }
|
||||
.customer-name.is-deleted { color: #c24137; text-decoration: line-through; text-decoration-color: #e8a39e; }
|
||||
.customer-cell small { overflow: hidden; color: #98a2b3; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
:deep(.is-deleted-fan-row > td.el-table__cell) { background: #fff3f2 !important; }
|
||||
}
|
||||
.deleted-status {
|
||||
display: inline-grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
|
||||
small { color: #b14e47; font-size: 10px; }
|
||||
}
|
||||
.fans-detail-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 14px;
|
||||
color: #8491a2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||
.range-text { margin-left: 0; }
|
||||
@@ -698,5 +981,7 @@ onMounted(loadDashboard)
|
||||
.employee-select, .dept-select, .channel-select { width: 100%; }
|
||||
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
|
||||
.target-summary { grid-template-columns: 1fr; }
|
||||
.fans-detail-footer { align-items: flex-start; flex-direction: column; }
|
||||
.fans-detail-footer :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+6
-3
@@ -239,7 +239,7 @@
|
||||
|
||||
<div class="install-tip">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
<span><strong>无需暴露链接池</strong>浮窗点击后仍由服务端执行可用性筛选、权重随机和访问统计。</span>
|
||||
<span><strong>一个方案,一个官方链接</strong>浮窗会直接打开企业微信链接,多名医助由企业微信执行均衡分流。</span>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -274,6 +274,8 @@ interface PromotionPool {
|
||||
name?: string
|
||||
public_key?: string
|
||||
script_url?: string
|
||||
main_url?: string
|
||||
compat_go_url?: string
|
||||
go_url?: string
|
||||
install_code?: string
|
||||
trigger_code?: string
|
||||
@@ -437,11 +439,12 @@ async function copySnippet(value: string | undefined, label: string) {
|
||||
}
|
||||
|
||||
function openTestLink() {
|
||||
if (!props.pool.go_url) {
|
||||
const targetUrl = props.pool.main_url || props.pool.go_url || props.pool.compat_go_url
|
||||
if (!targetUrl) {
|
||||
ElMessage.warning('当前方案暂无测试链接')
|
||||
return
|
||||
}
|
||||
window.open(props.pool.go_url, '_blank', 'noopener,noreferrer')
|
||||
window.open(targetUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<span class="heading-icon"><el-icon><Promotion /></el-icon></span>
|
||||
<div>
|
||||
<h1>企业微信获客助手</h1>
|
||||
<p>对接企业微信获客助手官方 API,按角色与部门管理获客成员,并将链接安全分流到推广页面</p>
|
||||
<p>一个方案保留一条企业微信官方直链,由企业微信在多个可用医助之间原生路由</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
@@ -22,15 +22,15 @@
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-icon is-blue"><el-icon><SetUp /></el-icon></span>
|
||||
<div><small>分流方案</small><strong>{{ overview.summary.pool_count }}</strong><p>每个方案生成独立 JS</p></div>
|
||||
<div><small>分流方案</small><strong>{{ overview.summary.pool_count }}</strong><p>每个方案对应一个官方链接</p></div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-icon is-green"><el-icon><Link /></el-icon></span>
|
||||
<div><small>在线链接</small><strong>{{ overview.summary.online_links }}</strong><p>停用或超限自动排除</p></div>
|
||||
<div><small>官方链接</small><strong>{{ overview.summary.online_links }}</strong><p>链接不变,成员范围自动更新</p></div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-icon is-orange"><el-icon><Mouse /></el-icon></span>
|
||||
<div><small>今日分流点击</small><strong>{{ overview.summary.today_clicks }}</strong><p>只记录脱敏访问数据</p></div>
|
||||
<div><small>今日实际获客</small><strong>{{ overview.summary.today_clicks }}</strong><p>按企业微信回调确认承接成员</p></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -52,9 +52,9 @@
|
||||
|
||||
<div v-if="activeTab === 'links'" class="tab-content links-tab">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>获客助手链接分流</h2>
|
||||
<p>访客点击时由服务端从当前可用的获客助手链接中按权重随机选择,页面端不会暴露完整链接池。</p>
|
||||
<div>
|
||||
<h2>获客助手分流方案</h2>
|
||||
<p>选择几名医助就生成几条成员规则;官方链接只在方案顶部复制,成员行不重复展示链接。</p>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">新建分流方案</el-button>
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@
|
||||
@click="selectedPoolId = Number(pool.id)"
|
||||
>
|
||||
<span class="pool-status" :class="Number(pool.status) === 1 ? 'online' : 'offline'" />
|
||||
<span><strong>{{ pool.name }}</strong><small>{{ linkCount(pool.id) }} 条链接 · {{ formatNumber(pool.click_count) }} 次点击</small></span>
|
||||
<span><strong>{{ pool.name }}</strong><small>{{ memberSummary(pool) }} · {{ pool.main_url ? '官方链接已生成' : '待生成链接' }}</small></span>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</aside>
|
||||
@@ -84,100 +84,77 @@
|
||||
</span>
|
||||
<span class="owner-tag">{{ selectedPool.dept_name || '未分部门' }} · {{ selectedPool.owner_name || '系统' }}</span>
|
||||
</div>
|
||||
<p>公开键:{{ selectedPool.public_key }}</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
|
||||
<el-button :icon="Refresh" :loading="syncingRemote" @click="syncRemoteLinks">同步企业微信</el-button>
|
||||
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
|
||||
<el-button :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" @click="removePool(selectedPool)">删除</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openLinkDialog()">创建官方获客链接</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="selectedLegacyCount > 0 && selectedOfficialCount === 0"
|
||||
class="legacy-sync-alert"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
:title="`当前方案的 ${selectedLegacyCount} 条链接都是历史手工链接,企业微信同步不会返回它们`"
|
||||
description="官方接口只返回当前应用通过 API 创建的获客链接。现有链接仍可参与本地分流;如需同步 link_id、客户和官方统计,请点击“创建官方获客链接”。"
|
||||
/>
|
||||
|
||||
<el-table :data="selectedLinks" class="link-table" stripe>
|
||||
<el-table-column label="推广成员 / 分组" min-width="180" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="member-cell"><strong>{{ row.name }}</strong><small>{{ row.group_name || '默认分组' }}</small></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="企业微信链接 ID" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="link-id-cell">
|
||||
<span v-if="row.remote_link_id" class="remote-id" :title="row.remote_link_id">{{ row.remote_link_id }}</span>
|
||||
<span v-else class="legacy-tag">历史手工链接</span>
|
||||
<a
|
||||
v-if="row.wecom_url"
|
||||
class="wecom-url"
|
||||
:href="row.wecom_url"
|
||||
:title="row.wecom_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click.stop
|
||||
>{{ row.wecom_url }}</a>
|
||||
<el-button
|
||||
v-if="row.wecom_url"
|
||||
type="primary"
|
||||
link
|
||||
class="copy-url-btn"
|
||||
@click.stop="copyText(row.wecom_url, '企业微信链接')"
|
||||
>复制链接</el-button>
|
||||
<span v-else class="muted">暂无链接地址</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="可用成员" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span>{{ remoteMemberLabel(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权重" prop="weight" width="84" align="center" />
|
||||
<el-table-column label="今日 / 上限" min-width="120" align="center">
|
||||
<template #default="{ row }">{{ todayCount(row) }} / {{ Number(row.daily_limit) ? row.daily_limit : '不限' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="累计点击" prop="click_count" min-width="105" align="center" />
|
||||
<el-table-column label="当前可用性" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="availability" :class="eligibility(row).className">{{ eligibility(row).label }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上线" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="Number(row.status) === 1" :disabled="Number(row.remote_status) === 2" @change="(value) => handleToggle(row, value)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="245" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.is_official && Number(row.remote_status) !== 2" type="primary" link :loading="refreshingLinkId === Number(row.id)" @click="refreshRemoteDetail(row)">刷新详情</el-button>
|
||||
<el-button v-if="!row.is_official || Number(row.remote_status) !== 2" type="primary" link @click="openLinkDialog(row)">编辑</el-button>
|
||||
<el-dropdown v-if="row.is_official" trigger="click" @command="(command) => handleLinkCommand(command, row)">
|
||||
<el-button type="primary" link>更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="local">从本地移除</el-dropdown-item>
|
||||
<el-dropdown-item v-if="Number(row.remote_status) !== 2" command="remote" divided class="danger-command">永久删除企业微信链接</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button v-else type="danger" link @click="removeLink(row)">从本地移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :image-size="72" description="当前方案还没有获客助手链接" /></template>
|
||||
</el-table>
|
||||
<p>公开键:{{ selectedPool.public_key }}</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
|
||||
<el-button :icon="DocumentCopy" :disabled="!selectedPool.main_url" @click="copyText(selectedPool.main_url, '官方获客链接')">复制链接</el-button>
|
||||
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
|
||||
<el-button :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" :loading="deletingPoolId === Number(selectedPool.id)" :disabled="deletingPoolId > 0" @click="removePool(selectedPool)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="selectedPool.migration_state === 'needs_resolution'"
|
||||
class="legacy-sync-alert"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="检测到旧版方案存在多个官方链接"
|
||||
description="当前显示最新链接;保存方案后,其余旧链接只会在本地下线,企业微信端与历史客户归因仍会保留。"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
class="legacy-sync-alert"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="企业微信原生多人路由"
|
||||
description="当前全部可用医助会同时写入官方链接的成员范围,由企业微信在打开和添加阶段直接进行多人路由。回调只用于统计实际承接结果,并在禁用、过期或达到上限后更新成员范围。"
|
||||
/>
|
||||
|
||||
<el-table :data="selectedMemberRules" class="link-table" stripe>
|
||||
<el-table-column label="推广成员" min-width="210" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="member-cell">
|
||||
<strong>{{ row.name || row.userid }}</strong>
|
||||
<small>{{ memberRuleDetail(row) }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="调度" width="105" 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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="今日 / 上限" width="125" align="center">
|
||||
<template #default="{ row }">{{ todayCount(row) }} / {{ Number(row.daily_limit) ? row.daily_limit : '不限' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="累计获客" prop="total_count" width="105" align="center" />
|
||||
<el-table-column label="当前可用性" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="availability" :class="eligibility(row).className">{{ eligibility(row).label }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上线" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="Number(row.enabled) === 1" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openMemberDialog(row)">编辑规则</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :image-size="72" description="编辑方案并选择获客医助" /></template>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="先创建一个分流方案,再添加企业微信获客助手链接">
|
||||
<el-empty v-else description="创建方案并选择多个医助,保存后自动生成一个企业微信官方获客链接">
|
||||
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">创建第一个方案</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
@@ -220,7 +197,7 @@
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="消息次数回调尚未配置完成"
|
||||
description="复用企业微信自建应用现有的“API 接收消息”地址即可,无需新增第二个回调;请确保该地址为 QywxExternalContactCallback/notify,且服务器 Token、AESKey 与企微后台一致。未完成前无法得到精确接收消息次数。"
|
||||
description="复用企业微信自建应用现有的“API 接收消息”地址即可,无需新增第二个回调;请确保该地址为 /api/qywx/external-contact/notify,且服务器 Token、AESKey 与企微后台一致。未完成前无法自动累计成员获客数量,也无法在成员达到上限后自动更新官方范围。"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
@@ -363,101 +340,156 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="poolDialogVisible" :title="poolForm.id ? '编辑分流方案' : '新建分流方案'" width="560px" destroy-on-close>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="方案名称" required><el-input v-model="poolForm.name" maxlength="60" show-word-limit placeholder="例如:官网咨询分流" /></el-form-item>
|
||||
<el-form-item label="兜底获客助手链接">
|
||||
<el-input v-model="poolForm.fallback_url" placeholder="可选;没有可用成员时跳转" />
|
||||
<span class="form-tip">仅接受企业微信获客助手生成的 https://work.weixin.qq.com/ca/... 链接。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="运行状态"><el-switch v-model="poolForm.status" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" @click="savePool">保存方案</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="linkDialogVisible" :title="linkForm.id ? '编辑获客助手链接' : '创建企业微信官方获客链接'" width="720px" destroy-on-close>
|
||||
<el-form label-position="top" class="link-form">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="获客链接名称" required><el-input v-model="linkForm.name" maxlength="80" placeholder="用于企业微信和后台识别" /></el-form-item>
|
||||
<el-form-item label="分组"><el-input v-model="linkForm.group_name" maxlength="60" placeholder="默认分组" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="!linkForm.legacy" label="获客成员" required>
|
||||
<el-select v-model="linkForm.member_admin_ids" multiple filterable collapse-tags collapse-tags-tooltip placeholder="仅显示当前角色和部门可管理、且已绑定企业微信的成员" style="width: 100%">
|
||||
<el-option v-for="member in overview.member_options" :key="member.id" :label="memberOptionLabel(member)" :value="Number(member.id)" />
|
||||
</el-select>
|
||||
<span class="form-tip">官方链接按这里的企业微信 userid 建立可见范围;越权成员不会出现在列表中。</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="企业微信获客助手链接" required>
|
||||
<el-input v-model="linkForm.wecom_url" type="textarea" :rows="2" :placeholder="overview.customer_acquisition_link_example" />
|
||||
<span class="form-tip">这是历史手工链接,只更新本地分流规则,不会调用企业微信创建或修改接口。</span>
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="分流权重" required><el-input-number v-model="linkForm.weight" :min="1" :max="100" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="每日点击上限"><el-input-number v-model="linkForm.daily_limit" :min="0" :max="1000000" controls-position="right" /><span class="form-tip">0 表示不限</span></el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="!linkForm.legacy" label="添加客户时跳过验证">
|
||||
<el-switch v-model="linkForm.skip_verify" :active-value="1" :inactive-value="0" active-text="跳过验证" inactive-text="需要验证" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上线状态"><el-switch v-model="linkForm.status" :active-value="1" :inactive-value="0" active-text="上线" inactive-text="下线" /></el-form-item>
|
||||
<el-form-item label="生效时间段">
|
||||
<el-date-picker v-model="linkForm.active_range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" start-placeholder="开始时间" end-placeholder="结束时间" range-separator="至" />
|
||||
<span class="form-tip">不选择表示长期有效</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="linkForm.remark" maxlength="255" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="linkDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingLink" @click="saveLink">保存链接</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog
|
||||
v-model="poolDialogVisible"
|
||||
:title="poolForm.id ? '编辑分流方案' : '新建分流方案'"
|
||||
width="680px"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!savingPool"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<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-tree-select
|
||||
v-model="poolForm.member_admin_ids"
|
||||
:data="memberDepartmentTree"
|
||||
:props="memberTreeProps"
|
||||
:default-expanded-keys="memberTreeDefaultExpandedKeys"
|
||||
:filter-node-method="filterMemberTreeNode"
|
||||
:disabled="savingPool"
|
||||
node-key="value"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
show-checkbox
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:max-collapse-tags="2"
|
||||
:multiple-limit="500"
|
||||
:render-after-expand="false"
|
||||
placeholder="按部门展开,或搜索姓名 / 部门 / 企微 userid"
|
||||
style="width: 100%"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<span class="member-tree-option" :class="`is-${data.kind}`">
|
||||
<span class="member-tree-option__label">{{ data.label }}</span>
|
||||
<span v-if="data.kind === 'department'" class="member-tree-option__count">{{ data.member_count }} 人</span>
|
||||
<span v-else class="member-tree-option__detail">{{ data.detail }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree-select>
|
||||
<span class="form-tip">已选 {{ poolForm.member_admin_ids.length }} 名;保存后会为每名医助生成一条成员规则,官方链接地址始终只有一个。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加客户时跳过验证">
|
||||
<el-switch v-model="poolForm.skip_verify" :disabled="savingPool" :active-value="1" :inactive-value="0" active-text="跳过验证" inactive-text="需要验证" />
|
||||
</el-form-item>
|
||||
<el-form-item label="兜底获客助手链接">
|
||||
<el-input v-model="poolForm.fallback_url" :disabled="savingPool" placeholder="可选;仅供旧版兼容跳转使用" />
|
||||
<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>
|
||||
</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>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="memberDialogVisible" title="编辑医助分流规则" width="560px" destroy-on-close :close-on-click-modal="false">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="获客医助">
|
||||
<el-input :model-value="memberForm.name ? `${memberForm.name} · ${memberForm.userid}` : memberForm.userid" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item label="每日获客上限">
|
||||
<el-input-number v-model="memberForm.daily_limit" :min="0" :max="1000000" :disabled="savingMember" style="width: 100%" />
|
||||
<span class="form-tip">0 表示不限;数量按实际获客回调累计,达到上限后会从企微官方成员范围移除。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="有效时间">
|
||||
<el-date-picker v-model="memberForm.active_range" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" value-format="YYYY-MM-DD HH:mm:ss" :disabled="savingMember" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上线状态">
|
||||
<el-switch v-model="memberForm.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" :disabled="savingMember" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="memberForm.remark" type="textarea" :rows="2" maxlength="255" show-word-limit :disabled="savingMember" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button :disabled="savingMember" @click="memberDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingMember" @click="saveMemberRule">保存规则</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="firstVisitWecomPromotionPage">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowDown, ArrowRight, ChatDotRound, CircleCheck, Connection, DataAnalysis, Delete,
|
||||
DocumentCopy, Edit, Key, Link, Lock, Mouse, OfficeBuilding, Plus,
|
||||
Promotion, Refresh, Search, SetUp, User, Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
wecomPromotionCheckApiPermission,
|
||||
wecomPromotionCustomerStats,
|
||||
wecomPromotionDeleteLink,
|
||||
wecomPromotionDeleteRemoteLink,
|
||||
wecomPromotionDeletePool,
|
||||
wecomPromotionOverview,
|
||||
wecomPromotionRemoteLinkDetail,
|
||||
wecomPromotionSaveLink,
|
||||
wecomPromotionSavePool,
|
||||
wecomPromotionSyncCustomers,
|
||||
wecomPromotionSyncRemoteLinks,
|
||||
wecomPromotionToggleLink
|
||||
} from '@/api/first_visit'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowRight, ChatDotRound, CircleCheck, Connection, DataAnalysis, Delete,
|
||||
DocumentCopy, Edit, Key, Link, Lock, Mouse, OfficeBuilding, Plus,
|
||||
Promotion, Refresh, Search, SetUp, User, Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
wecomPromotionCheckApiPermission,
|
||||
wecomPromotionCustomerStats,
|
||||
wecomPromotionDeletePool,
|
||||
wecomPromotionOverview,
|
||||
wecomPromotionSaveMember,
|
||||
wecomPromotionSavePool,
|
||||
wecomPromotionSyncCustomers,
|
||||
wecomPromotionToggleMember
|
||||
} from '@/api/first_visit'
|
||||
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
|
||||
|
||||
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
|
||||
|
||||
type TabName = 'links' | 'customer-stats' | 'configuration' | 'install'
|
||||
const emptyOverview = () => ({
|
||||
meta: { scope_label: '', generated_at: '' },
|
||||
config: { mode: 'internal', configured: false, ready: false, missing: [] as string[], corp_id_masked: '', agent_id: '', secret_configured: false, callback_ready: false, callback_url: '', official_doc: '' },
|
||||
summary: { configured_apps: 0, pool_count: 0, online_links: 0, today_clicks: 0 },
|
||||
pools: [] as any[], links: [] as any[], member_options: [] as any[], customer_acquisition_link_example: 'https://work.weixin.qq.com/ca/xxxxxxxx'
|
||||
})
|
||||
type TabName = 'links' | 'customer-stats' | 'configuration' | 'install'
|
||||
|
||||
interface PromotionDepartmentOption {
|
||||
id: number | string
|
||||
name: string
|
||||
children?: PromotionDepartmentOption[]
|
||||
}
|
||||
|
||||
interface PromotionMemberOption {
|
||||
id: number
|
||||
name: string
|
||||
userid: string
|
||||
display_dept_id?: number
|
||||
dept_ids: number[]
|
||||
dept_names: string[]
|
||||
}
|
||||
|
||||
interface PromotionMemberTreeNode {
|
||||
value: number | string
|
||||
label: string
|
||||
kind: 'department' | 'member'
|
||||
disabled?: boolean
|
||||
member_count?: number
|
||||
detail?: string
|
||||
search_text: string
|
||||
children?: PromotionMemberTreeNode[]
|
||||
}
|
||||
|
||||
const emptyOverview = () => ({
|
||||
meta: { scope_label: '', generated_at: '' },
|
||||
config: { mode: 'internal', configured: false, ready: false, missing: [] as string[], corp_id_masked: '', agent_id: '', secret_configured: false, callback_ready: false, callback_url: '', official_doc: '' },
|
||||
summary: { configured_apps: 0, pool_count: 0, online_links: 0, today_clicks: 0 },
|
||||
pools: [] as any[],
|
||||
links: [] as any[],
|
||||
member_options: [] as PromotionMemberOption[],
|
||||
department_options: [] as PromotionDepartmentOption[],
|
||||
customer_acquisition_link_example: 'https://work.weixin.qq.com/ca/xxxxxxxx'
|
||||
})
|
||||
|
||||
const overview = reactive(emptyOverview())
|
||||
const loading = ref(false)
|
||||
const activeTab = ref<TabName>('links')
|
||||
const selectedPoolId = ref<number>()
|
||||
const selectedInstallPoolId = ref<number>()
|
||||
const poolDialogVisible = ref(false)
|
||||
const linkDialogVisible = ref(false)
|
||||
const savingPool = ref(false)
|
||||
const savingLink = ref(false)
|
||||
const checkingApi = ref(false)
|
||||
const syncingRemote = ref(false)
|
||||
const refreshingLinkId = ref(0)
|
||||
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1 })
|
||||
const linkForm = reactive({ id: 0, pool_id: 0, name: '', group_name: '默认分组', wecom_url: '', member_admin_ids: [] as number[], skip_verify: 0, legacy: false, weight: 1, status: 1, daily_limit: 0, active_range: [] as string[], remark: '' })
|
||||
const selectedPoolId = ref<number>()
|
||||
const selectedInstallPoolId = ref<number>()
|
||||
const poolDialogVisible = ref(false)
|
||||
const savingPool = ref(false)
|
||||
const checkingApi = ref(false)
|
||||
const memberDialogVisible = ref(false)
|
||||
const savingMember = ref(false)
|
||||
const togglingMemberId = ref(0)
|
||||
const deletingPoolId = ref(0)
|
||||
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1, member_admin_ids: [] as number[], skip_verify: 0, main_url: '' })
|
||||
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)
|
||||
const syncingCustomers = ref(false)
|
||||
@@ -475,12 +507,13 @@ const customerStats = reactive({
|
||||
meta: { last_synced_at: '' }
|
||||
})
|
||||
|
||||
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
|
||||
const selectedLinks = computed(() => overview.links.filter((item: any) => Number(item.pool_id) === selectedPoolId.value))
|
||||
const selectedOfficialCount = computed(() => selectedLinks.value.filter((item: any) => Boolean(item.remote_link_id)).length)
|
||||
const selectedLegacyCount = computed(() => selectedLinks.value.length - selectedOfficialCount.value)
|
||||
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
|
||||
const customerLinkOptions = computed(() => customerStats.link_options.length
|
||||
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
|
||||
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
|
||||
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
|
||||
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))
|
||||
const customerLinkOptions = computed(() => customerStats.link_options.length
|
||||
? customerStats.link_options
|
||||
: overview.links
|
||||
.filter((item: any) => item.id)
|
||||
@@ -516,24 +549,42 @@ async function loadOverview() {
|
||||
}
|
||||
}
|
||||
|
||||
function linkCount(poolId: number) {
|
||||
return overview.links.filter((item: any) => Number(item.pool_id) === Number(poolId)).length
|
||||
}
|
||||
|
||||
function openPoolDialog(pool?: any) {
|
||||
Object.assign(poolForm, pool ? { id: Number(pool.id), name: pool.name, fallback_url: pool.fallback_url || '', status: Number(pool.status) } : { id: 0, name: '', fallback_url: '', status: 1 })
|
||||
poolDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function savePool() {
|
||||
if (!poolForm.name.trim()) return ElMessage.warning('请输入分流方案名称')
|
||||
savingPool.value = true
|
||||
function openPoolDialog(pool?: any) {
|
||||
const selectableMemberIds = new Set(overview.member_options.map((member) => Number(member.id)))
|
||||
Object.assign(poolForm, pool ? {
|
||||
id: Number(pool.id),
|
||||
name: pool.name,
|
||||
fallback_url: pool.fallback_url || '',
|
||||
status: Number(pool.status),
|
||||
member_admin_ids: Array.isArray(pool.member_admin_ids)
|
||||
? 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 || ''
|
||||
} : {
|
||||
id: 0,
|
||||
name: '',
|
||||
fallback_url: '',
|
||||
status: 1,
|
||||
member_admin_ids: [],
|
||||
skip_verify: 0,
|
||||
main_url: ''
|
||||
})
|
||||
poolDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function savePool() {
|
||||
if (!poolForm.name.trim()) return ElMessage.warning('请输入分流方案名称')
|
||||
if (!poolForm.member_admin_ids.length) return ElMessage.warning('请至少选择一名获客医助')
|
||||
savingPool.value = true
|
||||
try {
|
||||
const result: any = await wecomPromotionSavePool({ ...poolForm })
|
||||
poolDialogVisible.value = false
|
||||
await loadOverview()
|
||||
if (result?.id) selectedPoolId.value = Number(result.id)
|
||||
ElMessage.success('分流方案已保存')
|
||||
result?.sync_error
|
||||
? ElMessage.warning('方案已保存,企业微信多人范围将在后台自动重试同步')
|
||||
: ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '分流方案保存失败')
|
||||
} finally {
|
||||
@@ -541,59 +592,79 @@ async function savePool() {
|
||||
}
|
||||
}
|
||||
|
||||
async function removePool(pool: any) {
|
||||
await ElMessageBox.confirm(`删除“${pool.name}”后,该方案下的获客助手链接也会停用。确认继续?`, '删除分流方案', { type: 'warning' })
|
||||
try {
|
||||
await wecomPromotionDeletePool({ id: Number(pool.id) })
|
||||
ElMessage.success('分流方案已删除')
|
||||
await loadOverview()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openLinkDialog(row?: any) {
|
||||
if (!selectedPool.value) return
|
||||
const range = row && Number(row.active_start) > 0 && Number(row.active_end) > 0
|
||||
? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)] : []
|
||||
const selectedUserIds = new Set<string>((row?.range_userids || []).map((item: unknown) => String(item)))
|
||||
const selectedMemberIds = overview.member_options
|
||||
.filter((member: any) => selectedUserIds.has(String(member.userid)))
|
||||
.map((member: any) => Number(member.id))
|
||||
Object.assign(linkForm, row ? {
|
||||
id: Number(row.id), pool_id: Number(row.pool_id),
|
||||
name: row.name, group_name: row.group_name || '默认分组', wecom_url: row.wecom_url,
|
||||
member_admin_ids: selectedMemberIds, skip_verify: Number(row.skip_verify) === 1 ? 1 : 0, legacy: !row.is_official,
|
||||
weight: Number(row.weight) || 1, status: Number(row.status), daily_limit: Number(row.daily_limit) || 0,
|
||||
active_range: range, remark: row.remark || ''
|
||||
} : {
|
||||
id: 0, pool_id: Number(selectedPool.value.id), name: '', group_name: '默认分组',
|
||||
wecom_url: '', member_admin_ids: [], skip_verify: 0, legacy: false,
|
||||
weight: 1, status: 1, daily_limit: 0, active_range: [], remark: ''
|
||||
})
|
||||
linkDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveLink() {
|
||||
if (!linkForm.name.trim()) return ElMessage.warning('请输入获客链接名称')
|
||||
if (linkForm.legacy && !linkForm.wecom_url.trim()) return ElMessage.warning('请填写企业微信获客助手链接')
|
||||
if (!linkForm.legacy && !linkForm.member_admin_ids.length) return ElMessage.warning('请至少选择一名获客成员')
|
||||
savingLink.value = true
|
||||
try {
|
||||
await wecomPromotionSaveLink({
|
||||
...linkForm,
|
||||
active_start: linkForm.active_range?.[0] || '',
|
||||
active_end: linkForm.active_range?.[1] || ''
|
||||
})
|
||||
linkDialogVisible.value = false
|
||||
await loadOverview()
|
||||
ElMessage.success(linkForm.legacy ? '本地分流规则已保存' : '企业微信官方获客链接已保存')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '获客助手链接保存失败')
|
||||
} finally {
|
||||
savingLink.value = false
|
||||
}
|
||||
}
|
||||
async function removePool(pool: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`删除“${pool.name}”将同时永久删除企业微信后台中的官方获客链接,已投放的链接会失效且无法恢复。确认继续?`,
|
||||
'永久删除分流方案',
|
||||
{ type: 'warning', confirmButtonText: '永久删除', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
deletingPoolId.value = Number(pool.id)
|
||||
try {
|
||||
await wecomPromotionDeletePool({ id: Number(pool.id) })
|
||||
ElMessage.success('分流方案及企业微信官方获客链接已永久删除')
|
||||
await loadOverview()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '删除失败')
|
||||
} finally {
|
||||
deletingPoolId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function openMemberDialog(row: any) {
|
||||
const range = Number(row.active_start) > 0 && Number(row.active_end) > 0
|
||||
? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)]
|
||||
: []
|
||||
Object.assign(memberForm, {
|
||||
id: Number(row.id),
|
||||
name: row.name || '',
|
||||
userid: row.userid || '',
|
||||
daily_limit: Number(row.daily_limit) || 0,
|
||||
status: Number(row.enabled) === 1 ? 1 : 0,
|
||||
active_range: range,
|
||||
remark: row.remark || ''
|
||||
})
|
||||
memberDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveMemberRule() {
|
||||
savingMember.value = true
|
||||
try {
|
||||
const result: any = await wecomPromotionSaveMember({
|
||||
...memberForm,
|
||||
active_start: memberForm.active_range?.[0] || '',
|
||||
active_end: memberForm.active_range?.[1] || ''
|
||||
})
|
||||
memberDialogVisible.value = false
|
||||
await loadOverview()
|
||||
result?.sync_error
|
||||
? ElMessage.warning('规则已保存,企业微信成员范围将在后台自动重试同步')
|
||||
: ElMessage.success('医助分流规则已保存')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '医助分流规则保存失败')
|
||||
} finally {
|
||||
savingMember.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMemberToggle(row: any, value: unknown) {
|
||||
togglingMemberId.value = Number(row.id)
|
||||
try {
|
||||
const result: any = await wecomPromotionToggleMember({ id: Number(row.id), status: value ? 1 : 0 })
|
||||
await loadOverview()
|
||||
result?.sync_error
|
||||
? ElMessage.warning('成员状态已保存,企业微信成员范围将在后台自动重试同步')
|
||||
: ElMessage.success('成员规则已保存,企微多人路由范围已重新计算')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '成员状态更新失败')
|
||||
await loadOverview()
|
||||
} finally {
|
||||
togglingMemberId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function checkApiPermission() {
|
||||
checkingApi.value = true
|
||||
@@ -710,119 +781,144 @@ async function syncCustomers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRemoteLinks() {
|
||||
if (!selectedPool.value) return ElMessage.warning('请先选择分流方案')
|
||||
syncingRemote.value = true
|
||||
try {
|
||||
const result: any = await wecomPromotionSyncRemoteLinks({ pool_id: Number(selectedPool.value.id) })
|
||||
const message = `扫描 ${Number(result?.scanned || 0)} 条,新建 ${Number(result?.created || 0)} 条,更新 ${Number(result?.updated || 0)} 条,按权限跳过 ${Number(result?.skipped || 0)} 条,失败 ${Number(result?.failed || 0)} 条`
|
||||
if (Number(result?.scanned || 0) === 0) {
|
||||
await ElMessageBox.alert(
|
||||
`${result?.empty_reason || '当前应用没有可同步的官方获客链接'}\n\n${result?.suggestion || ''}`,
|
||||
'没有可同步的官方链接',
|
||||
{ type: 'warning', confirmButtonText: '知道了' }
|
||||
)
|
||||
} else {
|
||||
result?.failed ? ElMessage.warning(message) : ElMessage.success(message)
|
||||
}
|
||||
await loadOverview()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '同步企业微信获客链接失败')
|
||||
} finally {
|
||||
syncingRemote.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRemoteDetail(row: any) {
|
||||
refreshingLinkId.value = Number(row.id)
|
||||
try {
|
||||
await wecomPromotionRemoteLinkDetail({ id: Number(row.id) })
|
||||
ElMessage.success('企业微信官方详情已刷新')
|
||||
await loadOverview()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '刷新官方详情失败')
|
||||
} finally {
|
||||
refreshingLinkId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLinkCommand(command: string, row: any) {
|
||||
if (command === 'local') {
|
||||
await removeLink(row)
|
||||
return
|
||||
}
|
||||
if (command !== 'remote') return
|
||||
await ElMessageBox.confirm(
|
||||
`将永久删除企业微信后台中的“${row.name}”,现有推广页面会立即失效,且无法恢复。确认继续?`,
|
||||
'永久删除企业微信获客链接',
|
||||
{ type: 'error', confirmButtonText: '永久删除', cancelButtonText: '取消' }
|
||||
)
|
||||
try {
|
||||
await wecomPromotionDeleteRemoteLink({ id: Number(row.id) })
|
||||
ElMessage.success('企业微信获客链接已永久删除,本地记录已保留并停止分流')
|
||||
await loadOverview()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '永久删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(row: any, value: unknown) {
|
||||
try {
|
||||
await wecomPromotionToggleLink({ id: Number(row.id), status: value ? 1 : 0 })
|
||||
row.status = value ? 1 : 0
|
||||
ElMessage.success(value ? '链接已上线' : '链接已下线')
|
||||
await loadOverview()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '状态更新失败')
|
||||
await loadOverview()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLink(row: any) {
|
||||
await ElMessageBox.confirm(`仅从本地分流池移除“${row.name}”,不会删除企业微信后台的官方链接。确认继续?`, '从本地移除', { type: 'warning' })
|
||||
try {
|
||||
await wecomPromotionDeleteLink({ id: Number(row.id) })
|
||||
ElMessage.success('已从本地分流池移除')
|
||||
await loadOverview()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function eligibility(row: any) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (Number(row.remote_status) === 2) return { label: '企业微信已删除', className: 'is-error' }
|
||||
if (row.valid_customer_acquisition_link === false) return { label: '非获客助手链接', className: 'is-error' }
|
||||
if (Number(row.status) !== 1) return { label: '已下线', className: 'is-muted' }
|
||||
if (Number(row.active_start) > 0 && Number(row.active_start) > now) return { label: '尚未生效', className: 'is-waiting' }
|
||||
function eligibility(row: any) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (Number(row.enabled) !== 1) return { label: '已禁用', className: 'is-muted' }
|
||||
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' }
|
||||
return { label: '可参与分流', className: 'is-ok' }
|
||||
}
|
||||
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' }
|
||||
return { label: '可参与分流', className: 'is-ok' }
|
||||
}
|
||||
|
||||
function todayCount(row: any) {
|
||||
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())
|
||||
return row.today_date === today ? Number(row.today_count || 0) : 0
|
||||
}
|
||||
}
|
||||
|
||||
function buildMemberDepartmentTree(
|
||||
departmentOptions: PromotionDepartmentOption[],
|
||||
memberOptions: PromotionMemberOption[]
|
||||
): PromotionMemberTreeNode[] {
|
||||
const availableDeptIds = new Set<number>()
|
||||
const collectDeptIds = (departments: PromotionDepartmentOption[]) => {
|
||||
departments.forEach((department) => {
|
||||
const departmentId = Number(department.id)
|
||||
if (departmentId > 0) availableDeptIds.add(departmentId)
|
||||
if (Array.isArray(department.children)) collectDeptIds(department.children)
|
||||
})
|
||||
}
|
||||
collectDeptIds(Array.isArray(departmentOptions) ? departmentOptions : [])
|
||||
|
||||
const members: PromotionMemberOption[] = []
|
||||
const seenMemberIds = new Set<number>()
|
||||
for (const rawMember of Array.isArray(memberOptions) ? memberOptions : []) {
|
||||
const memberId = Number(rawMember.id)
|
||||
if (memberId <= 0 || seenMemberIds.has(memberId)) continue
|
||||
seenMemberIds.add(memberId)
|
||||
members.push({
|
||||
id: memberId,
|
||||
name: String(rawMember.name || rawMember.userid || `成员 ${memberId}`),
|
||||
userid: String(rawMember.userid || ''),
|
||||
display_dept_id: Number(rawMember.display_dept_id || 0),
|
||||
dept_ids: Array.isArray(rawMember.dept_ids)
|
||||
? Array.from(new Set(rawMember.dept_ids.map(Number).filter((id) => id > 0)))
|
||||
: [],
|
||||
dept_names: Array.isArray(rawMember.dept_names)
|
||||
? rawMember.dept_names.map(String).filter(Boolean)
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
const membersByDept = new Map<number, PromotionMemberOption[]>()
|
||||
members.forEach((member) => {
|
||||
const explicitDeptId = Number(member.display_dept_id || 0)
|
||||
const displayDeptId = availableDeptIds.has(explicitDeptId)
|
||||
? explicitDeptId
|
||||
: (member.dept_ids.find((id) => availableDeptIds.has(id)) || 0)
|
||||
if (!membersByDept.has(displayDeptId)) membersByDept.set(displayDeptId, [])
|
||||
membersByDept.get(displayDeptId)?.push(member)
|
||||
})
|
||||
|
||||
const placedMemberIds = new Set<number>()
|
||||
const toMemberNode = (member: PromotionMemberOption): PromotionMemberTreeNode => {
|
||||
const departments = member.dept_names.length ? member.dept_names.join(' / ') : '未分部门'
|
||||
const detail = member.userid ? `${departments} · ${member.userid}` : departments
|
||||
return {
|
||||
value: member.id,
|
||||
label: member.name,
|
||||
kind: 'member',
|
||||
detail,
|
||||
search_text: `${member.name} ${member.userid} ${member.dept_names.join(' ')}`.toLocaleLowerCase()
|
||||
}
|
||||
}
|
||||
const byMemberName = (left: PromotionMemberOption, right: PromotionMemberOption) => left.name.localeCompare(right.name, 'zh-CN')
|
||||
|
||||
const buildDepartments = (departments: PromotionDepartmentOption[], parentPath: string[]): PromotionMemberTreeNode[] => {
|
||||
const result: PromotionMemberTreeNode[] = []
|
||||
for (const department of Array.isArray(departments) ? departments : []) {
|
||||
const departmentId = Number(department.id)
|
||||
if (departmentId <= 0) continue
|
||||
const departmentName = String(department.name || `部门 ${departmentId}`)
|
||||
const path = [...parentPath, departmentName]
|
||||
const departmentChildren = buildDepartments(
|
||||
Array.isArray(department.children) ? department.children : [],
|
||||
path
|
||||
)
|
||||
const directMembers = [...(membersByDept.get(departmentId) || [])].sort(byMemberName)
|
||||
directMembers.forEach((member) => placedMemberIds.add(member.id))
|
||||
const memberChildren = directMembers.map(toMemberNode)
|
||||
const children = [...departmentChildren, ...memberChildren]
|
||||
if (!children.length) continue
|
||||
const memberCount = children.reduce(
|
||||
(count, child) => count + (child.kind === 'member' ? 1 : Number(child.member_count || 0)),
|
||||
0
|
||||
)
|
||||
result.push({
|
||||
value: `dept:${departmentId}`,
|
||||
label: departmentName,
|
||||
kind: 'department',
|
||||
disabled: true,
|
||||
member_count: memberCount,
|
||||
search_text: `${path.join(' ')} ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
|
||||
children
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const tree = buildDepartments(Array.isArray(departmentOptions) ? departmentOptions : [], [])
|
||||
const unassigned = members.filter((member) => !placedMemberIds.has(member.id)).sort(byMemberName)
|
||||
if (unassigned.length) {
|
||||
const children = unassigned.map(toMemberNode)
|
||||
tree.push({
|
||||
value: 'dept:unassigned',
|
||||
label: '未分部门',
|
||||
kind: 'department',
|
||||
disabled: true,
|
||||
member_count: children.length,
|
||||
search_text: `未分部门 ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
|
||||
children
|
||||
})
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
function filterMemberTreeNode(keyword: string, data: any) {
|
||||
const query = String(keyword || '').trim().toLocaleLowerCase()
|
||||
return query === '' || data.search_text.includes(query)
|
||||
}
|
||||
|
||||
function memberSummary(pool: any) {
|
||||
const ids = Array.isArray(pool?.member_admin_ids) ? pool.member_admin_ids : []
|
||||
return ids.length ? `${ids.length} 名医助` : '未选择医助'
|
||||
}
|
||||
|
||||
function memberOptionLabel(member: any) {
|
||||
const departments = Array.isArray(member.dept_names) && member.dept_names.length
|
||||
? member.dept_names.join(' / ')
|
||||
: '未分部门'
|
||||
return `${member.name} · ${departments} · ${member.userid}`
|
||||
}
|
||||
|
||||
function remoteMemberLabel(row: any) {
|
||||
const userIds = Array.isArray(row.range_userids) ? row.range_userids : []
|
||||
const names = userIds.map((userid: unknown) => {
|
||||
const member = overview.member_options.find((item: any) => String(item.userid) === String(userid))
|
||||
return member?.name || String(userid)
|
||||
})
|
||||
if (names.length) return names.length > 2 ? `${names.slice(0, 2).join('、')} 等 ${names.length} 人` : names.join('、')
|
||||
const departments = Array.isArray(row.range_department_ids) ? row.range_department_ids : []
|
||||
if (departments.length) return `企微部门 ${departments.join('、')}`
|
||||
return row.is_official ? '未返回成员范围' : '手工配置'
|
||||
}
|
||||
function memberRuleDetail(row: any) {
|
||||
const departments = Array.isArray(row.dept_names) && row.dept_names.length ? row.dept_names.join(' / ') : '未分部门'
|
||||
return `${departments} · ${row.userid || '未绑定 userid'}`
|
||||
}
|
||||
|
||||
function memberFilterLabel(member: any) {
|
||||
const department = member.department_name || member.dept_name || ''
|
||||
@@ -909,17 +1005,17 @@ function formatCustomerRate(value: unknown) {
|
||||
return `${Number(value || 0).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatNumber(value: unknown) {
|
||||
return Number(value || 0).toLocaleString('zh-CN')
|
||||
}
|
||||
function formatNumber(value: unknown) {
|
||||
return Number(value || 0).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function formatPickerTime(value: unknown) {
|
||||
const date = new Date(Number(value || 0) * 1000)
|
||||
const pad = (number: number) => String(number).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
function formatPickerTime(value: unknown) {
|
||||
const date = new Date(Number(value || 0) * 1000)
|
||||
const pad = (number: number) => String(number).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
async function copyText(value: string, label: string) {
|
||||
async function copyText(value: string, label: string) {
|
||||
if (!value) return ElMessage.warning(`${label}暂无内容`)
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
@@ -930,8 +1026,9 @@ async function copyText(value: string, label: string) {
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
textarea.remove()
|
||||
const copied = document.execCommand('copy')
|
||||
textarea.remove()
|
||||
if (!copied) throw new Error('copy command failed')
|
||||
}
|
||||
ElMessage.success(`${label}已复制`)
|
||||
}
|
||||
@@ -1008,9 +1105,14 @@ h1, h2, h3, p { margin: 0; }
|
||||
.configuration-state { display: flex; align-items: center; gap: 12px; }.configuration-state > span { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 9px; color: #fff; background: var(--teal); font-size: 18px; }.is-pending .configuration-state > span { background: #e69a43; }.configuration-state strong { font-size: 14px; }.configuration-state p { margin-top: 4px; color: #718094; font-size: 11px; }
|
||||
.missing-fields { margin-top: 12px; color: #9c651f; font-size: 11px; }.missing-fields code { margin-left: 6px; padding: 3px 6px; border-radius: 4px; background: rgba(224,153,63,.11); }
|
||||
.callback-list { margin: 14px 0 0; border-top: 1px solid rgba(124,150,157,.16); }.callback-list > div { display: grid; grid-template-columns: 120px 1fr 50px; align-items: center; min-height: 40px; border-bottom: 1px solid rgba(124,150,157,.12); font-size: 11px; }.callback-list dt { color: #657488; }.callback-list dd { overflow: hidden; margin: 0; color: #27374c; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }.callback-list button { border: 0; color: #148f83; background: transparent; cursor: pointer; }
|
||||
.configuration-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
|
||||
.install-pool-select { width: 220px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.form-tip { display: block; margin-top: 5px; color: #8b97a6; font-size: 10px; }.link-form .el-select, .link-form .el-input-number { width: 100%; }
|
||||
.configuration-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
|
||||
.install-pool-select { width: 220px; }
|
||||
.member-tree-option { display: inline-flex; min-width: 0; flex: 1; align-items: center; gap: 10px; }
|
||||
.member-tree-option__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.member-tree-option.is-department .member-tree-option__label { color: #344054; font-weight: 600; }
|
||||
.member-tree-option__count { flex: 0 0 auto; color: #98a2b3; font-size: 11px; }
|
||||
.member-tree-option__detail { overflow: hidden; color: #8b97a6; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.form-grid, .rule-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.form-tip { display: block; margin-top: 5px; color: #8b97a6; font-size: 10px; }.link-form .el-select, .link-form .el-input-number { width: 100%; }
|
||||
@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; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .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-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; } }
|
||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.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-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; } }
|
||||
</style>
|
||||
|
||||
@@ -1775,9 +1775,31 @@ const appointmentCellClasses = (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅已预约(1)可进视频/小程序码 */
|
||||
const isAppointmentActiveForVideo = (row: any) =>
|
||||
row.has_appointment && Number(row.appointment_status) === 1
|
||||
/**
|
||||
* 找到当前可用的已预约挂号。
|
||||
* 同一诊单当天可能先完成一条挂号、随后又新增一条预约,此时行级 appointment_* 仍可能指向旧记录。
|
||||
*/
|
||||
const activeAppointment = (row: any) =>
|
||||
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
|
||||
|
||||
/** 二维码必须使用已预约挂号对应的医生和时间,不能继续沿用行级旧挂号字段。 */
|
||||
const activeAppointmentRow = (row: any) => {
|
||||
const apt = activeAppointment(row)
|
||||
if (!apt) return null
|
||||
|
||||
return {
|
||||
...row,
|
||||
has_appointment: 1,
|
||||
appointment_id: apt.id,
|
||||
appointment_status: apt.status,
|
||||
appointment_doctor_id: apt.doctor_id,
|
||||
appointment_doctor_name: apt.doctor_name,
|
||||
appointment_time_text: apt.time_text
|
||||
}
|
||||
}
|
||||
|
||||
/** 任一挂号记录处于已预约(1)即可进视频/小程序码。 */
|
||||
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
|
||||
|
||||
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
|
||||
const canCancelAppointmentRow = (row: any) => {
|
||||
@@ -1962,17 +1984,18 @@ const submitFillIdCard = async () => {
|
||||
}
|
||||
|
||||
// 生成视频二维码(跳转登录页)- 仅已预约(1)可生成
|
||||
const handleVideoQRCode = async (row: any) => {
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
||||
return
|
||||
}
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
lastQRCodeType.value = 'video'
|
||||
currentQRCodePatient.value = row
|
||||
const handleVideoQRCode = async (row: any) => {
|
||||
const qrcodeRow = activeAppointmentRow(row)
|
||||
if (!qrcodeRow) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
||||
return
|
||||
}
|
||||
if (!qrcodeRow.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
lastQRCodeType.value = 'video'
|
||||
currentQRCodePatient.value = qrcodeRow
|
||||
qrcodeDialogVisible.value = true
|
||||
qrcodeLoading.value = true
|
||||
qrcodeUrl.value = ''
|
||||
@@ -1983,11 +2006,11 @@ const handleVideoQRCode = async (row: any) => {
|
||||
feedback.msgError('小程序未配置,请先配置小程序信息')
|
||||
qrcodeDialogVisible.value = false
|
||||
return
|
||||
}
|
||||
const result = await generateMiniProgramQrcode({
|
||||
doctor_id: row.appointment_doctor_id,
|
||||
diagnosis_id: row.appointment_doctor_id,
|
||||
patient_id: row.patient_id,
|
||||
}
|
||||
const result = await generateMiniProgramQrcode({
|
||||
doctor_id: qrcodeRow.appointment_doctor_id,
|
||||
diagnosis_id: qrcodeRow.appointment_doctor_id,
|
||||
patient_id: qrcodeRow.patient_id,
|
||||
share_user_id: userStore.userInfo?.id || '',
|
||||
mini_program_path: 'pages/login/login'
|
||||
})
|
||||
@@ -2004,17 +2027,18 @@ const handleVideoQRCode = async (row: any) => {
|
||||
}
|
||||
|
||||
// 生成确认诊单二维码
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
|
||||
return
|
||||
}
|
||||
if (!row.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
lastQRCodeType.value = 'confirm'
|
||||
currentQRCodePatient.value = row
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
const qrcodeRow = activeAppointmentRow(row)
|
||||
if (!qrcodeRow) {
|
||||
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
|
||||
return
|
||||
}
|
||||
if (!qrcodeRow.patient_id) {
|
||||
feedback.msgWarning('患者信息不完整')
|
||||
return
|
||||
}
|
||||
lastQRCodeType.value = 'confirm'
|
||||
currentQRCodePatient.value = qrcodeRow
|
||||
qrcodeDialogVisible.value = true
|
||||
qrcodeLoading.value = true
|
||||
qrcodeUrl.value = ''
|
||||
@@ -2033,11 +2057,11 @@ const handleMiniProgramQRCode = async (row: any) => {
|
||||
// 获取当前登录用户信息
|
||||
const currentUser = userStore.userInfo
|
||||
|
||||
// 调用生成二维码接口
|
||||
const result = await generateMiniProgramQrcode({
|
||||
diagnosis_id: row.id,
|
||||
doctor_id: row.appointment_doctor_id,
|
||||
patient_id: row.patient_id,
|
||||
// 调用生成二维码接口
|
||||
const result = await generateMiniProgramQrcode({
|
||||
diagnosis_id: qrcodeRow.id,
|
||||
doctor_id: qrcodeRow.appointment_doctor_id,
|
||||
patient_id: qrcodeRow.patient_id,
|
||||
share_user_id: currentUser?.id || ''
|
||||
})
|
||||
|
||||
|
||||
@@ -946,8 +946,28 @@ const appointmentRowClass = (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isAppointmentActiveForVideo = (row: any) =>
|
||||
row.has_appointment && Number(row.appointment_status) === 1
|
||||
/**
|
||||
* 同一诊单可能同时有已完成的旧挂号和已预约的新挂号,二维码应使用明细里的有效预约。
|
||||
*/
|
||||
const activeAppointment = (row: any) =>
|
||||
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
|
||||
|
||||
const activeAppointmentRow = (row: any) => {
|
||||
const apt = activeAppointment(row)
|
||||
if (!apt) return null
|
||||
|
||||
return {
|
||||
...row,
|
||||
has_appointment: 1,
|
||||
appointment_id: apt.id,
|
||||
appointment_status: apt.status,
|
||||
appointment_doctor_id: apt.doctor_id,
|
||||
appointment_doctor_name: apt.doctor_name,
|
||||
appointment_time_text: apt.time_text
|
||||
}
|
||||
}
|
||||
|
||||
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
|
||||
|
||||
const canCancelAppointmentRow = (row: any) => {
|
||||
const s = Number(row.appointment_status)
|
||||
@@ -1241,13 +1261,14 @@ const qrcodeAppointmentTimeText = computed(() => {
|
||||
return p.appointment_time_text || '—'
|
||||
})
|
||||
|
||||
const handleVideoQRCode = async (row: any) => {
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
|
||||
}
|
||||
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
||||
lastQRCodeType.value = 'video'
|
||||
currentQRCodePatient.value = row
|
||||
const handleVideoQRCode = async (row: any) => {
|
||||
const qrcodeRow = activeAppointmentRow(row)
|
||||
if (!qrcodeRow) {
|
||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
|
||||
}
|
||||
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
||||
lastQRCodeType.value = 'video'
|
||||
currentQRCodePatient.value = qrcodeRow
|
||||
qrcodeDialogVisible.value = true
|
||||
qrcodeLoading.value = true
|
||||
qrcodeUrl.value = ''
|
||||
@@ -1258,11 +1279,11 @@ const handleVideoQRCode = async (row: any) => {
|
||||
feedback.msgError('小程序未配置')
|
||||
qrcodeDialogVisible.value = false
|
||||
return
|
||||
}
|
||||
const result = await generateMiniProgramQrcode({
|
||||
doctor_id: row.appointment_doctor_id,
|
||||
diagnosis_id: row.appointment_doctor_id,
|
||||
patient_id: row.patient_id,
|
||||
}
|
||||
const result = await generateMiniProgramQrcode({
|
||||
doctor_id: qrcodeRow.appointment_doctor_id,
|
||||
diagnosis_id: qrcodeRow.appointment_doctor_id,
|
||||
patient_id: qrcodeRow.patient_id,
|
||||
share_user_id: userStore.userInfo?.id || '',
|
||||
mini_program_path: 'pages/login/login'
|
||||
})
|
||||
@@ -1275,13 +1296,14 @@ const handleVideoQRCode = async (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
if (!isAppointmentActiveForVideo(row)) {
|
||||
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
|
||||
}
|
||||
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
||||
lastQRCodeType.value = 'confirm'
|
||||
currentQRCodePatient.value = row
|
||||
const handleMiniProgramQRCode = async (row: any) => {
|
||||
const qrcodeRow = activeAppointmentRow(row)
|
||||
if (!qrcodeRow) {
|
||||
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
|
||||
}
|
||||
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
||||
lastQRCodeType.value = 'confirm'
|
||||
currentQRCodePatient.value = qrcodeRow
|
||||
qrcodeDialogVisible.value = true
|
||||
qrcodeLoading.value = true
|
||||
qrcodeUrl.value = ''
|
||||
@@ -1292,11 +1314,11 @@ const handleMiniProgramQRCode = async (row: any) => {
|
||||
feedback.msgError('小程序未配置')
|
||||
qrcodeDialogVisible.value = false
|
||||
return
|
||||
}
|
||||
const result = await generateMiniProgramQrcode({
|
||||
diagnosis_id: row.id,
|
||||
doctor_id: row.appointment_doctor_id,
|
||||
patient_id: row.patient_id,
|
||||
}
|
||||
const result = await generateMiniProgramQrcode({
|
||||
diagnosis_id: qrcodeRow.id,
|
||||
doctor_id: qrcodeRow.appointment_doctor_id,
|
||||
patient_id: qrcodeRow.patient_id,
|
||||
share_user_id: userStore.userInfo?.id || ''
|
||||
})
|
||||
if (result?.qrcode_url) qrcodeUrl.value = result.qrcode_url
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -45,9 +45,10 @@ export default defineConfig(({ mode }) => {
|
||||
: 'http://127.0.0.1:8080'
|
||||
|
||||
return {
|
||||
base: '/admin/',
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
base: '/admin/',
|
||||
server: {
|
||||
port: 5555,
|
||||
host: '0.0.0.0',
|
||||
hmr: true,
|
||||
open: true,
|
||||
proxy: {
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "zhenyang-doctor-workstation"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
description = "Cross-platform doctor consultation workstation for Windows and macOS"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -27,6 +27,21 @@ class ConversionController extends BaseAdminController
|
||||
));
|
||||
}
|
||||
|
||||
public function fansDetail()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法查看加粉明细');
|
||||
}
|
||||
|
||||
@set_time_limit(120);
|
||||
|
||||
return $this->data(FirstVisitConversionLogic::fansDetail(
|
||||
$this->request->get(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
));
|
||||
}
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
|
||||
@@ -62,7 +62,7 @@ class WecomPromotionController extends BaseAdminController
|
||||
return $this->run(function () use ($id) {
|
||||
WecomPromotionLogic::deletePool($id, $this->adminId, $this->adminInfo);
|
||||
|
||||
return $this->success('分流方案已删除');
|
||||
return $this->success('分流方案及企业微信官方获客链接已永久删除');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,6 +79,33 @@ class WecomPromotionController extends BaseAdminController
|
||||
)));
|
||||
}
|
||||
|
||||
public function saveMember()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('成员分流规则已保存', WecomPromotionLogic::saveMember(
|
||||
$this->request->post(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function toggleMember()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
$status = (int) $this->request->post('status', 0);
|
||||
|
||||
return $this->run(fn () => $this->success(
|
||||
'成员状态已更新',
|
||||
WecomPromotionLogic::toggleMember($id, $status, $this->adminId, $this->adminInfo)
|
||||
));
|
||||
}
|
||||
|
||||
public function checkApiPermission()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
|
||||
@@ -31,49 +31,22 @@ class FirstVisitConversionLogic
|
||||
/** @return array<string,mixed> */
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
[$startDate, $endDate, $timeType, $timeLabel] = self::resolveTimeRange($params);
|
||||
$baseVisibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
|
||||
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
|
||||
$requestedMediaChannelCode = trim((string)($params['media_channel_code'] ?? ''));
|
||||
$selectedMediaChannel = $requestedMediaChannelCode !== ''
|
||||
? MediaChannelService::getCurrentTagChannelByCode($requestedMediaChannelCode)
|
||||
: null;
|
||||
$selectedMediaChannelCode = $selectedMediaChannel !== null ? $requestedMediaChannelCode : '';
|
||||
|
||||
$deptSelectionValid = $selectedDeptId <= 0
|
||||
|| $allowedDeptSet === null
|
||||
|| isset($allowedDeptSet[$selectedDeptId]);
|
||||
$selectedDeptIds = [];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$selectedDeptIds = array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
DeptLogic::getSelfAndDescendantIds($selectedDeptId)
|
||||
), static fn (int $id): bool => $id > 0)));
|
||||
if ($allowedDeptSet !== null) {
|
||||
$selectedDeptIds = array_values(array_filter(
|
||||
$selectedDeptIds,
|
||||
static fn (int $id): bool => isset($allowedDeptSet[$id])
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveAdminIds = $deptSelectionValid ? $baseVisibleAdminIds : [];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$deptAdminIds = $selectedDeptIds === []
|
||||
? []
|
||||
: self::normalizeIds(AdminDept::whereIn('dept_id', $selectedDeptIds)->column('admin_id'));
|
||||
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
|
||||
}
|
||||
|
||||
$selectedAssistantValid = $selectedAssistantId <= 0;
|
||||
if ($selectedAssistantId > 0) {
|
||||
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
|
||||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||||
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
|
||||
}
|
||||
$context = self::resolveScopeContext($params, $adminId, $adminInfo);
|
||||
$startDate = $context['start_date'];
|
||||
$endDate = $context['end_date'];
|
||||
$timeType = $context['time_type'];
|
||||
$timeLabel = $context['time_label'];
|
||||
$allowedDeptSet = $context['allowed_dept_set'];
|
||||
$baseVisibleAdminIds = $context['base_visible_admin_ids'];
|
||||
$scopeValue = $context['scope_value'];
|
||||
$selectedDeptId = $context['selected_dept_id'];
|
||||
$selectedAssistantId = $context['selected_assistant_id'];
|
||||
$selectedMediaChannel = $context['selected_media_channel'];
|
||||
$selectedMediaChannelCode = $context['selected_media_channel_code'];
|
||||
$deptSelectionValid = $context['dept_selection_valid'];
|
||||
$selectedDeptIds = $context['selected_dept_ids'];
|
||||
$effectiveAdminIds = $context['effective_admin_ids'];
|
||||
$selectedAssistantValid = $context['selected_assistant_valid'];
|
||||
$costAllocationAdminIds = self::costAllocationAdminIds(
|
||||
$effectiveAdminIds,
|
||||
$scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0
|
||||
@@ -208,6 +181,337 @@ class FirstVisitConversionLogic
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function fansDetail(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$context = self::resolveScopeContext($params, $adminId, $adminInfo);
|
||||
$context['channel_dept_ids'] = ConversionLogic::fanDetailChannelDeptIds(
|
||||
$context['selected_media_channel']
|
||||
);
|
||||
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
|
||||
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20)));
|
||||
$empty = [
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'date_range' => [$context['start_date'], $context['end_date']],
|
||||
'entity' => null,
|
||||
];
|
||||
|
||||
$entityType = strtolower(trim((string) ($params['entity_type'] ?? '')));
|
||||
if (!in_array($entityType, ['dept', 'member'], true)) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$entityId = trim((string) ($params['entity_id'] ?? ''));
|
||||
$requestedAdminId = max(0, (int) ($params['admin_id'] ?? 0));
|
||||
$resolved = self::resolveFanDetailEntity(
|
||||
$entityType,
|
||||
$entityId,
|
||||
$requestedAdminId,
|
||||
$context
|
||||
);
|
||||
if ($resolved === null) {
|
||||
return $empty;
|
||||
}
|
||||
$target = $resolved['target'];
|
||||
|
||||
$conversionParams = [
|
||||
'dimension' => 'dept',
|
||||
'time_type' => 'custom',
|
||||
'start_date' => $context['start_date'],
|
||||
'end_date' => $context['end_date'],
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
];
|
||||
if ((int) $context['selected_dept_id'] > 0 && !empty($context['dept_selection_valid'])) {
|
||||
$conversionParams['dept_id'] = (int) $context['selected_dept_id'];
|
||||
}
|
||||
if ((string) $context['selected_media_channel_code'] !== '') {
|
||||
$conversionParams['media_channel_code'] = (string) $context['selected_media_channel_code'];
|
||||
}
|
||||
|
||||
$result = ConversionLogic::fanDetails(
|
||||
$conversionParams,
|
||||
$target,
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$context['effective_admin_ids'],
|
||||
$context['selected_media_channel']
|
||||
);
|
||||
if ((int) ($result['count'] ?? 0) <= 0) {
|
||||
return $empty;
|
||||
}
|
||||
$result['entity'] = [
|
||||
'type' => $entityType,
|
||||
'id' => $resolved['id'],
|
||||
'admin_id' => (int) $resolved['admin_id'],
|
||||
'name' => (string) $resolved['name'],
|
||||
'add_fans_count' => (int) $result['count'],
|
||||
'deleted_fans_count' => (int) ($result['deleted_count'] ?? 0),
|
||||
];
|
||||
unset($result['deleted_count']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve only the clicked entity and its authorized target range. This is
|
||||
* deliberately structural: it avoids recomputing all overview metrics,
|
||||
* while still rejecting ids that do not exist in the current DataScope.
|
||||
*
|
||||
* @param array<string,mixed> $context
|
||||
* @return array{id:string|int,admin_id:int,name:string,target:array<string,mixed>}|null
|
||||
*/
|
||||
private static function resolveFanDetailEntity(
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
int $requestedAdminId,
|
||||
array $context
|
||||
): ?array {
|
||||
$visibleAdminIds = $context['effective_admin_ids'];
|
||||
$selectedDeptId = (int) $context['selected_dept_id'];
|
||||
$channelDeptIds = $context['channel_dept_ids'] ?? null;
|
||||
|
||||
if ($entityType === 'member' && str_starts_with($entityId, 'U_')) {
|
||||
$wecomUserId = trim(substr($entityId, 2));
|
||||
if ($wecomUserId === '' || $requestedAdminId > 0 || $visibleAdminIds !== null || $selectedDeptId > 0) {
|
||||
return null;
|
||||
}
|
||||
$isBound = Db::name('admin')
|
||||
->whereNull('delete_time')
|
||||
->where('work_wechat_userid', $wecomUserId)
|
||||
->count() > 0;
|
||||
if ($isBound) {
|
||||
return null;
|
||||
}
|
||||
$historicalName = trim((string) (Db::name('admin')
|
||||
->where('work_wechat_userid', $wecomUserId)
|
||||
->order('id', 'desc')
|
||||
->value('name') ?? ''));
|
||||
|
||||
return [
|
||||
'id' => $entityId,
|
||||
'admin_id' => 0,
|
||||
'name' => $historicalName !== ''
|
||||
? $historicalName . '(' . $wecomUserId . ')'
|
||||
: $wecomUserId,
|
||||
'target' => ['type' => 'wecom_user', 'wecom_userid' => $wecomUserId],
|
||||
];
|
||||
}
|
||||
|
||||
if ($entityType === 'member') {
|
||||
if (!preg_match('/^M([1-9]\d*)_(-?\d+)$/', $entityId, $matches)) {
|
||||
return null;
|
||||
}
|
||||
$targetAdminId = (int) $matches[1];
|
||||
$rowDeptId = (int) $matches[2];
|
||||
if (($requestedAdminId > 0 && $requestedAdminId !== $targetAdminId)
|
||||
|| ($visibleAdminIds !== null && !in_array($targetAdminId, $visibleAdminIds, true))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
$admin = Db::name('admin')
|
||||
->where('id', $targetAdminId)
|
||||
->whereNull('delete_time')
|
||||
->where('work_wechat_userid', '<>', '')
|
||||
->field('id,name')
|
||||
->find();
|
||||
$resolvedDeptId = self::resolveFanDetailMemberDeptId($targetAdminId, $context);
|
||||
if (!$admin
|
||||
|| $resolvedDeptId !== $rowDeptId
|
||||
|| ($resolvedDeptId === -2 && $selectedDeptId > 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $entityId,
|
||||
'admin_id' => $targetAdminId,
|
||||
'name' => trim((string) ($admin['name'] ?? '')),
|
||||
'target' => ['type' => 'member', 'admin_id' => $targetAdminId],
|
||||
];
|
||||
}
|
||||
|
||||
if ($entityType !== 'dept' || !preg_match('/^-?\d+$/', $entityId)) {
|
||||
return null;
|
||||
}
|
||||
$deptId = (int) $entityId;
|
||||
if (in_array($deptId, [-1, -2], true)) {
|
||||
if ($selectedDeptId > 0 || $visibleAdminIds !== null) {
|
||||
return null;
|
||||
}
|
||||
$name = $deptId === -1 ? '未绑定后台账号' : '未分配部门';
|
||||
|
||||
return [
|
||||
'id' => $deptId,
|
||||
'admin_id' => 0,
|
||||
'name' => $name,
|
||||
'target' => ['type' => 'dept', 'dept_ids' => [$deptId]],
|
||||
];
|
||||
}
|
||||
if ($deptId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dept = Db::name('dept')->where('id', $deptId)->whereNull('delete_time')->field('id,name')->find();
|
||||
if (!$dept) {
|
||||
return null;
|
||||
}
|
||||
$universeDeptIds = $selectedDeptId > 0
|
||||
? self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId))
|
||||
: self::normalizeIds(Db::name('dept')->whereNull('delete_time')->column('id'));
|
||||
if ($channelDeptIds !== null) {
|
||||
$universeDeptIds = array_values(array_intersect($universeDeptIds, $channelDeptIds));
|
||||
}
|
||||
$rowDeptIds = self::visibleRowDeptIds($visibleAdminIds);
|
||||
if (!in_array($deptId, $universeDeptIds, true)
|
||||
|| ($rowDeptIds !== null && !in_array($deptId, $rowDeptIds, true))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
$targetDeptIds = array_values(array_intersect(
|
||||
self::normalizeIds(DeptLogic::getSelfAndDescendantIds($deptId)),
|
||||
$universeDeptIds,
|
||||
$rowDeptIds ?? $universeDeptIds
|
||||
));
|
||||
if ($targetDeptIds === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $deptId,
|
||||
'admin_id' => 0,
|
||||
'name' => trim((string) ($dept['name'] ?? '')),
|
||||
'target' => ['type' => 'dept', 'dept_ids' => $targetDeptIds],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $context */
|
||||
private static function resolveFanDetailMemberDeptId(int $adminId, array $context): int
|
||||
{
|
||||
// Other roles are rendered by ConversionLogic in the unassigned bucket.
|
||||
$hasMedicalRole = Db::name('admin_role')
|
||||
->where('admin_id', $adminId)
|
||||
->whereIn('role_id', [1, self::ASSISTANT_ROLE_ID])
|
||||
->count() > 0;
|
||||
if (!$hasMedicalRole) {
|
||||
return -2;
|
||||
}
|
||||
|
||||
$selectedDeptId = (int) $context['selected_dept_id'];
|
||||
$validDeptIds = $selectedDeptId > 0
|
||||
? self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId))
|
||||
: self::normalizeIds(Db::name('dept')->whereNull('delete_time')->column('id'));
|
||||
if (($context['channel_dept_ids'] ?? null) !== null) {
|
||||
$validDeptIds = array_values(array_intersect($validDeptIds, $context['channel_dept_ids']));
|
||||
}
|
||||
$validDeptSet = array_fill_keys($validDeptIds, true);
|
||||
$deptRows = Db::name('dept')->whereNull('delete_time')->field('id,pid,sort')->select()->toArray();
|
||||
$deptMeta = [];
|
||||
foreach ($deptRows as $deptRow) {
|
||||
$id = (int) ($deptRow['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$deptMeta[$id] = ['pid' => (int) ($deptRow['pid'] ?? 0), 'sort' => (int) ($deptRow['sort'] ?? 0)];
|
||||
}
|
||||
}
|
||||
$depthCache = [];
|
||||
$depthOf = static function (int $id) use (&$depthOf, &$depthCache, $deptMeta): int {
|
||||
if ($id <= 0 || !isset($deptMeta[$id])) {
|
||||
return 0;
|
||||
}
|
||||
if (isset($depthCache[$id])) {
|
||||
return $depthCache[$id];
|
||||
}
|
||||
$pid = (int) ($deptMeta[$id]['pid'] ?? 0);
|
||||
if ($pid <= 0 || $pid === $id || !isset($deptMeta[$pid])) {
|
||||
return $depthCache[$id] = 0;
|
||||
}
|
||||
|
||||
return $depthCache[$id] = $depthOf($pid) + 1;
|
||||
};
|
||||
$adminDeptIds = self::normalizeIds(AdminDept::where('admin_id', $adminId)->column('dept_id'));
|
||||
usort($adminDeptIds, static function (int $left, int $right) use ($depthOf, $deptMeta): int {
|
||||
return ($depthOf($right) <=> $depthOf($left))
|
||||
?: ((int) ($deptMeta[$right]['sort'] ?? 0) <=> (int) ($deptMeta[$left]['sort'] ?? 0))
|
||||
?: ($left <=> $right);
|
||||
});
|
||||
foreach ($adminDeptIds as $adminDeptId) {
|
||||
if (isset($validDeptSet[$adminDeptId])) {
|
||||
return $adminDeptId;
|
||||
}
|
||||
}
|
||||
|
||||
return -2;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function resolveScopeContext(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
[$startDate, $endDate, $timeType, $timeLabel] = self::resolveTimeRange($params);
|
||||
$baseVisibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
|
||||
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
|
||||
$requestedMediaChannelCode = trim((string) ($params['media_channel_code'] ?? ''));
|
||||
$selectedMediaChannel = $requestedMediaChannelCode !== ''
|
||||
? MediaChannelService::getCurrentTagChannelByCode($requestedMediaChannelCode)
|
||||
: null;
|
||||
$selectedMediaChannelCode = $selectedMediaChannel !== null ? $requestedMediaChannelCode : '';
|
||||
|
||||
$deptSelectionValid = $selectedDeptId <= 0
|
||||
|| $allowedDeptSet === null
|
||||
|| isset($allowedDeptSet[$selectedDeptId]);
|
||||
$selectedDeptIds = [];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$selectedDeptIds = array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
DeptLogic::getSelfAndDescendantIds($selectedDeptId)
|
||||
), static fn (int $id): bool => $id > 0)));
|
||||
if ($allowedDeptSet !== null) {
|
||||
$selectedDeptIds = array_values(array_filter(
|
||||
$selectedDeptIds,
|
||||
static fn (int $id): bool => isset($allowedDeptSet[$id])
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveAdminIds = $deptSelectionValid ? $baseVisibleAdminIds : [];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$deptAdminIds = $selectedDeptIds === []
|
||||
? []
|
||||
: self::normalizeIds(AdminDept::whereIn('dept_id', $selectedDeptIds)->column('admin_id'));
|
||||
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
|
||||
}
|
||||
|
||||
$selectedAssistantValid = $selectedAssistantId <= 0;
|
||||
if ($selectedAssistantId > 0) {
|
||||
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
|
||||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||||
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
|
||||
}
|
||||
|
||||
return [
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'time_type' => $timeType,
|
||||
'time_label' => $timeLabel,
|
||||
'base_visible_admin_ids' => $baseVisibleAdminIds,
|
||||
'allowed_dept_set' => $allowedDeptSet,
|
||||
'scope_value' => $scopeValue,
|
||||
'selected_dept_id' => $selectedDeptId,
|
||||
'selected_assistant_id' => $selectedAssistantId,
|
||||
'selected_media_channel' => $selectedMediaChannel,
|
||||
'selected_media_channel_code' => $selectedMediaChannelCode,
|
||||
'dept_selection_valid' => $deptSelectionValid,
|
||||
'selected_dept_ids' => $selectedDeptIds,
|
||||
'effective_admin_ids' => $effectiveAdminIds,
|
||||
'selected_assistant_valid' => $selectedAssistantValid,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string,2:string,3:string} */
|
||||
private static function resolveTimeRange(array $params): array
|
||||
{
|
||||
|
||||
@@ -105,7 +105,7 @@ class WecomAcquisitionCustomerLogic
|
||||
->leftJoin('dept d', 'd.id = c.dept_id')
|
||||
->leftJoin('qywx_promotion_link l', 'l.id = c.promotion_link_id')
|
||||
->leftJoin('qywx_promotion_pool p', 'p.id = l.pool_id');
|
||||
self::applyScope($query, 'c', $visibleIds);
|
||||
self::applyCustomerScope($query, $visibleIds);
|
||||
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? 0));
|
||||
if ($localLinkId > 0) {
|
||||
$query->where('c.promotion_link_id', $localLinkId);
|
||||
@@ -125,6 +125,31 @@ class WecomAcquisitionCustomerLogic
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户优先按实际承接成员做数据隔离;企微成员尚未绑定后台账号时,改按获客链接归属兜底。
|
||||
*
|
||||
* 企微 customer_list 的 state/customer_channel 允许为空,不能因此丢掉已返回的客户;
|
||||
* 同时也不能直接放开全部 owner_admin_id=0 的记录,否则会跨部门泄露未映射客户。
|
||||
*/
|
||||
private static function applyCustomerScope($query, ?array $visibleIds): void
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$ids = array_values(array_unique(array_map('intval', $visibleIds)));
|
||||
$query->where(function ($scope) use ($ids): void {
|
||||
$scope->whereIn('c.owner_admin_id', $ids)
|
||||
->whereOr(function ($unmapped) use ($ids): void {
|
||||
$unmapped->where('c.owner_admin_id', 0)
|
||||
->whereIn('l.owner_admin_id', $ids);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static function applyScope($query, string $alias, ?array $visibleIds): void
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
|
||||
@@ -4,9 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
|
||||
use app\common\service\qywx\QywxPromotionMemberRange;
|
||||
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
|
||||
use app\common\service\qywx\QywxPromotionRangeSyncService;
|
||||
use app\common\service\qywx\QywxPromotionWidgetService;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
@@ -16,6 +20,7 @@ class WecomPromotionLogic
|
||||
{
|
||||
public static function overview(int $adminId, array $adminInfo, string $domain): array
|
||||
{
|
||||
self::assertMemberDispatchSchema();
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$poolsQuery = Db::name('qywx_promotion_pool')->alias('p')
|
||||
->leftJoin('admin u', 'u.id = p.owner_admin_id')
|
||||
@@ -46,15 +51,12 @@ class WecomPromotionLogic
|
||||
unset($pool['widget_config_json']);
|
||||
$key = (string) $pool['public_key'];
|
||||
$scriptUrl = $domain . '/api/qywx-promotion/js/' . $key;
|
||||
$goUrl = $domain . '/api/qywx-promotion/go/' . $key;
|
||||
$compatGoUrl = $domain . '/api/qywx-promotion/go/' . $key;
|
||||
$pool['script_url'] = $scriptUrl;
|
||||
$pool['go_url'] = $goUrl;
|
||||
$pool['compat_go_url'] = $compatGoUrl;
|
||||
$pool['install_code'] = '<script src="'
|
||||
. htmlspecialchars($scriptUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '" defer></script>';
|
||||
$pool['trigger_code'] = '<a href="'
|
||||
. htmlspecialchars($goUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '" data-wecom-promotion="' . $key . '">添加企业微信</a>';
|
||||
}
|
||||
unset($pool);
|
||||
|
||||
@@ -70,12 +72,101 @@ class WecomPromotionLogic
|
||||
if ((int) ($link['status'] ?? 0) === 1 && $link['valid_customer_acquisition_link']) {
|
||||
$onlineLinks++;
|
||||
}
|
||||
if ((string) ($link['today_date'] ?? '') === $today) {
|
||||
$todayClicks += (int) ($link['today_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
unset($link);
|
||||
|
||||
$memberOptions = self::memberOptions($adminId, $adminInfo);
|
||||
$adminIdByUserId = [];
|
||||
$memberOptionByAdminId = [];
|
||||
foreach ($memberOptions as $member) {
|
||||
$adminIdByUserId[(string) $member['userid']] = (int) $member['id'];
|
||||
$memberOptionByAdminId[(int) $member['id']] = $member;
|
||||
}
|
||||
$linksByPool = [];
|
||||
foreach ($links as $link) {
|
||||
$linksByPool[(int) $link['pool_id']][] = $link;
|
||||
}
|
||||
self::backfillPoolMembers($pools, $linksByPool, $adminIdByUserId);
|
||||
$memberRulesByPool = [];
|
||||
$syncByPool = [];
|
||||
if ($poolIds !== []) {
|
||||
$memberRows = Db::name('qywx_promotion_pool_member')
|
||||
->whereIn('pool_id', $poolIds)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->select()->toArray();
|
||||
foreach ($memberRows as $memberRow) {
|
||||
$memberAdminId = (int) ($memberRow['admin_id'] ?? 0);
|
||||
$option = $memberOptionByAdminId[$memberAdminId] ?? [];
|
||||
$memberRow['name'] = (string) ($option['name'] ?? $memberRow['userid'] ?? '未知成员');
|
||||
$memberRow['dept_names'] = array_values((array) ($option['dept_names'] ?? []));
|
||||
$memberRow['status'] = (int) ($memberRow['enabled'] ?? 0);
|
||||
$memberRow['today_count'] = (string) ($memberRow['today_date'] ?? '') === $today
|
||||
? (int) ($memberRow['today_count'] ?? 0)
|
||||
: 0;
|
||||
$memberRulesByPool[(int) $memberRow['pool_id']][] = $memberRow;
|
||||
$todayClicks += (int) $memberRow['today_count'];
|
||||
}
|
||||
foreach (Db::name('qywx_promotion_range_sync')->whereIn('pool_id', $poolIds)->select()->toArray() as $syncRow) {
|
||||
$syncByPool[(int) $syncRow['pool_id']] = $syncRow;
|
||||
}
|
||||
}
|
||||
foreach ($pools as &$pool) {
|
||||
$poolLinks = $linksByPool[(int) $pool['id']] ?? [];
|
||||
$officialLinks = array_values(array_filter(
|
||||
$poolLinks,
|
||||
static fn (array $link): bool => !empty($link['is_official'])
|
||||
&& (int) ($link['remote_status'] ?? 0) !== 2
|
||||
));
|
||||
$legacyCount = count(array_filter(
|
||||
$poolLinks,
|
||||
static fn (array $link): bool => empty($link['is_official'])
|
||||
));
|
||||
// links 已按 id 倒序返回;旧多链接方案暂以最新官方链接作为主链接,
|
||||
// 保存方案时会把其余链接仅在本地下线,保留远端链接与历史客户归因。
|
||||
$officialLink = $officialLinks[0] ?? null;
|
||||
$memberRules = $memberRulesByPool[(int) $pool['id']] ?? [];
|
||||
$sync = $syncByPool[(int) $pool['id']] ?? [];
|
||||
$remoteUserIds = array_fill_keys((array) ($officialLink['range_userids'] ?? []), true);
|
||||
foreach ($memberRules as &$memberRule) {
|
||||
$memberRule['is_current'] = false;
|
||||
$memberRule['is_applied'] = false;
|
||||
$memberRule['is_in_remote_range'] = isset($remoteUserIds[(string) ($memberRule['userid'] ?? '')]);
|
||||
$memberRule['sync_status'] = (int) ($sync['status'] ?? 0);
|
||||
$memberRule['sync_error'] = (string) ($sync['last_error'] ?? '');
|
||||
}
|
||||
unset($memberRule);
|
||||
$memberAdminIds = [];
|
||||
foreach ($memberRules as $memberRule) {
|
||||
if ((int) ($memberRule['admin_id'] ?? 0) > 0) {
|
||||
$memberAdminIds[] = (int) $memberRule['admin_id'];
|
||||
}
|
||||
}
|
||||
$mainUrl = $officialLink === null
|
||||
? ''
|
||||
: QywxCustomerAcquisitionLinkService::withCustomerChannel(
|
||||
(string) ($officialLink['wecom_url'] ?? ''),
|
||||
'zyt_pool:' . (int) $pool['id']
|
||||
);
|
||||
$pool['official_link'] = $officialLink;
|
||||
$pool['official_link_count'] = count($officialLinks);
|
||||
$pool['legacy_link_count'] = $legacyCount;
|
||||
$pool['member_admin_ids'] = array_values(array_unique($memberAdminIds));
|
||||
$pool['member_rules'] = $memberRules;
|
||||
$pool['dispatch_sync'] = $sync;
|
||||
$pool['skip_verify'] = (int) ($officialLink['skip_verify'] ?? 0);
|
||||
$pool['migration_state'] = count($officialLinks) > 1
|
||||
? 'needs_resolution'
|
||||
: ($officialLink === null ? ($legacyCount > 0 ? 'legacy_only' : 'missing') : 'ready');
|
||||
$pool['main_url'] = $mainUrl;
|
||||
// go_url 现在代表可直接分享的企业微信官方链接;compat_go_url 保留旧 302 入口。
|
||||
$pool['go_url'] = $mainUrl;
|
||||
$pool['trigger_code'] = $mainUrl === '' ? '' : '<a href="'
|
||||
. htmlspecialchars($mainUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '" data-wecom-promotion="' . (string) $pool['public_key'] . '">添加企业微信</a>';
|
||||
}
|
||||
unset($pool);
|
||||
|
||||
$config = self::internalApplicationStatus($domain);
|
||||
|
||||
return [
|
||||
@@ -92,14 +183,19 @@ class WecomPromotionLogic
|
||||
],
|
||||
'pools' => $pools,
|
||||
'links' => $links,
|
||||
'member_options' => self::memberOptions($adminId, $adminInfo),
|
||||
'member_options' => $memberOptions,
|
||||
'department_options' => DeptLogic::getAllDataScoped($adminId, $adminInfo),
|
||||
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function savePool(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
self::assertMemberDispatchSchema();
|
||||
$id = max(0, (int) ($params['id'] ?? 0));
|
||||
$existingPool = $id > 0
|
||||
? self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo)
|
||||
: null;
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 60) {
|
||||
throw new RuntimeException('请输入 1-60 个字符的分流方案名称');
|
||||
@@ -108,28 +204,166 @@ class WecomPromotionLogic
|
||||
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
|
||||
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
|
||||
}
|
||||
$members = self::resolveMembers((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo);
|
||||
$userIds = array_values(array_column($members, 'userid'));
|
||||
$eligibleUserIds = self::eligibleSelectedUserIds($id, $members);
|
||||
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
|
||||
$status = (int) ($params['status'] ?? 1) === 1 ? 1 : 0;
|
||||
$now = time();
|
||||
$data = [
|
||||
$poolData = [
|
||||
'name' => $name,
|
||||
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
|
||||
'status' => $status,
|
||||
'fallback_url' => $fallback,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($id > 0) {
|
||||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
|
||||
Db::name('qywx_promotion_pool')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$data += [
|
||||
'public_key' => bin2hex(random_bytes(16)),
|
||||
'owner_admin_id' => $adminId,
|
||||
'dept_id' => self::primaryDeptId($adminId),
|
||||
'click_count' => 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($data);
|
||||
|
||||
$existingLink = null;
|
||||
if ($existingPool !== null) {
|
||||
$existingLink = Db::name('qywx_promotion_link')
|
||||
->where('pool_id', $id)
|
||||
->whereNull('delete_time')
|
||||
->where('remote_link_id', '<>', '')
|
||||
->where('remote_status', '<>', 2)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
}
|
||||
|
||||
return ['id' => $id];
|
||||
$api = new QywxCustomerAcquisitionApiService();
|
||||
$createdRemote = false;
|
||||
$remote = null;
|
||||
if ($existingLink === null) {
|
||||
$payload = [
|
||||
'link_name' => mb_substr($name, 0, 30),
|
||||
// 一个官方直链同时绑定全部当前可用医助,由企业微信原生多人路由直接分配。
|
||||
'range' => ['user_list' => $eligibleUserIds],
|
||||
'skip_verify' => $skipVerify === 1,
|
||||
];
|
||||
$created = $api->createLink($payload);
|
||||
$remoteLinkId = self::extractRemoteLinkId($created);
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('企业微信已创建链接,但接口未返回 link_id,请执行“同步企业微信”确认结果');
|
||||
}
|
||||
$createdRemote = true;
|
||||
try {
|
||||
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
|
||||
if (!QywxPromotionMemberRange::same($remote['range_userids'], $eligibleUserIds)) {
|
||||
throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$api->deleteLink($remoteLinkId);
|
||||
} catch (\Throwable) {
|
||||
// 保留详情读取的原始异常;孤立链接仍可通过企业微信同步找回。
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
} else {
|
||||
$remoteLinkId = trim((string) ($existingLink['remote_link_id'] ?? ''));
|
||||
}
|
||||
$remoteData = $remote !== null ? self::remoteColumns($remote, $now) : [];
|
||||
|
||||
$linkId = (int) ($existingLink['id'] ?? 0);
|
||||
try {
|
||||
Db::transaction(function () use (
|
||||
&$id,
|
||||
&$linkId,
|
||||
$poolData,
|
||||
$remoteData,
|
||||
$status,
|
||||
$existingPool,
|
||||
$members,
|
||||
$skipVerify,
|
||||
$createdRemote,
|
||||
$now,
|
||||
$adminId
|
||||
): void {
|
||||
if ($existingPool === null) {
|
||||
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($poolData + [
|
||||
'public_key' => bin2hex(random_bytes(16)),
|
||||
'owner_admin_id' => $adminId,
|
||||
'dept_id' => self::primaryDeptId($adminId),
|
||||
'click_count' => 0,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
} else {
|
||||
Db::name('qywx_promotion_pool')->where('id', $id)->update($poolData);
|
||||
}
|
||||
|
||||
$linkCommon = [
|
||||
'pool_id' => $id,
|
||||
'name' => mb_substr((string) $poolData['name'], 0, 30),
|
||||
'group_name' => '方案官方链接',
|
||||
'status' => $status,
|
||||
'skip_verify' => $skipVerify,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($linkId > 0) {
|
||||
Db::name('qywx_promotion_link')->where('id', $linkId)->update($remoteData + $linkCommon);
|
||||
} else {
|
||||
$linkId = (int) Db::name('qywx_promotion_link')->insertGetId($remoteData + $linkCommon + [
|
||||
'account_id' => 0,
|
||||
'weight' => 1,
|
||||
'daily_limit' => 0,
|
||||
'today_count' => 0,
|
||||
'today_date' => null,
|
||||
'active_start' => 0,
|
||||
'active_end' => 0,
|
||||
'click_count' => 0,
|
||||
'last_click_time' => 0,
|
||||
'owner_admin_id' => (int) ($existingPool['owner_admin_id'] ?? 0) ?: $adminId,
|
||||
'dept_id' => (int) ($existingPool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
|
||||
'remark' => '',
|
||||
'create_time' => $now,
|
||||
]);
|
||||
}
|
||||
self::persistPoolMembers($id, $members, $now);
|
||||
if ($createdRemote) {
|
||||
QywxPromotionMemberSchedulerService::initialisePool($id, $linkId);
|
||||
}
|
||||
|
||||
// 旧多链接只在本地下线,企业微信远端与历史客户归因继续保留。
|
||||
Db::name('qywx_promotion_link')
|
||||
->where('pool_id', $id)
|
||||
->where('id', '<>', $linkId)
|
||||
->whereNull('delete_time')
|
||||
->update(['status' => 0, 'update_time' => $now]);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
if ($createdRemote) {
|
||||
try {
|
||||
$api->deleteLink($remoteLinkId);
|
||||
} catch (\Throwable) {
|
||||
// 企业微信补偿失败时保留原始异常;可通过官方列表找回孤立链接。
|
||||
}
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$syncError = '';
|
||||
if (!$createdRemote) {
|
||||
QywxPromotionMemberSchedulerService::requestPoolSync($id, $linkId);
|
||||
try {
|
||||
(new QywxPromotionRangeSyncService())->syncPool($id);
|
||||
} catch (\Throwable $e) {
|
||||
// 本地方案和成员规则已保存;后台分钟任务会继续重试最新完整范围。
|
||||
$syncError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
$savedLink = Db::name('qywx_promotion_link')->where('id', $linkId)->find() ?: [];
|
||||
$savedUrl = (string) ($savedLink['wecom_url'] ?? $remote['url'] ?? '');
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'remote_link_id' => (string) ($savedLink['remote_link_id'] ?? $remoteLinkId),
|
||||
'wecom_url' => $savedUrl,
|
||||
'main_url' => QywxCustomerAcquisitionLinkService::withCustomerChannel(
|
||||
$savedUrl,
|
||||
'zyt_pool:' . $id
|
||||
),
|
||||
'member_userids' => $userIds,
|
||||
'range_userids' => $eligibleUserIds,
|
||||
'sync_error' => $syncError,
|
||||
];
|
||||
}
|
||||
|
||||
public static function saveWidget(array $params, int $adminId, array $adminInfo): array
|
||||
@@ -147,22 +381,206 @@ class WecomPromotionLogic
|
||||
return ['id' => $id, 'widget_config' => $config];
|
||||
}
|
||||
|
||||
public static function deletePool(int $id, int $adminId, array $adminInfo): void
|
||||
public static function deletePool(
|
||||
int $id,
|
||||
int $adminId,
|
||||
array $adminInfo,
|
||||
?QywxCustomerAcquisitionApiService $api = null
|
||||
): void
|
||||
{
|
||||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
|
||||
$links = Db::name('qywx_promotion_link')
|
||||
->where('pool_id', $id)
|
||||
->order('id', 'asc')
|
||||
->select()->toArray();
|
||||
$now = time();
|
||||
$remoteLinks = array_values(array_filter($links, static fn (array $link): bool =>
|
||||
trim((string) ($link['remote_link_id'] ?? '')) !== ''
|
||||
&& (int) ($link['remote_status'] ?? 0) !== 2
|
||||
));
|
||||
if ($remoteLinks !== []) {
|
||||
// 使用方案级删除租约阻止双击、重复请求以及回调/分钟任务继续更新远端范围。
|
||||
$deleteToken = bin2hex(random_bytes(16));
|
||||
Db::transaction(function () use ($id, $remoteLinks, $deleteToken, $now): void {
|
||||
$sync = Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->lock(true)->find();
|
||||
if ($sync && (int) ($sync['status'] ?? 0) === 5 && (int) ($sync['lock_until'] ?? 0) > $now) {
|
||||
throw new RuntimeException('该分流方案正在删除,请勿重复提交');
|
||||
}
|
||||
$data = [
|
||||
'promotion_link_id' => (int) ($sync['promotion_link_id'] ?? $remoteLinks[0]['id'] ?? 0),
|
||||
'desired_member_id' => 0,
|
||||
'desired_version' => max(1, (int) ($sync['desired_version'] ?? 0) + 1),
|
||||
'status' => 5,
|
||||
'next_retry' => 0,
|
||||
'lock_token' => $deleteToken,
|
||||
'lock_until' => $now + 300,
|
||||
'last_error' => '分流方案正在删除企业微信官方链接',
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($sync) {
|
||||
Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->update($data);
|
||||
} else {
|
||||
Db::name('qywx_promotion_range_sync')->insert($data + [
|
||||
'pool_id' => $id,
|
||||
'applied_member_id' => 0,
|
||||
'applied_version' => 0,
|
||||
'attempts' => 0,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
}
|
||||
});
|
||||
$api ??= new QywxCustomerAcquisitionApiService();
|
||||
foreach ($remoteLinks as $link) {
|
||||
$linkId = (int) ($link['id'] ?? 0);
|
||||
$remoteLinkId = trim((string) ($link['remote_link_id'] ?? ''));
|
||||
try {
|
||||
$api->deleteLink($remoteLinkId);
|
||||
} catch (\Throwable $e) {
|
||||
// 删除是幂等操作:企业微信明确返回 invalid link_id 时,远端目标已不存在
|
||||
// 或已不再属于当前应用,继续完成本地清理;其他错误仍保留方案以便重试。
|
||||
if (!self::isRemoteLinkAlreadyMissing($e)) {
|
||||
$message = '企业微信官方获客链接删除失败,本地方案已保留:' . $e->getMessage();
|
||||
Db::name('qywx_promotion_link')->where('id', $linkId)->update([
|
||||
'sync_error' => mb_substr($message, 0, 500),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->where('lock_token', $deleteToken)->update([
|
||||
'status' => 4,
|
||||
'next_retry' => 0,
|
||||
'lock_token' => '',
|
||||
'lock_until' => 0,
|
||||
'last_error' => mb_substr($message, 0, 500),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException($message, 0, $e);
|
||||
}
|
||||
}
|
||||
// 多个历史官方链接部分成功时也保存进度,用户重试删除不会再次请求已删除链接。
|
||||
Db::name('qywx_promotion_link')->where('id', $linkId)->update([
|
||||
'status' => 0,
|
||||
'remote_status' => 2,
|
||||
'last_sync_time' => time(),
|
||||
'sync_error' => '',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Db::transaction(function () use ($id, $now): void {
|
||||
Db::name('qywx_promotion_pool')->where('id', $id)->update(['delete_time' => $now, 'update_time' => $now]);
|
||||
Db::name('qywx_promotion_link')->where('pool_id', $id)->whereNull('delete_time')->update(['delete_time' => $now, 'update_time' => $now]);
|
||||
Db::name('qywx_promotion_link')->where('pool_id', $id)->whereNull('delete_time')->update([
|
||||
'status' => 0,
|
||||
'delete_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::name('qywx_promotion_pool_member')->where('pool_id', $id)->whereNull('delete_time')->update([
|
||||
'enabled' => 0,
|
||||
'delete_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->update([
|
||||
'status' => 4,
|
||||
'next_retry' => 0,
|
||||
'lock_token' => '',
|
||||
'lock_until' => 0,
|
||||
'last_error' => '分流方案及企业微信官方链接已删除',
|
||||
'update_time' => $now,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private static function isRemoteLinkAlreadyMissing(\Throwable $error): bool
|
||||
{
|
||||
$message = strtolower($error->getMessage());
|
||||
$isInvalidLinkId = str_contains($message, 'invalid link_id');
|
||||
$isInvalidParameter = (int) $error->getCode() === 40058
|
||||
|| str_contains($message, '[40058]');
|
||||
|
||||
return $isInvalidParameter && $isInvalidLinkId;
|
||||
}
|
||||
|
||||
public static function saveMember(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
self::assertMemberDispatchSchema();
|
||||
$id = max(0, (int) ($params['id'] ?? 0));
|
||||
$member = Db::name('qywx_promotion_pool_member')->where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$member) {
|
||||
throw new RuntimeException('分流成员不存在或已移除');
|
||||
}
|
||||
$poolId = (int) $member['pool_id'];
|
||||
self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
|
||||
$enabled = (int) ($params['status'] ?? $params['enabled'] ?? 1) === 1 ? 1 : 0;
|
||||
if ($enabled === 0) {
|
||||
$otherEnabled = (int) Db::name('qywx_promotion_pool_member')
|
||||
->where('pool_id', $poolId)
|
||||
->where('id', '<>', $id)
|
||||
->where('enabled', 1)
|
||||
->whereNull('delete_time')
|
||||
->count();
|
||||
if ($otherEnabled <= 0) {
|
||||
throw new RuntimeException('至少需要保留一名启用的获客医助');
|
||||
}
|
||||
}
|
||||
$startAt = self::parseTime($params['active_start'] ?? null);
|
||||
$endAt = self::parseTime($params['active_end'] ?? null);
|
||||
if ($startAt > 0 && $endAt > 0 && $endAt <= $startAt) {
|
||||
throw new RuntimeException('生效结束时间必须晚于开始时间');
|
||||
}
|
||||
$now = time();
|
||||
Db::transaction(function () use ($id, $enabled, $params, $startAt, $endAt, $now): void {
|
||||
Db::name('qywx_promotion_pool_member')->where('id', $id)->update([
|
||||
'enabled' => $enabled,
|
||||
// 企业微信原生多人路由不支持逐成员权重;字段固定为 1,仅兼容已部署表结构。
|
||||
'weight' => 1,
|
||||
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
|
||||
'active_start' => $startAt,
|
||||
'active_end' => $endAt,
|
||||
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
});
|
||||
$planned = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
|
||||
$syncError = '';
|
||||
if ($planned['queued']) {
|
||||
try {
|
||||
(new QywxPromotionRangeSyncService())->syncPool($poolId);
|
||||
} catch (\Throwable $e) {
|
||||
// 本地规则已保存;后台分钟任务会继续重试,不把成员状态回滚成错误值。
|
||||
$syncError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return ['id' => $id, 'pool_id' => $poolId, 'dispatch' => $planned, 'sync_error' => $syncError];
|
||||
}
|
||||
|
||||
public static function toggleMember(int $id, int $status, int $adminId, array $adminInfo): array
|
||||
{
|
||||
self::assertMemberDispatchSchema();
|
||||
$row = Db::name('qywx_promotion_pool_member')->where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$row) {
|
||||
throw new RuntimeException('分流成员不存在或已移除');
|
||||
}
|
||||
|
||||
return self::saveMember([
|
||||
'id' => $id,
|
||||
'status' => $status,
|
||||
'daily_limit' => (int) ($row['daily_limit'] ?? 0),
|
||||
'active_start' => (int) ($row['active_start'] ?? 0),
|
||||
'active_end' => (int) ($row['active_end'] ?? 0),
|
||||
'remark' => (string) ($row['remark'] ?? ''),
|
||||
], $adminId, $adminInfo);
|
||||
}
|
||||
|
||||
public static function saveLink(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$id = max(0, (int) ($params['id'] ?? 0));
|
||||
$poolId = max(0, (int) ($params['pool_id'] ?? 0));
|
||||
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
|
||||
self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
|
||||
$existing = $id > 0 ? self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo) : null;
|
||||
if ($existing === null || trim((string) ($existing['remote_link_id'] ?? '')) !== '') {
|
||||
throw new RuntimeException('官方获客链接由分流方案统一维护,请直接编辑方案中的获客医助');
|
||||
}
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 80) {
|
||||
throw new RuntimeException('请输入 1-80 个字符的获客链接名称');
|
||||
@@ -187,66 +605,15 @@ class WecomPromotionLogic
|
||||
'update_time' => $now,
|
||||
];
|
||||
|
||||
// 历史手工链接只维护本地分流规则,不会在企业微信端创建重复链接。
|
||||
if ($existing !== null && trim((string) ($existing['remote_link_id'] ?? '')) === '') {
|
||||
$url = trim((string) ($params['wecom_url'] ?? $existing['wecom_url'] ?? ''));
|
||||
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
|
||||
throw new RuntimeException('历史链接必须是 https://work.weixin.qq.com/ca/... 格式');
|
||||
}
|
||||
$data['wecom_url'] = $url;
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
|
||||
|
||||
return ['id' => $id, 'mode' => 'legacy'];
|
||||
// 仅保留历史手工链接的兼容编辑;官方链接只能经 savePool 和版本化同步服务修改。
|
||||
$url = trim((string) ($params['wecom_url'] ?? $existing['wecom_url'] ?? ''));
|
||||
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
|
||||
throw new RuntimeException('历史链接必须是 https://work.weixin.qq.com/ca/... 格式');
|
||||
}
|
||||
$data['wecom_url'] = $url;
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
|
||||
|
||||
$userIds = self::resolveMemberUserIds((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo);
|
||||
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
|
||||
$payload = [
|
||||
'link_name' => $name,
|
||||
'range' => ['user_list' => $userIds],
|
||||
'skip_verify' => $skipVerify === 1,
|
||||
];
|
||||
|
||||
$api = new QywxCustomerAcquisitionApiService();
|
||||
if ($existing !== null) {
|
||||
$remoteLinkId = trim((string) ($existing['remote_link_id'] ?? ''));
|
||||
$payload['link_id'] = $remoteLinkId;
|
||||
$api->updateLink($payload);
|
||||
} else {
|
||||
$created = $api->createLink($payload);
|
||||
$remoteLinkId = self::extractRemoteLinkId($created);
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('企业微信已创建链接,但接口未返回 link_id,请先执行“同步企业微信”确认结果');
|
||||
}
|
||||
}
|
||||
|
||||
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
|
||||
$data += self::remoteColumns($remote, $now);
|
||||
if ($existing !== null) {
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$data += [
|
||||
'owner_admin_id' => (int) ($pool['owner_admin_id'] ?? 0) ?: $adminId,
|
||||
'dept_id' => (int) ($pool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
|
||||
'click_count' => 0,
|
||||
'today_count' => 0,
|
||||
'today_date' => null,
|
||||
'last_click_time' => 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
try {
|
||||
$id = (int) Db::name('qywx_promotion_link')->insertGetId($data);
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$api->deleteLink($remoteLinkId);
|
||||
} catch (\Throwable) {
|
||||
// 远端补偿失败时保留原始异常,管理员可通过“同步企业微信”找回链接。
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return ['id' => $id, 'remote_link_id' => $remoteLinkId, 'mode' => 'official'];
|
||||
return ['id' => $id, 'mode' => 'legacy'];
|
||||
}
|
||||
|
||||
/** 验证 CorpID、应用 Secret、可信 IP 与获客助手接口权限。 */
|
||||
@@ -346,7 +713,7 @@ class WecomPromotionLogic
|
||||
return self::remotePublicPayload($remote);
|
||||
}
|
||||
|
||||
/** 永久删除企业微信端获客链接,本地保留审计记录并停止分流。 */
|
||||
/** 官方链接由方案统一删除,避免绕过方案删除租约和同步状态机。 */
|
||||
public static function deleteRemoteLink(int $id, int $adminId, array $adminInfo): void
|
||||
{
|
||||
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
@@ -354,14 +721,8 @@ class WecomPromotionLogic
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('历史手工链接只能从本地移除');
|
||||
}
|
||||
(new QywxCustomerAcquisitionApiService())->deleteLink($remoteLinkId);
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update([
|
||||
'status' => 0,
|
||||
'remote_status' => 2,
|
||||
'last_sync_time' => time(),
|
||||
'sync_error' => '',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException('官方获客链接由分流方案统一维护,请使用“删除分流方案”同时删除企业微信链接');
|
||||
}
|
||||
|
||||
public static function toggleLink(int $id, int $status, int $adminId, array $adminInfo): void
|
||||
@@ -378,14 +739,17 @@ class WecomPromotionLogic
|
||||
|
||||
public static function deleteLink(int $id, int $adminId, array $adminInfo): void
|
||||
{
|
||||
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
if (trim((string) ($row['remote_link_id'] ?? '')) !== '') {
|
||||
throw new RuntimeException('官方获客链接不能仅从本地移除,请使用“删除分流方案”同步删除企业微信链接');
|
||||
}
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update([
|
||||
'delete_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array{id:int,name:string,userid:string,dept_ids:list<int>,dept_names:list<string>}> */
|
||||
/** @return list<array{id:int,name:string,userid:string,display_dept_id:int,dept_ids:list<int>,dept_names:list<string>}> */
|
||||
private static function memberOptions(int $adminId, array $adminInfo): array
|
||||
{
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
@@ -394,6 +758,7 @@ class WecomPromotionLogic
|
||||
}
|
||||
$query = Db::name('admin')->alias('a')
|
||||
->whereNull('a.delete_time')
|
||||
->where('a.disable', 0)
|
||||
->where('a.work_wechat_userid', '<>', '');
|
||||
if ($visibleIds !== null) {
|
||||
$query->whereIn('a.id', $visibleIds);
|
||||
@@ -426,11 +791,15 @@ class WecomPromotionLogic
|
||||
}
|
||||
$seenUserIds[$userId] = true;
|
||||
$aid = (int) $admin['id'];
|
||||
$deptIds = array_values(array_unique(array_filter($departments[$aid]['ids'] ?? [])));
|
||||
$result[] = [
|
||||
'id' => $aid,
|
||||
'name' => (string) ($admin['name'] ?? $userId),
|
||||
'userid' => $userId,
|
||||
'dept_ids' => array_values(array_unique(array_filter($departments[$aid]['ids'] ?? []))),
|
||||
// admin_dept 没有主部门字段;与现有获客归属、复诊统计口径一致,
|
||||
// 取最小 dept_id 作为树形下拉的唯一展示归属,避免多部门成员重复选择。
|
||||
'display_dept_id' => (int) ($deptIds[0] ?? 0),
|
||||
'dept_ids' => $deptIds,
|
||||
'dept_names' => array_values(array_unique($departments[$aid]['names'] ?? [])),
|
||||
];
|
||||
}
|
||||
@@ -438,8 +807,8 @@ class WecomPromotionLogic
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
|
||||
/** @return list<array{id:int,name:string,userid:string,display_dept_id:int,dept_ids:list<int>,dept_names:list<string>}> */
|
||||
private static function resolveMembers(array $adminIds, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$requested = array_values(array_unique(array_filter(array_map('intval', $adminIds))));
|
||||
if ($requested === []) {
|
||||
@@ -447,47 +816,194 @@ class WecomPromotionLogic
|
||||
}
|
||||
$available = [];
|
||||
foreach (self::memberOptions($adminId, $adminInfo) as $member) {
|
||||
$available[$member['id']] = $member['userid'];
|
||||
$available[(int) $member['id']] = $member;
|
||||
}
|
||||
$userIds = [];
|
||||
$members = [];
|
||||
foreach ($requested as $requestedId) {
|
||||
if (!isset($available[$requestedId])) {
|
||||
throw new RuntimeException('选择的获客成员超出当前角色或部门的数据范围,或尚未绑定企业微信 userid');
|
||||
}
|
||||
$userIds[] = $available[$requestedId];
|
||||
$members[] = $available[$requestedId];
|
||||
}
|
||||
if (count($userIds) > 500) {
|
||||
if (count($members) > 500) {
|
||||
throw new RuntimeException('单个获客链接最多配置 500 名成员');
|
||||
}
|
||||
|
||||
return array_values(array_unique($userIds));
|
||||
return $members;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
|
||||
{
|
||||
return array_values(array_column(self::resolveMembers($adminIds, $adminId, $adminInfo), 'userid'));
|
||||
}
|
||||
|
||||
/** @param list<array{id:int,userid:string}> $members @return list<string> */
|
||||
private static function eligibleSelectedUserIds(int $poolId, array $members): array
|
||||
{
|
||||
if ($members === []) {
|
||||
throw new RuntimeException('请至少选择一名获客成员');
|
||||
}
|
||||
$rules = $poolId > 0
|
||||
? Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->whereNull('delete_time')->select()->toArray()
|
||||
: [];
|
||||
$rulesByUserId = [];
|
||||
foreach ($rules as $rule) {
|
||||
$rulesByUserId[(string) $rule['userid']] = $rule;
|
||||
}
|
||||
$now = time();
|
||||
$today = date('Y-m-d', $now);
|
||||
$candidates = [];
|
||||
foreach ($members as $member) {
|
||||
$userId = (string) $member['userid'];
|
||||
$rule = $rulesByUserId[$userId] ?? [
|
||||
'enabled' => 1,
|
||||
'daily_limit' => 0,
|
||||
'today_count' => 0,
|
||||
'today_date' => $today,
|
||||
'active_start' => 0,
|
||||
'active_end' => 0,
|
||||
];
|
||||
$rule['userid'] = $userId;
|
||||
$candidates[] = $rule;
|
||||
}
|
||||
$range = QywxPromotionMemberRange::evaluate($candidates, $today, $now);
|
||||
if ($range['userids'] === []) {
|
||||
throw new RuntimeException('至少需要一名已启用、已生效且未达到今日上限的获客医助');
|
||||
}
|
||||
|
||||
return $range['userids'];
|
||||
}
|
||||
|
||||
/** @param list<array{id:int,userid:string}> $members */
|
||||
private static function persistPoolMembers(int $poolId, array $members, int $now): void
|
||||
{
|
||||
$selectedUserIds = [];
|
||||
foreach ($members as $member) {
|
||||
$userId = (string) $member['userid'];
|
||||
$selectedUserIds[] = $userId;
|
||||
$existing = Db::name('qywx_promotion_pool_member')
|
||||
->where('pool_id', $poolId)->where('userid', $userId)->find();
|
||||
if ($existing) {
|
||||
Db::name('qywx_promotion_pool_member')->where('id', (int) $existing['id'])->update([
|
||||
'admin_id' => (int) $member['id'],
|
||||
'userid' => $userId,
|
||||
'delete_time' => null,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
Db::name('qywx_promotion_pool_member')->insert([
|
||||
'pool_id' => $poolId,
|
||||
'admin_id' => (int) $member['id'],
|
||||
'userid' => $userId,
|
||||
'enabled' => 1,
|
||||
'weight' => 1,
|
||||
'current_weight' => 0,
|
||||
'daily_limit' => 0,
|
||||
'today_count' => 0,
|
||||
'today_date' => null,
|
||||
'total_count' => 0,
|
||||
'active_start' => 0,
|
||||
'active_end' => 0,
|
||||
'last_assigned_time' => 0,
|
||||
'remark' => '',
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
$removeQuery = Db::name('qywx_promotion_pool_member')
|
||||
->where('pool_id', $poolId)->whereNull('delete_time');
|
||||
if ($selectedUserIds !== []) {
|
||||
$removeQuery->whereNotIn('userid', $selectedUserIds);
|
||||
}
|
||||
$removeQuery->update(['delete_time' => $now, 'enabled' => 0, 'update_time' => $now]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版方案第一次打开时,把官方链接原有 range.user_list 回填为成员规则;不修改远端链接。
|
||||
*
|
||||
* @param list<array<string,mixed>> $pools
|
||||
* @param array<int,list<array<string,mixed>>> $linksByPool
|
||||
* @param array<string,int> $adminIdByUserId
|
||||
*/
|
||||
private static function backfillPoolMembers(array $pools, array $linksByPool, array $adminIdByUserId): void
|
||||
{
|
||||
$now = time();
|
||||
foreach ($pools as $pool) {
|
||||
$poolId = (int) ($pool['id'] ?? 0);
|
||||
if ($poolId <= 0 || Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->whereNull('delete_time')->count() > 0) {
|
||||
continue;
|
||||
}
|
||||
$officialLink = null;
|
||||
foreach ($linksByPool[$poolId] ?? [] as $link) {
|
||||
if (!empty($link['is_official']) && (int) ($link['remote_status'] ?? 0) !== 2) {
|
||||
$officialLink = $link;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($officialLink === null) {
|
||||
continue;
|
||||
}
|
||||
$rangeUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): string => trim((string) $value),
|
||||
(array) ($officialLink['range_userids'] ?? [])
|
||||
), static fn (string $value): bool => $value !== '')));
|
||||
if ($rangeUserIds === []) {
|
||||
continue;
|
||||
}
|
||||
$mappedMembers = [];
|
||||
foreach ($rangeUserIds as $userId) {
|
||||
$mappedAdminId = (int) ($adminIdByUserId[$userId] ?? 0);
|
||||
if ($mappedAdminId <= 0) {
|
||||
// 受限账号或本地缺少映射时禁止部分回填,避免定时任务误删企微中的不可见成员。
|
||||
$mappedMembers = [];
|
||||
break;
|
||||
}
|
||||
$mappedMembers[] = ['userid' => $userId, 'admin_id' => $mappedAdminId];
|
||||
}
|
||||
if (count($mappedMembers) !== count($rangeUserIds)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($mappedMembers as $mappedMember) {
|
||||
$userId = (string) $mappedMember['userid'];
|
||||
$mappedAdminId = (int) $mappedMember['admin_id'];
|
||||
if ($userId === '' || $mappedAdminId <= 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Db::name('qywx_promotion_pool_member')->insert([
|
||||
'pool_id' => $poolId,
|
||||
'admin_id' => $mappedAdminId,
|
||||
'userid' => $userId,
|
||||
'enabled' => 1,
|
||||
'weight' => 1,
|
||||
'current_weight' => 0,
|
||||
'daily_limit' => 0,
|
||||
'today_count' => 0,
|
||||
'today_date' => null,
|
||||
'total_count' => 0,
|
||||
'active_start' => 0,
|
||||
'active_end' => 0,
|
||||
'last_assigned_time' => 0,
|
||||
'remark' => '',
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// 并发打开 overview 时唯一键会阻止重复回填。
|
||||
}
|
||||
}
|
||||
if ($mappedMembers !== []) {
|
||||
QywxPromotionMemberSchedulerService::initialisePool($poolId, (int) $officialLink['id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function normaliseRemoteLink(array $response, string $fallbackId = ''): array
|
||||
{
|
||||
$link = isset($response['link']) && is_array($response['link']) ? $response['link'] : $response;
|
||||
$linkId = trim((string) ($link['link_id'] ?? $response['link_id'] ?? $fallbackId));
|
||||
$url = trim((string) ($link['url'] ?? $link['link_url'] ?? $response['url'] ?? ''));
|
||||
if ($linkId === '') {
|
||||
throw new RuntimeException('企业微信获客链接详情缺少 link_id');
|
||||
}
|
||||
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
|
||||
throw new RuntimeException('企业微信获客链接详情未返回有效的 https://work.weixin.qq.com/ca/... 地址');
|
||||
}
|
||||
$range = isset($link['range']) && is_array($link['range']) ? $link['range'] : [];
|
||||
|
||||
return [
|
||||
'link_id' => $linkId,
|
||||
'link_name' => trim((string) ($link['link_name'] ?? $link['name'] ?? $linkId)),
|
||||
'url' => $url,
|
||||
'create_time' => max(0, (int) ($link['create_time'] ?? 0)),
|
||||
'range_userids' => self::normaliseScalarList($range['user_list'] ?? []),
|
||||
'range_department_ids' => self::normaliseScalarList($range['department_list'] ?? []),
|
||||
'skip_verify' => !empty($link['skip_verify']),
|
||||
'priority_option' => isset($link['priority_option']) && is_array($link['priority_option']) ? $link['priority_option'] : [],
|
||||
'snapshot' => $link,
|
||||
];
|
||||
return QywxCustomerAcquisitionLinkService::normaliseRemoteResponse($response, $fallbackId);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
@@ -681,6 +1197,19 @@ class WecomPromotionLogic
|
||||
return $time === false ? 0 : $time;
|
||||
}
|
||||
|
||||
private static function assertMemberDispatchSchema(): void
|
||||
{
|
||||
try {
|
||||
Db::name('qywx_promotion_pool_member')->limit(1)->find();
|
||||
Db::name('qywx_promotion_dispatch_event')->limit(1)->find();
|
||||
Db::name('qywx_promotion_range_sync')->limit(1)->find();
|
||||
} catch (\Throwable $e) {
|
||||
throw new RuntimeException(
|
||||
'获客成员调度数据表尚未安装,请先执行 server/sql/1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static function mask(string $value): string
|
||||
{
|
||||
$length = strlen($value);
|
||||
@@ -742,7 +1271,7 @@ class WecomPromotionLogic
|
||||
'callback_ready' => $callbackTokenConfigured && $callbackAesConfigured,
|
||||
// 复用自建应用现有的「API 接收消息」入口;获客助手事件与其他应用事件
|
||||
// 由同一个控制器按 Event/ChangeType 分发,不需要再配置第二个回调地址。
|
||||
'callback_url' => rtrim($domain, '/') . '/api/QywxExternalContactCallback/notify',
|
||||
'callback_url' => rtrim($domain, '/') . '/api/qywx/external-contact/notify',
|
||||
'official_doc' => 'https://developer.work.weixin.qq.com/document/path/97297',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -340,6 +340,304 @@ class ConversionLogic
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the distinct external contacts behind an add_fans_count row.
|
||||
*
|
||||
* The caller must pass the already-authorized admin range and the exact
|
||||
* department ids represented by the clicked tree node. This keeps the
|
||||
* detail endpoint on the same data-scope and department-primary-mapping
|
||||
* rules as overview(), including descendant departments and virtual
|
||||
* buckets.
|
||||
*
|
||||
* @param array<string,mixed> $params
|
||||
* @param array<string,mixed> $target
|
||||
* @param int[]|null $trustedVisibleAdminIdsOverride
|
||||
* @param array<string,mixed>|null $trustedMediaChannelOverride
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function fanDetails(
|
||||
array $params,
|
||||
array $target,
|
||||
int $adminId = 0,
|
||||
?array $adminInfo = null,
|
||||
?array $trustedVisibleAdminIdsOverride = null,
|
||||
?array $trustedMediaChannelOverride = null
|
||||
): array {
|
||||
self::$requestRowsCache = [];
|
||||
|
||||
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
|
||||
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
|
||||
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20)));
|
||||
$visibleAdminIds = $trustedVisibleAdminIdsOverride;
|
||||
if ($trustedVisibleAdminIdsOverride === null && $adminInfo !== null && $adminId > 0) {
|
||||
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
}
|
||||
if ($visibleAdminIds !== null) {
|
||||
$visibleAdminIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', $visibleAdminIds),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
}
|
||||
|
||||
$empty = [
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
];
|
||||
if ($visibleAdminIds === []) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$mediaChannel = $trustedMediaChannelOverride;
|
||||
$requestedMediaChannelCode = trim((string) ($params['media_channel_code'] ?? ''));
|
||||
if ($mediaChannel === null && $requestedMediaChannelCode !== '') {
|
||||
$mediaChannel = MediaChannelService::getChannelByCode($requestedMediaChannelCode);
|
||||
}
|
||||
|
||||
$targetType = (string) ($target['type'] ?? '');
|
||||
$targetAdminId = max(0, (int) ($target['admin_id'] ?? 0));
|
||||
$targetWecomUserId = trim((string) ($target['wecom_userid'] ?? ''));
|
||||
$targetDeptIds = array_values(array_unique(array_map('intval', (array) ($target['dept_ids'] ?? []))));
|
||||
$targetDeptSet = array_fill_keys($targetDeptIds, true);
|
||||
if (($targetType === 'member' && $targetAdminId <= 0)
|
||||
|| ($targetType === 'wecom_user' && $targetWecomUserId === '')
|
||||
|| ($targetType === 'dept' && $targetDeptSet === [])
|
||||
) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$allEntities = [];
|
||||
$entityIds = [];
|
||||
$adminToDeptIds = [];
|
||||
$targetWorkWechatUserIds = [];
|
||||
$unboundOnly = false;
|
||||
if ($targetType === 'dept') {
|
||||
$allEntities = self::loadEntities('dept', $params, $visibleAdminIds);
|
||||
$adminToDeptIds = self::loadAdminDeptMap();
|
||||
$channelDeptIds = self::fanDetailChannelDeptIds($mediaChannel);
|
||||
if ($channelDeptIds !== null) {
|
||||
$allEntities = self::filterEntitiesByChannelDeptScope(
|
||||
$allEntities,
|
||||
'dept',
|
||||
$channelDeptIds,
|
||||
$adminToDeptIds
|
||||
);
|
||||
}
|
||||
$entityIds = array_keys($allEntities);
|
||||
foreach ($targetDeptIds as $targetDeptId) {
|
||||
if (!isset($allEntities[$targetDeptId])) {
|
||||
return $empty;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($targetDeptSet[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) {
|
||||
// The unbound bucket exists only under SCOPE_ALL. Keep the
|
||||
// exclusion in SQL so this bucket does not first load every
|
||||
// fan pair in the installation.
|
||||
if ($visibleAdminIds !== null || count($targetDeptSet) !== 1) {
|
||||
return $empty;
|
||||
}
|
||||
$unboundOnly = true;
|
||||
} else {
|
||||
$candidateAdminQuery = Db::name('admin')
|
||||
->whereNull('delete_time')
|
||||
->where('work_wechat_userid', '<>', '')
|
||||
->field('id,work_wechat_userid');
|
||||
if ($visibleAdminIds !== null) {
|
||||
$candidateAdminQuery->whereIn('id', $visibleAdminIds);
|
||||
}
|
||||
foreach ($candidateAdminQuery->select()->toArray() as $candidateAdmin) {
|
||||
$candidateAdminId = (int) ($candidateAdmin['id'] ?? 0);
|
||||
$mappedIds = self::mapEntityIds(
|
||||
'dept',
|
||||
$candidateAdminId,
|
||||
$entityIds,
|
||||
$adminToDeptIds,
|
||||
$visibleAdminIds
|
||||
);
|
||||
$mappedDeptId = $mappedIds !== []
|
||||
? (int) $mappedIds[0]
|
||||
: self::VIRTUAL_DEPT_UNASSIGNED_ID;
|
||||
if (isset($targetDeptSet[$mappedDeptId])) {
|
||||
$workWechatUserId = trim((string) ($candidateAdmin['work_wechat_userid'] ?? ''));
|
||||
if ($workWechatUserId !== '') {
|
||||
$targetWorkWechatUserIds[$workWechatUserId] = $workWechatUserId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($targetType === 'member') {
|
||||
if ($visibleAdminIds !== null && !in_array($targetAdminId, $visibleAdminIds, true)) {
|
||||
return $empty;
|
||||
}
|
||||
$targetAdmin = Db::name('admin')
|
||||
->where('id', $targetAdminId)
|
||||
->whereNull('delete_time')
|
||||
->where('work_wechat_userid', '<>', '')
|
||||
->field('id,work_wechat_userid')
|
||||
->find();
|
||||
if (!$targetAdmin) {
|
||||
return $empty;
|
||||
}
|
||||
$targetWorkWechatUserIds[] = trim((string) $targetAdmin['work_wechat_userid']);
|
||||
} else {
|
||||
// Unbound WeCom rows are never visible in a restricted scope.
|
||||
if ($visibleAdminIds !== null) {
|
||||
return $empty;
|
||||
}
|
||||
$boundAdminExists = Db::name('admin')
|
||||
->whereNull('delete_time')
|
||||
->where('work_wechat_userid', $targetWecomUserId)
|
||||
->count() > 0;
|
||||
if ($boundAdminExists) {
|
||||
return $empty;
|
||||
}
|
||||
$targetWorkWechatUserIds[] = $targetWecomUserId;
|
||||
}
|
||||
|
||||
$targetWorkWechatUserIds = array_values(array_unique(array_filter(
|
||||
array_map('strval', $targetWorkWechatUserIds),
|
||||
static fn (string $userId): bool => $userId !== ''
|
||||
)));
|
||||
if (!$unboundOnly && $targetWorkWechatUserIds === []) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$pairs = self::loadFanDetailRows(
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
$mediaChannel,
|
||||
null,
|
||||
$unboundOnly ? null : $targetWorkWechatUserIds,
|
||||
$unboundOnly
|
||||
);
|
||||
// Target membership has already been resolved before the event query,
|
||||
// so sorting and pagination operate on the clicked row's small set.
|
||||
$matched = $pairs;
|
||||
|
||||
usort($matched, static function (array $left, array $right): int {
|
||||
$timeCompare = ((int) ($right['add_time'] ?? 0)) <=> ((int) ($left['add_time'] ?? 0));
|
||||
if ($timeCompare !== 0) {
|
||||
return $timeCompare;
|
||||
}
|
||||
|
||||
return strcmp(
|
||||
(string) ($left['external_userid'] ?? ''),
|
||||
(string) ($right['external_userid'] ?? '')
|
||||
) ?: strcmp(
|
||||
(string) ($left['user_id'] ?? ''),
|
||||
(string) ($right['user_id'] ?? '')
|
||||
);
|
||||
});
|
||||
|
||||
$count = count($matched);
|
||||
$deletedCount = count(array_filter(
|
||||
$matched,
|
||||
static fn (array $row): bool => !empty($row['is_deleted'])
|
||||
));
|
||||
$offset = ($pageNo - 1) * $pageSize;
|
||||
$pageRows = array_slice($matched, $offset, $pageSize);
|
||||
$externalUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
|
||||
$pageRows
|
||||
))));
|
||||
$customerNames = $externalUserIds === []
|
||||
? []
|
||||
: Db::name('qywx_external_contact')
|
||||
->whereIn('external_userid', $externalUserIds)
|
||||
->column('name', 'external_userid');
|
||||
$deletedPageRows = array_values(array_filter(
|
||||
$pageRows,
|
||||
static fn (array $row): bool => !empty($row['is_deleted'])
|
||||
));
|
||||
$deleteTimesByPair = [];
|
||||
if ($deletedPageRows !== []) {
|
||||
$deletedUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
|
||||
$deletedPageRows
|
||||
))));
|
||||
$deletedExternalUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
|
||||
$deletedPageRows
|
||||
))));
|
||||
$deletedEvents = Db::name('qywx_external_contact_event')
|
||||
->where('change_type', 'del_external_contact')
|
||||
->where('event_time', '<=', $endTimestamp)
|
||||
->whereIn('user_id', $deletedUserIds)
|
||||
->whereIn('external_userid', $deletedExternalUserIds)
|
||||
->field('user_id,external_userid,MAX(event_time) AS delete_time')
|
||||
->group('user_id,external_userid')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($deletedEvents as $deletedEvent) {
|
||||
$key = trim((string) ($deletedEvent['user_id'] ?? ''))
|
||||
. "\0"
|
||||
. trim((string) ($deletedEvent['external_userid'] ?? ''));
|
||||
$deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0));
|
||||
}
|
||||
}
|
||||
$staffNames = self::resolveQywxUserNames(array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
|
||||
$pageRows
|
||||
)))));
|
||||
|
||||
$lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array {
|
||||
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
|
||||
$wecomUserId = trim((string) ($row['user_id'] ?? ''));
|
||||
$addTime = max(0, (int) ($row['add_time'] ?? 0));
|
||||
$deleted = !empty($row['is_deleted']);
|
||||
$deleteTime = $deleted
|
||||
? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0))
|
||||
: 0;
|
||||
if ($deleteTime < $addTime) {
|
||||
$deleteTime = 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'external_userid' => $externalUserId,
|
||||
'customer_name' => trim((string) ($customerNames[$externalUserId] ?? '')),
|
||||
'wecom_userid' => $wecomUserId,
|
||||
'wecom_staff_name' => trim((string) ($staffNames[$wecomUserId] ?? '')),
|
||||
'add_time' => $addTime > 0 ? date('Y-m-d H:i:s', $addTime) : null,
|
||||
'is_deleted' => $deleted,
|
||||
'delete_time' => $deleted && $deleteTime > 0 ? date('Y-m-d H:i:s', $deleteTime) : null,
|
||||
];
|
||||
}, $pageRows);
|
||||
|
||||
return [
|
||||
'lists' => $lists,
|
||||
'count' => $count,
|
||||
'deleted_count' => $deletedCount,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Department scope imposed by an actively maintained media-channel cost
|
||||
* binding. null means the channel has no binding and therefore does not
|
||||
* restrict business statistics.
|
||||
*
|
||||
* @param array<string,mixed>|null $mediaChannel
|
||||
* @return int[]|null
|
||||
*/
|
||||
public static function fanDetailChannelDeptIds(?array $mediaChannel): ?array
|
||||
{
|
||||
if ($mediaChannel === null) {
|
||||
return null;
|
||||
}
|
||||
$mediaChannelCodes = MediaChannelService::getChannelCodesForStats($mediaChannel);
|
||||
if ($mediaChannelCodes === []) {
|
||||
return null;
|
||||
}
|
||||
$boundDeptIds = self::loadChannelBoundDeptIds($mediaChannelCodes);
|
||||
|
||||
return $boundDeptIds === [] ? null : self::expandDeptIdsWithDescendants($boundDeptIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL)
|
||||
* @param int[] $eligibleDeptIds 当媒体渠道筛选生效时收窄过的部门作用域
|
||||
@@ -655,6 +953,7 @@ class ConversionLogic
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'add_fans_count' => 0,
|
||||
'deleted_fans_count' => 0,
|
||||
'total_open_count' => 0, // TODO: 接入 IM 会话统计后回填
|
||||
'unreplied_count' => 0, // TODO: 接入 IM 会话统计后回填
|
||||
'paid_appointment_count' => 0,
|
||||
@@ -924,11 +1223,13 @@ class ConversionLogic
|
||||
$userId = (string)($row['user_id'] ?? '');
|
||||
$adminId = (int)($adminByUserId[$userId]['id'] ?? 0);
|
||||
$addFansCount = (int)($row['add_fans_count'] ?? 0);
|
||||
$deletedFansCount = (int)($row['deleted_fans_count'] ?? 0);
|
||||
|
||||
if ($dimension === 'dept' && $adminId <= 0) {
|
||||
// -1 桶仅在 SCOPE_ALL(visibleAdminIds === null)时存在;其他范围下未绑定 admin 的加粉不可归属,跳过。
|
||||
if ($visibleAdminIds === null && isset($entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) {
|
||||
$entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['add_fans_count'] += $addFansCount;
|
||||
$entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['deleted_fans_count'] += $deletedFansCount;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -945,6 +1246,7 @@ class ConversionLogic
|
||||
}
|
||||
if (isset($entities[self::VIRTUAL_DEPT_UNASSIGNED_ID])) {
|
||||
$entities[self::VIRTUAL_DEPT_UNASSIGNED_ID]['add_fans_count'] += $addFansCount;
|
||||
$entities[self::VIRTUAL_DEPT_UNASSIGNED_ID]['deleted_fans_count'] += $deletedFansCount;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -955,33 +1257,61 @@ class ConversionLogic
|
||||
|
||||
foreach ($hitEntityIds as $entityId) {
|
||||
$entities[$entityId]['add_fans_count'] += $addFansCount;
|
||||
$entities[$entityId]['deleted_fans_count'] += $deletedFansCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 区间有效加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
|
||||
* 区间新增加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
|
||||
*
|
||||
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数):
|
||||
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
|
||||
* - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间,
|
||||
* 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计);
|
||||
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
|
||||
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
|
||||
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
|
||||
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
|
||||
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
|
||||
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
|
||||
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除),仍计入加粉总数,
|
||||
* 并额外计入已删提示子集;不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
|
||||
* - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享};
|
||||
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
|
||||
*
|
||||
* @param array<string, mixed>|null $mediaChannel
|
||||
* @param int[]|null $adminIds null means all active/unbound WeCom users
|
||||
* @return array<int, array{user_id: string, add_fans_count: int|string}>
|
||||
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
|
||||
*/
|
||||
private static function loadFanRows(
|
||||
int $startTimestamp,
|
||||
int $endTimestamp,
|
||||
?array $mediaChannel,
|
||||
?array $adminIds = null
|
||||
): array {
|
||||
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds);
|
||||
$effectivePairs = array_values(array_filter(
|
||||
$detailRows,
|
||||
static fn (array $row): bool => empty($row['is_deleted'])
|
||||
));
|
||||
|
||||
return self::buildFanCountRows($detailRows, $effectivePairs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the exact distinct (user_id, external_userid) pairs represented by
|
||||
* add_fans_count. Both aggregate statistics and the detail endpoint consume
|
||||
* this method, so channel, add-way, prior-add and deletion rules cannot
|
||||
* drift apart.
|
||||
*
|
||||
* @param array<string, mixed>|null $mediaChannel
|
||||
* @param int[]|null $adminIds
|
||||
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
|
||||
*/
|
||||
private static function loadFanDetailRows(
|
||||
int $startTimestamp,
|
||||
int $endTimestamp,
|
||||
?array $mediaChannel,
|
||||
?array $adminIds = null,
|
||||
?array $exactWorkWechatUserIds = null,
|
||||
bool $unboundOnly = false
|
||||
): array {
|
||||
if ($adminIds !== null) {
|
||||
$adminIds = array_values(array_unique(array_filter(
|
||||
@@ -994,21 +1324,38 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
$baseKey = self::requestRowsCacheKey('fans-effective-v7', [
|
||||
if ($exactWorkWechatUserIds !== null) {
|
||||
$exactWorkWechatUserIds = array_values(array_unique(array_filter(
|
||||
array_map('strval', $exactWorkWechatUserIds),
|
||||
static fn (string $userId): bool => $userId !== ''
|
||||
)));
|
||||
sort($exactWorkWechatUserIds);
|
||||
if ($exactWorkWechatUserIds === [] && !$unboundOnly) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
$baseKey = self::requestRowsCacheKey('fans-detail-v1', [
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
self::mediaChannelCacheKey($mediaChannel),
|
||||
]);
|
||||
$allKey = $baseKey . ':all';
|
||||
$cacheKey = $adminIds === null
|
||||
? $allKey
|
||||
: $baseKey . ':admins:' . implode(',', $adminIds);
|
||||
if ($unboundOnly) {
|
||||
$cacheKey = $baseKey . ':unbound';
|
||||
} elseif ($exactWorkWechatUserIds !== null) {
|
||||
$cacheKey = $baseKey . ':wecom:' . implode(',', $exactWorkWechatUserIds);
|
||||
} else {
|
||||
$cacheKey = $adminIds === null
|
||||
? $allKey
|
||||
: $baseKey . ':admins:' . implode(',', $adminIds);
|
||||
}
|
||||
if (isset(self::$requestRowsCache[$cacheKey])) {
|
||||
return self::$requestRowsCache[$cacheKey];
|
||||
}
|
||||
|
||||
$workWechatUserIds = null;
|
||||
if ($adminIds !== null) {
|
||||
$workWechatUserIds = $exactWorkWechatUserIds;
|
||||
if ($workWechatUserIds === null && $adminIds !== null) {
|
||||
$workWechatUserIds = Db::name('admin')
|
||||
->whereIn('id', $adminIds)
|
||||
->whereNull('delete_time')
|
||||
@@ -1036,18 +1383,9 @@ class ConversionLogic
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->where('e.user_id', '<>', '')
|
||||
->where('e.external_userid', '<>', '')
|
||||
->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` del_e'
|
||||
. ' WHERE del_e.user_id = e.user_id'
|
||||
. ' AND del_e.external_userid = e.external_userid'
|
||||
. ' AND del_e.change_type = ?'
|
||||
. ' AND del_e.event_time >= e.event_time'
|
||||
. ' AND del_e.event_time <= ?)',
|
||||
['del_external_contact', $endTimestamp]
|
||||
)
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->where('e.user_id', '<>', '')
|
||||
->where('e.external_userid', '<>', '')
|
||||
->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
|
||||
. ' WHERE prev_e.user_id = e.user_id'
|
||||
@@ -1056,40 +1394,173 @@ class ConversionLogic
|
||||
. ' AND prev_e.event_time < ?)',
|
||||
['add_external_contact', $startTimestamp]
|
||||
)
|
||||
->field(['e.user_id', 'e.external_userid'])
|
||||
->field('e.user_id,e.external_userid,MIN(e.event_time) AS add_time')
|
||||
->group('e.user_id, e.external_userid');
|
||||
if ($workWechatUserIds !== null) {
|
||||
$query->whereIn('e.user_id', $workWechatUserIds);
|
||||
} elseif ($unboundOnly) {
|
||||
$adminTable = config('database.connections.mysql.prefix') . 'admin';
|
||||
$query->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $adminTable . '` bound_a'
|
||||
. ' WHERE bound_a.work_wechat_userid = e.user_id'
|
||||
. ' AND bound_a.delete_time IS NULL)'
|
||||
);
|
||||
}
|
||||
$effectiveQuery = clone $query;
|
||||
if ($mediaChannel !== null) {
|
||||
MediaChannelService::applyExternalUserChannelFilter($query, 'e.external_userid', $mediaChannel);
|
||||
// 有效加粉保持原口径:只按未删客户的当前标签/渠道关系筛选。
|
||||
MediaChannelService::applyExternalUserChannelFilter($effectiveQuery, 'e.external_userid', $mediaChannel);
|
||||
}
|
||||
$effectivePairs = $effectiveQuery
|
||||
->whereRaw(
|
||||
'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
|
||||
. ' WHERE surviving_e.user_id = e.user_id'
|
||||
. ' AND surviving_e.external_userid = e.external_userid'
|
||||
. ' AND surviving_e.change_type = ?'
|
||||
. ' AND surviving_e.event_time >= ?'
|
||||
. ' AND surviving_e.event_time <= ?'
|
||||
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
|
||||
. ' WHERE surviving_del.user_id = surviving_e.user_id'
|
||||
. ' AND surviving_del.external_userid = surviving_e.external_userid'
|
||||
. ' AND surviving_del.change_type = ?'
|
||||
. ' AND surviving_del.event_time >= surviving_e.event_time'
|
||||
. ' AND surviving_del.event_time <= ?))',
|
||||
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
|
||||
)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$pairs = $query->select()->toArray();
|
||||
$pairs = self::excludeUncountedFanPairs($pairs);
|
||||
|
||||
$countsByUser = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
if ($userId === '') {
|
||||
continue;
|
||||
}
|
||||
$countsByUser[$userId] = ($countsByUser[$userId] ?? 0) + 1;
|
||||
$deletedQuery = clone $query;
|
||||
if ($mediaChannel !== null) {
|
||||
// del_external_contact 会软删 contact 并清掉关系表,已删归属改用保留的 follow_users 快照。
|
||||
MediaChannelService::applyHistoricalExternalUserChannelFilter($deletedQuery, 'e.external_userid', $mediaChannel);
|
||||
}
|
||||
$deletedPairs = $deletedQuery
|
||||
->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
|
||||
. ' WHERE surviving_e.user_id = e.user_id'
|
||||
. ' AND surviving_e.external_userid = e.external_userid'
|
||||
. ' AND surviving_e.change_type = ?'
|
||||
. ' AND surviving_e.event_time >= ?'
|
||||
. ' AND surviving_e.event_time <= ?'
|
||||
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
|
||||
. ' WHERE surviving_del.user_id = surviving_e.user_id'
|
||||
. ' AND surviving_del.external_userid = surviving_e.external_userid'
|
||||
. ' AND surviving_del.change_type = ?'
|
||||
. ' AND surviving_del.event_time >= surviving_e.event_time'
|
||||
. ' AND surviving_del.event_time <= ?))',
|
||||
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
|
||||
)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$rows = [];
|
||||
foreach ($countsByUser as $userId => $count) {
|
||||
$rows[] = [
|
||||
'user_id' => $userId,
|
||||
'add_fans_count' => $count,
|
||||
];
|
||||
}
|
||||
// add_way 筛选对有效与已删粉丝使用同一口径。
|
||||
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
|
||||
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
|
||||
$rows = self::buildFanDetailRows($candidatePairs, $effectivePairs);
|
||||
|
||||
self::$requestRowsCache[$cacheKey] = $rows;
|
||||
|
||||
return self::$requestRowsCache[$cacheKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $candidatePairs
|
||||
* @param array<int,array<string,mixed>> $effectivePairs
|
||||
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
|
||||
*/
|
||||
private static function buildFanDetailRows(
|
||||
array $candidatePairs,
|
||||
array $effectivePairs
|
||||
): array {
|
||||
$effectiveKeys = [];
|
||||
foreach ($effectivePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId !== '' && $externalUserId !== '') {
|
||||
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$detailsByKey = [];
|
||||
foreach ($candidatePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $userId . "\0" . $externalUserId;
|
||||
$addTime = max(0, (int) ($pair['add_time'] ?? 0));
|
||||
if (isset($detailsByKey[$key])) {
|
||||
$existingTime = (int) ($detailsByKey[$key]['add_time'] ?? 0);
|
||||
if ($addTime > 0 && ($existingTime <= 0 || $addTime < $existingTime)) {
|
||||
$detailsByKey[$key]['add_time'] = $addTime;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$detailsByKey[$key] = [
|
||||
'user_id' => $userId,
|
||||
'external_userid' => $externalUserId,
|
||||
'add_time' => $addTime,
|
||||
'is_deleted' => !isset($effectiveKeys[$key]),
|
||||
'delete_time' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($detailsByKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按员工聚合全部候选新增,并标记其中期末已删除的子集。
|
||||
*
|
||||
* 候选对已经过“区间前未添加”、渠道与 add_way 规则,每个去重候选对都计入
|
||||
* add_fans_count;有效对是期末未删除的子集,因此候选对与有效对的差集另计入
|
||||
* deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。
|
||||
* 若区间内删除后又重加且期末仍有效,该对仍在有效子集中,不会误计为已删。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $candidatePairs
|
||||
* @param array<int, array<string, mixed>> $effectivePairs
|
||||
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
|
||||
*/
|
||||
private static function buildFanCountRows(array $candidatePairs, array $effectivePairs): array
|
||||
{
|
||||
$effectiveKeys = [];
|
||||
foreach ($effectivePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
|
||||
}
|
||||
|
||||
$countsByUser = [];
|
||||
$seenCandidateKeys = [];
|
||||
foreach ($candidatePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $userId . "\0" . $externalUserId;
|
||||
if (isset($seenCandidateKeys[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seenCandidateKeys[$key] = true;
|
||||
$countsByUser[$userId] ??= [
|
||||
'user_id' => $userId,
|
||||
'add_fans_count' => 0,
|
||||
'deleted_fans_count' => 0,
|
||||
];
|
||||
++$countsByUser[$userId]['add_fans_count'];
|
||||
if (!isset($effectiveKeys[$key])) {
|
||||
++$countsByUser[$userId]['deleted_fans_count'];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($countsByUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* 剔除不应计入加粉的跟进来源:
|
||||
* - add_way=1 扫一扫(客户通过扫一扫添加);
|
||||
@@ -1738,6 +2209,7 @@ class ConversionLogic
|
||||
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
|
||||
$interviewCount = (int)$entity['interview_count'];
|
||||
$addFansCount = (int)$entity['add_fans_count'];
|
||||
$deletedFansCount = (int)($entity['deleted_fans_count'] ?? 0);
|
||||
$totalOpenCount = (int)$entity['total_open_count'];
|
||||
$completedOrderCount = (int)$entity['completed_order_count'];
|
||||
$businessOrderAmount = round((float)$entity['business_order_amount'], 2);
|
||||
@@ -1746,6 +2218,7 @@ class ConversionLogic
|
||||
$effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount, $isEligibleForAccountCost);
|
||||
|
||||
$entity['paid_appointment_count'] = $paidAppointmentCount;
|
||||
$entity['deleted_fans_count'] = $deletedFansCount;
|
||||
$entity['free_appointment_count'] = $freeAppointmentCount;
|
||||
$entity['appointment_total_count'] = $appointmentTotalCount;
|
||||
$entity['business_order_amount'] = $businessOrderAmount;
|
||||
@@ -1830,6 +2303,7 @@ class ConversionLogic
|
||||
$childNode = $buildNode((int)$childId);
|
||||
$children[] = $childNode;
|
||||
$node['add_fans_count'] += (int)$childNode['add_fans_count'];
|
||||
$node['deleted_fans_count'] += (int)($childNode['deleted_fans_count'] ?? 0);
|
||||
$node['total_open_count'] += (int)$childNode['total_open_count'];
|
||||
$node['unreplied_count'] += (int)$childNode['unreplied_count'];
|
||||
$node['paid_appointment_count'] += (int)$childNode['paid_appointment_count'];
|
||||
@@ -1972,6 +2446,7 @@ class ConversionLogic
|
||||
$appointmentTotalCount = (int)($node['appointment_total_count'] ?? 0);
|
||||
$interviewCount = (int)$node['interview_count'];
|
||||
$addFansCount = (int)$node['add_fans_count'];
|
||||
$deletedFansCount = (int)($node['deleted_fans_count'] ?? 0);
|
||||
$totalOpenCount = (int)$node['total_open_count'];
|
||||
$completedOrderCount = (int)$node['completed_order_count'];
|
||||
$businessOrderAmount = round((float)$node['business_order_amount'], 2);
|
||||
@@ -1984,6 +2459,7 @@ class ConversionLogic
|
||||
}
|
||||
|
||||
$node['paid_appointment_count'] = $paidAppointmentCount;
|
||||
$node['deleted_fans_count'] = $deletedFansCount;
|
||||
$node['free_appointment_count'] = $freeAppointmentCount;
|
||||
$node['appointment_total_count'] = $appointmentTotalCount;
|
||||
$node['business_order_amount'] = $businessOrderAmount;
|
||||
@@ -2103,7 +2579,7 @@ class ConversionLogic
|
||||
}
|
||||
foreach ($doctorEntities as $adminId => $entity) {
|
||||
if (isset($combined[$adminId])) {
|
||||
foreach (['add_fans_count', 'total_open_count', 'unreplied_count', 'paid_appointment_count', 'free_appointment_count', 'appointment_total_count', 'interview_count', 'completed_order_count'] as $intKey) {
|
||||
foreach (['add_fans_count', 'deleted_fans_count', 'total_open_count', 'unreplied_count', 'paid_appointment_count', 'free_appointment_count', 'appointment_total_count', 'interview_count', 'completed_order_count'] as $intKey) {
|
||||
$combined[$adminId][$intKey] = (int)($combined[$adminId][$intKey] ?? 0) + (int)($entity[$intKey] ?? 0);
|
||||
}
|
||||
foreach (['business_order_amount', 'completed_order_amount'] as $moneyKey) {
|
||||
@@ -2277,7 +2753,8 @@ class ConversionLogic
|
||||
foreach ($rows as $row) {
|
||||
$userId = (string)($row['user_id'] ?? '');
|
||||
$addFansCount = (int)($row['add_fans_count'] ?? 0);
|
||||
if ($userId === '' || $addFansCount <= 0) {
|
||||
$deletedFansCount = (int)($row['deleted_fans_count'] ?? 0);
|
||||
if ($userId === '' || ($addFansCount <= 0 && $deletedFansCount <= 0)) {
|
||||
continue;
|
||||
}
|
||||
$resolvedName = $nameByUserId[$userId] ?? '';
|
||||
@@ -2289,6 +2766,7 @@ class ConversionLogic
|
||||
'未绑定 admin'
|
||||
);
|
||||
$memberRow['add_fans_count'] = $addFansCount;
|
||||
$memberRow['deleted_fans_count'] = $deletedFansCount;
|
||||
$result[] = $memberRow;
|
||||
}
|
||||
|
||||
@@ -2327,7 +2805,8 @@ class ConversionLogic
|
||||
$admin = $adminByUserId[$userId] ?? null;
|
||||
$adminId = (int)($admin['id'] ?? 0);
|
||||
$addFansCount = (int)($row['add_fans_count'] ?? 0);
|
||||
if ($adminId <= 0 || $addFansCount <= 0) {
|
||||
$deletedFansCount = (int)($row['deleted_fans_count'] ?? 0);
|
||||
if ($adminId <= 0 || ($addFansCount <= 0 && $deletedFansCount <= 0)) {
|
||||
continue;
|
||||
}
|
||||
if (isset($assignedAdminIds[$adminId])) {
|
||||
@@ -2345,6 +2824,7 @@ class ConversionLogic
|
||||
);
|
||||
$memberRow['admin_id'] = $adminId;
|
||||
$memberRow['add_fans_count'] = $addFansCount;
|
||||
$memberRow['deleted_fans_count'] = $deletedFansCount;
|
||||
$result[] = $memberRow;
|
||||
}
|
||||
|
||||
@@ -2401,6 +2881,7 @@ class ConversionLogic
|
||||
'role' => $role,
|
||||
'is_leader' => false,
|
||||
'add_fans_count' => 0,
|
||||
'deleted_fans_count' => 0,
|
||||
'total_open_count' => 0,
|
||||
'unreplied_count' => 0,
|
||||
'paid_appointment_count' => 0,
|
||||
@@ -2448,6 +2929,7 @@ class ConversionLogic
|
||||
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
|
||||
$interviewCount = (int)($entity['interview_count'] ?? 0);
|
||||
$addFansCount = (int)($entity['add_fans_count'] ?? 0);
|
||||
$deletedFansCount = (int)($entity['deleted_fans_count'] ?? 0);
|
||||
$totalOpenCount = (int)($entity['total_open_count'] ?? 0);
|
||||
$completedOrderCount = (int)($entity['completed_order_count'] ?? 0);
|
||||
$businessOrderAmount = round((float)($entity['business_order_amount'] ?? 0), 2);
|
||||
@@ -2463,6 +2945,7 @@ class ConversionLogic
|
||||
'role' => $role,
|
||||
'is_leader' => $isLeader,
|
||||
'add_fans_count' => $addFansCount,
|
||||
'deleted_fans_count' => $deletedFansCount,
|
||||
'total_open_count' => $totalOpenCount,
|
||||
'unreplied_count' => (int)($entity['unreplied_count'] ?? 0),
|
||||
'paid_appointment_count' => $paidAppointmentCount,
|
||||
@@ -2625,6 +3108,7 @@ class ConversionLogic
|
||||
{
|
||||
$summary = [
|
||||
'add_fans_count' => 0,
|
||||
'deleted_fans_count' => 0,
|
||||
'total_open_count' => 0,
|
||||
'unreplied_count' => 0,
|
||||
'paid_appointment_count' => 0,
|
||||
@@ -2638,6 +3122,7 @@ class ConversionLogic
|
||||
];
|
||||
foreach ($rows as $row) {
|
||||
$summary['add_fans_count'] += (int)$row['add_fans_count'];
|
||||
$summary['deleted_fans_count'] += (int)($row['deleted_fans_count'] ?? 0);
|
||||
$summary['total_open_count'] += (int)$row['total_open_count'];
|
||||
$summary['unreplied_count'] += (int)$row['unreplied_count'];
|
||||
$summary['paid_appointment_count'] += (int)$row['paid_appointment_count'];
|
||||
@@ -2775,6 +3260,7 @@ class ConversionLogic
|
||||
private static function hasBusinessMetrics(array $row): bool
|
||||
{
|
||||
return (int)($row['add_fans_count'] ?? 0) > 0
|
||||
|| (int)($row['deleted_fans_count'] ?? 0) > 0
|
||||
|| (int)($row['paid_appointment_count'] ?? 0) > 0
|
||||
|| (int)($row['free_appointment_count'] ?? 0) > 0
|
||||
|| (int)($row['appointment_total_count'] ?? 0) > 0
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace app\api\controller;
|
||||
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
|
||||
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
|
||||
use app\common\service\qywx\QywxPromotionRangeSyncService;
|
||||
use EasyWeChat\Kernel\Exceptions\BadRequestException;
|
||||
use EasyWeChat\Work\Application;
|
||||
use EasyWeChat\Work\Message;
|
||||
@@ -178,6 +180,29 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
return;
|
||||
}
|
||||
|
||||
if ($changeType === 'add_external_contact' && $state !== '' && $userId !== '') {
|
||||
// customer_channel=zyt_pool:{id} 会原样进入 State;以实际 UserID 幂等记账并更新可用成员范围。
|
||||
try {
|
||||
$dispatch = QywxPromotionMemberSchedulerService::recordFromState(
|
||||
$state,
|
||||
$userId,
|
||||
$extId,
|
||||
$eventTime,
|
||||
'external_contact'
|
||||
);
|
||||
if (($dispatch['status'] ?? '') === 'counted' && (int) ($dispatch['pool_id'] ?? 0) > 0) {
|
||||
try {
|
||||
// 回调后立即应用上限变化;分钟任务仍负责网络异常、并发版本变化等情况的兜底重试。
|
||||
(new QywxPromotionRangeSyncService())->syncPool((int) $dispatch['pool_id']);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('qywx promotion immediate range sync failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 客户资料同步与成员调度互不阻塞;调度异常保留日志,获客会话回调仍可补偿。
|
||||
Log::error('qywx promotion callback dispatch failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
// 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
|
||||
CustomerLogic::upsertSingleExternalContactFromApi($extId);
|
||||
}
|
||||
|
||||
@@ -20,13 +20,13 @@ class QywxPromotionPublicController extends BaseApiController
|
||||
return response('/* promotion pool not found */', 404, ['Content-Type' => 'application/javascript; charset=utf-8']);
|
||||
}
|
||||
// 由安装脚本自身的 src 解析 API 域名,避免把请求 Host 写入可公开缓存的 JavaScript。
|
||||
$goUrl = '/api/qywx-promotion/go/' . $key;
|
||||
$targetUrl = (string) ($pool['target_url'] ?? '');
|
||||
$config = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
|
||||
$javascript = QywxPromotionWidgetService::renderScript(
|
||||
$key,
|
||||
$goUrl,
|
||||
$targetUrl,
|
||||
$config,
|
||||
(int) ($pool['status'] ?? 0) === 1
|
||||
(int) ($pool['status'] ?? 0) === 1 && $targetUrl !== ''
|
||||
);
|
||||
|
||||
return response($javascript, 200, [
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\qywx\QywxPromotionRangeSyncService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
/** 重算并重试尚未同步到企业微信的获客链接多人范围。 */
|
||||
class QywxSyncPromotionRanges extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:sync-promotion-ranges')
|
||||
->setDescription('同步企业微信官方获客链接的多人路由成员范围');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$result = (new QywxPromotionRangeSyncService())->syncPending(100);
|
||||
$output->writeln(sprintf(
|
||||
'QYWX_PROMOTION_RANGE_SYNC selected=%d synced=%d failed=%d',
|
||||
$result['selected'],
|
||||
$result['synced'],
|
||||
$result['failed']
|
||||
));
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -472,6 +472,45 @@ SQL;
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter an external_userid fact by the channel snapshot retained in
|
||||
* qywx_external_contact.follow_users, including soft-deleted contacts.
|
||||
*
|
||||
* del_external_contact removes normalized tag relations, so historical
|
||||
* deleted-fan attribution cannot use applyExternalUserChannelFilter(). The
|
||||
* contact row itself retains follow_users and is the best available channel
|
||||
* snapshot for this specific historical statistic.
|
||||
*
|
||||
* @param array<string, mixed>|null $channel
|
||||
*/
|
||||
public static function applyHistoricalExternalUserChannelFilter(Query $query, string $field, ?array $channel): void
|
||||
{
|
||||
if ($channel === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$patterns = self::buildLikePatterns($channel);
|
||||
if ($patterns === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
$bindings = [];
|
||||
foreach ($patterns as $pattern) {
|
||||
$segments[] = 'historical_channel_contact.follow_users LIKE ?';
|
||||
$bindings[] = $pattern;
|
||||
}
|
||||
$contactTable = self::tableWithPrefix('qywx_external_contact');
|
||||
$query->whereRaw(
|
||||
"EXISTS (SELECT 1 FROM {$contactTable} historical_channel_contact"
|
||||
. " WHERE historical_channel_contact.external_userid = {$field}"
|
||||
. ' AND (' . implode(' OR ', $segments) . '))',
|
||||
$bindings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{scanned_contacts: int, discovered_tags: int, inserted_or_updated: int}
|
||||
*/
|
||||
|
||||
@@ -183,12 +183,20 @@ class QywxCustomerAcquisitionApiService
|
||||
|
||||
return $this->request($method, $path, $body, true);
|
||||
}
|
||||
$errorMessage = trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误';
|
||||
if ($errcode === 60111) {
|
||||
throw new RuntimeException(
|
||||
'所选医助的企业微信 userid 不存在,或不在获客助手可调用应用的可见范围;'
|
||||
. '请检查后台账号绑定和企业微信应用可见范围。企业微信返回:' . $errorMessage,
|
||||
$errcode
|
||||
);
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
'企业微信获客助手接口失败[%d]:%s',
|
||||
$errcode,
|
||||
trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误'
|
||||
));
|
||||
$errorMessage
|
||||
), $errcode);
|
||||
}
|
||||
|
||||
private function accessToken(): string
|
||||
|
||||
@@ -93,6 +93,13 @@ class QywxCustomerAcquisitionCustomerService
|
||||
'event_time' => $eventTime,
|
||||
'snapshot' => $message,
|
||||
], false);
|
||||
self::recordPromotionAssignment(
|
||||
(string) ($message['State'] ?? $message['state'] ?? ''),
|
||||
$remoteLinkId,
|
||||
$userId,
|
||||
$externalUserId,
|
||||
$eventTime
|
||||
);
|
||||
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
|
||||
|
||||
return ['duplicate' => false, 'status' => 'success'];
|
||||
@@ -126,6 +133,13 @@ class QywxCustomerAcquisitionCustomerService
|
||||
'event_time' => $eventTime,
|
||||
'snapshot' => $chat,
|
||||
], true);
|
||||
self::recordPromotionAssignment(
|
||||
(string) ($chatInfo['state'] ?? $message['State'] ?? ''),
|
||||
$remoteLinkId,
|
||||
$userId,
|
||||
$externalUserId,
|
||||
$eventTime
|
||||
);
|
||||
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
|
||||
|
||||
return ['duplicate' => false, 'status' => 'success'];
|
||||
@@ -419,6 +433,40 @@ class QywxCustomerAcquisitionCustomerService
|
||||
return [$adminId, $deptId];
|
||||
}
|
||||
|
||||
private static function recordPromotionAssignment(
|
||||
string $state,
|
||||
string $remoteLinkId,
|
||||
string $userId,
|
||||
string $externalUserId,
|
||||
int $eventTime
|
||||
): void {
|
||||
$result = QywxPromotionMemberSchedulerService::recordFromState(
|
||||
$state,
|
||||
$userId,
|
||||
$externalUserId,
|
||||
$eventTime,
|
||||
'customer_acquisition'
|
||||
);
|
||||
if ((int) ($result['pool_id'] ?? 0) <= 0) {
|
||||
$result = QywxPromotionMemberSchedulerService::recordFromRemoteLink(
|
||||
$remoteLinkId,
|
||||
$userId,
|
||||
$externalUserId,
|
||||
$eventTime,
|
||||
'customer_acquisition'
|
||||
);
|
||||
}
|
||||
if (($result['status'] ?? '') !== 'counted' || (int) ($result['pool_id'] ?? 0) <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 优先在本次回调完成后切换,后台分钟任务继续承担失败重试。
|
||||
(new QywxPromotionRangeSyncService())->syncPool((int) $result['pool_id']);
|
||||
} catch (\Throwable) {
|
||||
// 同步服务已经保存失败原因和下次重试时间,不能让远端网络错误回滚已记账的客户。
|
||||
}
|
||||
}
|
||||
|
||||
private static function encodeJson(mixed $value): string
|
||||
{
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** 企业微信获客助手链接校验。 */
|
||||
class QywxCustomerAcquisitionLinkService
|
||||
{
|
||||
@@ -39,4 +41,98 @@ class QywxCustomerAcquisitionLinkService
|
||||
{
|
||||
return 'https://work.weixin.qq.com/ca/xxxxxxxx';
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化企业微信 create_link/get 返回的链接详情。
|
||||
*
|
||||
* get 接口把 range、priority_option 放在响应根级,link 只包含链接本身;
|
||||
* 旧响应或测试桩可能把这些字段放在 link 内,因此保留兼容回退。
|
||||
*
|
||||
* @return array{
|
||||
* link_id:string,
|
||||
* link_name:string,
|
||||
* url:string,
|
||||
* create_time:int,
|
||||
* range_userids:list<string>,
|
||||
* range_department_ids:list<string>,
|
||||
* skip_verify:bool,
|
||||
* priority_option:array<string,mixed>,
|
||||
* snapshot:array<string,mixed>
|
||||
* }
|
||||
*/
|
||||
public static function normaliseRemoteResponse(array $response, string $fallbackId = ''): array
|
||||
{
|
||||
$link = isset($response['link']) && is_array($response['link']) ? $response['link'] : $response;
|
||||
$range = isset($response['range']) && is_array($response['range'])
|
||||
? $response['range']
|
||||
: (isset($link['range']) && is_array($link['range']) ? $link['range'] : []);
|
||||
$priorityOption = isset($response['priority_option']) && is_array($response['priority_option'])
|
||||
? $response['priority_option']
|
||||
: (isset($link['priority_option']) && is_array($link['priority_option']) ? $link['priority_option'] : []);
|
||||
$linkId = trim((string) ($link['link_id'] ?? $response['link_id'] ?? $fallbackId));
|
||||
$url = trim((string) ($link['url'] ?? $link['link_url'] ?? $response['url'] ?? ''));
|
||||
if ($linkId === '') {
|
||||
throw new RuntimeException('企业微信获客链接详情缺少 link_id');
|
||||
}
|
||||
if (!self::isAllowed($url)) {
|
||||
throw new RuntimeException('企业微信获客链接详情未返回有效的 https://work.weixin.qq.com/ca/... 地址');
|
||||
}
|
||||
|
||||
return [
|
||||
'link_id' => $linkId,
|
||||
'link_name' => trim((string) ($link['link_name'] ?? $link['name'] ?? $linkId)),
|
||||
'url' => $url,
|
||||
'create_time' => max(0, (int) ($link['create_time'] ?? 0)),
|
||||
'range_userids' => self::normaliseScalarList($range['user_list'] ?? []),
|
||||
'range_department_ids' => self::normaliseScalarList($range['department_list'] ?? []),
|
||||
'skip_verify' => !empty($link['skip_verify']),
|
||||
'priority_option' => $priorityOption,
|
||||
'snapshot' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
/** 追加或替换企业微信获客助手的自定义渠道标识。 */
|
||||
public static function withCustomerChannel(string $url, string $channel): string
|
||||
{
|
||||
$url = trim($url);
|
||||
$channel = trim($channel);
|
||||
if (!self::isAllowed($url) || $channel === '' || strlen($channel) > 64) {
|
||||
return '';
|
||||
}
|
||||
// 冒号在查询参数中是合法字符,保留“命名空间:编号”的可读结构;其余字符仍编码。
|
||||
$parameter = 'customer_channel=' . str_replace('%3A', ':', rawurlencode($channel));
|
||||
if (preg_match('/([?&])customer_channel=[^&#]*/i', $url) === 1) {
|
||||
return (string) preg_replace_callback(
|
||||
'/([?&])customer_channel=[^&#]*/i',
|
||||
static fn (array $matches): string => $matches[1] . $parameter,
|
||||
$url,
|
||||
1
|
||||
);
|
||||
}
|
||||
$fragment = '';
|
||||
$fragmentPosition = strpos($url, '#');
|
||||
if ($fragmentPosition !== false) {
|
||||
$fragment = substr($url, $fragmentPosition);
|
||||
$url = substr($url, 0, $fragmentPosition);
|
||||
}
|
||||
|
||||
return $url . (str_contains($url, '?') ? '&' : '?') . $parameter . $fragment;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function normaliseScalarList(mixed $values): array
|
||||
{
|
||||
$result = [];
|
||||
$seen = [];
|
||||
foreach ((array) $values as $value) {
|
||||
$normalised = trim((string) $value);
|
||||
$key = 'value:' . $normalised;
|
||||
if ($normalised !== '' && !isset($seen[$key])) {
|
||||
$seen[$key] = true;
|
||||
$result[] = $normalised;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
/** 计算应交给企业微信官方多人路由的成员范围。 */
|
||||
class QywxPromotionMemberRange
|
||||
{
|
||||
/**
|
||||
* @param list<array<string,mixed>> $members
|
||||
* @return array{userids:list<string>,members:list<array<string,mixed>>,eligible_count:int}
|
||||
*/
|
||||
public static function evaluate(array $members, string $today, int $now): array
|
||||
{
|
||||
$userIds = [];
|
||||
foreach ($members as &$member) {
|
||||
if ((string) ($member['today_date'] ?? '') !== $today) {
|
||||
$member['today_date'] = $today;
|
||||
$member['today_count'] = 0;
|
||||
}
|
||||
$member['current_weight'] = 0;
|
||||
if (!self::eligible($member, $now)) {
|
||||
continue;
|
||||
}
|
||||
$userId = trim((string) ($member['userid'] ?? ''));
|
||||
if ($userId !== '') {
|
||||
$userIds[$userId] = true;
|
||||
}
|
||||
}
|
||||
unset($member);
|
||||
|
||||
return [
|
||||
'userids' => array_keys($userIds),
|
||||
'members' => array_values($members),
|
||||
'eligible_count' => count($userIds),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $member */
|
||||
public static function eligible(array $member, int $now): bool
|
||||
{
|
||||
if ((int) ($member['enabled'] ?? 0) !== 1) {
|
||||
return false;
|
||||
}
|
||||
$start = max(0, (int) ($member['active_start'] ?? 0));
|
||||
$end = max(0, (int) ($member['active_end'] ?? 0));
|
||||
if (($start > 0 && $start > $now) || ($end > 0 && $end < $now)) {
|
||||
return false;
|
||||
}
|
||||
$limit = max(0, (int) ($member['daily_limit'] ?? 0));
|
||||
|
||||
return $limit === 0 || (int) ($member['today_count'] ?? 0) < $limit;
|
||||
}
|
||||
|
||||
/** @param list<string> $left @param list<string> $right */
|
||||
public static function same(array $left, array $right): bool
|
||||
{
|
||||
$normalise = static function (array $values): array {
|
||||
$result = [];
|
||||
foreach ($values as $value) {
|
||||
$userId = trim((string) $value);
|
||||
if ($userId !== '') {
|
||||
$result[$userId] = true;
|
||||
}
|
||||
}
|
||||
$result = array_keys($result);
|
||||
sort($result, SORT_STRING);
|
||||
|
||||
return $result;
|
||||
};
|
||||
|
||||
return $normalise($left) === $normalise($right);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/** 根据实际获客回调记账,并维护同一个官方链接的可用成员范围。 */
|
||||
class QywxPromotionMemberSchedulerService
|
||||
{
|
||||
public static function poolIdFromState(string $state): int
|
||||
{
|
||||
$state = trim($state);
|
||||
|
||||
return preg_match('/^zyt_pool:(\d+)$/', $state, $matches) === 1
|
||||
? max(0, (int) $matches[1])
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** @return array{status:string,pool_id:int,member_id:int,next_member_id:int} */
|
||||
public static function recordFromState(
|
||||
string $state,
|
||||
string $userId,
|
||||
string $externalUserId,
|
||||
int $eventTime = 0,
|
||||
string $source = 'external_contact'
|
||||
): array {
|
||||
$poolId = self::poolIdFromState($state);
|
||||
if ($poolId <= 0) {
|
||||
return ['status' => 'ignored_state', 'pool_id' => 0, 'member_id' => 0, 'next_member_id' => 0];
|
||||
}
|
||||
|
||||
return self::record($poolId, $userId, $externalUserId, $eventTime, $source);
|
||||
}
|
||||
|
||||
/** @return array{status:string,pool_id:int,member_id:int,next_member_id:int} */
|
||||
public static function recordFromRemoteLink(
|
||||
string $remoteLinkId,
|
||||
string $userId,
|
||||
string $externalUserId,
|
||||
int $eventTime = 0,
|
||||
string $source = 'customer_acquisition'
|
||||
): array {
|
||||
$poolId = (int) (Db::name('qywx_promotion_link')
|
||||
->where('remote_link_id', trim($remoteLinkId))
|
||||
->whereNull('delete_time')
|
||||
->value('pool_id') ?? 0);
|
||||
if ($poolId <= 0) {
|
||||
return ['status' => 'ignored_link', 'pool_id' => 0, 'member_id' => 0, 'next_member_id' => 0];
|
||||
}
|
||||
|
||||
return self::record($poolId, $userId, $externalUserId, $eventTime, $source);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端变更成员开关、上限或有效期后,重新计算企业微信官方多人路由范围。
|
||||
*
|
||||
* @return array{pool_id:int,next_member_id:int,queued:bool,blocked:bool}
|
||||
*/
|
||||
public static function reconcilePool(int $poolId): array
|
||||
{
|
||||
return Db::transaction(function () use ($poolId): array {
|
||||
$now = time();
|
||||
$today = date('Y-m-d', $now);
|
||||
$linkId = self::promotionLinkId($poolId);
|
||||
$sync = self::lockedSyncRow($poolId);
|
||||
$members = self::lockedMembers($poolId);
|
||||
$syncStatus = (int) ($sync['status'] ?? 0);
|
||||
$syncError = (string) ($sync['last_error'] ?? '');
|
||||
if ($syncStatus === 5 || ($syncStatus === 4 && str_starts_with($syncError, '企业微信官方获客链接删除失败'))) {
|
||||
return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true];
|
||||
}
|
||||
if ($linkId <= 0 || $members === []) {
|
||||
return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true];
|
||||
}
|
||||
|
||||
return self::queueEligibleRangeLocked($poolId, $linkId, $sync, $members, $today, $now);
|
||||
});
|
||||
}
|
||||
|
||||
public static function initialisePool(int $poolId, int $promotionLinkId): int
|
||||
{
|
||||
if ($poolId <= 0 || $promotionLinkId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$now = time();
|
||||
$existing = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find();
|
||||
$data = [
|
||||
'promotion_link_id' => $promotionLinkId,
|
||||
'desired_member_id' => 0,
|
||||
'applied_member_id' => 0,
|
||||
'status' => 0,
|
||||
'attempts' => 0,
|
||||
'next_retry' => 0,
|
||||
'lock_token' => '',
|
||||
'lock_until' => 0,
|
||||
'last_error' => '',
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($existing) {
|
||||
$version = max(1, (int) ($existing['desired_version'] ?? 0) + 1);
|
||||
$data['desired_version'] = $version;
|
||||
$data['applied_version'] = $version;
|
||||
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update($data);
|
||||
} else {
|
||||
$data += ['pool_id' => $poolId, 'desired_version' => 1, 'applied_version' => 1, 'create_time' => $now];
|
||||
Db::name('qywx_promotion_range_sync')->insert($data);
|
||||
}
|
||||
|
||||
return $promotionLinkId;
|
||||
}
|
||||
|
||||
/** 强制排队一次成员范围同步,也用于方案名称或免验证配置变化。 */
|
||||
public static function requestPoolSync(int $poolId, int $promotionLinkId): void
|
||||
{
|
||||
if ($poolId <= 0 || $promotionLinkId <= 0) {
|
||||
return;
|
||||
}
|
||||
Db::transaction(function () use ($poolId, $promotionLinkId): void {
|
||||
$now = time();
|
||||
$existing = self::lockedSyncRow($poolId);
|
||||
if ($existing === null) {
|
||||
Db::name('qywx_promotion_range_sync')->insert([
|
||||
'pool_id' => $poolId,
|
||||
'promotion_link_id' => $promotionLinkId,
|
||||
'desired_member_id' => 0,
|
||||
'desired_version' => 1,
|
||||
'applied_member_id' => 0,
|
||||
'applied_version' => 0,
|
||||
'status' => 1,
|
||||
'attempts' => 0,
|
||||
'next_retry' => $now,
|
||||
'lock_token' => '',
|
||||
'lock_until' => 0,
|
||||
'last_error' => '',
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
$existingStatus = (int) ($existing['status'] ?? 0);
|
||||
$leaseActive = in_array($existingStatus, [2, 5], true)
|
||||
&& (int) ($existing['lock_until'] ?? 0) > $now;
|
||||
$data = [
|
||||
'promotion_link_id' => $promotionLinkId,
|
||||
'desired_member_id' => 0,
|
||||
'desired_version' => max(1, (int) ($existing['desired_version'] ?? 0) + 1),
|
||||
'applied_member_id' => 0,
|
||||
'status' => $leaseActive ? $existingStatus : 1,
|
||||
'next_retry' => $leaseActive ? (int) ($existing['next_retry'] ?? 0) : $now,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if (!$leaseActive) {
|
||||
$data += ['attempts' => 0, 'last_error' => ''];
|
||||
}
|
||||
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update($data);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{status:string,pool_id:int,member_id:int,next_member_id:int} */
|
||||
private static function record(
|
||||
int $poolId,
|
||||
string $userId,
|
||||
string $externalUserId,
|
||||
int $eventTime,
|
||||
string $source
|
||||
): array {
|
||||
$userId = trim($userId);
|
||||
$externalUserId = trim($externalUserId);
|
||||
if ($poolId <= 0 || $userId === '' || $externalUserId === '') {
|
||||
return ['status' => 'ignored_identity', 'pool_id' => $poolId, 'member_id' => 0, 'next_member_id' => 0];
|
||||
}
|
||||
|
||||
return Db::transaction(function () use ($poolId, $userId, $externalUserId, $eventTime, $source): array {
|
||||
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find();
|
||||
if (!$pool) {
|
||||
return ['status' => 'ignored_pool', 'pool_id' => $poolId, 'member_id' => 0, 'next_member_id' => 0];
|
||||
}
|
||||
$members = self::lockedMembers($poolId);
|
||||
$actualMember = null;
|
||||
foreach ($members as $member) {
|
||||
if ((string) ($member['userid'] ?? '') === $userId) {
|
||||
$actualMember = $member;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($actualMember === null) {
|
||||
return ['status' => 'ignored_member', 'pool_id' => $poolId, 'member_id' => 0, 'next_member_id' => 0];
|
||||
}
|
||||
$actualMemberId = (int) $actualMember['id'];
|
||||
// 同一方案中的同一客户只计一次;后续更换跟进成员不能重复占用医助额度。
|
||||
$eventKey = hash('sha256', $poolId . '|' . $externalUserId);
|
||||
try {
|
||||
Db::name('qywx_promotion_dispatch_event')->insert([
|
||||
'event_key' => $eventKey,
|
||||
'pool_id' => $poolId,
|
||||
'member_id' => $actualMemberId,
|
||||
'userid' => $userId,
|
||||
'external_userid' => $externalUserId,
|
||||
'source' => mb_substr($source, 0, 32),
|
||||
'event_time' => max(0, $eventTime),
|
||||
'create_time' => time(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
if (!Db::name('qywx_promotion_dispatch_event')->where('event_key', $eventKey)->find()) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['status' => 'duplicate', 'pool_id' => $poolId, 'member_id' => $actualMemberId, 'next_member_id' => 0];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$today = date('Y-m-d', $now);
|
||||
foreach ($members as &$member) {
|
||||
if ((string) ($member['today_date'] ?? '') !== $today) {
|
||||
$member['today_date'] = $today;
|
||||
$member['today_count'] = 0;
|
||||
}
|
||||
if ((int) $member['id'] === $actualMemberId) {
|
||||
$member['today_count'] = (int) ($member['today_count'] ?? 0) + 1;
|
||||
$member['total_count'] = (int) ($member['total_count'] ?? 0) + 1;
|
||||
$member['last_assigned_time'] = max($now, max(0, $eventTime));
|
||||
}
|
||||
}
|
||||
unset($member);
|
||||
self::persistMemberCursors($members, $now);
|
||||
|
||||
$linkId = self::promotionLinkId($poolId);
|
||||
$sync = self::lockedSyncRow($poolId);
|
||||
if ($linkId <= 0) {
|
||||
return ['status' => 'counted_stale', 'pool_id' => $poolId, 'member_id' => $actualMemberId, 'next_member_id' => 0];
|
||||
}
|
||||
|
||||
$planned = self::queueEligibleRangeLocked($poolId, $linkId, $sync, $members, $today, $now);
|
||||
|
||||
return [
|
||||
'status' => $planned['blocked'] ? 'counted_blocked' : 'counted',
|
||||
'pool_id' => $poolId,
|
||||
'member_id' => $actualMemberId,
|
||||
'next_member_id' => $planned['next_member_id'],
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{pool_id:int,next_member_id:int,queued:bool,blocked:bool} */
|
||||
private static function queueEligibleRangeLocked(
|
||||
int $poolId,
|
||||
int $linkId,
|
||||
?array $sync,
|
||||
array $members,
|
||||
string $today,
|
||||
int $now
|
||||
): array {
|
||||
$range = QywxPromotionMemberRange::evaluate($members, $today, $now);
|
||||
self::persistMemberCursors($range['members'], $now);
|
||||
if ($range['userids'] === []) {
|
||||
self::upsertSync($poolId, $linkId, false, $sync, $now, '所有成员均已禁用、未生效或达到今日上限');
|
||||
|
||||
return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true];
|
||||
}
|
||||
$appliedUserIds = self::linkRangeUserIds($linkId);
|
||||
$changed = !QywxPromotionMemberRange::same($range['userids'], $appliedUserIds);
|
||||
$alreadyPending = in_array((int) ($sync['status'] ?? 0), [1, 3], true);
|
||||
$needsSync = $changed || $alreadyPending;
|
||||
self::upsertSync($poolId, $linkId, $needsSync, $sync, $now);
|
||||
|
||||
return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => $needsSync, 'blocked' => false];
|
||||
}
|
||||
|
||||
private static function upsertSync(
|
||||
int $poolId,
|
||||
int $linkId,
|
||||
bool $pending,
|
||||
?array $existing,
|
||||
int $now,
|
||||
string $error = ''
|
||||
): void {
|
||||
$desiredChanged = $pending || ($error !== '' && (int) ($existing['status'] ?? 0) !== 4);
|
||||
$version = max(1, (int) ($existing['desired_version'] ?? 0) + ($desiredChanged ? 1 : 0));
|
||||
$existingStatus = (int) ($existing['status'] ?? 0);
|
||||
$leaseActive = in_array($existingStatus, [2, 5], true)
|
||||
&& (int) ($existing['lock_until'] ?? 0) > $now;
|
||||
$data = [
|
||||
'promotion_link_id' => $linkId,
|
||||
'desired_member_id' => 0,
|
||||
'desired_version' => $version,
|
||||
// 活跃租约不能被回调或分钟重算抢占;版本变化会让当前工作完成后继续同步。
|
||||
'status' => $leaseActive ? $existingStatus : ($error !== '' ? 4 : ($pending ? 1 : 0)),
|
||||
'next_retry' => $leaseActive
|
||||
? (int) ($existing['next_retry'] ?? 0)
|
||||
: ($error !== '' ? strtotime('tomorrow', $now) : ($pending ? $now : 0)),
|
||||
'last_error' => $leaseActive
|
||||
? (string) ($existing['last_error'] ?? '')
|
||||
: mb_substr($error, 0, 500),
|
||||
'applied_member_id' => 0,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if (!$leaseActive) {
|
||||
$data['attempts'] = 0;
|
||||
}
|
||||
if ($existing) {
|
||||
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update($data);
|
||||
} else {
|
||||
$data += [
|
||||
'pool_id' => $poolId,
|
||||
'applied_member_id' => 0,
|
||||
'applied_version' => 0,
|
||||
'attempts' => 0,
|
||||
'lock_token' => '',
|
||||
'lock_until' => 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
Db::name('qywx_promotion_range_sync')->insert($data);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private static function lockedMembers(int $poolId): array
|
||||
{
|
||||
return Db::name('qywx_promotion_pool_member')
|
||||
->where('pool_id', $poolId)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->lock(true)
|
||||
->select()->toArray();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private static function lockedSyncRow(int $poolId): ?array
|
||||
{
|
||||
$row = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
private static function promotionLinkId(int $poolId): int
|
||||
{
|
||||
$syncLinkId = (int) (Db::name('qywx_promotion_range_sync')
|
||||
->where('pool_id', $poolId)->value('promotion_link_id') ?? 0);
|
||||
if ($syncLinkId > 0) {
|
||||
return $syncLinkId;
|
||||
}
|
||||
|
||||
return (int) (Db::name('qywx_promotion_link')
|
||||
->where('pool_id', $poolId)
|
||||
->where('remote_link_id', '<>', '')
|
||||
->where('remote_status', '<>', 2)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'desc')
|
||||
->value('id') ?? 0);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function linkRangeUserIds(int $linkId): array
|
||||
{
|
||||
$json = (string) (Db::name('qywx_promotion_link')->where('id', $linkId)->value('range_user_json') ?? '[]');
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $value): string => trim((string) $value),
|
||||
is_array($decoded) ? $decoded : []
|
||||
), static fn (string $value): bool => $value !== ''));
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $members */
|
||||
private static function persistMemberCursors(array $members, int $now): void
|
||||
{
|
||||
foreach ($members as $member) {
|
||||
Db::name('qywx_promotion_pool_member')->where('id', (int) $member['id'])->update([
|
||||
'current_weight' => (int) ($member['current_weight'] ?? 0),
|
||||
'today_count' => max(0, (int) ($member['today_count'] ?? 0)),
|
||||
'today_date' => (string) ($member['today_date'] ?? '') ?: null,
|
||||
'total_count' => max(0, (int) ($member['total_count'] ?? 0)),
|
||||
'last_assigned_time' => max(0, (int) ($member['last_assigned_time'] ?? 0)),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 把当前全部可用医助同步到同一条企业微信官方获客链接,由企微执行原生多人路由。 */
|
||||
class QywxPromotionRangeSyncService
|
||||
{
|
||||
private QywxCustomerAcquisitionApiService $api;
|
||||
|
||||
public function __construct(?QywxCustomerAcquisitionApiService $api = null)
|
||||
{
|
||||
$this->api = $api ?? new QywxCustomerAcquisitionApiService();
|
||||
}
|
||||
|
||||
/** @return array{status:string,pool_id:int,member_id:int} */
|
||||
public function syncPool(int $poolId): array
|
||||
{
|
||||
$claim = Db::transaction(function () use ($poolId): ?array {
|
||||
$row = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$status = (int) ($row['status'] ?? 0);
|
||||
$now = time();
|
||||
if ($status === 0 || $status === 4 || $status === 5 || ($status === 2 && (int) ($row['lock_until'] ?? 0) > $now)) {
|
||||
return null;
|
||||
}
|
||||
if ($status === 3 && (int) ($row['next_retry'] ?? 0) > $now) {
|
||||
return null;
|
||||
}
|
||||
$token = bin2hex(random_bytes(16));
|
||||
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
|
||||
'status' => 2,
|
||||
'lock_token' => $token,
|
||||
'lock_until' => $now + 90,
|
||||
'attempts' => (int) ($row['attempts'] ?? 0) + 1,
|
||||
'last_error' => '',
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$row['lock_token'] = $token;
|
||||
|
||||
return $row;
|
||||
});
|
||||
if ($claim === null) {
|
||||
return ['status' => 'noop', 'pool_id' => $poolId, 'member_id' => 0];
|
||||
}
|
||||
|
||||
$token = (string) $claim['lock_token'];
|
||||
$desiredVersion = (int) ($claim['desired_version'] ?? 0);
|
||||
try {
|
||||
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->find();
|
||||
$link = Db::name('qywx_promotion_link')
|
||||
->where('id', (int) ($claim['promotion_link_id'] ?? 0))
|
||||
->whereNull('delete_time')->find();
|
||||
$members = Db::name('qywx_promotion_pool_member')
|
||||
->where('pool_id', $poolId)->whereNull('delete_time')->order('id', 'asc')->select()->toArray();
|
||||
if (!$pool || !$link || $members === []) {
|
||||
throw new RuntimeException('分流方案、官方链接或方案成员已失效');
|
||||
}
|
||||
$remoteLinkId = trim((string) ($link['remote_link_id'] ?? ''));
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('官方链接 ID 为空');
|
||||
}
|
||||
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time());
|
||||
$desiredUserIds = $range['userids'];
|
||||
if ($desiredUserIds === []) {
|
||||
$message = '所有成员均已禁用、未生效或达到今日上限;企业微信官方链接至少需要保留一名成员';
|
||||
Db::name('qywx_promotion_range_sync')
|
||||
->where('pool_id', $poolId)->where('lock_token', $token)->update([
|
||||
'status' => 4,
|
||||
'desired_member_id' => 0,
|
||||
'applied_member_id' => 0,
|
||||
'next_retry' => strtotime('tomorrow'),
|
||||
'attempts' => 0,
|
||||
'lock_token' => '',
|
||||
'lock_until' => 0,
|
||||
'last_error' => $message,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([
|
||||
'sync_error' => $message,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
return ['status' => 'blocked', 'pool_id' => $poolId, 'member_id' => 0];
|
||||
}
|
||||
|
||||
$this->api->updateLink([
|
||||
'link_id' => $remoteLinkId,
|
||||
'link_name' => mb_substr((string) ($pool['name'] ?? '获客分流方案'), 0, 30),
|
||||
'range' => ['user_list' => $desiredUserIds],
|
||||
'skip_verify' => (int) ($link['skip_verify'] ?? 0) === 1,
|
||||
]);
|
||||
$response = $this->api->getLink($remoteLinkId);
|
||||
$remote = QywxCustomerAcquisitionLinkService::normaliseRemoteResponse($response, $remoteLinkId);
|
||||
$actualUserIds = $remote['range_userids'];
|
||||
if (!QywxPromotionMemberRange::same($actualUserIds, $desiredUserIds)) {
|
||||
throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致');
|
||||
}
|
||||
$url = $remote['url'];
|
||||
$snapshot = json_encode($remote['snapshot'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([
|
||||
'wecom_url' => $url,
|
||||
'remote_status' => 1,
|
||||
'range_user_json' => json_encode($actualUserIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'range_department_json' => json_encode($remote['range_department_ids'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'remote_snapshot' => $snapshot === false ? null : $snapshot,
|
||||
'last_sync_time' => time(),
|
||||
'sync_error' => '',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
$freshVersion = (int) (Db::name('qywx_promotion_range_sync')
|
||||
->where('pool_id', $poolId)->value('desired_version') ?? 0);
|
||||
Db::name('qywx_promotion_range_sync')
|
||||
->where('pool_id', $poolId)
|
||||
->where('lock_token', $token)
|
||||
->update([
|
||||
'status' => $freshVersion === $desiredVersion ? 0 : 1,
|
||||
'desired_member_id' => 0,
|
||||
'applied_member_id' => 0,
|
||||
'applied_version' => $desiredVersion,
|
||||
'next_retry' => $freshVersion === $desiredVersion ? 0 : time(),
|
||||
'attempts' => 0,
|
||||
'lock_token' => '',
|
||||
'lock_until' => 0,
|
||||
'last_error' => '',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
return ['status' => 'synced', 'pool_id' => $poolId, 'member_id' => 0];
|
||||
} catch (\Throwable $e) {
|
||||
$attempts = max(1, (int) ($claim['attempts'] ?? 0) + 1);
|
||||
Db::name('qywx_promotion_range_sync')
|
||||
->where('pool_id', $poolId)
|
||||
->where('lock_token', $token)
|
||||
->update([
|
||||
'status' => 3,
|
||||
'next_retry' => time() + min(300, 15 * $attempts),
|
||||
'lock_token' => '',
|
||||
'lock_until' => 0,
|
||||
'last_error' => mb_substr($e->getMessage(), 0, 500),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
Db::name('qywx_promotion_link')
|
||||
->where('id', (int) ($claim['promotion_link_id'] ?? 0))
|
||||
->update(['sync_error' => mb_substr($e->getMessage(), 0, 500), 'update_time' => time()]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{selected:int,synced:int,failed:int} */
|
||||
public function syncPending(int $limit = 100): array
|
||||
{
|
||||
// 每分钟分页重算全部方案,避免有效期、跨日上限变化漏同步,也避免方案数超过批次后饥饿。
|
||||
$lastPoolId = 0;
|
||||
do {
|
||||
$reconcilePoolIds = Db::name('qywx_promotion_range_sync')
|
||||
->where('pool_id', '>', $lastPoolId)
|
||||
->order('pool_id', 'asc')
|
||||
->limit(500)
|
||||
->column('pool_id');
|
||||
foreach ($reconcilePoolIds as $reconcilePoolId) {
|
||||
$lastPoolId = (int) $reconcilePoolId;
|
||||
QywxPromotionMemberSchedulerService::reconcilePool($lastPoolId);
|
||||
}
|
||||
} while (count($reconcilePoolIds) === 500);
|
||||
|
||||
$now = time();
|
||||
$poolIds = Db::name('qywx_promotion_range_sync')
|
||||
->where(function ($query) use ($now): void {
|
||||
$query->whereIn('status', [1, 3])->where('next_retry', '<=', $now)
|
||||
->whereOr(function ($running) use ($now): void {
|
||||
$running->where('status', 2)->where('lock_until', '<=', $now);
|
||||
});
|
||||
})
|
||||
->order('next_retry', 'asc')
|
||||
->limit(min(500, max(1, $limit)))
|
||||
->column('pool_id');
|
||||
$synced = 0;
|
||||
$failed = 0;
|
||||
foreach ($poolIds as $poolId) {
|
||||
try {
|
||||
$result = $this->syncPool((int) $poolId);
|
||||
if ($result['status'] === 'synced') {
|
||||
$synced++;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return ['selected' => count($poolIds), 'synced' => $synced, 'failed' => $failed];
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,10 @@ namespace app\common\service\qywx;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/** 公开获客助手链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
|
||||
/** 公开获客助手链接:新方案直达单个官方链接,旧 /go 入口继续兼容历史分流。 */
|
||||
class QywxPromotionRedirectService
|
||||
{
|
||||
/** @return array{status:int,widget_config_json:?string}|null */
|
||||
/** @return array{status:int,widget_config_json:?string,target_url:string}|null */
|
||||
public static function publicPoolConfig(string $publicKey): ?array
|
||||
{
|
||||
if (preg_match('/^[a-f0-9]{32}$/', $publicKey) !== 1) {
|
||||
@@ -19,17 +19,32 @@ class QywxPromotionRedirectService
|
||||
$row = Db::name('qywx_promotion_pool')
|
||||
->where('public_key', $publicKey)
|
||||
->whereNull('delete_time')
|
||||
->field('status,widget_config_json')
|
||||
->field('id,status,widget_config_json')
|
||||
->find();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$link = Db::name('qywx_promotion_link')
|
||||
->where('pool_id', (int) $row['id'])
|
||||
->where('status', 1)
|
||||
->where('remote_status', 1)
|
||||
->where('remote_link_id', '<>', '')
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'desc')
|
||||
->field('wecom_url')
|
||||
->find();
|
||||
$targetUrl = QywxCustomerAcquisitionLinkService::withCustomerChannel(
|
||||
(string) ($link['wecom_url'] ?? ''),
|
||||
'zyt_pool:' . (int) $row['id']
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => (int) ($row['status'] ?? 0),
|
||||
'widget_config_json' => isset($row['widget_config_json'])
|
||||
? (string) $row['widget_config_json']
|
||||
: null,
|
||||
'target_url' => $targetUrl,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -131,11 +131,11 @@ class QywxPromotionWidgetService
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成可直接跨站安装的完整脚本。真实获客链接始终只由跳转端点选择。
|
||||
* 生成可直接跨站安装的完整脚本,点击后直达当前方案的企业微信官方链接。
|
||||
*
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public static function renderScript(string $key, string $goUrl, array $config, bool $poolEnabled = true): string
|
||||
public static function renderScript(string $key, string $targetUrl, array $config, bool $poolEnabled = true): string
|
||||
{
|
||||
$config = self::fromInput($config);
|
||||
if (!$poolEnabled) {
|
||||
@@ -143,14 +143,14 @@ class QywxPromotionWidgetService
|
||||
}
|
||||
|
||||
$jsonKey = self::jsonForScript($key);
|
||||
$jsonGo = self::jsonForScript($goUrl);
|
||||
$jsonTarget = self::jsonForScript($targetUrl);
|
||||
$jsonConfig = self::jsonForScript($config);
|
||||
|
||||
return <<<JS
|
||||
(function(w,d){
|
||||
'use strict';
|
||||
var key={$jsonKey},goPath={$jsonGo},config={$jsonConfig},scriptNode=d.currentScript||null;
|
||||
var go=resolveGoUrl(goPath);
|
||||
var key={$jsonKey},targetPath={$jsonTarget},config={$jsonConfig},scriptNode=d.currentScript||null;
|
||||
var targetUrl=resolveTargetUrl(targetPath);
|
||||
var registry=w.WecomPromotion=w.WecomPromotion||{};
|
||||
var previous=registry[key];
|
||||
if(previous&&previous.__widgetVersion===1&&typeof previous.destroy==='function'){
|
||||
@@ -170,7 +170,7 @@ class QywxPromotionWidgetService
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveGoUrl(value){
|
||||
function resolveTargetUrl(value){
|
||||
if(/^https?:\/\//i.test(value)){return value;}
|
||||
var node=findScriptNode();
|
||||
if(node&&node.src&&typeof w.URL==='function'){
|
||||
@@ -179,14 +179,8 @@ class QywxPromotionWidgetService
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceUrl(){
|
||||
var location=w.location||{};
|
||||
var origin=location.origin||((location.protocol&&location.host)?location.protocol+'//'+location.host:'');
|
||||
return origin+(location.pathname||'/');
|
||||
}
|
||||
|
||||
function openPromotion(){
|
||||
w.location.assign(go+'?from='+encodeURIComponent(sourceUrl()));
|
||||
if(targetUrl){w.location.assign(targetUrl);}
|
||||
}
|
||||
|
||||
function handleDocumentClick(event){
|
||||
|
||||
@@ -30,8 +30,10 @@ return [
|
||||
'qywx:scan-media-channel' => 'app\\command\\QywxScanMediaChannel',
|
||||
// 企业微信会话内容存档同步(需开通会话存档 License 并配置 msgaudit_* 相关项 + 动态库)
|
||||
'qywx:sync-msg-archive' => 'app\\command\\QywxSyncMsgArchive',
|
||||
// 获客助手 ChatKey 仅 30 分钟有效,部署时应每分钟执行一次
|
||||
'qywx:retry-customer-acquisition-events' => 'app\\command\\QywxRetryCustomerAcquisitionEvents',
|
||||
// 获客助手 ChatKey 仅 30 分钟有效,部署时应每分钟执行一次
|
||||
'qywx:retry-customer-acquisition-events' => 'app\\command\\QywxRetryCustomerAcquisitionEvents',
|
||||
// 回调确认实际承接成员后,按权重/上限切换同一条官方获客链接的成员范围
|
||||
'qywx:sync-promotion-ranges' => 'app\\command\\QywxSyncPromotionRanges',
|
||||
// 甘草订单物流路由同步(GET_TASK_ROUTE_LIST)
|
||||
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
|
||||
'ej-pharmacy:sync-catalog' => 'app\\command\\EjPharmacySyncCatalog',
|
||||
|
||||
@@ -30,19 +30,51 @@ HOST = "https://公开访问域名"
|
||||
- 更新获客链接 `update_link`
|
||||
- 删除获客链接 `delete_link`
|
||||
|
||||
“永久删除企业微信链接”会调用官方删除接口且无法恢复;“从本地移除”只退出当前分流池,不会修改企业微信后台。
|
||||
当前管理端按“一个分流方案对应一个官方获客链接”管理。删除方案时先调用企业微信 `delete_link` 永久删除关联的官方链接,成功后再软删除本地方案、成员和链接记录;如果企业微信删除失败,本地方案会保留并返回错误,避免两端状态不一致。官方链接删除后无法恢复,已投放 URL 会失效,历史本地客户归因记录仍保留。
|
||||
|
||||
获客成员来自后台管理员的 `work_wechat_userid`。管理员可管理全量;组长、医助等账号只返回 `DataScopeService` 当前角色与部门范围内的成员。同步远端链接时,非全量账号只导入 `range.user_list` 与其可见成员有交集的数据;企业微信部门 ID 尚未建立本地映射时按安全原则隐藏,不会越权放行。
|
||||
|
||||
`list_link` 只返回当前获客助手可调用应用通过 API 创建的官方链接。后台历史手工粘贴的 `work.weixin.qq.com/ca/...` 链接,以及其他应用创建的链接,不会出现在当前应用的同步列表中,也无法仅凭 URL 反查为官方 `link_id`。需要官方客户、统计和消息归因时,应在本页面使用“创建官方获客链接”。
|
||||
`list_link` 只返回当前获客助手可调用应用通过 API 创建的官方链接。后台历史手工粘贴的 `work.weixin.qq.com/ca/...` 链接,以及其他应用创建的链接,不会出现在当前应用的同步列表中,也无法仅凭 URL 反查为官方 `link_id`。需要官方客户、统计和消息归因时,应在本页面创建分流方案并选择获客成员。
|
||||
|
||||
链接分流只接受企业微信获客助手生成的链接:
|
||||
创建分流方案时必须选择一名或多名医助。一个方案只创建一条企业微信官方链接,当前全部可用医助会同时写入该链接的 `range.user_list`,由企业微信在打开、添加阶段执行官方多人路由。成员可配置启用状态、每日上限和有效时间,系统按实际获客回调累计数量。
|
||||
|
||||
企业微信 `range.user_list` 只接受成员 userid 列表,不提供逐成员权重字段,因此本站不再展示或执行 2:1:1 一类权重规则。官方多人路由的实际承接还会受到成员可服务状态、客户已有好友关系等企业微信规则影响,不能承诺每次刷新严格随机或短期样本绝对平均。
|
||||
|
||||
系统只使用企业微信获客助手生成的链接:
|
||||
|
||||
```text
|
||||
https://work.weixin.qq.com/ca/xxxxxxxx
|
||||
```
|
||||
|
||||
“联系我”、客户群、自有网页或其他外部链接均会被拒绝;已有的非获客助手历史链接也不会参与随机分流。Secret 与 access_token 不会返回到浏览器,也不会写入接口错误日志。
|
||||
“联系我”、客户群、自有网页或其他外部链接均会被拒绝;已有的非获客助手历史链接不会作为方案主链接。Secret 与 access_token 不会返回到浏览器,也不会写入接口错误日志。
|
||||
|
||||
可复制的主链接会追加分流方案渠道参数,结构如下:
|
||||
|
||||
```text
|
||||
https://work.weixin.qq.com/ca/xxxxxxxx?customer_channel=zyt_pool:123
|
||||
```
|
||||
|
||||
其中 `customer_channel` 是本站写入的自定义渠道值,格式为 `zyt_pool:分流方案ID`;它与示例中的 `qywx_ca:...` 作用相同,但命名空间和数值由各系统自行定义。
|
||||
|
||||
## 回调统计与成员范围维护
|
||||
|
||||
部署时必须执行:
|
||||
|
||||
```text
|
||||
server/sql/1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql
|
||||
```
|
||||
|
||||
并在企业微信后台把“API 接收消息”配置为:
|
||||
|
||||
```text
|
||||
https://你的域名/api/qywx/external-contact/notify
|
||||
```
|
||||
|
||||
回调优先使用 `change_external_contact/add_external_contact` 事件中的 `State`、`UserID` 和 `ExternalUserID`。`State` 来自主链接的 `customer_channel=zyt_pool:方案ID`,因此可以定位方案及实际承接医助;获客会话回调会通过 `ChatKey → get_chat_info` 作为补偿。方案和客户组合使用唯一幂等键,同一实际获客不会因重复回调或后续更换跟进成员而重复计数。
|
||||
|
||||
创建或编辑方案时,系统把所有已启用、已生效且未达到今日上限的成员一次写入同一个官方链接。禁用、尚未生效、已过期或达到今日上限的成员会从官方范围移出;跨日或重新进入有效期后会自动加入。`qywx:sync-promotion-ranges` 每分钟重算全部方案并重试失败同步,管理端修改成员规则时也会立即尝试同步。
|
||||
|
||||
每日数量属于回调驱动的近实时软上限,并非点击前的强事务:多个客户在企微回调或 `update_link` 生效前并发访问时,可能出现少量超量;所有成员都达到上限时,企业微信不允许把 `range.user_list` 更新为空,系统会标记“无可用成员”并保留最后一次有效范围。因此该上限用于自动退出后续官方路由,不承诺并发场景下绝对零超量。
|
||||
|
||||
如果需要为点击 IP 生成不可逆服务端哈希,可在 `[qywx_promotion]` 下额外设置独立的 `CREDENTIAL_KEY`。
|
||||
|
||||
@@ -50,9 +82,11 @@ https://work.weixin.qq.com/ca/xxxxxxxx
|
||||
|
||||
```html
|
||||
<script src="https://你的域名/api/qywx-promotion/js/分流方案KEY" defer></script>
|
||||
<a href="https://你的域名/api/qywx-promotion/go/分流方案KEY" data-wecom-promotion="分流方案KEY">添加企业微信</a>
|
||||
<a href="https://work.weixin.qq.com/ca/xxxxxxxx?customer_channel=zyt_pool:123">添加企业微信</a>
|
||||
```
|
||||
|
||||
旧的 `/api/qywx-promotion/go/分流方案KEY` 地址继续保留,兼容已经投放的安装代码;新建方案、管理端复制链接和新版浮窗均直接打开官方获客链接。
|
||||
|
||||
## 公开浮窗
|
||||
|
||||
每个分流方案可选择是否由同一段公开 JS 自动挂载客服浮窗。关闭浮窗时,已有的
|
||||
@@ -66,7 +100,7 @@ https://work.weixin.qq.com/ca/xxxxxxxx
|
||||
- 标题、副标题、按钮文案和 `#RRGGBB` 主题色
|
||||
- 16-160 像素底部距离、移动端展示开关和浮窗总开关
|
||||
|
||||
公开脚本仅下发经过白名单校验的展示配置,不下发兜底链接或真实获客链接池。模板
|
||||
公开脚本仅下发经过白名单校验的展示配置和当前方案的单个官方目标链接,不下发历史链接池或 Secret。模板
|
||||
由脚本内置,管理端文案通过 DOM `textContent` 写入,不接受自定义 HTML、CSS 或脚本。
|
||||
损坏配置、未知版本和非法枚举会按关闭浮窗处理。
|
||||
|
||||
@@ -79,10 +113,7 @@ window.WecomPromotion['分流方案KEY'].hide()
|
||||
window.WecomPromotion['分流方案KEY'].destroy()
|
||||
```
|
||||
|
||||
公开脚本缓存 60 秒,因此浮窗样式或开关更新最多延迟约 60 秒;方案运行状态仍会在
|
||||
每次服务端跳转时即时校验。脚本会从自身 `src` 解析跳转接口域名,不会把公开请求的
|
||||
Host 写入缓存内容。管理端安装代码优先使用 `[app] HOST`,请在生产环境配置唯一的
|
||||
HTTPS 公开域名。
|
||||
公开脚本缓存 60 秒,因此浮窗样式、开关或官方目标链接更新最多延迟约 60 秒。新版浮窗直接打开企业微信官方链接,不再经过本站逐次 302;已经复制到外部的官方链接也不会因本地关闭方案而失效。管理端安装代码优先使用 `[app] HOST`,请在生产环境配置唯一的 HTTPS 公开域名。
|
||||
|
||||
接入站点若启用了严格 CSP,需要允许脚本域名,并给安装 `<script>` 添加站点当前请求
|
||||
的 `nonce`。公开脚本会把该 `nonce` 传给 Shadow DOM 内的动态样式:
|
||||
@@ -91,7 +122,6 @@ HTTPS 公开域名。
|
||||
<script nonce="当前请求的 nonce" src="https://你的域名/api/qywx-promotion/js/分流方案KEY" defer></script>
|
||||
```
|
||||
|
||||
点击来源只上报页面的 origin 与 pathname,不包含查询参数或 fragment。推广页路径中也
|
||||
不应放置手机号、患者 ID、重置令牌等敏感信息。
|
||||
旧 `/go` 兼容入口记录点击来源时,只保存页面的 origin 与 pathname,不包含查询参数或 fragment。推广页路径中也不应放置手机号、患者 ID、重置令牌等敏感信息。
|
||||
|
||||
随机分流在服务端完成。候选链接必须同时满足:方案启用、链接上线、处于有效时间段、未超过当日上限。权重越大,被选中的概率越高。
|
||||
用户始终看到同一个企业微信官方获客链接。本站只维护该链接的可用成员集合,实际多人分流由企业微信完成;回调负责记录实际承接结果,并在成员达到上限后更新集合。
|
||||
|
||||
+405
-403
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import t from"./error-BBmNUrV2.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-D8IOIRnm.js";import"./index-Zs1kxO8U.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
import t from"./error-BRBhqisw.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-Dwh6tNxD.js";import"./index-CLwe6ftX.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import e from"./error-BBmNUrV2.js";import{o,q as r,r as t,v as s}from"./.pnpm-D8IOIRnm.js";import"./index-Zs1kxO8U.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
import e from"./error-BRBhqisw.js";import{o,q as r,r as t,v as s}from"./.pnpm-Dwh6tNxD.js";import"./index-CLwe6ftX.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-D8IOIRnm.js";import{a as V}from"./doctor-dPZ3bn3u.js";import{m as A,_ as M}from"./index-Zs1kxO8U.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-Dwh6tNxD.js";import{a as V}from"./doctor-CD6XYjKU.js";import{m as A,_ as M}from"./index-CLwe6ftX.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-D8IOIRnm.js";import{ae as V}from"./tcm-DCMTHyTh.js";import{_ as q}from"./index-Zs1kxO8U.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-Dwh6tNxD.js";import{ae as V}from"./tcm-BzzBMfBw.js";import{_ as q}from"./index-CLwe6ftX.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,dg as c}from"./.pnpm-D8IOIRnm.js";import{af as Y}from"./tcm-DCMTHyTh.js";import{_ as q}from"./index-Zs1kxO8U.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function M(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:M},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,dg as c}from"./.pnpm-Dwh6tNxD.js";import{af as Y}from"./tcm-BzzBMfBw.js";import{_ as q}from"./index-CLwe6ftX.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function M(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:M},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as N,di as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as j,T as u,s as y,bi as z,M as v}from"./.pnpm-D8IOIRnm.js";import M from"./RecordingPlaybackBlock-BqimoWFI.js";import{U as k}from"./index-1YRXJLNz.js";import{i as c,_ as q}from"./index-Zs1kxO8U.js";import{aj as K,ak as x,al as A}from"./tcm-DCMTHyTh.js";import"./RecordingVideoPlayer-ByKpMeyO.js";import"./file-ZgzS-7se.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await x({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await x({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=j,I=L,B=z;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(k,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(M,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(k,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
import{o as N,di as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as j,T as u,s as y,bi as z,M as v}from"./.pnpm-Dwh6tNxD.js";import M from"./RecordingPlaybackBlock-CMHUdU8f.js";import{U as k}from"./index-BWeecgSM.js";import{i as c,_ as q}from"./index-CLwe6ftX.js";import{aj as K,ak as x,al as A}from"./tcm-BzzBMfBw.js";import"./RecordingVideoPlayer-CDqcv1v9.js";import"./file-CAEOOa9_.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await x({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await x({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=j,I=L,B=z;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(k,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(M,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(k,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-D8IOIRnm.js";import{am as q}from"./tcm-DCMTHyTh.js";import{_ as H}from"./index-Zs1kxO8U.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-Dwh6tNxD.js";import{am as q}from"./tcm-BzzBMfBw.js";import{_ as H}from"./index-CLwe6ftX.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-DjqQICvT.js";import"./.pnpm-D8IOIRnm.js";import"./tcm-DCMTHyTh.js";import"./index-Zs1kxO8U.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-hLNJ9AIP.js";import"./.pnpm-Dwh6tNxD.js";import"./tcm-BzzBMfBw.js";import"./index-CLwe6ftX.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-D8IOIRnm.js";import{p as j}from"./tcm-DCMTHyTh.js";import{i as C}from"./index-Zs1kxO8U.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-Dwh6tNxD.js";import{p as j}from"./tcm-BzzBMfBw.js";import{i as C}from"./index-CLwe6ftX.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cV as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as se,a8 as ae,E as C}from"./.pnpm-D8IOIRnm.js";import{d as te}from"./dayjs-BiWNb60_.js";import{ar as ne,as as oe}from"./tcm-DCMTHyTh.js";import{p as re}from"./im-business-message-parse-CP5EJO-k.js";import{_ as le}from"./index-Zs1kxO8U.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,a)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=se,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),a[1]||(a[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),a[2]||(a[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,s=>(t(),n("div",{key:s.raw.msg_id,class:X(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(V,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(Y,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cV as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as se,a8 as ae,E as C}from"./.pnpm-Dwh6tNxD.js";import{d as te}from"./dayjs-Cbxn44tS.js";import{ar as ne,as as oe}from"./tcm-BzzBMfBw.js";import{p as re}from"./im-business-message-parse-CVnz1EnV.js";import{_ as le}from"./index-CLwe6ftX.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,a)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=se,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),a[1]||(a[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),a[2]||(a[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,s=>(t(),n("div",{key:s.raw.msg_id,class:X(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(V,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(Y,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-Bw6z1XEk.js";import"./.pnpm-D8IOIRnm.js";export{m as default};
|
||||
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-B_HCfW5z.js";import"./.pnpm-Dwh6tNxD.js";export{m as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-D8IOIRnm.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
|
||||
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-Dwh6tNxD.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-D8IOIRnm.js";import{t as j,_ as J}from"./index-Zs1kxO8U.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-Dwh6tNxD.js";import{t as j,_ as J}from"./index-CLwe6ftX.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d7 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-D8IOIRnm.js";import{_ as fe}from"./picker-jKtBhV23.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-Zs1kxO8U.js";import{a as T,d as he}from"./patient-rZSGu5K1.js";import{h as ke}from"./perm-BmzF-wLh.js";import"./index-LuA5g-VX.js";import"./index-BO7hrwDl.js";import"./index.vue_vue_type_script_setup_true_lang-D3COGOx-.js";import"./index-DfHLSacw.js";import"./index-1YRXJLNz.js";import"./file-ZgzS-7se.js";import"./index.vue_vue_type_script_setup_true_lang-DwIj7MLL.js";import"./usePaging-DYO4VVWq.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d7 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-Dwh6tNxD.js";import{_ as fe}from"./picker-Dr1-lH_q.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-CLwe6ftX.js";import{a as T,d as he}from"./patient-4QJ3YgMJ.js";import{h as ke}from"./perm-CHalL7pT.js";import"./index-De13kk0O.js";import"./index-BhX1mXHA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./index-DUJcOhOZ.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./usePaging-DOuAwzL9.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
`).filter(Boolean):[],Q=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const i=e.slice(y.value);y.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,tongue_images:i}).then(()=>{f.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const i=e.slice(h.value);h.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,report_files:i}).then(()=>{f.msgSuccess("检查报告已添加"),I("refresh")})};oe(()=>c.notes,()=>{S.value=[],D.value=[],y.value=0,h.value=0});const ee=async()=>{if(!c.diagnosisId){f.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){f.msgWarning("请输入备注内容");return}N.value=!0;try{await T({diagnosis_id:c.diagnosisId,content:t}),f.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){f.msgError((e==null?void 0:e.message)||"保存失败")}finally{N.value=!1}},A=async(t,e,i)=>{try{await ge.confirm("确认删除?","提示",{type:"warning"})}catch{return}await he({note_id:t,image_type:e,image_path:i}),f.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const i=le,b=_e,F=fe,L=me,x=ce,te=ae,se=re,ne=de;return n(),o("div",we,[!u.readonly&&u.diagnosisId?(n(),o("div",Ie,[X.value?(n(),k(i,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=s=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[P(" 添加备注 ",-1)])]),_:1})):m("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=s=>S.value=s),limit:99,type:"image","exclude-domain":!0,onChange:Q},{upload:r(()=>[d("div",Ce,[l(b,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=d("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:D.value,"onUpdate:modelValue":e[2]||(e[2]=s=>D.value=s),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[d("div",be,[l(b,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=d("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):m("",!0),u.notes.length?(n(),o("div",xe,[(n(!0),o(w,null,E(u.notes,s=>{var R,G;return n(),o("div",{key:s.id,class:"timeline-node"},[e[11]||(e[11]=d("div",{class:"timeline-dot"},null,-1)),d("div",Ee,U(s.note_date),1),d("div",Ve,[s.content?(n(),o("div",Ne,[(n(!0),o(w,null,E(J(s.content),(a,p)=>(n(),o("div",{key:p,class:"content-line"},U(a),1))),128))])):m("",!0),(R=s.tongue_images)!=null&&R.length?(n(),o("div",Se,[e[9]||(e[9]=d("span",{class:"images-label"},"舌苔照片",-1)),(n(!0),o(w,null,E(s.tongue_images,(a,p)=>(n(),o("div",{key:p,class:"thumb-wrap"},[l(L,{src:_(a),"preview-src-list":s.tongue_images.map(_),"initial-index":p,"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"tongue_images",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))),128))])):m("",!0),(G=s.report_files)!=null&&G.length?(n(),o("div",De,[e[10]||(e[10]=d("span",{class:"images-label"},"检查报告",-1)),(n(!0),o(w,null,E(s.report_files,(a,p)=>(n(),o(w,{key:p},[B(a)?(n(),o("div",Be,[l(L,{src:_(a),"preview-src-list":K(s.report_files),"initial-index":Z(s.report_files,p),"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))])):(n(),o("div",Ae,[d("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(x,{size:20},{default:r(()=>[l(V(pe))]),_:1}),d("span",Ue,U($(a)),1)],8,Pe),u.readonly?m("",!0):(n(),k(x,{key:0,class:"file-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))],64))),128))])):m("",!0)])])}),128))])):m("",!0),!u.notes.length&&u.readonly?(n(),k(te,{key:2,description:"暂无备注","image-size":48})):m("",!0),l(ne,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=s=>v.value=s),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(i,{onClick:e[4]||(e[4]=s=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[P("取消",-1)])]),_:1}),l(i,{type:"primary",loading:N.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[P("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(se,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=s=>C.value=s),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Ke=ye(ze,[["__scopeId","data-v-530ad386"]]);export{Ke as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-D8IOIRnm.js";import{_ as V}from"./index-Zs1kxO8U.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
|
||||
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-Dwh6tNxD.js";import{_ as V}from"./index-CLwe6ftX.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-D8IOIRnm.js";import H from"./RecordingVideoPlayer-ByKpMeyO.js";import{e as I,_ as P}from"./index-Zs1kxO8U.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
|
||||
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-Dwh6tNxD.js";import H from"./RecordingVideoPlayer-CDqcv1v9.js";import{e as I,_ as P}from"./index-CLwe6ftX.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-D8IOIRnm.js","assets/.pnpm-B3v8nGpq.css"])))=>i.map(i=>d[i]);
|
||||
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-D8IOIRnm.js";import{e as ae,_ as ne}from"./index-Zs1kxO8U.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?N(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function N(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function U(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-D8IOIRnm.js").then(M=>M.dN),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function C(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{C()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:U},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-Dwh6tNxD.js","assets/.pnpm-B3v8nGpq.css"])))=>i.map(i=>d[i]);
|
||||
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-Dwh6tNxD.js";import{e as ae,_ as ne}from"./index-CLwe6ftX.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?N(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function N(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function U(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-Dwh6tNxD.js").then(M=>M.dN),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function C(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{C()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:U},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,2 +1,2 @@
|
||||
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-D8IOIRnm.js";import{a4 as L}from"./tcm-DCMTHyTh.js";import{i as M,_ as S}from"./index-Zs1kxO8U.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
|
||||
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-Dwh6tNxD.js";import{a4 as L}from"./tcm-BzzBMfBw.js";import{i as M,_ as S}from"./index-CLwe6ftX.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
|
||||
`).filter(Boolean):[],k=async()=>{if(!c.diagnosisId)return;const s=o.value.trim();if(s){l.value=!0;try{await L({diagnosis_id:c.diagnosisId,tracking_content:s}),M.msgSuccess("已添加"),o.value="",y("refresh")}finally{l.value=!1}}};return(s,i)=>{const b=V,h=E,B=z;return e(),t("div",D,[!n.readonly&&n.diagnosisId?(e(),t("div",F,[m(b,{modelValue:o.value,"onUpdate:modelValue":i[0]||(i[0]=a=>o.value=a),type:"textarea",rows:2,placeholder:"输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录",maxlength:"1000","show-word-limit":"",resize:"none",disabled:l.value},null,8,["modelValue","disabled"]),d("div",H,[m(h,{type:"primary",size:"small",loading:l.value,disabled:!o.value.trim(),onClick:k},{default:w(()=>[...i[1]||(i[1]=[I(" 添加 ",-1)])]),_:1},8,["loading","disabled"])])])):r("",!0),n.notes.length?(e(),t("div",q,[(e(!0),t(u,null,v(n.notes,a=>(e(),t("div",{key:a.id,class:"timeline-node"},[i[2]||(i[2]=d("div",{class:"timeline-dot"},null,-1)),d("div",A,g(a.note_date),1),d("div",G,[a.content?(e(),t("div",K,[(e(!0),t(u,null,v(_(a.content),(x,N)=>(e(),t("div",{key:N,class:"content-line"},g(x),1))),128))])):r("",!0)])]))),128))])):r("",!0),!n.notes.length&&n.readonly?(e(),C(B,{key:2,description:"暂无跟踪备注","image-size":48})):r("",!0)])}}}),Q=S(O,[["__scopeId","data-v-82b635bd"]]);export{Q as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-D8V2sQ39.js";import"./.pnpm-Dwh6tNxD.js";import"./index-De13kk0O.js";import"./index-CLwe6ftX.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-CAA8c9nR.js";import"./.pnpm-D8IOIRnm.js";import"./index-LuA5g-VX.js";import"./index-Zs1kxO8U.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-D8IOIRnm.js";import{_ as L}from"./index-LuA5g-VX.js";import{i as V}from"./index-Zs1kxO8U.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
|
||||
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-Dwh6tNxD.js";import{_ as L}from"./index-De13kk0O.js";import{i as V}from"./index-CLwe6ftX.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./add-nav.vue_vue_type_script_setup_true_lang-u_Jd9hfC.js";import"./.pnpm-D8IOIRnm.js";import"./index-DfHLSacw.js";import"./index-Zs1kxO8U.js";import"./picker-CATr7Rqz.js";import"./index-LuA5g-VX.js";import"./index.vue_vue_type_script_setup_true_lang-D3COGOx-.js";import"./article-bnFzIDeX.js";import"./usePaging-DYO4VVWq.js";import"./picker-jKtBhV23.js";import"./index-BO7hrwDl.js";import"./index-1YRXJLNz.js";import"./file-ZgzS-7se.js";import"./index.vue_vue_type_script_setup_true_lang-DwIj7MLL.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./add-nav.vue_vue_type_script_setup_true_lang-Cd2ygMrI.js";import"./.pnpm-Dwh6tNxD.js";import"./index-DUJcOhOZ.js";import"./index-CLwe6ftX.js";import"./picker-DYZuEtRo.js";import"./index-De13kk0O.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Cg76y5pa.js";import"./usePaging-DOuAwzL9.js";import"./picker-Dr1-lH_q.js";import"./index-BhX1mXHA.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as E,q as p,r as C,s as l,v as a,u as c,bQ as B,C as N,D as d,O as $,b7 as z,b6 as D,I,K as A,L,p as R}from"./.pnpm-D8IOIRnm.js";import{_ as q}from"./index-DfHLSacw.js";import{_ as F}from"./picker-CATr7Rqz.js";import{_ as K}from"./picker-jKtBhV23.js";import{c as O,i as r}from"./index-Zs1kxO8U.js";const P={class:"bg-fill-light flex items-center w-full p-4 mb-4"},Q={class:"upload-btn w-[60px] h-[60px]"},S={class:"ml-3 flex-1"},T={class:"flex items-center"},j={class:"flex items-center mt-[18px]"},G={class:"flex-1 flex items-center"},H={class:"drag-move cursor-move ml-auto"},Z=E({__name:"add-nav",props:{modelValue:{type:Array,default:()=>[]},max:{type:Number,default:100},min:{type:Number,default:1}},emits:["update:modelValue"],setup(_,{emit:f}){const t=_,V=f,m=R({get(){return t.modelValue},set(s){V("update:modelValue",s)}}),x=()=>{var s;((s=t.modelValue)==null?void 0:s.length)<t.max?m.value.push({image:"",name:"导航名称",link:{},is_show:"1"}):r.msgError(`最多添加${t.max}个`)},v=s=>{var e;if(((e=t.modelValue)==null?void 0:e.length)<=t.min)return r.msgError(`最少保留${t.min}个`);m.value.splice(s,1)};return(s,e)=>{const u=O,g=K,b=z,h=F,k=I,w=D,y=q,U=A;return p(),C("div",null,[l("div",null,[a(c(B),{class:"draggable",modelValue:c(m),"onUpdate:modelValue":e[0]||(e[0]=o=>N(m)?m.value=o:null),animation:"300",handle:".drag-move","item-key":"index"},{item:d(({element:o,index:i})=>[(p(),$(y,{class:"w-[467px]",key:i,onClose:n=>v(i)},{default:d(()=>[l("div",P,[a(g,{modelValue:o.image,"onUpdate:modelValue":n=>o.image=n,"upload-class":"bg-body",size:"60px","exclude-domain":""},{upload:d(()=>[l("div",Q,[a(u,{name:"el-icon-Plus",size:20})])]),_:1},8,["modelValue","onUpdate:modelValue"]),l("div",S,[l("div",T,[e[1]||(e[1]=l("span",{class:"text-tx-regular flex-none mr-3"},"名称",-1)),a(b,{modelValue:o.name,"onUpdate:modelValue":n=>o.name=n,placeholder:"请输入名称"},null,8,["modelValue","onUpdate:modelValue"])]),l("div",j,[e[2]||(e[2]=l("span",{class:"text-tx-regular flex-none mr-3"},"链接",-1)),a(h,{modelValue:o.link,"onUpdate:modelValue":n=>o.link=n},null,8,["modelValue","onUpdate:modelValue"])]),a(w,{label:"是否显示",class:"mt-[18px]"},{default:d(()=>[l("div",G,[a(k,{modelValue:o.is_show,"onUpdate:modelValue":n=>o.is_show=n,"active-value":"1","inactive-value":"0"},null,8,["modelValue","onUpdate:modelValue"]),l("div",H,[a(u,{name:"el-icon-Rank",size:"18"})])])]),_:2},1024)])])]),_:2},1032,["onClose"]))]),_:1},8,["modelValue"])]),l("div",null,[a(U,{type:"primary",onClick:x},{default:d(()=>[...e[3]||(e[3]=[L("添加",-1)])]),_:1})])])}}});export{Z as _};
|
||||
import{o as E,q as p,r as C,s as l,v as a,u as c,bQ as B,C as N,D as d,O as $,b7 as z,b6 as D,I,K as A,L,p as R}from"./.pnpm-Dwh6tNxD.js";import{_ as q}from"./index-DUJcOhOZ.js";import{_ as F}from"./picker-DYZuEtRo.js";import{_ as K}from"./picker-Dr1-lH_q.js";import{c as O,i as r}from"./index-CLwe6ftX.js";const P={class:"bg-fill-light flex items-center w-full p-4 mb-4"},Q={class:"upload-btn w-[60px] h-[60px]"},S={class:"ml-3 flex-1"},T={class:"flex items-center"},j={class:"flex items-center mt-[18px]"},G={class:"flex-1 flex items-center"},H={class:"drag-move cursor-move ml-auto"},Z=E({__name:"add-nav",props:{modelValue:{type:Array,default:()=>[]},max:{type:Number,default:100},min:{type:Number,default:1}},emits:["update:modelValue"],setup(_,{emit:f}){const t=_,V=f,m=R({get(){return t.modelValue},set(s){V("update:modelValue",s)}}),x=()=>{var s;((s=t.modelValue)==null?void 0:s.length)<t.max?m.value.push({image:"",name:"导航名称",link:{},is_show:"1"}):r.msgError(`最多添加${t.max}个`)},v=s=>{var e;if(((e=t.modelValue)==null?void 0:e.length)<=t.min)return r.msgError(`最少保留${t.min}个`);m.value.splice(s,1)};return(s,e)=>{const u=O,g=K,b=z,h=F,k=I,w=D,y=q,U=A;return p(),C("div",null,[l("div",null,[a(c(B),{class:"draggable",modelValue:c(m),"onUpdate:modelValue":e[0]||(e[0]=o=>N(m)?m.value=o:null),animation:"300",handle:".drag-move","item-key":"index"},{item:d(({element:o,index:i})=>[(p(),$(y,{class:"w-[467px]",key:i,onClose:n=>v(i)},{default:d(()=>[l("div",P,[a(g,{modelValue:o.image,"onUpdate:modelValue":n=>o.image=n,"upload-class":"bg-body",size:"60px","exclude-domain":""},{upload:d(()=>[l("div",Q,[a(u,{name:"el-icon-Plus",size:20})])]),_:1},8,["modelValue","onUpdate:modelValue"]),l("div",S,[l("div",T,[e[1]||(e[1]=l("span",{class:"text-tx-regular flex-none mr-3"},"名称",-1)),a(b,{modelValue:o.name,"onUpdate:modelValue":n=>o.name=n,placeholder:"请输入名称"},null,8,["modelValue","onUpdate:modelValue"])]),l("div",j,[e[2]||(e[2]=l("span",{class:"text-tx-regular flex-none mr-3"},"链接",-1)),a(h,{modelValue:o.link,"onUpdate:modelValue":n=>o.link=n},null,8,["modelValue","onUpdate:modelValue"])]),a(w,{label:"是否显示",class:"mt-[18px]"},{default:d(()=>[l("div",G,[a(k,{modelValue:o.is_show,"onUpdate:modelValue":n=>o.is_show=n,"active-value":"1","inactive-value":"0"},null,8,["modelValue","onUpdate:modelValue"]),l("div",H,[a(u,{name:"el-icon-Rank",size:"18"})])])]),_:2},1024)])])]),_:2},1032,["onClose"]))]),_:1},8,["modelValue"])]),l("div",null,[a(U,{type:"primary",onClick:x},{default:d(()=>[...e[3]||(e[3]=[L("添加",-1)])]),_:1})])])}}});export{Z as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{r as n}from"./index-Zs1kxO8U.js";function e(t){return n.get({url:"/auth.admin/lists",params:t},{ignoreCancelToken:!0})}function i(t){return n.post({url:"/auth.admin/add",params:t})}function r(t){return n.post({url:"/auth.admin/edit",params:t})}function u(t){return n.post({url:"/auth.admin/delete",params:t})}function d(t){return n.get({url:"/auth.admin/detail",params:t})}export{e as a,r as b,u as c,i as d,d as e};
|
||||
import{r as n}from"./index-CLwe6ftX.js";function e(t){return n.get({url:"/auth.admin/lists",params:t},{ignoreCancelToken:!0})}function i(t){return n.post({url:"/auth.admin/add",params:t})}function r(t){return n.post({url:"/auth.admin/edit",params:t})}function u(t){return n.post({url:"/auth.admin/delete",params:t})}function d(t){return n.get({url:"/auth.admin/detail",params:t})}export{e as a,r as b,u as c,i as d,d as e};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{r as e}from"./index-Zs1kxO8U.js";function a(t){return e.get({url:"/article.articleCate/lists",params:t})}function l(t){return e.get({url:"/article.articleCate/all",params:t})}function i(t){return e.post({url:"/article.articleCate/add",params:t})}function c(t){return e.post({url:"/article.articleCate/edit",params:t})}function u(t){return e.post({url:"/article.articleCate/delete",params:t})}function n(t){return e.get({url:"/article.articleCate/detail",params:t})}function s(t){return e.post({url:"/article.articleCate/updateStatus",params:t})}function o(t){return e.get({url:"/article.article/lists",params:t})}function d(t){return e.post({url:"/article.article/add",params:t})}function f(t){return e.post({url:"/article.article/edit",params:t})}function C(t){return e.post({url:"/article.article/delete",params:t})}function p(t){return e.get({url:"/article.article/detail",params:t})}function g(t){return e.post({url:"/article.article/updateStatus",params:t})}export{a,u as b,s as c,c as d,i as e,n as f,p as g,f as h,d as i,l as j,o as k,g as l,C as m};
|
||||
import{r as e}from"./index-CLwe6ftX.js";function a(t){return e.get({url:"/article.articleCate/lists",params:t})}function l(t){return e.get({url:"/article.articleCate/all",params:t})}function i(t){return e.post({url:"/article.articleCate/add",params:t})}function c(t){return e.post({url:"/article.articleCate/edit",params:t})}function u(t){return e.post({url:"/article.articleCate/delete",params:t})}function n(t){return e.get({url:"/article.articleCate/detail",params:t})}function s(t){return e.post({url:"/article.articleCate/updateStatus",params:t})}function o(t){return e.get({url:"/article.article/lists",params:t})}function d(t){return e.post({url:"/article.article/add",params:t})}function f(t){return e.post({url:"/article.article/edit",params:t})}function C(t){return e.post({url:"/article.article/delete",params:t})}function p(t){return e.get({url:"/article.article/detail",params:t})}function g(t){return e.post({url:"/article.article/updateStatus",params:t})}export{a,u as b,s as c,c as d,i as e,n as f,p as g,f as h,d as i,l as j,o as k,g as l,C as m};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{r as e}from"./index-Zs1kxO8U.js";function r(s){return e.get({url:"/asset.AssetUser/lists",params:s})}function u(s){return e.post({url:"/asset.AssetUser/add",params:s})}function a(s){return e.post({url:"/asset.AssetUser/edit",params:s})}function i(s){return e.post({url:"/asset.AssetUser/delete",params:s})}function o(s){return e.get({url:"/asset.AssetResource/lists",params:s})}function n(s){return e.post({url:"/asset.AssetResource/add",params:s})}function A(s){return e.post({url:"/asset.AssetResource/edit",params:s})}function c(s){return e.post({url:"/asset.AssetResource/delete",params:s})}export{o as a,c as b,A as c,n as d,r as e,a as f,i as g,u as h};
|
||||
import{r as e}from"./index-CLwe6ftX.js";function r(s){return e.get({url:"/asset.AssetUser/lists",params:s})}function u(s){return e.post({url:"/asset.AssetUser/add",params:s})}function a(s){return e.post({url:"/asset.AssetUser/edit",params:s})}function i(s){return e.post({url:"/asset.AssetUser/delete",params:s})}function o(s){return e.get({url:"/asset.AssetResource/lists",params:s})}function n(s){return e.post({url:"/asset.AssetResource/add",params:s})}function A(s){return e.post({url:"/asset.AssetResource/edit",params:s})}function c(s){return e.post({url:"/asset.AssetResource/delete",params:s})}export{o as a,c as b,A as c,n as d,r as e,a as f,i as g,u as h};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-D-eHFthM.js";import"./.pnpm-D8IOIRnm.js";import"./index-DfHLSacw.js";import"./index-Zs1kxO8U.js";import"./picker-CATr7Rqz.js";import"./index-LuA5g-VX.js";import"./index.vue_vue_type_script_setup_true_lang-D3COGOx-.js";import"./article-bnFzIDeX.js";import"./usePaging-DYO4VVWq.js";import"./picker-jKtBhV23.js";import"./index-BO7hrwDl.js";import"./index-1YRXJLNz.js";import"./file-ZgzS-7se.js";import"./index.vue_vue_type_script_setup_true_lang-DwIj7MLL.js";import"./index.vue_vue_type_script_setup_true_lang-Da3pYTW9.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as m}from"./attr.vue_vue_type_script_setup_true_lang-B4PfotVc.js";import"./.pnpm-Dwh6tNxD.js";export{m as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-B9-lm8bS.js";import"./.pnpm-D8IOIRnm.js";import"./add-nav.vue_vue_type_script_setup_true_lang-u_Jd9hfC.js";import"./index-DfHLSacw.js";import"./index-Zs1kxO8U.js";import"./picker-CATr7Rqz.js";import"./index-LuA5g-VX.js";import"./index.vue_vue_type_script_setup_true_lang-D3COGOx-.js";import"./article-bnFzIDeX.js";import"./usePaging-DYO4VVWq.js";import"./picker-jKtBhV23.js";import"./index-BO7hrwDl.js";import"./index-1YRXJLNz.js";import"./file-ZgzS-7se.js";import"./index.vue_vue_type_script_setup_true_lang-DwIj7MLL.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-CVCkEkKY.js";import"./.pnpm-Dwh6tNxD.js";import"./index.vue_vue_type_script_setup_true_lang-Cq5qrGbU.js";import"./picker-Dr1-lH_q.js";import"./index-De13kk0O.js";import"./index-CLwe6ftX.js";import"./index-BhX1mXHA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./index-DUJcOhOZ.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./usePaging-DOuAwzL9.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-DiYI-N-j.js";import"./.pnpm-Dwh6tNxD.js";import"./picker-Dr1-lH_q.js";import"./index-De13kk0O.js";import"./index-CLwe6ftX.js";import"./index-BhX1mXHA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./index-DUJcOhOZ.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./usePaging-DOuAwzL9.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as m}from"./attr.vue_vue_type_script_setup_true_lang--WwiPUCL.js";import"./.pnpm-Dwh6tNxD.js";export{m as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-SwwwDk4U.js";import"./.pnpm-Dwh6tNxD.js";import"./add-nav.vue_vue_type_script_setup_true_lang-Cd2ygMrI.js";import"./index-DUJcOhOZ.js";import"./index-CLwe6ftX.js";import"./picker-DYZuEtRo.js";import"./index-De13kk0O.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Cg76y5pa.js";import"./usePaging-DOuAwzL9.js";import"./picker-Dr1-lH_q.js";import"./index-BhX1mXHA.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-C2wYb6Py.js";import"./.pnpm-Dwh6tNxD.js";import"./index-DUJcOhOZ.js";import"./index-CLwe6ftX.js";import"./picker-DYZuEtRo.js";import"./index-De13kk0O.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Cg76y5pa.js";import"./usePaging-DOuAwzL9.js";import"./picker-Dr1-lH_q.js";import"./index-BhX1mXHA.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./index.vue_vue_type_script_setup_true_lang-Cq5qrGbU.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-CKi5mc6k.js";import"./.pnpm-D8IOIRnm.js";import"./index-DfHLSacw.js";import"./index-Zs1kxO8U.js";import"./picker-CATr7Rqz.js";import"./index-LuA5g-VX.js";import"./index.vue_vue_type_script_setup_true_lang-D3COGOx-.js";import"./article-bnFzIDeX.js";import"./usePaging-DYO4VVWq.js";import"./picker-jKtBhV23.js";import"./index-BO7hrwDl.js";import"./index-1YRXJLNz.js";import"./file-ZgzS-7se.js";import"./index.vue_vue_type_script_setup_true_lang-DwIj7MLL.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-D91XaciT.js";import"./.pnpm-Dwh6tNxD.js";import"./index-DUJcOhOZ.js";import"./index-CLwe6ftX.js";import"./picker-DYZuEtRo.js";import"./index-De13kk0O.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Cg76y5pa.js";import"./usePaging-DOuAwzL9.js";import"./picker-Dr1-lH_q.js";import"./index-BhX1mXHA.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as m}from"./attr.vue_vue_type_script_setup_true_lang-ZRl2Gu1l.js";import"./.pnpm-D8IOIRnm.js";export{m as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-C-C-sXP0.js";import"./.pnpm-D8IOIRnm.js";import"./index.vue_vue_type_script_setup_true_lang-Da3pYTW9.js";import"./picker-jKtBhV23.js";import"./index-LuA5g-VX.js";import"./index-Zs1kxO8U.js";import"./index-BO7hrwDl.js";import"./index.vue_vue_type_script_setup_true_lang-D3COGOx-.js";import"./index-DfHLSacw.js";import"./index-1YRXJLNz.js";import"./file-ZgzS-7se.js";import"./index.vue_vue_type_script_setup_true_lang-DwIj7MLL.js";import"./usePaging-DYO4VVWq.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as m}from"./attr.vue_vue_type_script_setup_true_lang-BugyX-pI.js";import"./.pnpm-D8IOIRnm.js";export{m as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as b,q as c,r as V,s as v,v as t,D as a,Y as x,X as E,u as l,b9 as g,F as k,p as w}from"./.pnpm-D8IOIRnm.js";import{_ as p}from"./menu-set.vue_vue_type_script_setup_true_lang-CuiSzQ2d.js";import"./index-DfHLSacw.js";import"./index-Zs1kxO8U.js";import"./picker-CATr7Rqz.js";import"./index-LuA5g-VX.js";import"./index.vue_vue_type_script_setup_true_lang-D3COGOx-.js";import"./article-bnFzIDeX.js";import"./usePaging-DYO4VVWq.js";import"./picker-jKtBhV23.js";import"./index-BO7hrwDl.js";import"./index-1YRXJLNz.js";import"./file-ZgzS-7se.js";import"./index.vue_vue_type_script_setup_true_lang-DwIj7MLL.js";const $=b({__name:"attr",props:{modelValue:{type:Object,default:()=>({nav:[],menu:{}})}},emits:["update:modelValue"],setup(s,{emit:u}){const d=s,i=u,o=w({get(){return d.modelValue},set(n){i("update:modelValue",n)}});return(n,e)=>{const r=E,f=x,_=g;return c(),V(k,null,[e[2]||(e[2]=v("div",{class:"title flex items-center before:w-[3px] before:h-[14px] before:block before:bg-primary before:mr-2"}," pc导航设置 ",-1)),t(_,{class:"mt-4","label-width":"70px"},{default:a(()=>[t(f,{"model-value":"nav"},{default:a(()=>[t(r,{label:"主导航设置",name:"nav"},{default:a(()=>[t(p,{modelValue:l(o).nav,"onUpdate:modelValue":e[0]||(e[0]=m=>l(o).nav=m)},null,8,["modelValue"])]),_:1}),t(r,{label:"菜单设置",name:"menu"},{default:a(()=>[t(p,{modelValue:l(o).menu,"onUpdate:modelValue":e[1]||(e[1]=m=>l(o).menu=m)},null,8,["modelValue"])]),_:1})]),_:1})]),_:1})],64)}}});export{$ as default};
|
||||
import{o as b,q as c,r as V,s as v,v as t,D as a,Y as x,X as E,u as l,b9 as g,F as k,p as w}from"./.pnpm-Dwh6tNxD.js";import{_ as p}from"./menu-set.vue_vue_type_script_setup_true_lang-CbLdZhC4.js";import"./index-DUJcOhOZ.js";import"./index-CLwe6ftX.js";import"./picker-DYZuEtRo.js";import"./index-De13kk0O.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Cg76y5pa.js";import"./usePaging-DOuAwzL9.js";import"./picker-Dr1-lH_q.js";import"./index-BhX1mXHA.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";const $=b({__name:"attr",props:{modelValue:{type:Object,default:()=>({nav:[],menu:{}})}},emits:["update:modelValue"],setup(s,{emit:u}){const d=s,i=u,o=w({get(){return d.modelValue},set(n){i("update:modelValue",n)}});return(n,e)=>{const r=E,f=x,_=g;return c(),V(k,null,[e[2]||(e[2]=v("div",{class:"title flex items-center before:w-[3px] before:h-[14px] before:block before:bg-primary before:mr-2"}," pc导航设置 ",-1)),t(_,{class:"mt-4","label-width":"70px"},{default:a(()=>[t(f,{"model-value":"nav"},{default:a(()=>[t(r,{label:"主导航设置",name:"nav"},{default:a(()=>[t(p,{modelValue:l(o).nav,"onUpdate:modelValue":e[0]||(e[0]=m=>l(o).nav=m)},null,8,["modelValue"])]),_:1}),t(r,{label:"菜单设置",name:"menu"},{default:a(()=>[t(p,{modelValue:l(o).menu,"onUpdate:modelValue":e[1]||(e[1]=m=>l(o).menu=m)},null,8,["modelValue"])]),_:1})]),_:1})]),_:1})],64)}}});export{$ as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-BQ6gAYGK.js";import"./.pnpm-D8IOIRnm.js";import"./index-DfHLSacw.js";import"./index-Zs1kxO8U.js";import"./picker-CATr7Rqz.js";import"./index-LuA5g-VX.js";import"./index.vue_vue_type_script_setup_true_lang-D3COGOx-.js";import"./article-bnFzIDeX.js";import"./usePaging-DYO4VVWq.js";import"./picker-jKtBhV23.js";import"./index-BO7hrwDl.js";import"./index-1YRXJLNz.js";import"./file-ZgzS-7se.js";import"./index.vue_vue_type_script_setup_true_lang-DwIj7MLL.js";export{o as default};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user