Files
qiji/frontend/src/views/desktop/LightBoard.vue
T
2026-07-12 16:35:00 +08:00

393 lines
23 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.
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard'
import { ElMessage } from 'element-plus'
import api from '@/api/index'
import { todayStr } from '@/utils'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const board = ref<any>(null)
const expandedManagers = ref<Set<string>>(new Set())
const monthOffset = ref(0)
// ── Single dialog state ──
const dialogVisible = ref(false)
const selectedCustomer = ref<any>(null)
const planDate = ref(todayStr())
const planContent = ref('')
const planSaving = ref(false)
const referenceMonth = computed(() => {
const d = new Date()
d.setMonth(d.getMonth() + monthOffset.value)
return d.toISOString().slice(0, 7)
})
const isCurrentMonth = computed(() => monthOffset.value >= 0)
function changeMonth(delta: number) { monthOffset.value += delta; loadData() }
function goCurrentMonth() { monthOffset.value = 0; loadData() }
function toggleManager(id: string) {
if (expandedManagers.value.has(id)) expandedManagers.value.delete(id)
else expandedManagers.value.add(id)
}
async function loadData() {
loading.value = true
try {
const refDate = new Date()
refDate.setMonth(refDate.getMonth() + monthOffset.value)
const res = await dashboardApi.getLightBoard({ reference_date: refDate.toISOString().slice(0, 10) })
board.value = res.data
} catch (e: any) {
ElMessage.error('加载亮灯表失败')
} finally { loading.value = false }
}
onMounted(loadData)
function goManagerReport(managerId: string) {
router.push({ path: '/weekly-report', query: { manager_id: managerId } })
}
const coverageColor = (rate: number): string => {
if (rate >= 0.8) return 'var(--sage)'
if (rate >= 0.5) return 'var(--gold)'
return 'var(--vermilion)'
}
// ── Customer Dialog ──
function openCustomerDialog(cust: any) {
selectedCustomer.value = cust
planContent.value = ''
// Default plan date: today for red, Friday for yellow
if (cust.status === 'red') {
planDate.value = todayStr()
} else if (cust.status === 'yellow') {
const d = new Date()
d.setDate(d.getDate() + ((5 - d.getDay()) % 7 + 7) % 7 || 7)
planDate.value = d.toISOString().slice(0, 10)
} else {
planDate.value = todayStr()
}
dialogVisible.value = true
}
async function handleCreatePlan() {
if (!planContent.value.trim()) { ElMessage.warning('请输入计划内容'); return }
planSaving.value = true
try {
await api.post('/work-plans/', {
customer_id: selectedCustomer.value.customer_id,
plan_content: planContent.value.trim(),
plan_date: planDate.value,
status: '计划中',
})
ElMessage.success('拜访计划已制定')
planContent.value = ''
dialogVisible.value = false
await loadData() // Refresh to show new plan on card
} catch (e: any) {
ElMessage.error('制定失败: ' + (e.response?.data?.detail || e.message))
} finally { planSaving.value = false }
}
function goWeeklyReport(customerId: string) {
router.push({ path: '/weekly-report', query: { customer_id: customerId } })
}
// Status label map
const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow: '31-60天前 (临期)', red: '急需拜访', gray: '未分配' }
</script>
<template>
<div class="light-board" v-loading="loading">
<!-- ═══ Page Header ═══ -->
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">客户拜访亮灯表</h2>
<div class="month-nav">
<button class="month-nav-btn" @click="changeMonth(-1)"></button>
<span class="month-label">{{ referenceMonth }}</span>
<button class="month-nav-btn" @click="changeMonth(1)" :disabled="isCurrentMonth"></button>
<button v-if="!isCurrentMonth" class="month-nav-reset" @click="goCurrentMonth">回到本月</button>
</div>
</div>
</div>
<div class="page-rule"></div>
</div>
<!-- ═══ Team Summary Bar ═══ -->
<div v-if="board" class="team-bar">
<div class="team-stat"><span class="team-stat-num">{{ board.team_summary.total_customers }}</span><span class="team-stat-label">总客户</span></div>
<div class="team-stat team-stat--green"><span class="team-stat-num">{{ board.team_summary.visited_this_month }}</span><span class="team-stat-label">已拜访</span></div>
<div class="team-stat team-stat--yellow"><span class="team-stat-num">{{ board.team_summary.visited_last_month_only }}</span><span class="team-stat-label">临期</span></div>
<div class="team-stat team-stat--red"><span class="team-stat-num">{{ board.team_summary.not_visited_2months }}</span><span class="team-stat-label">未拜访</span></div>
<div class="team-stat" v-if="board.team_summary.unassigned"><span class="team-stat-num">{{ board.team_summary.unassigned }}</span><span class="team-stat-label">未分配</span></div>
<div class="team-stat team-stat--coverage"><span class="team-stat-num">{{ (board.team_summary.coverage_rate * 100).toFixed(0) }}%</span><span class="team-stat-label">覆盖率</span></div>
</div>
<!-- ═══ Manager Rows ═══ -->
<div v-if="board" class="manager-list">
<div v-for="m in board.managers" :key="m.manager_id" class="manager-row" :class="{ 'manager-row--expanded': expandedManagers.has(m.manager_id) }">
<div class="manager-summary" @click="toggleManager(m.manager_id)">
<div class="manager-info">
<span class="manager-expand">{{ expandedManagers.has(m.manager_id) ? '▼' : '▶' }}</span>
<span class="manager-name" @click.stop="goManagerReport(m.manager_id)">{{ m.manager_name }}</span>
<span class="manager-count">{{ m.total_customers }} 个客户</span>
<span class="manager-metrics">
已拜访<strong>{{ m.visited_this_month }}</strong> ·
未拜访<strong>{{ m.not_visited_2months }}</strong> ·
完成率<strong :style="{ color: coverageColor(m.coverage_rate) }">{{ (m.coverage_rate * 100).toFixed(0) }}%</strong>
</span>
</div>
<div class="manager-lights">
<span class="light-dot light-dot--green">{{ m.visited_this_month }}</span>
<span class="light-dot light-dot--yellow">{{ m.visited_last_month_only }}</span>
<span class="light-dot light-dot--red">{{ m.not_visited_2months }}</span>
</div>
<div class="manager-coverage">
<div class="coverage-bar"><div class="coverage-fill" :style="{ width: (m.coverage_rate * 100) + '%', background: coverageColor(m.coverage_rate) }"></div></div>
<span class="coverage-pct" :style="{ color: coverageColor(m.coverage_rate) }">{{ (m.coverage_rate * 100).toFixed(0) }}%</span>
</div>
</div>
<div v-if="expandedManagers.has(m.manager_id)" class="customer-grid">
<div v-for="cust in m.customers" :key="cust.customer_id" class="customer-card" :class="`customer-card--${cust.status}`" @click="openCustomerDialog(cust)">
<div class="card-status-stripe"></div>
<div class="card-body">
<div class="card-name-row">
<span class="status-circle" :class="`status-circle--${cust.status}`"></span>
<strong class="card-name">{{ cust.customer_name }}</strong>
</div>
<div class="card-meta">
<span v-if="cust.industry" class="card-industry">{{ cust.industry }}</span>
<span v-if="cust.in_use_services" class="card-services">{{ cust.in_use_services }}</span>
</div>
<div class="card-visit-info">
<span class="card-last-visit" v-if="cust.last_visit_date">{{ { green: '最近', yellow: '上次', red: '上次' }[cust.status] }}{{ cust.last_visit_date }}</span>
<span class="card-last-visit card-last-visit--never" v-else>从未拜访</span>
<span v-if="cust.last_visit_method" class="card-method">{{ cust.last_visit_method }}</span>
</div>
<!-- Plan badges -->
<div v-if="cust.plans?.length" class="card-plans">
<span v-for="p in cust.plans" :key="p.plan_date" class="plan-badge" :class="{ 'plan-overdue': p.plan_overdue }">
{{ p.plan_overdue ? '⚠ 过期' : '📅' }} {{ p.plan_date }}
</span>
</div>
<div v-if="cust.status === 'red' && cust.consecutive_missed_months > 0" class="card-warning">
连续 {{ cust.consecutive_missed_months }} 个周期未拜访
</div>
</div>
</div>
<div v-if="m.customers.length === 0" class="empty">暂无客户</div>
</div>
</div>
<!-- ═══ Unassigned Customers ═══ -->
<div v-if="board.unassigned_customers.length > 0" class="manager-row unassigned-section">
<div class="manager-summary unassigned-summary" @click="toggleManager('unassigned')">
<div class="manager-info">
<span class="manager-expand">{{ expandedManagers.has('unassigned') ? '▼' : '▶' }}</span>
<span class="manager-name" style="color: var(--warm-gray)">未分配客户</span>
<span class="manager-count">{{ board.unassigned_customers.length }} </span>
</div>
</div>
<div v-if="expandedManagers.has('unassigned')" class="customer-grid">
<div v-for="cust in board.unassigned_customers" :key="cust.customer_id" class="customer-card customer-card--gray" @click="router.push('/customers')">
<div class="card-status-stripe"></div>
<div class="card-body">
<div class="card-name-row"><span class="status-circle status-circle--gray"></span><strong class="card-name">{{ cust.customer_name }}</strong></div>
<div class="card-meta"><span v-if="cust.industry" class="card-industry">{{ cust.industry }}</span></div>
<div class="card-visit-info"><span class="card-last-visit card-last-visit--never">未分配客户经理</span></div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!loading && !board" class="empty-state"><p class="empty-text">暂无数据</p></div>
<!-- ═══ Customer Detail Dialog ═══ -->
<el-dialog v-model="dialogVisible" :title="selectedCustomer?.customer_name" width="520px" v-if="selectedCustomer">
<!-- Status header -->
<div class="dlg-status" :class="`dlg-status--${selectedCustomer.status}`">
{{ statusLabel[selectedCustomer.status] }}
<span v-if="selectedCustomer.consecutive_missed_months > 1" style="margin-left:6px">
(连续 {{ selectedCustomer.consecutive_missed_months }} 个周期)
</span>
</div>
<!-- Customer Info -->
<div class="dlg-info">
<div class="dlg-info-row" v-if="selectedCustomer.industry"><span class="dlg-label">行业</span><span>{{ selectedCustomer.industry }}</span></div>
<div class="dlg-info-row" v-if="selectedCustomer.in_use_services"><span class="dlg-label">在用业务</span><span>{{ selectedCustomer.in_use_services }}</span></div>
<div class="dlg-info-row" v-if="selectedCustomer.monthly_fee"><span class="dlg-label">月费</span><span>{{ selectedCustomer.monthly_fee }}</span></div>
<div class="dlg-info-row"><span class="dlg-label">上次拜访</span><span>{{ selectedCustomer.last_visit_date || '从未' }}<span v-if="selectedCustomer.last_visit_method"> · {{ selectedCustomer.last_visit_method }}</span></span></div>
</div>
<!-- Recent Visits (green/yellow) -->
<div v-if="selectedCustomer.recent_visits?.length" class="dlg-section">
<h4 class="dlg-section-title">最近拜访记录</h4>
<div v-for="v in selectedCustomer.recent_visits" :key="v.visit_date" class="dlg-visit-item">
<span class="dlg-vdate">{{ v.visit_date }}</span>
<span class="dlg-vmethod">{{ v.visit_method }}</span>
<span class="dlg-vcontent">{{ v.content }}</span>
</div>
</div>
<!-- Existing Plans -->
<div v-if="selectedCustomer.plans?.length" class="dlg-section">
<h4 class="dlg-section-title">已有计划</h4>
<div v-for="p in selectedCustomer.plans" :key="p.plan_date" class="dlg-plan-item" :class="{ 'dlg-plan-overdue': p.plan_overdue }">
<span>📅 {{ p.plan_date }}</span>
<span>{{ p.plan_content }}</span>
<el-tag v-if="p.plan_overdue" size="small" type="danger">已过期</el-tag>
</div>
</div>
<!-- Quick Plan Form (yellow/red) -->
<div v-if="selectedCustomer.status !== 'green' && selectedCustomer.status !== 'gray'" class="dlg-section dlg-plan-form">
<h4 class="dlg-section-title">{{ selectedCustomer.status === 'red' ? '⚡ 快速制定拜访计划' : '📋 制定拜访计划' }}</h4>
<div class="plan-form-row">
<el-date-picker v-model="planDate" type="date" style="width:150px" value-format="YYYY-MM-DD" />
<el-input v-model="planContent" placeholder="计划内容,如:上门拜访了解云业务需求" style="flex:1" :disabled="planSaving" />
<el-button type="primary" :loading="planSaving" @click="handleCreatePlan">制定</el-button>
</div>
</div>
<!-- Link to weekly report -->
<div class="dlg-section" style="text-align:center;padding-top:8px">
<el-link type="primary" :underline="false" @click="goWeeklyReport(selectedCustomer.customer_id); dialogVisible=false">
🔗 查看该客户周报记录
</el-link>
</div>
</el-dialog>
</div>
</template>
<style scoped>
/* ═══ Page Header ═══ */
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.month-nav { display: flex; align-items: center; gap: 8px; margin: 4px 0 6px; }
.month-nav-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 3px 8px; cursor: pointer; color: var(--warm-gray); font-size: 12px; }
.month-nav-btn:hover:not(:disabled) { color: var(--ink); border-color: var(--ink); }
.month-nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.month-label { font-family: var(--font-mono); font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
.month-nav-reset { background: none; border: 1px solid var(--gold); color: var(--gold); padding: 3px 8px; cursor: pointer; font-size: 12px; }
.month-nav-reset:hover { background: var(--gold); color: #fff; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
/* ═══ Team Bar ═══ */
.team-bar { display: flex; gap: 0; background: var(--surface); border: 1px solid var(--warm-border); margin-bottom: 20px; }
.team-stat { flex: 1; text-align: center; padding: 16px 8px; border-right: 1px solid var(--warm-border); display: flex; flex-direction: column; gap: 4px; }
.team-stat:last-child { border-right: none; }
.team-stat-num { font-family: var(--font-heading); font-size: 26px; color: var(--ink); line-height: 1.1; }
.team-stat-label { font-family: var(--font-body); font-size: 11px; color: var(--warm-gray); letter-spacing: 0.04em; }
.team-stat--green .team-stat-num { color: var(--sage); }
.team-stat--yellow .team-stat-num { color: var(--gold); }
.team-stat--red .team-stat-num { color: var(--vermilion); }
.team-stat--coverage .team-stat-num { color: var(--ink); }
/* ═══ Manager Rows ═══ */
.manager-list { display: flex; flex-direction: column; gap: 8px; }
.manager-row { background: var(--surface); border: 1px solid var(--warm-border); transition: border-color 0.2s; }
.manager-row--expanded { border-color: var(--ink); }
.manager-summary { display: flex; align-items: center; gap: 16px; padding: 14px 18px; cursor: pointer; transition: background 0.2s; user-select: none; }
.manager-summary:hover { background: rgba(196,147,74,0.03); }
.manager-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.manager-expand { font-size: 10px; color: var(--warm-gray); width: 14px; flex-shrink: 0; }
.manager-name { font-family: var(--font-heading); font-size: 15px; color: var(--ink); letter-spacing: 0.04em; cursor: pointer; }
.manager-name:hover { color: var(--gold); }
.manager-count { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); }
.manager-metrics { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); white-space: nowrap; }
.manager-metrics strong { font-weight: 600; color: var(--ink); margin: 0 1px; }
.manager-lights { display: flex; gap: 6px; }
.light-dot { display: inline-flex; align-items: center; justify-content: center; min-width: 26px; height: 26px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; font-weight: 600; cursor: help; }
.light-dot--green { background: #EDF2EC; color: var(--sage); }
.light-dot--yellow { background: #FBF6EE; color: var(--gold); }
.light-dot--red { background: #FBF1EE; color: var(--vermilion); }
.manager-coverage { display: flex; align-items: center; gap: 10px; width: 180px; flex-shrink: 0; }
.coverage-bar { flex: 1; height: 6px; background: var(--paper-dark); border-radius: 3px; overflow: hidden; }
.coverage-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; }
.coverage-pct { font-family: var(--font-mono); font-size: 13px; font-weight: 600; width: 40px; text-align: right; }
/* ═══ Customer Grid ═══ */
.customer-grid { display: flex; flex-wrap: wrap; gap: 10px; padding: 0 18px 16px; border-top: 1px solid var(--warm-border); padding-top: 14px; }
/* ═══ Customer Card ═══ */
.customer-card { width: 220px; background: var(--paper); border: 1px solid var(--warm-border); cursor: pointer; transition: all 0.2s; display: flex; flex-direction: column; overflow: hidden; position: relative; }
.customer-card:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(28,55,56,0.08); }
.customer-card:active { transform: scale(0.99); }
/* ── Card background by status (Option A: full-color tint) ── */
.customer-card--green { background: #ECF7EE; border-color: #B7DFC2; }
.customer-card--yellow { background: #FFF8EB; border-color: #F0D9A0; }
.customer-card--red { background: #FFF0EF; border-color: #F5C4C0; }
.customer-card--gray { background: #F5F5F5; border-color: #DDDDDD; }
/* ── Top accent stripe (subtle, secondary indicator) ── */
.card-status-stripe { height: 3px; width: 100%; flex-shrink: 0; }
.customer-card--green .card-status-stripe { background: var(--sage); }
.customer-card--yellow .card-status-stripe { background: var(--gold); }
.customer-card--red .card-status-stripe { background: var(--vermilion); }
.customer-card--gray .card-status-stripe { background: var(--warm-gray); }
/* ── Red card pulse ── */
.customer-card--red { animation: pulse-card 3s ease-in-out infinite; }
@keyframes pulse-card { 0%,100% { box-shadow: 0 0 0 0 rgba(184,71,46,0); } 50% { box-shadow: 0 0 0 4px rgba(184,71,46,0.12); } }
.card-body { padding: 12px 14px; flex: 1; min-width: 0; }
.card-name-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 6px; }
.status-circle { display: inline-block; width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; margin-top: 2px; }
.status-circle--green { background: var(--sage); }
.status-circle--yellow { background: conic-gradient(var(--gold) 50%, transparent 50%); box-shadow: inset 0 0 0 1.5px var(--gold); }
.status-circle--red { background: transparent; border: 2px solid var(--vermilion); }
.status-circle--gray { background: transparent; border: 1.5px dashed var(--warm-gray); }
.card-name { font-family: var(--font-heading); font-size: 13px; color: var(--ink); letter-spacing: 0.03em; line-height: 1.4; word-break: break-all; }
.card-meta { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
.card-industry,.card-services { font-family: var(--font-body); font-size: 10px; color: var(--warm-gray); }
.card-visit-info { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.card-last-visit { font-family: var(--font-mono); font-size: 10px; color: var(--warm-gray); }
.card-last-visit--never { color: var(--vermilion); }
.card-method { font-family: var(--font-body); font-size: 10px; padding: 1px 6px; border: 1px solid var(--warm-border); color: var(--warm-gray); }
.card-plans { display: flex; gap: 4px; flex-wrap: wrap; }
.plan-badge { font-family: var(--font-mono); font-size: 9px; padding: 2px 6px; border-radius: 8px; background: #EDF2EC; color: var(--sage); }
.plan-badge.plan-overdue { background: #FBF1EE; color: var(--vermilion); animation: pulse-warn 2s ease-in-out infinite; }
.card-warning { margin-top: 6px; font-family: var(--font-body); font-size: 10px; color: var(--vermilion); }
.unassigned-section { border-style: dashed; border-color: var(--warm-gray); }
.unassigned-summary { cursor: pointer; }
.empty-state { text-align: center; padding: 48px 0; }
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); }
.empty { text-align: center; padding: 20px; color: var(--c-text-muted); font-family: var(--font-body); }
/* ═══ Dialog ═══ */
.dlg-status { padding: 8px 12px; border-radius: 6px; font-size: 13px; font-weight: 600; margin-bottom: 12px; }
.dlg-status--green { background: #EDF2EC; color: var(--sage); }
.dlg-status--yellow { background: #FBF6EE; color: var(--gold); }
.dlg-status--red { background: #FBF1EE; color: var(--vermilion); }
.dlg-status--gray { background: #f0ede5; color: var(--warm-gray); }
.dlg-info { background: var(--c-bg-light, #faf9f6); border-radius: 6px; padding: 12px 16px; margin-bottom: 12px; }
.dlg-info-row { display: flex; gap: 16px; padding: 4px 0; font-size: 13px; }
.dlg-label { color: var(--c-text-muted); min-width: 60px; }
.dlg-section { border-top: 1px solid var(--warm-border); padding-top: 10px; margin-top: 10px; }
.dlg-section-title { margin: 0 0 8px; font-size: 13px; color: var(--ink); }
.dlg-visit-item { display: flex; gap: 8px; padding: 4px 0; font-size: 12px; }
.dlg-vdate { font-family: var(--font-mono); color: var(--warm-gray); white-space: nowrap; }
.dlg-vmethod { color: var(--sage); white-space: nowrap; }
.dlg-vcontent { color: var(--c-text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dlg-plan-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; }
.dlg-plan-item.dlg-plan-overdue { background: #FBF1EE; margin: 2px -4px; padding: 4px; border-radius: 4px; }
.dlg-plan-form { background: var(--c-bg-light, #faf9f6); padding: 10px 12px; border-radius: 6px; }
.plan-form-row { display: flex; gap: 8px; align-items: center; }
</style>