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>
This commit is contained in:
@@ -37,5 +37,12 @@ WECOM_SECRET=your-app-secret
|
|||||||
WECOM_TOKEN=your-token
|
WECOM_TOKEN=your-token
|
||||||
WECOM_ENCODING_AES_KEY=your-encoding-aes-key
|
WECOM_ENCODING_AES_KEY=your-encoding-aes-key
|
||||||
|
|
||||||
|
# ── AI / LLM (OpenAI 兼容接口,用于周报摘要) ──
|
||||||
|
# 支持 OpenAI / DeepSeek / 通义千问 / 本地 Ollama 等
|
||||||
|
AI_API_URL=https://api.openai.com/v1/chat/completions
|
||||||
|
AI_API_KEY=sk-your-api-key
|
||||||
|
AI_MODEL=gpt-4o
|
||||||
|
AI_MAX_TOKENS=2000
|
||||||
|
|
||||||
# ── CORS (前端地址) ──
|
# ── CORS (前端地址) ──
|
||||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""AI summary endpoints — LLM-powered weekly report narrative."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.database import get_db
|
||||||
|
from app.middleware.auth import get_current_user
|
||||||
|
from app.services.ai_summary import generate_summary
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ai", tags=["AI Summary"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/summary")
|
||||||
|
async def ai_summary(
|
||||||
|
reference_date: Optional[str] = Query(None),
|
||||||
|
period: str = Query("week", regex="^(week|month)$"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Generate AI-powered weekly report summary. Director/leader only."""
|
||||||
|
role = current_user["role"]
|
||||||
|
if role not in ("director", "leader"):
|
||||||
|
raise HTTPException(status_code=403, detail="仅限支局长和分管领导使用")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ref = date.fromisoformat(reference_date) if reference_date else None
|
||||||
|
summary = await generate_summary(
|
||||||
|
db=db,
|
||||||
|
user_id=uuid.UUID(current_user["user_id"]),
|
||||||
|
role=role,
|
||||||
|
reference_date=ref,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
return {"summary": summary, "period": period, "reference_date": str(ref or date.today())}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"AI 摘要生成失败:{str(e)}")
|
||||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.middleware.auth import get_current_user
|
from app.middleware.auth import get_current_user
|
||||||
from app.services.dashboard import get_dashboard_stats, get_reporting_progress, get_weekly_report
|
from app.services.dashboard import get_dashboard_stats, get_reporting_progress, get_weekly_report
|
||||||
|
from app.services.light_board import get_light_board
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||||
|
|
||||||
@@ -51,3 +52,14 @@ async def weekly_report(
|
|||||||
filter_customer_id=uuid.UUID(customer_id) if customer_id else None,
|
filter_customer_id=uuid.UUID(customer_id) if customer_id else None,
|
||||||
reference_date=ref,
|
reference_date=ref,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/light-board")
|
||||||
|
async def light_board(
|
||||||
|
reference_date: Optional[str] = Query(None),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get customer visit coverage matrix (light board). Director/leader only."""
|
||||||
|
ref = date.fromisoformat(reference_date) if reference_date else None
|
||||||
|
return await get_light_board(db, ref)
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ class Settings(BaseSettings):
|
|||||||
WECOM_TOKEN: str = ""
|
WECOM_TOKEN: str = ""
|
||||||
WECOM_ENCODING_AES_KEY: str = ""
|
WECOM_ENCODING_AES_KEY: str = ""
|
||||||
|
|
||||||
|
# AI / LLM (OpenAI-compatible)
|
||||||
|
AI_API_URL: str = ""
|
||||||
|
AI_API_KEY: str = ""
|
||||||
|
AI_MODEL: str = "gpt-4o"
|
||||||
|
AI_MAX_TOKENS: int = 2000
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,7 @@ from app.config import settings
|
|||||||
from app.database import engine, Base
|
from app.database import engine, Base
|
||||||
from app.api import router as api_router
|
from app.api import router as api_router
|
||||||
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
|
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
|
||||||
from app.api import dashboard, upload, export, import_data, wecom, daily_notes
|
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -62,6 +62,7 @@ app.include_router(export.router, prefix="/api")
|
|||||||
app.include_router(import_data.router, prefix="/api")
|
app.include_router(import_data.router, prefix="/api")
|
||||||
app.include_router(wecom.router, prefix="/api")
|
app.include_router(wecom.router, prefix="/api")
|
||||||
app.include_router(daily_notes.router, prefix="/api")
|
app.include_router(daily_notes.router, prefix="/api")
|
||||||
|
app.include_router(ai_summary.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""AI-powered weekly report summary using an OpenAI-compatible LLM."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.dashboard import get_weekly_report, get_week_range
|
||||||
|
from app.services.light_board import get_light_board
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
SUMMARY_SYSTEM_PROMPT = """你是一位经验丰富的政企客户经理团队的周报分析助手。你的分析将被直接提交给支局长作为工作周报的文字摘要。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
- 严格基于提供的数据进行分析,绝不编造数据中不存在的信息
|
||||||
|
- 使用正式但不生硬的中文,适合放入政企工作周报
|
||||||
|
- 每条分析简洁有力,1-2句话即可,避免空泛套话
|
||||||
|
- 如果某个结论是基于数据推断的,请使用"数据显示""从本周情况看"等表述
|
||||||
|
- 对于覆盖不足的情况,请明确指出具体客户名称和负责人,方便支局长跟进
|
||||||
|
|
||||||
|
## 输出格式(使用 Markdown)
|
||||||
|
|
||||||
|
### 一、本周概况
|
||||||
|
[2-3句话,涵盖:拜访总量、覆盖客户数、团队参与情况、拜访方式分布]
|
||||||
|
|
||||||
|
### 二、拜访重点与客户需求
|
||||||
|
[2-3个值得关注的客户需求或沟通内容要点,有具体客户名称]
|
||||||
|
|
||||||
|
### 三、客户覆盖分析
|
||||||
|
[引用覆盖数据,明确指出:覆盖率、低于60%的经理、红灯客户名单、需要关注的客户]
|
||||||
|
|
||||||
|
### 四、下周建议
|
||||||
|
[2-3条针对性的工作建议,基于数据中暴露的问题和客户需求]"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary_prompt(
|
||||||
|
weekly_report: dict,
|
||||||
|
light_board: dict,
|
||||||
|
period: str,
|
||||||
|
reference_date: str,
|
||||||
|
) -> str:
|
||||||
|
"""Build the user prompt with structured visit data for the LLM."""
|
||||||
|
|
||||||
|
# ── Summary stats ──
|
||||||
|
visits = weekly_report.get("visits", [])
|
||||||
|
daily_notes = weekly_report.get("daily_notes", [])
|
||||||
|
managers_involved: set[str] = set()
|
||||||
|
customers_visited: set[str] = set()
|
||||||
|
methods: dict[str, int] = {}
|
||||||
|
demands: list[str] = []
|
||||||
|
|
||||||
|
for v in visits:
|
||||||
|
managers_involved.add(v.get("manager_name", ""))
|
||||||
|
customers_visited.add(v.get("customer_name", ""))
|
||||||
|
method = v.get("visit_method", "")
|
||||||
|
methods[method] = methods.get(method, 0) + 1
|
||||||
|
demand = v.get("customer_demand", "")
|
||||||
|
if demand and demand.strip():
|
||||||
|
demands.append(f"{v.get('customer_name', '未知')}: {demand.strip()}")
|
||||||
|
|
||||||
|
# Manager breakdown
|
||||||
|
manager_visits: dict[str, list] = {}
|
||||||
|
for v in visits:
|
||||||
|
mn = v.get("manager_name", "未知")
|
||||||
|
if mn not in manager_visits:
|
||||||
|
manager_visits[mn] = []
|
||||||
|
manager_visits[mn].append({
|
||||||
|
"client": v.get("customer_name", ""),
|
||||||
|
"method": v.get("visit_method", ""),
|
||||||
|
"content": (v.get("communication_content", "") or "")[:120],
|
||||||
|
"demand": v.get("customer_demand", "") or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build the data block
|
||||||
|
data_block = f"""## 基本信息
|
||||||
|
- 分析周期:{period}
|
||||||
|
- 参考日期:{reference_date}
|
||||||
|
- 周范围:{weekly_report.get('week_start', '')} — {weekly_report.get('week_end', '')}
|
||||||
|
|
||||||
|
## 拜访总览
|
||||||
|
- 拜访记录总数:{len(visits)}
|
||||||
|
- 覆盖客户数:{len(customers_visited)}
|
||||||
|
- 参与经理数:{len(managers_involved)}
|
||||||
|
- 拜访方式分布:{json.dumps(methods, ensure_ascii=False)}
|
||||||
|
|
||||||
|
## 各客户经理拜访明细
|
||||||
|
"""
|
||||||
|
for mn, items in manager_visits.items():
|
||||||
|
data_block += f"\n### {mn}({len(items)}条)\n"
|
||||||
|
for item in items[:10]: # cap per manager
|
||||||
|
data_block += f"- {item['method']}拜访 {item['client']}"
|
||||||
|
if item['content']:
|
||||||
|
data_block += f" — {item['content'][:100]}"
|
||||||
|
if item['demand']:
|
||||||
|
data_block += f" [需求: {item['demand'][:80]}]"
|
||||||
|
data_block += "\n"
|
||||||
|
|
||||||
|
# Customer demands
|
||||||
|
if demands:
|
||||||
|
data_block += "\n## 客户需求汇总\n"
|
||||||
|
for d in demands[:15]:
|
||||||
|
data_block += f"- {d[:200]}\n"
|
||||||
|
|
||||||
|
# Daily notes summary
|
||||||
|
notes_by_cat: dict[str, int] = {}
|
||||||
|
for n in daily_notes:
|
||||||
|
cat = n.get("category", "其他")
|
||||||
|
notes_by_cat[cat] = notes_by_cat.get(cat, 0) + 1
|
||||||
|
if notes_by_cat:
|
||||||
|
data_block += "\n## 纪要分类统计\n"
|
||||||
|
data_block += json.dumps(notes_by_cat, ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
# Light board data
|
||||||
|
team = light_board.get("team_summary", {})
|
||||||
|
data_block += f"""
|
||||||
|
## 客户覆盖数据(亮灯表)
|
||||||
|
- 团队总客户数:{team.get('total_customers', 0)}
|
||||||
|
- 本月已拜访(绿灯):{team.get('visited_this_month', 0)}
|
||||||
|
- 仅上月拜访(黄灯):{team.get('visited_last_month_only', 0)}
|
||||||
|
- 连续未拜访(红灯):{team.get('not_visited_2months', 0)}
|
||||||
|
- 未分配客户:{team.get('unassigned', 0)}
|
||||||
|
- 整体覆盖率:{team.get('coverage_rate', 0) * 100:.1f}%
|
||||||
|
|
||||||
|
### 各经理覆盖率
|
||||||
|
"""
|
||||||
|
for m in light_board.get("managers", []):
|
||||||
|
data_block += (
|
||||||
|
f"- {m['manager_name']}: {m['coverage_rate'] * 100:.0f}% "
|
||||||
|
f"({m['visited_this_month']}/{m['total_customers']}) "
|
||||||
|
f"🟢{m['visited_this_month']} 🟡{m['visited_last_month_only']} 🔴{m['not_visited_2months']}\n"
|
||||||
|
)
|
||||||
|
# List red customers
|
||||||
|
red_customers = [c for c in m.get("customers", []) if c["status"] == "red"]
|
||||||
|
if red_customers:
|
||||||
|
data_block += " 红灯客户:\n"
|
||||||
|
for rc in red_customers[:5]:
|
||||||
|
lvd = rc.get("last_visit_date") or "从未"
|
||||||
|
data_block += f" - {rc['customer_name']}(上次拜访: {lvd})\n"
|
||||||
|
|
||||||
|
return data_block
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_summary(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: UUID,
|
||||||
|
role: str,
|
||||||
|
reference_date: date | None = None,
|
||||||
|
period: str = "week",
|
||||||
|
) -> str:
|
||||||
|
"""Generate an AI-powered weekly summary.
|
||||||
|
|
||||||
|
Raises ValueError if AI config is missing, httpx.HTTPError on API failure.
|
||||||
|
"""
|
||||||
|
if not settings.AI_API_URL:
|
||||||
|
raise ValueError("AI_API_URL not configured")
|
||||||
|
|
||||||
|
# Gather data
|
||||||
|
ref = reference_date or date.today()
|
||||||
|
weekly_report = await get_weekly_report(
|
||||||
|
db=db, user_id=user_id, role=role, reference_date=ref,
|
||||||
|
)
|
||||||
|
light_board = await get_light_board(db, ref)
|
||||||
|
user_prompt = build_summary_prompt(weekly_report, light_board, period, str(ref))
|
||||||
|
|
||||||
|
# Call LLM
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if settings.AI_API_KEY:
|
||||||
|
headers["Authorization"] = f"Bearer {settings.AI_API_KEY}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": settings.AI_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
"max_tokens": settings.AI_MAX_TOKENS,
|
||||||
|
"temperature": 0.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||||
|
resp = await client.post(settings.AI_API_URL, json=payload, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
if not content:
|
||||||
|
raise ValueError("AI returned empty response")
|
||||||
|
|
||||||
|
return content
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Customer light board — visit coverage matrix per manager.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
green — visited this month (亮灯)
|
||||||
|
yellow — visited last month but not this month (临期)
|
||||||
|
red — not visited in 2+ months, or never visited (灭灯+警示)
|
||||||
|
gray — customer has no assigned primary manager (未分配)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy import select, func, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
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.utils.timezone import today_cst
|
||||||
|
|
||||||
|
|
||||||
|
def month_start(ref: date) -> date:
|
||||||
|
return ref.replace(day=1)
|
||||||
|
|
||||||
|
|
||||||
|
def month_end(ref: date) -> date:
|
||||||
|
nxt = ref.replace(day=28) + timedelta(days=4)
|
||||||
|
return nxt - timedelta(days=nxt.day)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(last_visit_date: date | None, ref: date) -> tuple[str, int]:
|
||||||
|
"""Return (status, consecutive_missed_months)."""
|
||||||
|
if last_visit_date is None:
|
||||||
|
return ("red", 99) # never visited
|
||||||
|
|
||||||
|
this_month = month_start(ref)
|
||||||
|
last_month = month_start(this_month - timedelta(days=1))
|
||||||
|
|
||||||
|
if last_visit_date >= this_month:
|
||||||
|
return ("green", 0)
|
||||||
|
elif last_visit_date >= last_month:
|
||||||
|
return ("yellow", 0)
|
||||||
|
|
||||||
|
# Count how many consecutive months missed
|
||||||
|
cursor = month_start(ref)
|
||||||
|
missed = 0
|
||||||
|
while True:
|
||||||
|
cursor = month_start(cursor - timedelta(days=1))
|
||||||
|
if last_visit_date >= cursor:
|
||||||
|
break
|
||||||
|
missed += 1
|
||||||
|
if missed > 24: # safety cap
|
||||||
|
break
|
||||||
|
return ("red", missed)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_light_board(db: AsyncSession, reference_date: date | None = None) -> dict:
|
||||||
|
"""Get per-manager customer visit coverage for the light board."""
|
||||||
|
ref = reference_date or today_cst()
|
||||||
|
|
||||||
|
# ── All managers ──
|
||||||
|
managers_result = await db.execute(select(User).where(User.role == "manager"))
|
||||||
|
managers = managers_result.scalars().all()
|
||||||
|
manager_map: dict[UUID, dict] = {
|
||||||
|
m.id: {
|
||||||
|
"manager_id": str(m.id),
|
||||||
|
"manager_name": m.name,
|
||||||
|
"total_customers": 0,
|
||||||
|
"visited_this_month": 0,
|
||||||
|
"visited_last_month_only": 0,
|
||||||
|
"not_visited_2months": 0,
|
||||||
|
"coverage_rate": 0.0,
|
||||||
|
"customers": [],
|
||||||
|
}
|
||||||
|
for m in managers
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── All customers with their last visit per manager ──
|
||||||
|
# Subquery: latest visit date + method per (manager_id, customer_id)
|
||||||
|
latest_visit = (
|
||||||
|
select(
|
||||||
|
Visit.manager_id,
|
||||||
|
Visit.customer_id,
|
||||||
|
func.max(Visit.visit_date).label("last_visit_date"),
|
||||||
|
)
|
||||||
|
.group_by(Visit.manager_id, Visit.customer_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Join: assignments → customers → latest_visit → visits (for method)
|
||||||
|
rows = await db.execute(
|
||||||
|
select(
|
||||||
|
CustomerAssignment.manager_id,
|
||||||
|
CustomerAssignment.customer_id,
|
||||||
|
Customer.id.label("c_id"),
|
||||||
|
Customer.name.label("c_name"),
|
||||||
|
Customer.industry,
|
||||||
|
Customer.in_use_services,
|
||||||
|
Customer.monthly_fee,
|
||||||
|
latest_visit.c.last_visit_date,
|
||||||
|
Visit.visit_method,
|
||||||
|
)
|
||||||
|
.join(Customer, Customer.id == CustomerAssignment.customer_id)
|
||||||
|
.outerjoin(latest_visit, and_(
|
||||||
|
latest_visit.c.manager_id == CustomerAssignment.manager_id,
|
||||||
|
latest_visit.c.customer_id == CustomerAssignment.customer_id,
|
||||||
|
))
|
||||||
|
.outerjoin(Visit, and_(
|
||||||
|
Visit.manager_id == CustomerAssignment.manager_id,
|
||||||
|
Visit.customer_id == CustomerAssignment.customer_id,
|
||||||
|
Visit.visit_date == latest_visit.c.last_visit_date,
|
||||||
|
))
|
||||||
|
.where(CustomerAssignment.role == "primary")
|
||||||
|
.order_by(Customer.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_customer_ids: set[UUID] = set()
|
||||||
|
|
||||||
|
for row in rows.all():
|
||||||
|
mgr_id, cust_id, c_id, c_name, industry, services, fee, lvd, method = row
|
||||||
|
assigned_customer_ids.add(c_id)
|
||||||
|
|
||||||
|
status, missed = classify(lvd, ref)
|
||||||
|
cust_entry = {
|
||||||
|
"customer_id": str(c_id),
|
||||||
|
"customer_name": c_name,
|
||||||
|
"industry": industry or "",
|
||||||
|
"in_use_services": services or "",
|
||||||
|
"monthly_fee": str(fee) if fee else "",
|
||||||
|
"last_visit_date": str(lvd) if lvd else None,
|
||||||
|
"last_visit_method": method or "",
|
||||||
|
"status": status,
|
||||||
|
"consecutive_missed_months": missed,
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr_entry = manager_map.get(mgr_id)
|
||||||
|
if mgr_entry:
|
||||||
|
mgr_entry["customers"].append(cust_entry)
|
||||||
|
mgr_entry["total_customers"] += 1
|
||||||
|
if status == "green":
|
||||||
|
mgr_entry["visited_this_month"] += 1
|
||||||
|
elif status == "yellow":
|
||||||
|
mgr_entry["visited_last_month_only"] += 1
|
||||||
|
else:
|
||||||
|
mgr_entry["not_visited_2months"] += 1
|
||||||
|
|
||||||
|
# ── Calculate coverage rates ──
|
||||||
|
for mgr_entry in manager_map.values():
|
||||||
|
total = mgr_entry["total_customers"]
|
||||||
|
if total > 0:
|
||||||
|
mgr_entry["coverage_rate"] = round(mgr_entry["visited_this_month"] / total, 3)
|
||||||
|
|
||||||
|
# Sort managers: lowest coverage first (most problematic first)
|
||||||
|
manager_list = sorted(manager_map.values(), key=lambda m: m["coverage_rate"])
|
||||||
|
|
||||||
|
# ── Unassigned customers (no primary manager) ──
|
||||||
|
unassigned_rows = await db.execute(
|
||||||
|
select(Customer)
|
||||||
|
.outerjoin(CustomerAssignment, and_(
|
||||||
|
CustomerAssignment.customer_id == Customer.id,
|
||||||
|
CustomerAssignment.role == "primary",
|
||||||
|
))
|
||||||
|
.where(CustomerAssignment.id == None)
|
||||||
|
.order_by(Customer.name)
|
||||||
|
)
|
||||||
|
unassigned = []
|
||||||
|
for c in unassigned_rows.scalars():
|
||||||
|
unassigned.append({
|
||||||
|
"customer_id": str(c.id),
|
||||||
|
"customer_name": c.name,
|
||||||
|
"industry": c.industry or "",
|
||||||
|
"in_use_services": c.in_use_services or "",
|
||||||
|
"monthly_fee": str(c.monthly_fee) if c.monthly_fee else "",
|
||||||
|
"last_visit_date": None,
|
||||||
|
"last_visit_method": "",
|
||||||
|
"status": "gray",
|
||||||
|
"consecutive_missed_months": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Team summary ──
|
||||||
|
all_total = sum(m["total_customers"] for m in manager_list)
|
||||||
|
all_green = sum(m["visited_this_month"] for m in manager_list)
|
||||||
|
all_yellow = sum(m["visited_last_month_only"] for m in manager_list)
|
||||||
|
all_red = sum(m["not_visited_2months"] for m in manager_list)
|
||||||
|
team_summary = {
|
||||||
|
"total_customers": all_total,
|
||||||
|
"visited_this_month": all_green,
|
||||||
|
"visited_last_month_only": all_yellow,
|
||||||
|
"not_visited_2months": all_red,
|
||||||
|
"unassigned": len(unassigned),
|
||||||
|
"coverage_rate": round(all_green / all_total, 3) if all_total > 0 else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"reference_month": ref.strftime("%Y-%m"),
|
||||||
|
"managers": manager_list,
|
||||||
|
"unassigned_customers": unassigned,
|
||||||
|
"team_summary": team_summary,
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import api from './index'
|
||||||
|
|
||||||
|
export const aiApi = {
|
||||||
|
generateSummary(params?: { reference_date?: string; period?: string }) {
|
||||||
|
return api.post('/ai/summary', null, { params })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -10,4 +10,7 @@ export const dashboardApi = {
|
|||||||
getWeeklyReport(params?: any) {
|
getWeeklyReport(params?: any) {
|
||||||
return api.get('/dashboard/weekly-report', { params })
|
return api.get('/dashboard/weekly-report', { params })
|
||||||
},
|
},
|
||||||
|
getLightBoard(params?: any) {
|
||||||
|
return api.get('/dashboard/light-board', { params })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
|
|||||||
items: [
|
items: [
|
||||||
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' },
|
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' },
|
||||||
{ path: '/weekly-report', label: '周报', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' },
|
{ path: '/weekly-report', label: '周报', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' },
|
||||||
|
{ path: '/light-board', label: '亮灯表', icon: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const router = createRouter({
|
|||||||
children: [
|
children: [
|
||||||
{ path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') },
|
{ path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') },
|
||||||
{ path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.vue') },
|
{ path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.vue') },
|
||||||
|
{ path: 'light-board', name: 'LightBoard', component: () => import('@/views/desktop/LightBoard.vue') },
|
||||||
{ path: 'work-plans', name: 'WorkPlans', component: () => import('@/views/desktop/WorkPlans.vue') },
|
{ path: 'work-plans', name: 'WorkPlans', component: () => import('@/views/desktop/WorkPlans.vue') },
|
||||||
{ path: 'mini-business', name: 'MiniBusiness', component: () => import('@/views/desktop/MiniBusiness.vue') },
|
{ path: 'mini-business', name: 'MiniBusiness', component: () => import('@/views/desktop/MiniBusiness.vue') },
|
||||||
{ path: 'key-visits', name: 'KeyVisits', component: () => import('@/views/desktop/KeyVisits.vue') },
|
{ path: 'key-visits', name: 'KeyVisits', component: () => import('@/views/desktop/KeyVisits.vue') },
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
<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>
|
||||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { dashboardApi } from '@/api/dashboard'
|
import { dashboardApi } from '@/api/dashboard'
|
||||||
import { uploadApi } from '@/api/upload'
|
import { uploadApi } from '@/api/upload'
|
||||||
|
import { aiApi } from '@/api/ai'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import ImagePreview from '@/components/ImagePreview.vue'
|
import ImagePreview from '@/components/ImagePreview.vue'
|
||||||
import api from '@/api/index'
|
import api from '@/api/index'
|
||||||
@@ -11,6 +12,9 @@ import api from '@/api/index'
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const aiLoading = ref(false)
|
||||||
|
const aiSummary = ref('')
|
||||||
|
const aiError = ref('')
|
||||||
const activeTab = ref('visits')
|
const activeTab = ref('visits')
|
||||||
const filterManagerId = ref('')
|
const filterManagerId = ref('')
|
||||||
const filterCustomerId = ref('')
|
const filterCustomerId = ref('')
|
||||||
@@ -91,6 +95,54 @@ function viewPhoto(url: string) {
|
|||||||
photoDialogVisible.value = true
|
photoDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function generateAISummary() {
|
||||||
|
aiLoading.value = true
|
||||||
|
aiSummary.value = ''
|
||||||
|
aiError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await aiApi.generateSummary({ reference_date: getRefDate(), period: 'week' })
|
||||||
|
aiSummary.value = res.data.summary
|
||||||
|
} catch (e: any) {
|
||||||
|
const detail = e.response?.data?.detail || 'AI 摘要生成失败,请检查 AI 服务配置或稍后重试'
|
||||||
|
aiError.value = detail
|
||||||
|
ElMessage.error(detail)
|
||||||
|
} finally {
|
||||||
|
aiLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySummary() {
|
||||||
|
if (!aiSummary.value) return
|
||||||
|
try {
|
||||||
|
// Strip markdown markers for plain text
|
||||||
|
const plain = aiSummary.value.replace(/^#{1,4}\s+/gm, '').replace(/\*\*/g, '').replace(/\*/g, '')
|
||||||
|
await navigator.clipboard.writeText(plain)
|
||||||
|
ElMessage.success('摘要已复制到剪贴板')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(md: string): string {
|
||||||
|
if (!md) return ''
|
||||||
|
let html = md
|
||||||
|
// Headers
|
||||||
|
.replace(/^#### (.+)$/gm, '<h4 class="ai-h4">$1</h4>')
|
||||||
|
.replace(/^### (.+)$/gm, '<h3 class="ai-h3">$1</h3>')
|
||||||
|
.replace(/^## (.+)$/gm, '<h2 class="ai-h2">$1</h2>')
|
||||||
|
.replace(/^# (.+)$/gm, '<h1 class="ai-h1">$1</h1>')
|
||||||
|
// Bold
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
// Unordered lists
|
||||||
|
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||||
|
// Wrap consecutive <li> in <ul>
|
||||||
|
.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>')
|
||||||
|
// Line breaks
|
||||||
|
.replace(/\n\n/g, '<br/><br/>')
|
||||||
|
.replace(/\n/g, '<br/>')
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
const visitsByDate = computed(() => {
|
const visitsByDate = computed(() => {
|
||||||
const grouped: Record<string, any[]> = {}
|
const grouped: Record<string, any[]> = {}
|
||||||
for (const v of report.value.visits) {
|
for (const v of report.value.visits) {
|
||||||
@@ -128,6 +180,12 @@ const notesByDate = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<el-button v-if="auth.isDirector || auth.isLeader" type="warning" :loading="aiLoading" @click="generateAISummary">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
|
||||||
|
</svg>
|
||||||
|
AI 生成摘要
|
||||||
|
</el-button>
|
||||||
<el-button v-if="auth.isDirector" type="success" @click="handleExport">
|
<el-button v-if="auth.isDirector" type="success" @click="handleExport">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
@@ -155,6 +213,43 @@ const notesByDate = computed(() => {
|
|||||||
</el-row>
|
</el-row>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- ═══ AI Summary Panel ═══ -->
|
||||||
|
<el-card v-if="aiSummary || aiLoading || aiError" class="ai-summary-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="ai-header">
|
||||||
|
<div class="ai-header-left">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--gold); margin-right:6px">
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
|
||||||
|
</svg>
|
||||||
|
<span class="ai-header-title">AI 周报摘要</span>
|
||||||
|
<span class="ai-header-hint">基于本周拜访数据自动生成,仅供参考</span>
|
||||||
|
</div>
|
||||||
|
<div class="ai-header-actions">
|
||||||
|
<el-button v-if="aiSummary" size="small" text @click="copySummary">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:3px">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
复制
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" text @click="aiSummary = ''; aiError = ''">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="aiLoading" class="ai-loading">
|
||||||
|
<span class="ai-loading-text">🤖 AI 正在分析本周拜访数据...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="aiError" class="ai-error">
|
||||||
|
{{ aiError }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="ai-content" v-html="renderMarkdown(aiSummary)"></div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<el-card>
|
<el-card>
|
||||||
<el-tabs v-model="activeTab">
|
<el-tabs v-model="activeTab">
|
||||||
@@ -262,4 +357,69 @@ const notesByDate = computed(() => {
|
|||||||
.mini-thumb { width: 36px; height: 36px; object-fit: cover; cursor: pointer; }
|
.mini-thumb { width: 36px; height: 36px; object-fit: cover; cursor: pointer; }
|
||||||
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
|
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
|
||||||
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
|
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
|
||||||
|
|
||||||
|
/* ═══ AI Summary Panel ═══ */
|
||||||
|
.ai-summary-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-color: var(--gold);
|
||||||
|
}
|
||||||
|
.ai-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.ai-header-left {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
}
|
||||||
|
.ai-header-title {
|
||||||
|
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||||
|
font-size: 15px; color: var(--ink); letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.ai-header-hint {
|
||||||
|
font-family: 'Noto Serif SC', STSong, serif;
|
||||||
|
font-size: 11px; color: var(--warm-gray); margin-left: 10px;
|
||||||
|
}
|
||||||
|
.ai-header-actions {
|
||||||
|
display: flex; gap: 4px;
|
||||||
|
}
|
||||||
|
.ai-loading {
|
||||||
|
text-align: center; padding: 32px 0;
|
||||||
|
}
|
||||||
|
.ai-loading-text {
|
||||||
|
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||||
|
font-size: 15px; color: var(--warm-gray);
|
||||||
|
animation: pulse-text 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse-text {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
.ai-error {
|
||||||
|
color: var(--vermilion);
|
||||||
|
font-family: 'Noto Serif SC', STSong, serif;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
.ai-content {
|
||||||
|
font-family: 'Noto Serif SC', STSong, serif;
|
||||||
|
line-height: 1.85;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.ai-content :deep(.ai-h3) {
|
||||||
|
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||||
|
font-size: 16px; color: var(--ink);
|
||||||
|
margin: 16px 0 8px; letter-spacing: 0.04em;
|
||||||
|
border-left: 3px solid var(--gold); padding-left: 10px;
|
||||||
|
}
|
||||||
|
.ai-content :deep(.ai-h4) {
|
||||||
|
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||||
|
font-size: 14px; color: var(--ink);
|
||||||
|
margin: 12px 0 6px; letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.ai-content :deep(ul) {
|
||||||
|
margin: 6px 0; padding-left: 20px;
|
||||||
|
}
|
||||||
|
.ai-content :deep(li) {
|
||||||
|
margin: 3px 0; font-size: 14px;
|
||||||
|
}
|
||||||
|
.ai-content :deep(strong) {
|
||||||
|
color: var(--ink); font-weight: 600;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user