From 0e25201ede1ea580f285f45192cefcdf731ddf38 Mon Sep 17 00:00:00 2001 From: v6ole Date: Sat, 27 Jun 2026 12:23:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=AE=E7=81=AF=E8=A1=A8=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20=E2=80=94=20=E7=82=B9=E5=87=BB=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E6=9B=BF=E4=BB=A3=E8=B7=B3=E8=BD=AC=E5=91=A8?= =?UTF-8?q?=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 light_board.py: - 每张客户卡片返回 plans[] (活跃工作计划) + recent_visits[] (最近2条) - 计划过期检测 (plan_overdue) 前端 LightBoard.vue: - 点击卡片 → el-dialog 弹窗 (不再跳转周报) - 绿灯: 显示最近拜访记录 - 黄/红灯: 客户信息 + 快速制定拜访计划表单 - 卡片显示计划标签 (📅正常 / ⚠过期闪烁) - 弹窗底部保留「查看周报记录」链接 计划过期逻辑: plan_date < 当月首日 且 status=计划中 Co-Authored-By: Claude --- backend/app/services/light_board.py | 54 +++ frontend/src/views/desktop/LightBoard.vue | 528 +++++++++------------- 2 files changed, 266 insertions(+), 316 deletions(-) diff --git a/backend/app/services/light_board.py b/backend/app/services/light_board.py index a93ebff..eb255ed 100644 --- a/backend/app/services/light_board.py +++ b/backend/app/services/light_board.py @@ -15,6 +15,7 @@ from app.models.user import User from app.models.customer import Customer from app.models.customer_assignment import CustomerAssignment from app.models.visit import Visit +from app.models.work_plan import WorkPlan from app.utils.timezone import today_cst @@ -156,6 +157,59 @@ async def get_light_board( else: mgr_entry["not_visited_2months"] += 1 + # ── Active work plans per customer ── + active_plans_result = await db.execute( + select( + WorkPlan.customer_id, + WorkPlan.plan_content, + WorkPlan.plan_date, + WorkPlan.status, + WorkPlan.manager_id, + ).where( + WorkPlan.customer_id.in_(assigned_customer_ids), + WorkPlan.status == "计划中", + ).order_by(WorkPlan.plan_date) + ) + plans_map: dict[UUID, list] = {} + for row in active_plans_result.all(): + cid, content, pdate, pstatus, pmanager = row + plans_map.setdefault(cid, []).append({ + "plan_content": content, + "plan_date": str(pdate), + "status": pstatus, + "plan_overdue": pdate < ref, + "manager_id": str(pmanager) if pmanager else None, + }) + + # ── Recent visits for green/yellow customers ── + recent_visits_result = await db.execute( + select( + Visit.customer_id, + Visit.visit_date, + Visit.visit_method, + Visit.communication_content, + Visit.manager_id, + ).where( + Visit.customer_id.in_(assigned_customer_ids), + ).order_by(Visit.visit_date.desc()).limit(500) + ) + visits_map: dict[UUID, list] = {} + for row in recent_visits_result.all(): + cid, vdate, vmethod, vcontent, vmanager = row + visits_map.setdefault(cid, []).append({ + "visit_date": str(vdate), + "visit_method": vmethod, + "content": (vcontent or "")[:120], + "manager_id": str(vmanager) if vmanager else None, + }) + + # ── Merge plans & visits into customer entries ── + for mgr_entry in manager_map.values(): + for c in mgr_entry["customers"]: + cid = UUID(c["customer_id"]) + c["plans"] = plans_map.get(cid, []) + c["recent_visits"] = (visits_map.get(cid, []) or [])[:2] + # ── Calculate coverage rates ── for mgr_entry in manager_map.values(): total = mgr_entry["total_customers"] diff --git a/frontend/src/views/desktop/LightBoard.vue b/frontend/src/views/desktop/LightBoard.vue index 7fc9e3d..1f9a333 100644 --- a/frontend/src/views/desktop/LightBoard.vue +++ b/frontend/src/views/desktop/LightBoard.vue @@ -1,15 +1,26 @@ @@ -232,252 +277,103 @@ const coverageColor = (rate: number): string => { /* ═══ 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; -} +.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-label { font-family: 'JetBrains 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 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-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-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: '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); } +.manager-count { font-family: 'Noto Serif SC', STSong, serif; font-size: 12px; color: var(--warm-gray); } +.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', 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; -} +.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', 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-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; - overflow: hidden; - position: relative; -} -.customer-card:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(28,55,56,0.06); -} +.customer-card { width: 220px; 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); } +.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: flex-start; gap: 8px; - margin-bottom: 6px; -} - -/* ═══ Status Circle Icons (geometric, no text) ═══ */ -.status-circle { - display: inline-block; - width: 16px; height: 16px; - border-radius: 50%; - flex-shrink: 0; - margin-top: 2px; -} -/* Visited this month — solid filled circle ● */ -.status-circle--green { - background: var(--sage); - box-shadow: 0 0 0 2px rgba(74,103,65,0.15); -} -/* Visited last month only — half-filled circle ◐ */ -.status-circle--yellow { - background: conic-gradient(var(--gold) 50%, transparent 50%); - box-shadow: inset 0 0 0 1.5px var(--gold); -} -/* Not visited 2+ months — empty circle ○ */ -.status-circle--red { - background: transparent; - border: 2px solid var(--vermilion); - box-shadow: 0 0 0 2px rgba(184,71,46,0.08); -} -/* Unassigned — dash */ -.status-circle--gray { - background: transparent; - border: 1.5px dashed var(--warm-gray); -} - -.card-name { - font-family: 'ZCOOL XiaoWei', STSong, serif; - 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: '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); -} +.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: flex-start; gap: 8px; margin-bottom: 6px; } +.status-circle { display: inline-block; width: 16px; height: 16px; border-radius: 50%; flex-shrink: 0; margin-top: 2px; } +.status-circle--green { background: var(--sage); box-shadow: 0 0 0 2px rgba(74,103,65,0.15); } +.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); box-shadow: 0 0 0 2px rgba(184,71,46,0.08); } +.status-circle--gray { background: transparent; border: 1.5px dashed var(--warm-gray); } +.card-name { font-family: 'ZCOOL XiaoWei', STSong, serif; 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: 'Noto Serif SC', STSong, serif; 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: 'JetBrains 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 ═══ */ +.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-plans { display: flex; gap: 4px; flex-wrap: wrap; } +.plan-badge { font-family: 'JetBrains Mono', monospace; 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: 'Noto Serif SC', STSong, serif; 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: '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; } + +/* ═══ 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: 'JetBrains Mono', monospace; 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; }