petstore-admin/src/views/WorkbenchView.vue
2026-08-01 23:47:58 +08:00

632 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="page-card workbench">
<header class="workbench-header">
<div>
<div class="eyebrow">TODAY · {{ dateLabel }}</div>
<h2>今天先做什么</h2>
<p>按影响履约的紧急程度排好顺序处理完再看经营结果</p>
</div>
<el-button :loading="loading" @click="load">刷新</el-button>
</header>
<el-alert
v-if="errorMessage"
class="load-error"
type="error"
:closable="false"
:title="errorMessage"
show-icon
/>
<section class="metrics" aria-label="今日概览">
<button
v-for="metric in metricCards"
:key="metric.key"
type="button"
class="metric-card"
@click="go(metric.to)"
>
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small>{{ metric.hint }}</small>
</button>
</section>
<section class="action-section">
<div class="section-heading">
<div>
<span class="section-kicker">行动队列</span>
<h3>先处理会卡住服务的事</h3>
</div>
<span class="action-total">{{ totalActions }} 项待处理</span>
</div>
<div v-if="loading && !workbenchData" class="skeletons">
<el-skeleton v-for="index in 3" :key="index" :rows="2" animated />
</div>
<el-empty
v-else-if="actionGroups.length === 0"
description="当前没有待处理事项,可以安心接待今天的顾客"
/>
<div v-else class="action-groups">
<article
v-for="group in actionGroups"
:key="group.kind"
class="action-group"
:class="`urgency-${group.urgency}`"
>
<div class="action-group-head">
<div class="action-copy">
<span class="priority-dot" />
<div>
<div class="action-title-line">
<h4>{{ groupMeta(group.kind).title }}</h4>
<el-tag :type="tagType(group.urgency)" effect="light" round>
{{ group.count }} 项
</el-tag>
</div>
<p>{{ groupMeta(group.kind).description }}</p>
</div>
</div>
<el-button type="primary" link @click="go(groupMeta(group.kind).to)">
全部处理
</el-button>
</div>
<div class="action-items">
<button
v-for="item in group.items"
:key="`${item.resourceType}-${item.resourceId}`"
type="button"
class="action-item"
@click="goItem(group.kind, item)"
>
<span class="item-main">
<strong>{{ item.petName || '未命名宠物' }}</strong>
<span>{{ item.serviceType || '服务待确认' }}</span>
</span>
<span class="item-time">{{ formatSchedule(item.scheduledAt) }}</span>
</button>
</div>
<div v-if="group.count > group.items.length" class="remaining">
另有 {{ group.count - group.items.length }} 项,点击“全部处理”查看
</div>
</article>
</div>
</section>
<section class="funnel-section">
<div class="section-heading compact">
<div>
<span class="section-kicker">服务结果</span>
<h3>今天的报告走到哪一步</h3>
</div>
<span class="funnel-note">发送与再次预约将在下一阶段接入真实状态</span>
</div>
<div class="funnel-grid">
<div v-for="step in funnelSteps" :key="step.label" class="funnel-step">
<span>{{ step.label }}</span>
<strong>{{ step.value }}</strong>
<small>{{ step.hint }}</small>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getWorkbenchActions } from '@/api'
type RouteTarget = { path: string; query?: Record<string, string | undefined> }
type WorkbenchMetrics = {
todayAppointmentCount: number
todayDoneCount: number
overdueAppointmentCount: number
inServiceCount: number
upcomingTodayCount: number
highlightAnomalyCount: number
dueFollowUpCount: number
}
type ActionItem = {
resourceType: 'appointment' | 'report' | 'lead'
resourceId: number
petName?: string | null
serviceType?: string | null
scheduledAt?: string | null
status?: string | null
overdue: boolean
}
type ActionGroup = {
kind: string
urgency: 'critical' | 'high' | 'normal'
count: number
items: ActionItem[]
}
type WorkbenchData = {
date: string
generatedAt: string
metrics: WorkbenchMetrics
actions: ActionGroup[]
reportFunnel: Record<string, unknown>
}
const router = useRouter()
const loading = ref(false)
const errorMessage = ref('')
const workbenchData = ref<WorkbenchData | null>(null)
const emptyMetrics: WorkbenchMetrics = {
todayAppointmentCount: 0,
todayDoneCount: 0,
overdueAppointmentCount: 0,
inServiceCount: 0,
upcomingTodayCount: 0,
highlightAnomalyCount: 0,
dueFollowUpCount: 0,
}
const metrics = computed(() => workbenchData.value?.metrics || emptyMetrics)
const actionGroups = computed(() => workbenchData.value?.actions || [])
const totalActions = computed(() => actionGroups.value.reduce((sum, group) => sum + Number(group.count || 0), 0))
const dateLabel = computed(() => {
const raw = workbenchData.value?.date
if (!raw) return '加载中'
const date = new Date(`${raw}T00:00:00`)
return `${date.getMonth() + 1}${date.getDate()}日 · 周${'日一二三四五六'[date.getDay()]}`
})
const metricCards = computed(() => [
{
key: 'today',
label: '今日预约',
value: metrics.value.todayAppointmentCount,
hint: `还有 ${metrics.value.upcomingTodayCount} 位待到店`,
to: { path: '/appointments', query: { date: 'today' } },
},
{
key: 'overdue',
label: '逾期未开始',
value: metrics.value.overdueAppointmentCount,
hint: '优先确认到店或改约',
to: { path: '/appointments', query: { status: 'new', overdue: '1' } },
},
{
key: 'doing',
label: '服务中',
value: metrics.value.inServiceCount,
hint: '完成报告才能闭环',
to: { path: '/appointments', query: { status: 'doing', missingReport: '1' } },
},
{
key: 'done',
label: '今日完成',
value: metrics.value.todayDoneCount,
hint: '已形成完整服务记录',
to: { path: '/appointments', query: { status: 'done', completedToday: '1' } },
},
] satisfies Array<{ key: string; label: string; value: number; hint: string; to: RouteTarget }>)
const groupDefinitions: Record<string, { title: string; description: string; to: RouteTarget }> = {
overdue_appointment: {
title: '预约已过时间',
description: '先确认顾客是否到店;未到店就改约或取消,别让状态悬着。',
to: { path: '/appointments', query: { status: 'new', overdue: '1' } },
},
in_service: {
title: '服务中,等待收尾',
description: '补齐服务前后照片与报告,提交后预约才会完成。',
to: { path: '/appointments', query: { status: 'doing', missingReport: '1' } },
},
upcoming_today: {
title: '今天接下来要到店',
description: '提前看服务项目和时长,安排好工位与接待。',
to: { path: '/appointments', query: { status: 'new', date: 'today' } },
},
highlight_anomaly: {
title: '服务短片异常',
description: '失败或处理超过 30 分钟,确认素材后再重试。',
to: { path: '/reports', query: { highlightStatus: 'anomaly' } },
},
follow_up_due: {
title: '回访日期已到',
description: '只显示低敏摘要;进入回访池后再按权限查看联系信息。',
to: { path: '/leads', query: { due: 'today' } },
},
}
function groupMeta(kind: string) {
return groupDefinitions[kind] || {
title: '其他待办',
description: '进入对应列表继续处理。',
to: { path: '/workbench' },
}
}
const funnelSteps = computed(() => {
const funnel = workbenchData.value?.reportFunnel || {}
const sentReady = funnel.reportSentTrackingReady === true
const rebookReady = funnel.rebookTrackingReady === true
return [
{ label: '报告提交', value: Number(funnel.reportSubmittedCount || 0), hint: '服务已完成' },
{ label: '确认发送', value: sentReady ? Number(funnel.reportSentCount || 0) : '—', hint: sentReady ? '门店已发送' : '状态待接入' },
{ label: '宠主打开', value: Number(funnel.reportOpenedCount || 0), hint: '按报告去重' },
{ label: '留下提醒', value: Number(funnel.leadSubmittedCount || 0), hint: '按线索去重' },
{ label: '再次预约', value: rebookReady ? Number(funnel.rebookCreatedCount || 0) : '—', hint: rebookReady ? '已归因' : '归因待接入' },
]
})
async function load() {
loading.value = true
errorMessage.value = ''
try {
const response = await getWorkbenchActions()
if (response.code !== 200 || !response.data) {
errorMessage.value = response.message || '工作台加载失败,请稍后重试'
return
}
workbenchData.value = response.data as WorkbenchData
} catch {
errorMessage.value = '工作台加载失败,请检查网络后重试'
} finally {
loading.value = false
}
}
function go(to: RouteTarget) {
const query: Record<string, string> = {}
for (const [key, value] of Object.entries(to.query || {})) {
if (value != null) query[key] = value
}
router.push({ path: to.path, query })
}
function goItem(kind: string, item: ActionItem) {
if (item.resourceType === 'appointment') {
go({ path: '/appointments', query: { id: String(item.resourceId) } })
return
}
if (item.resourceType === 'report') {
go({ path: '/reports', query: { q: String(item.resourceId), highlightStatus: 'anomaly' } })
return
}
go(groupMeta(kind).to)
}
function formatSchedule(value?: string | null) {
if (!value) return '日期待确认'
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return `${Number(value.slice(5, 7))}${Number(value.slice(8, 10))}`
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const isToday = workbenchData.value?.date === value.slice(0, 10)
const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
return isToday ? `今天 ${time}` : `${date.getMonth() + 1}${date.getDate()}${time}`
}
function tagType(urgency: ActionGroup['urgency']) {
if (urgency === 'critical') return 'danger'
if (urgency === 'high') return 'warning'
return 'info'
}
onMounted(load)
</script>
<style scoped>
.workbench {
padding: 0;
overflow: hidden;
background: transparent;
}
.workbench-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 26px 28px 24px;
color: #fff;
background:
radial-gradient(circle at 82% 12%, rgb(255 255 255 / 18%), transparent 28%),
linear-gradient(135deg, #2457b8 0%, var(--admin-accent) 58%, #4f8cff 100%);
}
.eyebrow,
.section-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
}
.eyebrow {
color: rgb(255 255 255 / 72%);
}
.workbench-header h2 {
margin: 8px 0 6px;
font-size: 28px;
letter-spacing: -0.02em;
}
.workbench-header p {
margin: 0;
color: rgb(255 255 255 / 78%);
}
.workbench-header :deep(.el-button) {
color: #fff;
border-color: rgb(255 255 255 / 42%);
background: rgb(255 255 255 / 12%);
}
.load-error {
margin: 16px 20px 0;
}
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding: 18px 20px 0;
}
.metric-card {
display: grid;
gap: 7px;
min-height: 120px;
padding: 17px;
text-align: left;
color: #1f2a37;
cursor: pointer;
background: #fff;
border: 1px solid #e5eaf1;
border-radius: 12px;
box-shadow: 0 8px 24px rgb(31 42 55 / 5%);
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease;
}
.metric-card:hover {
border-color: #b7caef;
box-shadow: 0 12px 30px rgb(47 111 237 / 10%);
transform: translateY(-2px);
}
.metric-card span {
color: #657184;
font-size: 13px;
}
.metric-card strong {
font-size: 30px;
line-height: 1;
}
.metric-card small {
color: #8a94a6;
}
.action-section,
.funnel-section {
margin: 16px 20px 0;
padding: 22px;
background: #fff;
border: 1px solid #e8edf3;
border-radius: 14px;
}
.funnel-section {
margin-bottom: 20px;
}
.section-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
.section-heading.compact {
align-items: center;
}
.section-kicker {
color: var(--admin-accent);
}
.section-heading h3 {
margin: 5px 0 0;
font-size: 18px;
}
.action-total,
.funnel-note {
color: #7a8494;
font-size: 12px;
}
.action-groups {
display: grid;
gap: 12px;
}
.action-group {
position: relative;
padding: 16px 16px 14px 18px;
overflow: hidden;
background: #fbfcfe;
border: 1px solid #e8edf3;
border-radius: 10px;
}
.action-group::before {
position: absolute;
inset: 0 auto 0 0;
width: 3px;
content: '';
background: #9aa4b2;
}
.action-group.urgency-critical::before { background: #d64545; }
.action-group.urgency-high::before { background: #d38a20; }
.action-group.urgency-normal::before { background: var(--admin-accent); }
.action-group-head,
.action-copy,
.action-title-line,
.item-main {
display: flex;
align-items: center;
}
.action-group-head {
justify-content: space-between;
gap: 16px;
}
.action-copy {
align-items: flex-start;
gap: 11px;
}
.priority-dot {
width: 9px;
height: 9px;
margin-top: 6px;
background: #9aa4b2;
border-radius: 50%;
}
.urgency-critical .priority-dot { background: #d64545; }
.urgency-high .priority-dot { background: #d38a20; }
.urgency-normal .priority-dot { background: var(--admin-accent); }
.action-title-line {
gap: 10px;
}
.action-title-line h4 {
margin: 0;
font-size: 15px;
}
.action-copy p {
margin: 5px 0 0;
color: #6d7888;
font-size: 13px;
}
.action-items {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 13px;
}
.action-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
text-align: left;
color: inherit;
cursor: pointer;
background: #fff;
border: 1px solid #e7ebf1;
border-radius: 8px;
}
.action-item:hover {
color: var(--admin-accent);
border-color: #b7caef;
}
.item-main {
align-items: flex-start;
flex-direction: column;
min-width: 0;
}
.item-main strong,
.item-main span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-main span,
.item-time,
.remaining {
color: #7a8494;
font-size: 12px;
}
.item-time {
flex: none;
}
.remaining {
margin: 10px 0 0 20px;
}
.funnel-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
background: #e7ebf1;
border: 1px solid #e7ebf1;
border-radius: 10px;
}
.funnel-step {
display: grid;
gap: 6px;
min-height: 100px;
padding: 14px;
background: #f8fafc;
}
.funnel-step span,
.funnel-step small {
color: #718096;
font-size: 12px;
}
.funnel-step strong {
font-size: 23px;
}
.skeletons {
display: grid;
gap: 14px;
}
@media (max-width: 1080px) {
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.action-items { grid-template-columns: 1fr; }
.funnel-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.workbench-header,
.section-heading,
.action-group-head {
align-items: flex-start;
flex-direction: column;
}
.metrics { grid-template-columns: 1fr; }
.funnel-grid { grid-template-columns: 1fr; }
}
</style>