Files
qiji/frontend/src/views/desktop/LightBoard.vue
T
v6ole 305ee5df09 feat: 客户亮灯表 + AI 周报摘要 — v0.2
亮灯表:
- 四色覆盖矩阵: 绿亮灯/黄临期/红灭灯/灰未分配
- 按客户经理折叠卡片流, 覆盖率进度条, 团队总览统计
- 红灯客户显示连续未拜访月份, 未分配客户专区
- 后端: light_board.py service + GET /api/dashboard/light-board
- 前端: LightBoard.vue + 路由 /light-board + 汇总侧边栏

AI 周报摘要:
- 接入 OpenAI 兼容大模型, 注入拜访数据+亮灯表覆盖数据
- 四段式结构化输出: 概况/需求/覆盖分析/建议
- 一键生成+Markdown渲染+复制纯文本
- 支局长/分管领导专用, 支持配置内部模型
- 后端: ai_summary.py service + POST /api/ai/summary
- 前端: WeeklyReport 集成按钮+结果面板
- 新增配置: AI_API_URL / AI_API_KEY / AI_MODEL

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 12:03:00 +08:00

474 lines
17 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 { dashboardApi } from '@/api/dashboard'
import { ElMessage } from 'element-plus'
const router = useRouter()
const loading = ref(false)
const board = ref<any>(null)
const expandedManagers = ref<Set<string>>(new Set())
const monthOffset = ref(0)
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 statusLabel(status: string): string {
const map: Record<string, string> = { green: '亮灯', yellow: '临期', red: '灭灯', gray: '未分配' }
return map[status] || status
}
function statusGlyph(status: string): string {
const map: Record<string, string> = { green: '亮', yellow: '临', red: '灭', gray: '—' }
return map[status] || '?'
}
function goCustomerVisits(customerId: string) {
router.push({ path: '/weekly-report', query: { customer_id: customerId } })
}
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)'
}
</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">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="margin-right:8px; color: var(--gold); vertical-align: -4px;">
<path d="M12 2l2.4 7.2h7.6l-6 4.8 2.4 7.2-6.4-4.8-6.4 4.8 2.4-7.2-6-4.8h7.6z"/>
</svg>
客户拜访亮灯表
</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) }"
>
<!-- Summary Row (always visible) -->
<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>
</div>
<div class="manager-lights">
<span class="light-dot light-dot--green" :title="`亮灯 ${m.visited_this_month}`">{{ m.visited_this_month }}</span>
<span class="light-dot light-dot--yellow" :title="`临期 ${m.visited_last_month_only}`">{{ m.visited_last_month_only }}</span>
<span class="light-dot light-dot--red" :title="`灭灯 ${m.not_visited_2months}`">{{ 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>
<!-- Customer Cards (expanded) -->
<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="goCustomerVisits(cust.customer_id)"
>
<div class="card-status-stripe"></div>
<div class="card-body">
<div class="card-name-row">
<span class="card-glyph" :class="`card-glyph--${cust.status}`">{{ statusGlyph(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>
<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="card-glyph card-glyph--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>
<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 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>
</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: 'ZCOOL XiaoWei', STSong, serif;
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: 'JetBrains Mono', 'SF Mono', monospace; 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 Summary 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: 'ZCOOL XiaoWei', STSong, serif;
font-size: 26px; color: var(--ink); line-height: 1.1;
}
.team-stat-label {
font-family: 'Noto Serif SC', STSong, serif;
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 ═══ */
.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: 'ZCOOL XiaoWei', STSong, serif;
font-size: 15px; color: var(--ink); letter-spacing: 0.04em;
cursor: pointer;
}
.manager-name:hover { color: var(--gold); }
.manager-count {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px; color: var(--warm-gray); letter-spacing: 0.03em;
}
.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: 'JetBrains Mono', 'SF Mono', monospace;
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: 'JetBrains Mono', 'SF Mono', monospace;
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: 200px;
background: var(--paper);
border: 1px solid var(--warm-border);
cursor: pointer;
transition: all 0.2s;
display: flex;
overflow: hidden;
position: relative;
}
.customer-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(28,55,56,0.06);
}
.customer-card:active { transform: scale(0.99); }
.card-status-stripe {
width: 4px; 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); }
.customer-card--red {
animation: pulse-warn 3s ease-in-out infinite;
}
@keyframes pulse-warn {
0%, 100% { border-color: var(--warm-border); }
50% { border-color: rgba(184,71,46,0.35); }
}
.card-body {
padding: 12px 14px;
flex: 1; min-width: 0;
}
.card-name-row {
display: flex; align-items: center; gap: 6px;
margin-bottom: 6px;
}
.card-glyph {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 2px;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 12px; color: #fff;
flex-shrink: 0;
}
.card-glyph--green { background: var(--sage); }
.card-glyph--yellow { background: var(--gold); }
.card-glyph--red { background: var(--vermilion); }
.card-glyph--gray { background: var(--warm-gray); }
.card-name {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 13px; color: var(--ink); letter-spacing: 0.03em;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.card-meta {
display: flex; gap: 6px; flex-wrap: wrap;
margin-bottom: 6px;
}
.card-industry, .card-services {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 10px; color: var(--warm-gray);
letter-spacing: 0.03em;
}
.card-visit-info {
display: flex; align-items: center; gap: 8px;
}
.card-last-visit {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 10px; color: var(--warm-gray);
}
.card-last-visit--never { color: var(--vermilion); }
.card-method {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 10px; padding: 1px 6px;
border: 1px solid var(--warm-border);
color: var(--warm-gray);
}
.card-warning {
margin-top: 6px;
font-family: 'Noto Serif SC', STSong, serif;
font-size: 10px; color: var(--vermilion);
letter-spacing: 0.03em;
}
/* ═══ Unassigned Section ═══ */
.unassigned-section {
border-style: dashed;
border-color: var(--warm-gray);
}
.unassigned-summary {
cursor: pointer;
}
/* ═══ Empty ═══ */
.empty-state { text-align: center; padding: 48px 0; }
.empty-text { font-family: 'Noto Serif SC', STSong, serif; font-size: 15px; color: var(--warm-gray); }
.empty { text-align: center; padding: 20px; color: var(--c-text-muted); font-family: 'Noto Serif SC', STSong, serif; }
</style>