feat: confirm report delivery in admin

This commit is contained in:
malei 2026-08-02 00:15:00 +08:00
parent 9199072435
commit 21cb134a29
3 changed files with 146 additions and 6 deletions

View File

@ -24,6 +24,10 @@ export const cancelAppointment = (id: number) =>
export const getReportList = (params: Record<string, unknown> = {}) => export const getReportList = (params: Record<string, unknown> = {}) =>
apiGet('/report/list', params) apiGet('/report/list', params)
/** 员工在真实发送完成后显式确认;复制链接或打开 H5 不会自动确认。 */
export const confirmReportSent = (reportId: number, channel: 'wechat' | 'qr' | 'other') =>
apiPost('/report/confirm-sent', { reportId, channel })
export const startHighlight = (reportId: number) => export const startHighlight = (reportId: number) =>
apiPost('/report/highlight/start', { reportId }) apiPost('/report/highlight/start', { reportId })

View File

@ -8,6 +8,11 @@
<el-option label="失败" value="failed" /> <el-option label="失败" value="failed" />
<el-option label="异常(失败+超时)" value="anomaly" /> <el-option label="异常(失败+超时)" value="anomaly" />
</el-select> </el-select>
<el-select v-model="sendStatus" clearable placeholder="发送状态" style="width: 150px">
<el-option label="待发送" value="unsent" />
<el-option label="已发送" value="sent" />
<el-option label="历史未知" value="unknown" />
</el-select>
<el-input v-model="keyword" clearable placeholder="宠物名 / 报告ID" style="width: 180px" /> <el-input v-model="keyword" clearable placeholder="宠物名 / 报告ID" style="width: 180px" />
<el-button type="primary" @click="load">查询</el-button> <el-button type="primary" @click="load">查询</el-button>
</div> </div>
@ -22,10 +27,34 @@
<div v-if="row.highlightFailReasonLabel" class="fail">{{ row.highlightFailReasonLabel }}</div> <div v-if="row.highlightFailReasonLabel" class="fail">{{ row.highlightFailReasonLabel }}</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="280" fixed="right"> <el-table-column label="发送" width="130">
<template #default="{ row }">
<el-tag :type="sendTagType(row.sendStatus)" effect="light">
{{ sendLabel(row.sendStatus) }}
</el-tag>
<div v-if="row.sentAt" class="sent-at">{{ row.sentAt }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="380" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button> <el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" @click="copyLink(row)">复制链接</el-button> <el-button link type="primary" @click="copyLink(row)">复制链接</el-button>
<el-dropdown
v-if="row.sendStatus !== 'sent'"
:disabled="confirmingId === row.id"
@command="onTableConfirm(row, $event)"
>
<el-button link type="success" :loading="confirmingId === row.id">
确认已发
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="wechat">微信消息</el-dropdown-item>
<el-dropdown-item command="qr">当面扫码</el-dropdown-item>
<el-dropdown-item command="other">其他方式</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button <el-button
v-if="row.highlightVideoStatus === 'failed' || isStale(row)" v-if="row.highlightVideoStatus === 'failed' || isStale(row)"
link link
@ -43,6 +72,9 @@
<el-descriptions-item label="服务">{{ current.serviceType }}</el-descriptions-item> <el-descriptions-item label="服务">{{ current.serviceType }}</el-descriptions-item>
<el-descriptions-item label="预约时间">{{ current.appointmentTime }}</el-descriptions-item> <el-descriptions-item label="预约时间">{{ current.appointmentTime }}</el-descriptions-item>
<el-descriptions-item label="成片">{{ hlLabel(current) }}</el-descriptions-item> <el-descriptions-item label="成片">{{ hlLabel(current) }}</el-descriptions-item>
<el-descriptions-item label="发送状态">{{ sendLabel(current.sendStatus) }}</el-descriptions-item>
<el-descriptions-item v-if="current.sentAt" label="首次确认发送">{{ current.sentAt }}</el-descriptions-item>
<el-descriptions-item v-if="current.sendChannel" label="发送方式">{{ channelLabel(current.sendChannel) }}</el-descriptions-item>
<el-descriptions-item v-if="current.highlightFailReasonLabel" label="失败原因"> <el-descriptions-item v-if="current.highlightFailReasonLabel" label="失败原因">
{{ current.highlightFailReasonLabel }} {{ current.highlightFailReasonLabel }}
</el-descriptions-item> </el-descriptions-item>
@ -57,6 +89,20 @@
<div class="drawer-actions"> <div class="drawer-actions">
<el-button type="primary" @click="copyLink(current)">复制公开链接</el-button> <el-button type="primary" @click="copyLink(current)">复制公开链接</el-button>
<el-button v-if="current.reportToken" @click="openH5(current)">打开 H5</el-button> <el-button v-if="current.reportToken" @click="openH5(current)">打开 H5</el-button>
<el-dropdown
v-if="current.sendStatus !== 'sent'"
:disabled="confirmingId === current.id"
@command="onCurrentConfirm"
>
<el-button type="success" :loading="confirmingId === current.id">确认已发给宠主</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="wechat">微信消息</el-dropdown-item>
<el-dropdown-item command="qr">当面扫码</el-dropdown-item>
<el-dropdown-item command="other">其他方式</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button <el-button
v-if="current.highlightVideoStatus === 'failed' || isStale(current)" v-if="current.highlightVideoStatus === 'failed' || isStale(current)"
type="warning" type="warning"
@ -71,8 +117,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getReportList, startHighlight } from '@/api' import { confirmReportSent, getReportList, startHighlight } from '@/api'
import { isHighlightProcessingStale, reportPublicUrl } from '@/utils/publicLinks' import { isHighlightProcessingStale, reportPublicUrl } from '@/utils/publicLinks'
type ReportRow = { type ReportRow = {
@ -85,6 +131,9 @@ type ReportRow = {
highlightVideoStatus?: string highlightVideoStatus?: string
highlightFailReasonLabel?: string highlightFailReasonLabel?: string
reportToken?: string reportToken?: string
sendStatus?: 'unknown' | 'unsent' | 'sent'
sentAt?: string
sendChannel?: 'wechat' | 'qr' | 'other'
beforePhotos?: string[] beforePhotos?: string[]
afterPhotos?: string[] afterPhotos?: string[]
} }
@ -93,7 +142,9 @@ const route = useRoute()
const loading = ref(false) const loading = ref(false)
const rows = ref<ReportRow[]>([]) const rows = ref<ReportRow[]>([])
const highlightStatus = ref((route.query.highlightStatus as string) || '') const highlightStatus = ref((route.query.highlightStatus as string) || '')
const sendStatus = ref((route.query.sendStatus as string) || '')
const keyword = ref((route.query.q as string) || '') const keyword = ref((route.query.q as string) || '')
const confirmingId = ref<number | null>(null)
const drawer = ref(false) const drawer = ref(false)
const current = ref<ReportRow | null>(null) const current = ref<ReportRow | null>(null)
@ -114,13 +165,20 @@ function hlLabel(row: ReportRow) {
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const res = await getReportList({ page: 1, pageSize: 200 }) const res = await getReportList({
page: 1,
pageSize: 200,
sendStatus: sendStatus.value || undefined,
})
let list = Array.isArray(res.data) ? (res.data as ReportRow[]) : [] let list = Array.isArray(res.data) ? (res.data as ReportRow[]) : []
if (highlightStatus.value === 'anomaly' || highlightStatus.value === 'failed') { if (highlightStatus.value === 'anomaly' || highlightStatus.value === 'failed') {
list = list.filter((r) => r.highlightVideoStatus === 'failed' || isStale(r)) list = list.filter((r) => r.highlightVideoStatus === 'failed' || isStale(r))
} else if (highlightStatus.value) { } else if (highlightStatus.value) {
list = list.filter((r) => r.highlightVideoStatus === highlightStatus.value) list = list.filter((r) => r.highlightVideoStatus === highlightStatus.value)
} }
if (sendStatus.value) {
list = list.filter((r) => r.sendStatus === sendStatus.value)
}
if (keyword.value.trim()) { if (keyword.value.trim()) {
const q = keyword.value.trim() const q = keyword.value.trim()
list = list.filter((r) => list = list.filter((r) =>
@ -133,6 +191,67 @@ async function load() {
} }
} }
function sendLabel(status?: ReportRow['sendStatus']) {
if (status === 'sent') return '已发送'
if (status === 'unsent') return '待发送'
if (status === 'unknown') return '历史未知'
return '—'
}
function sendTagType(status?: ReportRow['sendStatus']) {
if (status === 'sent') return 'success'
if (status === 'unsent') return 'warning'
return 'info'
}
function channelLabel(channel?: ReportRow['sendChannel']) {
if (channel === 'wechat') return '微信消息'
if (channel === 'qr') return '当面扫码'
if (channel === 'other') return '其他方式'
return '—'
}
async function confirmSent(row: ReportRow, rawChannel: unknown) {
const channel = rawChannel as 'wechat' | 'qr' | 'other'
if (!['wechat', 'qr', 'other'].includes(channel) || row.sendStatus === 'sent') return
try {
await ElMessageBox.confirm(
`确认宠主已通过“${channelLabel(channel)}”实际收到报告?复制链接或预览不算发送。`,
'确认已发送',
{ confirmButtonText: '确认已发送', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return
}
confirmingId.value = row.id
try {
const res = await confirmReportSent(row.id, channel)
if (res.code !== 200 || !res.data) {
ElMessage.error(res.message || '确认失败')
return
}
const data = res.data as Record<string, unknown>
row.sendStatus = 'sent'
row.sentAt = String(data.sentAt || '')
row.sendChannel = (data.sendChannel as ReportRow['sendChannel']) || channel
if (current.value?.id === row.id) current.value = row
ElMessage.success(data.alreadyConfirmed ? '此前已确认发送' : '已记录发送')
} catch {
ElMessage.error('确认失败,请检查网络后重试')
} finally {
confirmingId.value = null
}
}
function onTableConfirm(row: ReportRow, channel: unknown) {
void confirmSent(row, channel)
}
function onCurrentConfirm(channel: unknown) {
if (current.value) void confirmSent(current.value, channel)
}
function openDetail(row: ReportRow) { function openDetail(row: ReportRow) {
current.value = row current.value = row
drawer.value = true drawer.value = true
@ -170,6 +289,11 @@ onMounted(load)
color: #c45656; color: #c45656;
font-size: 12px; font-size: 12px;
} }
.sent-at {
margin-top: 4px;
color: #909399;
font-size: 11px;
}
.media { .media {
margin-top: 16px; margin-top: 16px;
} }

View File

@ -102,7 +102,7 @@
<span class="section-kicker">服务结果</span> <span class="section-kicker">服务结果</span>
<h3>今天的报告走到哪一步</h3> <h3>今天的报告走到哪一步</h3>
</div> </div>
<span class="funnel-note">发送与再次预约将在下一阶段接入真实状态</span> <span class="funnel-note">发送已接入真实确认再次预约仍待归因</span>
</div> </div>
<div class="funnel-grid"> <div class="funnel-grid">
<div v-for="step in funnelSteps" :key="step.label" class="funnel-step"> <div v-for="step in funnelSteps" :key="step.label" class="funnel-step">
@ -129,6 +129,7 @@ type WorkbenchMetrics = {
inServiceCount: number inServiceCount: number
upcomingTodayCount: number upcomingTodayCount: number
highlightAnomalyCount: number highlightAnomalyCount: number
unsentReportCount: number
dueFollowUpCount: number dueFollowUpCount: number
} }
@ -169,6 +170,7 @@ const emptyMetrics: WorkbenchMetrics = {
inServiceCount: 0, inServiceCount: 0,
upcomingTodayCount: 0, upcomingTodayCount: 0,
highlightAnomalyCount: 0, highlightAnomalyCount: 0,
unsentReportCount: 0,
dueFollowUpCount: 0, dueFollowUpCount: 0,
} }
@ -235,6 +237,11 @@ const groupDefinitions: Record<string, { title: string; description: string; to:
description: '失败或处理超过 30 分钟,确认素材后再重试。', description: '失败或处理超过 30 分钟,确认素材后再重试。',
to: { path: '/reports', query: { highlightStatus: 'anomaly' } }, to: { path: '/reports', query: { highlightStatus: 'anomaly' } },
}, },
unsent_report: {
title: '报告尚未发送',
description: '复制链接不等于发送;宠主实际收到后,请在报告列表显式确认。',
to: { path: '/reports', query: { sendStatus: 'unsent' } },
},
follow_up_due: { follow_up_due: {
title: '回访日期已到', title: '回访日期已到',
description: '只显示低敏摘要;进入回访池后再按权限查看联系信息。', description: '只显示低敏摘要;进入回访池后再按权限查看联系信息。',
@ -294,7 +301,12 @@ function goItem(kind: string, item: ActionItem) {
return return
} }
if (item.resourceType === 'report') { if (item.resourceType === 'report') {
go({ path: '/reports', query: { q: String(item.resourceId), highlightStatus: 'anomaly' } }) go({
path: '/reports',
query: kind === 'unsent_report'
? { q: String(item.resourceId), sendStatus: 'unsent' }
: { q: String(item.resourceId), highlightStatus: 'anomaly' },
})
return return
} }
go(groupMeta(kind).to) go(groupMeta(kind).to)