feat(complaint): 概览增强 A2 反复投诉(事件口径) + A5 时段热力(排班决策) (#72)

用户: 「其他的你发挥一下」→ 实施 #67 设计文档 §四点五 的 A2/A5 两项。

## A5 · 投诉登记时段热力(服务排班决策)
- 后端 `_hour_heatmap()`:按「北京时间 小时 × 星期」聚合登记量
  · 边际分布补齐 0(by_hour 24 项 / by_weekday 7 项,前端热力图需完整网格)
  · 峰谷按 **P75/P25 分位**判定(非固定阈值 → 自适应业务量级)
  · 用 `register_time` 而非 complaint_date:排班关心"何时有人来投诉"
- 前端热力图 + **峰谷结论文案**(把色块翻译成"哪些时段需值守/可减配")
实测量级:高峰 10-12 时 & 19-21 时(350~428 条)· 谷底 01-06 时(12~58 条)

## A2 · 反复投诉(「第一次没解决好」的直接证据)
- 后端 `_repeat_complaints()`:
  ★★ **计数单位是「投诉事件(SN + 北京日期去重)」,不是原始记录数**
     实测近 30 天:事件 5035 vs 原始记录 29546 —— 同日多渠道被正确折叠
     (若用记录数,会把"一天内多渠道登记"误判为反复,方向性错误)
  · 升级信号:同时含「内部/商户号」与「外部」的设备 → 最高优先级
  · 阈值可配(默认 3)+ 响应回显;清单按事件数降序、升级优先
- 前端:反复投诉摘要 + Top6 清单(SN 可点进设备审计 + 「已升级」红标)
- 结论条新增条目:「反复投诉 N 台设备…其中 M 台已升级到外部」

## 前端设计说明(遵循界面设计说明规范 · 三层落地)
两个区块均含:① 代码层 Design Rationale 注释块(五要素)
              ② 页面层 ⓘ 气泡(**含「取舍」段**:不做什么及原因)
取舍说明:不做"建议排班人数"(缺人均产能数据)· 不做周/月切换(日周期会被抹平)
         · 不自动判定"是否同一问题"(remark 质量不足)· 不做阈值自定义 UI

## 附带:Element Plus  deprecated API 全量清零
项目装 element-plus **2.14.4**(package.json 写 ^2.6.2)→ 三类运行时弃用告警:
- `[el-radio]` label → value:28 文件 / 158 处(前次已修)
- `[el-link]`  :underline="false" → underline="never":27 处
- `[el-pagination]` 裸 small → size="small":9 处(含跨行属性)
新增可复现脚本 scripts/migrate_ep_deprecations.py(幂等,见下)

**浏览器实测:弃用告警 93+ → 0**(遍历分析/渠道/主体/执行跟踪各页签)

## 测试
- 新增 tests/test_complaint_overview_a2a5.py(13 例)
  · A5:聚合自洽(格子==by_hour==by_weekday==total) / 网格补齐 / 峰谷合法不重叠
        / 高峰量级≥谷底(方向性检查)
  · A2:★事件口径(事件数≤原始记录数) / 阈值回显 / 清单≥阈值 / 降序
        / 升级语义 / rate 界内 / 阈值单调性
  · 集成:两个块在 /overview 中存在 / 不含卡商 / 反复投诉进结论条
- 空数据自动跳过(避免 "0==0 恒真" 的假绿)
- 真实数据只读验证:13 项断言全通过

## 验证
- ruff 全绿 · 后端 **1010 passed / 37 skipped / 0 failed**
- 前端 eslint 0 error · vitest 52 例全绿 · vite build 成功
- 脚本幂等(二次运行 0 处变更)
- 附带清理:删除锁定后遗留的 _pending 重复副本 + 修复 2 处引用断链

关联工单: #72(来源 #67 §四点五)
This commit is contained in:
2026-09-11 19:28:47 +08:00
parent cab6599ee2
commit 08d755d5c7
20 changed files with 837 additions and 38 deletions

View File

@@ -9,7 +9,7 @@ from collections import Counter
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy import Integer, and_, case, func, or_, select
from app.api.deps import CurrentUser, DBSession, ManagerUser
from app.core.redis_client import cache_ttl_for, cached_json
@@ -2063,6 +2063,9 @@ def complaint_backlog_export(
_AUTO_GRAIN_RULES: tuple[tuple[int, str], ...] = ((31, "day"), (120, "week"))
_OVERDUE_DAYS = 7 # 超期阈值: 累计未处理 >7 天(与积压页龄期口径一致)
_REPEAT_THRESHOLD = 3 # ★ #72 A2: 反复投诉阈值 —— 同一设备 ≥3 次投诉**事件**
# 口径重要提示: 计数单位是「事件(SN + 北京日期去重)」而非原始记录数。
# 实测差异 3 倍(237 台 vs 78 台) —— 同一天多渠道登记属同一笔投诉,不算反复。
_MIN_BASE_FOR_PCT = 5 # 结论条最小基数护栏: 对比期基数 <5 条不给百分比结论(防"3→6 报 +100%")
_MAX_CREDIBLE_PCT = 300.0 # 可信上限: 超过此幅度说明对比期数据覆盖不足(非真实业务信号)
_MAX_CONCLUSIONS = 5
@@ -2406,6 +2409,12 @@ def complaint_overview(
"rate": round(sn_escalated / sn_total * 100.0, 1) if sn_total else None,
}
# ── 7.5 A5: 投诉登记时段热力(#72) —— 服务排班决策 ──
hour_heatmap = _hour_heatmap(db, ts, te, src_expr)
# ── 7.6 A2: 反复投诉(#72) —— 事件口径(SN+北京日期),非记录口径 ──
repeat_stats = _repeat_complaints(db, ts, te, src_expr, threshold=_REPEAT_THRESHOLD)
# ── 8. 结论条(后端生成: 口径统一 + 可单测; 前端只渲染) ──
conclusions: list[dict] = []
src_rh_cur = _sources_reg_handled(db, ts, te)
@@ -2457,6 +2466,18 @@ def complaint_overview(
"text": f"涉诉退款 ¥{cur['refund_amt']:,.2f}({cur['refund_cnt']} 笔){suffix}",
"drill": {"tab": "external"},
})
# ★ #72 A2: 反复投诉(事件口径)—— 「第一次没解决好」的直接证据
if repeat_stats["repeat_units"]:
_esc = sum(1 for i in repeat_stats["items"] if i["escalated"])
_esc_txt = f",其中 {_esc} 台已升级到外部" if _esc else ""
conclusions.append({
"level": "warn",
"text": (
f"反复投诉 {repeat_stats['repeat_units']} 台设备"
f"(同一设备 ≥{repeat_stats['threshold']} 次投诉事件){_esc_txt}"
),
"drill": {"tab": "overview"},
})
conclusions.sort(key=lambda c: _CONCLUSION_ORDER.index(c["level"]))
conclusions = conclusions[:_MAX_CONCLUSIONS]
@@ -2480,6 +2501,10 @@ def complaint_overview(
"trend_reg_handled": trend_reg_handled,
"trend_refund": trend_refund,
"escalation": escalation,
# ★ #72 A5: 投诉登记时段热力(服务排班决策)
"hour_heatmap": hour_heatmap,
# ★ #72 A2: 反复投诉(事件口径,非记录口径)
"repeat": repeat_stats,
"backlog": {
"total": b_total, "overdue": b_overdue, "unassigned": b_unassigned, "max_aging": b_max_aging,
"by_source": [{"name": k, "count": v} for k, v in sorted(by_source_cnt.items(), key=lambda x: -x[1])],
@@ -3572,3 +3597,176 @@ def create_audit(payload: dict, db: DBSession, current_user: ManagerUser):
db.commit()
db.refresh(a)
return R(data={"id": a.id, "conclusion": a.conclusion, "attribution": a.attribution})
def _hour_heatmap(db, w_ts, w_te, src_expr) -> dict:
"""A5 · 投诉登记时段热力(小时 × 星期,北京时间)。
★ 决策价值:直接服务**排班决策**
投诉登记量随时段/星期呈明显节律(实测近 30 天呈双峰:09–12 时、19–21 时,
02–05 时为谷底)。排班若只看总量、不看分布,高峰期必然积压。
★ 为什么用「登记时间」而非「投诉日期」
排班关心的是"什么时候有人来投诉"(接待压力),不是"投诉属于哪一天"。
→ 用 `register_time` 的小时/星期;时区必须走 `Asia/Shanghai`
(见 docs/standards/数据时间字段使用规范.md)。
★ 峰谷判定用分位数而非固定阈值
不同业务量级差异大(日 10 单 vs 日 1000 单),固定阈值必然失效;
P75/P25 分位随数据自适应。
返回:
cells [{weekday, hour, count}] weekday: 1=周一 … 7=周日(ISO)
by_hour [{hour, count}] 边际分布(24 项, 含 0)
by_weekday [{weekday, count}] 边际分布(7 项, 含 0)
peak_hours [int] 登记量 ≥P75 的小时(排班需覆盖)
valley_hours[int] 登记量 ≤P25 的小时(可减配)
total int 参与统计的投诉登记数
"""
base = [
_ticket_category_case() == "投诉",
src_expr != CARD_SCOPE, # ★ #53: 卡商独立,不参与投诉排班统计
ServiceTicket.register_time.isnot(None),
]
if w_ts:
base.append(ServiceTicket.register_time >= w_ts)
if w_te:
base.append(ServiceTicket.register_time <= w_te)
# 北京时间小时/星期(ISO: 1=周一 … 7=周日)
sh_time = func.timezone("Asia/Shanghai", ServiceTicket.register_time)
hour_col = func.extract("hour", sh_time).cast(Integer)
wd_col = func.extract("isodow", sh_time).cast(Integer)
rows = db.execute(
select(wd_col, hour_col, func.count()).where(*base)
.group_by(wd_col, hour_col)
).all()
cells = [{"weekday": int(w), "hour": int(h), "count": int(c or 0)} for w, h, c in rows if w and h is not None]
total = sum(c["count"] for c in cells)
# 边际分布(补齐 0,前端热力图需要完整网格)
hmap: dict[int, int] = {h: 0 for h in range(24)}
wmap: dict[int, int] = {w: 0 for w in range(1, 8)}
for c in cells:
hmap[c["hour"]] += c["count"]
wmap[c["weekday"]] += c["count"]
# 峰谷判定:只在「有数据的小时」上算分位,避免凌晨空档把 P25 拉成 0
nonzero = sorted(v for v in hmap.values() if v > 0)
def _quantile(vals: list[int], q: float) -> float:
"""线性插值分位数(与 numpy.percentile 默认口径一致)。"""
if not vals:
return 0.0
if len(vals) == 1:
return float(vals[0])
pos = (len(vals) - 1) * q
lo = int(pos)
hi = min(lo + 1, len(vals) - 1)
return vals[lo] + (vals[hi] - vals[lo]) * (pos - lo)
p75 = _quantile(nonzero, 0.75)
p25 = _quantile(nonzero, 0.25)
peak_hours = sorted(h for h, v in hmap.items() if v > 0 and v >= p75)
valley_hours = sorted(h for h, v in hmap.items() if v > 0 and v <= p25)
return {
"cells": cells,
"by_hour": [{"hour": h, "count": hmap[h]} for h in range(24)],
"by_weekday": [{"weekday": w, "count": wmap[w]} for w in range(1, 8)],
"peak_hours": peak_hours,
"valley_hours": valley_hours,
"total": total,
}
def _repeat_complaints(db, w_ts, w_te, src_expr, threshold: int = 3, top_n: int = 10) -> dict:
"""A2 · 反复投诉(同一设备多次投诉**事件**)。
★★★ 口径关键(实测差 3 倍,务必理解):
按 SN **直接计数** ≥3 次 → 237 台;
按**投诉事件(SN + 北京日期)去重**后 ≥3 次 → **78 台**。
差异来自 #55 口径:**同一天多渠道呈现 = 同一笔投诉的 1 个事件,不算反复**。
故本函数以「(SN, 北京日期)」为**事件键**去重计数 ——
否则会把「一天内多渠道登记」误判为该设备在反复投诉,**方向性错误**。
★ 决策价值:
反复投诉 = 「第一次没解决好」的直接证据,比投诉总量更接近真相
(总量会被一次性事件稀释,反复投诉不会)。
★ 升级信号:
若某设备的 `sources` 同时含「内部/商户号」与「外部」→ 既反复、又已升级,
属**最高优先级**跟进对象(前端以红色标识)。
返回:
threshold/events_total/units_total/repeat_units/repeat_events/rate/items
"""
base = [
_ticket_category_case() == "投诉",
src_expr != CARD_SCOPE, # ★ #53: 卡商独立
ServiceTicket.device_sn.isnot(None),
ServiceTicket.device_sn != "",
ServiceTicket.register_time.isnot(None),
]
if w_ts:
base.append(ServiceTicket.register_time >= w_ts)
if w_te:
base.append(ServiceTicket.register_time <= w_te)
# 事件键 = (SN, 北京日期);同时带出来源桶用于「升级信号」判定
sh_day = func.date(func.timezone("Asia/Shanghai", ServiceTicket.register_time))
rows = db.execute(
select(ServiceTicket.device_sn, sh_day, src_expr).where(*base)
).all()
# 按 (SN, 日) 折叠为「事件」;同一事件内合并来源集合
events: dict[tuple[str, object], set[str]] = {}
for sn, day, src in rows:
key = (sn, day)
events.setdefault(key, set()).add(src or "内部投诉")
per_sn: dict[str, dict] = {}
for (sn, day), srcs in events.items():
b = per_sn.setdefault(sn, {"events": 0, "sources": set(), "days": []})
b["events"] += 1
b["sources"].update(srcs)
if day:
b["days"].append(day)
units_total = len(per_sn)
events_total = len(events)
repeats = []
for sn, b in per_sn.items():
if b["events"] >= threshold:
days = sorted(d for d in b["days"] if d)
srcs = sorted(b["sources"])
repeats.append({
"device_sn": sn,
"events": b["events"],
"first_day": str(days[0]) if days else None,
"last_day": str(days[-1]) if days else None,
"sources": srcs,
# 升级信号:既有内部/商户号、又有外部
"escalated": (
any(s in ("内部投诉", "商户号投诉") for s in srcs)
and "外部投诉" in srcs
),
})
# 排序:先按事件数降序,再让升级设备优先(同事件数时更该跟进)
repeats.sort(key=lambda x: (-x["events"], not x["escalated"], x["device_sn"]))
repeat_units = len(repeats)
repeat_events = sum(r["events"] for r in repeats)
return {
"threshold": threshold,
"events_total": events_total,
"units_total": units_total,
"repeat_units": repeat_units,
"repeat_events": repeat_events,
"rate": round(repeat_units / units_total * 100.0, 1) if units_total else None,
"items": repeats[:top_n],
}

View File

@@ -0,0 +1,305 @@
# -*- coding: utf-8 -*-
"""投诉概览增强 A2/A5 回归测试(工单 #72)。
- **A5 · 投诉登记时段热力**(`_hour_heatmap`)—— 服务排班决策
- **A2 · 反复投诉**(`_repeat_complaints`)—— 「第一次没解决好」的直接证据
设计: docs/arch/_pending/A-20260911-001-投诉分析整体重构.md §四点五(A2/A5 契约)
★ 本测试守护的核心(最容易出方向性错误的地方):
**A2 的计数单位必须是「投诉事件」而不是「原始记录数」。**
实测差异 3 倍(近 90 天:按 SN 直接计数 237 台 vs 按事件去重 78 台)。
差异来自 #55 口径:**同一天多渠道呈现 = 同一笔投诉的 1 个事件,不算反复**。
若用记录数,会把「一天内多渠道登记」误判为该设备在反复投诉 —— 方向性错误。
⚠️ 数据依赖:测试库为空时自动跳过(避免"0 == 0 恒真"的**假绿**),
与 `tests/test_complaint_overview.py` 的 `_require_window_data` 同策略。
"""
from datetime import datetime, timedelta
import pytest
from app.services.analytics import TZ
pytest.importorskip("sqlalchemy")
def _clear_cache():
try:
from app.core.redis_client import get_redis
r = get_redis()
ks = list(r.scan_iter("complaint:overview:*"))
if ks:
r.delete(*ks)
except Exception: # pragma: no cover
pass
@pytest.fixture
def db_session():
try:
from app.core.database import SessionLocal
db = SessionLocal()
except Exception as e: # pragma: no cover
pytest.skip(f"数据库不可用: {e}")
try:
yield db
finally:
db.close()
@pytest.fixture
def window():
"""近 30 天窗口(与概览默认一致)。"""
today = datetime.now(TZ).date()
return (today - timedelta(days=29)).isoformat(), today.isoformat()
@pytest.fixture
def db_with_complaints(db_session, window):
"""要求窗口内有投诉数据才运行(否则断言恒真无意义)。"""
from app.api.v1.complaints import _parse_dt, _parse_dt_end, _repeat_complaints
from app.services.analytics import _complaint_source_type_case
ts, te = _parse_dt(window[0]), _parse_dt_end(window[1])
r = _repeat_complaints(db_session, ts, te, _complaint_source_type_case())
if not r["events_total"]:
pytest.skip("窗口内无投诉数据 —— A2/A5 断言需真实数据才有意义")
return db_session
# ── A5 时段热力 ──
class TestHourHeatmap:
"""A5 · 投诉登记时段热力。"""
def test_marginals_are_consistent(self, db_with_complaints, window):
"""① 格子总数 == by_hour 之和 == by_weekday 之和(聚合自洽)。"""
from app.api.v1.complaints import _hour_heatmap, _parse_dt, _parse_dt_end
from app.services.analytics import _complaint_source_type_case
ts, te = _parse_dt(window[0]), _parse_dt_end(window[1])
h = _hour_heatmap(db_with_complaints, ts, te, _complaint_source_type_case())
cell_sum = sum(c["count"] for c in h["cells"])
hour_sum = sum(x["count"] for x in h["by_hour"])
wd_sum = sum(x["count"] for x in h["by_weekday"])
assert cell_sum == hour_sum == wd_sum == h["total"], (
f"聚合不自洽: cells={cell_sum} by_hour={hour_sum} by_weekday={wd_sum} total={h['total']}"
)
def test_grid_is_complete(self, db_with_complaints, window):
"""② 边际分布补齐 0 —— 前端热力图需完整网格(24 小时 / 7 天)。"""
from app.api.v1.complaints import _hour_heatmap, _parse_dt, _parse_dt_end
from app.services.analytics import _complaint_source_type_case
ts, te = _parse_dt(window[0]), _parse_dt_end(window[1])
h = _hour_heatmap(db_with_complaints, ts, te, _complaint_source_type_case())
assert len(h["by_hour"]) == 24, "by_hour 必须补齐 24 项"
assert [x["hour"] for x in h["by_hour"]] == list(range(24))
assert len(h["by_weekday"]) == 7, "by_weekday 必须补齐 7 项"
# ISO 周:1=周一 … 7=周日
assert [x["weekday"] for x in h["by_weekday"]] == list(range(1, 8))
def test_peak_valley_are_disjoint_and_valid(self, db_with_complaints, window):
"""③ 峰谷小时合法且不重叠(否则排班建议自相矛盾)。"""
from app.api.v1.complaints import _hour_heatmap, _parse_dt, _parse_dt_end
from app.services.analytics import _complaint_source_type_case
ts, te = _parse_dt(window[0]), _parse_dt_end(window[1])
h = _hour_heatmap(db_with_complaints, ts, te, _complaint_source_type_case())
assert all(0 <= x <= 23 for x in h["peak_hours"]), "高峰小时越界"
assert all(0 <= x <= 23 for x in h["valley_hours"]), "谷底小时越界"
# 只有在「有数据的小时」上判分位,故峰谷不应包含零量小时
hour_map = {x["hour"]: x["count"] for x in h["by_hour"]}
assert all(hour_map[x] > 0 for x in h["peak_hours"]), "高峰小时不应含零量小时"
assert all(hour_map[x] > 0 for x in h["valley_hours"]), "谷底小时不应含零量小时"
def test_peak_hours_have_higher_volume_than_valley(self, db_with_complaints, window):
"""④ 高峰小时量级 ≥ 谷底小时量级(分位判定的方向性检查)。
若反向 → 说明 P75/P25 用错了(例如把空档小时算进分位)。
"""
from app.api.v1.complaints import _hour_heatmap, _parse_dt, _parse_dt_end
from app.services.analytics import _complaint_source_type_case
ts, te = _parse_dt(window[0]), _parse_dt_end(window[1])
h = _hour_heatmap(db_with_complaints, ts, te, _complaint_source_type_case())
if not h["peak_hours"] or not h["valley_hours"]:
pytest.skip("窗口内数据不足以形成峰谷")
hour_map = {x["hour"]: x["count"] for x in h["by_hour"]}
min_peak = min(hour_map[x] for x in h["peak_hours"])
max_valley = max(hour_map[x] for x in h["valley_hours"])
assert min_peak >= max_valley, (
f"高峰最低 {min_peak} < 谷底最高 {max_valley} —— 分位判定方向错误"
)
# ── A2 反复投诉 ──
class TestRepeatComplaints:
"""A2 · 反复投诉(**事件口径**)。"""
def test_threshold_echoed(self, db_with_complaints, window):
"""① 阈值在响应中回显(前端据此渲染文案,避免硬编码)。"""
from app.api.v1.complaints import _parse_dt, _parse_dt_end, _repeat_complaints
from app.services.analytics import _complaint_source_type_case
ts, te = _parse_dt(window[0]), _parse_dt_end(window[1])
r = _repeat_complaints(db_with_complaints, ts, te, _complaint_source_type_case(), threshold=5)
assert r["threshold"] == 5
def test_event_based_not_record_based(self, db_with_complaints, window):
"""② ★★ 计数单位是「事件」,不是「原始记录数」。
这是本功能**最关键的口径**(实测差异 3 倍):
同一天多渠道登记 = 同一笔投诉的 1 个事件,不算反复。
验证方式:断言「事件总数 ≤ 原始记录数」且「设备数 ≤ 事件数」
—— 若把记录数当事件数,则事件数会等于记录数(同一天多渠道被拆成多次)。
"""
from sqlalchemy import func, select
from app.api.v1.complaints import _parse_dt, _parse_dt_end, _repeat_complaints
from app.models.ticket import ServiceTicket
from app.services.analytics import _complaint_source_type_case
ts, te = _parse_dt(window[0]), _parse_dt_end(window[1])
src = _complaint_source_type_case()
r = _repeat_complaints(db_with_complaints, ts, te, src)
# 原始记录数(无 SN 去重、无同日折叠)
raw = db_with_complaints.scalar(
select(func.count()).select_from(ServiceTicket).where(
ServiceTicket.device_sn.isnot(None),
ServiceTicket.device_sn != "",
ServiceTicket.register_time >= ts,
ServiceTicket.register_time <= te,
)
) or 0
assert r["events_total"] <= raw, (
f"事件数({r['events_total']}) 不应超过原始记录数({raw}) —— 事件口径失效"
)
assert r["units_total"] <= r["events_total"], (
"设备数不应超过事件数"
)
def test_repeat_units_meet_threshold(self, db_with_complaints, window):
"""③ 清单内每个设备的事件数都 ≥ 阈值。"""
from app.api.v1.complaints import _parse_dt, _parse_dt_end, _repeat_complaints
from app.services.analytics import _complaint_source_type_case
ts, te = _parse_dt(window[0]), _parse_dt_end(window[1])
r = _repeat_complaints(db_with_complaints, ts, te, _complaint_source_type_case(), threshold=3)
for it in r["items"]:
assert it["events"] >= 3, f"{it['device_sn']} 仅 {it['events']} 次,低于阈值"
def test_items_sorted_by_events_desc(self, db_with_complaints, window):
"""④ 清单按事件数降序(最该跟进的排最前)。"""
from app.api.v1.complaints import _parse_dt, _parse_dt_end, _repeat_complaints
from app.services.analytics import _complaint_source_type_case
ts, te = _parse_dt(window[0]), _parse_dt_end(window[1])
r = _repeat_complaints(db_with_complaints, ts, te, _complaint_source_type_case())
evs = [it["events"] for it in r["items"]]
assert evs == sorted(evs, reverse=True), f"未按事件数降序: {evs}"
def test_escalated_flag_semantics(self, db_with_complaints, window):
"""⑤ 升级信号语义正确:既有内部/商户号、又有外部。
这是「既反复、又已升级」的最高优先级跟进对象。
"""
from app.api.v1.complaints import _parse_dt, _parse_dt_end, _repeat_complaints
from app.services.analytics import _complaint_source_type_case
ts, te = _parse_dt(window[0]), _parse_dt_end(window[1])
r = _repeat_complaints(db_with_complaints, ts, te, _complaint_source_type_case())
for it in r["items"]:
srcs = set(it["sources"])
expect = (
bool(srcs & {"内部投诉", "商户号投诉"}) and "外部投诉" in srcs
)
assert it["escalated"] == expect, (
f"{it['device_sn']} escalated={it['escalated']} 但 sources={it['sources']}"
)
def test_rate_within_bounds(self, db_with_complaints, window):
"""⑥ 反复投诉率在 0~100 之间。"""
from app.api.v1.complaints import _parse_dt, _parse_dt_end, _repeat_complaints
from app.services.analytics import _complaint_source_type_case
ts, te = _parse_dt(window[0]), _parse_dt_end(window[1])
r = _repeat_complaints(db_with_complaints, ts, te, _complaint_source_type_case())
assert r["rate"] is None or 0 <= r["rate"] <= 100
def test_higher_threshold_yields_fewer_or_equal(self, db_with_complaints, window):
"""⑦ 阈值单调性:阈值越高,命中设备数不增。"""
from app.api.v1.complaints import _parse_dt, _parse_dt_end, _repeat_complaints
from app.services.analytics import _complaint_source_type_case
ts, te = _parse_dt(window[0]), _parse_dt_end(window[1])
src = _complaint_source_type_case()
n3 = _repeat_complaints(db_with_complaints, ts, te, src, threshold=3)["repeat_units"]
n5 = _repeat_complaints(db_with_complaints, ts, te, src, threshold=5)["repeat_units"]
assert n5 <= n3, f"阈值 5 命中 {n5} 台 > 阈值 3 的 {n3} 台 —— 单调性被破坏"
# ── 接口层(overview 集成) ──
class TestOverviewIntegration:
"""两个新增块在 /overview 响应中的契约。"""
def _overview(self, db, **kw):
from app.api.v1.complaints import complaint_overview
kw.setdefault("source_type", None)
res = complaint_overview(db=db, _=None, **kw)
if hasattr(res, "data"):
return res.data
return res["data"] if isinstance(res, dict) and "data" in res else res
def test_overview_has_new_blocks(self, db_with_complaints, window):
"""① 响应含 hour_heatmap 与 repeat 两个块。"""
_clear_cache()
d = self._overview(db_with_complaints, start=window[0], end=window[1],
grain=None, compare="none")
assert "hour_heatmap" in d, "缺少 A5 hour_heatmap"
assert "repeat" in d, "缺少 A2 repeat"
assert d["hour_heatmap"]["total"] >= 0
assert d["repeat"]["threshold"] >= 1
def test_card_bucket_excluded(self, db_with_complaints, window):
"""② 两个块均不含 (外)卡商(#53 独立口径)。
验证方式:A2 的设备清单里,其来源集合不应出现 (外)卡商;
A5 的总量应与「剔除卡商」的口径一致(无法直接验,靠代码层断言 + 总量合理)。
"""
_clear_cache()
d = self._overview(db_with_complaints, start=window[0], end=window[1],
grain=None, compare="none")
for it in d["repeat"]["items"]:
assert "(外)卡商" not in (it.get("sources") or []), (
f"{it['device_sn']} 的来源含卡商 —— 违反 #53 独立口径"
)
def test_repeat_conclusion_present_when_repeats_exist(self, db_with_complaints, window):
"""③ 有反复投诉时,结论条应出现对应条目。"""
_clear_cache()
d = self._overview(db_with_complaints, start=window[0], end=window[1],
grain=None, compare="none")
if not d["repeat"]["repeat_units"]:
pytest.skip("窗口内无反复投诉")
texts = [c["text"] for c in d["conclusions"]]
assert any("反复投诉" in t for t in texts), (
f"有 {d['repeat']['repeat_units']} 台反复投诉但结论条未提示: {texts}"
)

View File

@@ -31,7 +31,7 @@
> 5. **`brief` 的 `start/end` 保持纯日期**,遇分钟串 **400**(区间 `span_days`
> 计算会失真,不静默截断)。
> 6. **不变**:端点路径、scope、响应既有字段、限频、审计 —— 全部向后兼容。
> 详见 `docs/arch/_pending/A-20260911-002-只读分钟级窗口与AI自然语言时间识别.md`
> 详见 `docs/arch/A-20260911-002-只读分钟级窗口与AI自然语言时间识别.md`
> (终审通过,已并入本文档 v1.3)。
---

View File

@@ -42,7 +42,7 @@
> 「必须写明本次统计窗口」。含对比类词或具体日期的问句**强制注入**该卡。
> 9. **可观测**:`analyze` 记 `build_ms` / `llm_ms` / `total_ms` / `window_source`;
> `running` 兜底响应带 `waited_s`。
> 详见 `docs/arch/_pending/A-20260911-002-只读分钟级窗口与AI自然语言时间识别.md`
> 详见 `docs/arch/A-20260911-002-只读分钟级窗口与AI自然语言时间识别.md`
> (终审通过,已并入本文档 v1.3)。
> **本期(一期)决策(用户 2026-09-04 终审)**:直接实现带模型的**自由问答**(非"模板不经模型")——模型一期用 **DeepSeek V4 Flash(OpenAI 兼容官方端点)**,模型层可插拔,后续可切本地私有化模型;数据走**内部 service 直连聚合**(与 external brief 同一底层、同一缓存);业务口径以**口径卡(Business Glossary)作为上下文**喂模型;模板/口径存**代码常量**(二期可 DB 化);前端右上角抽屉。

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Element Plus 2.x 弃用项批量迁移(el-link / el-pagination)。
## 背景
项目 `package.json` 声明 `element-plus: ^2.6.2`,实际安装 **2.14.4**(npm 解析最新 minor)。
2.6 → 2.14 期间部分组件的 API 被标记弃用(3.0.0 移除),运行时持续打印:
[el-link] [API] The underline option (boolean) is about to be deprecated in
version 3.0.0, please use 'always' | 'hover' | 'never' instead.
本脚本做**机械迁移**,把"官方已明确等价"的写法换成新写法。
## 迁移规则(只改确定等价的部分)
### el-link
| 旧 | 新 | 等价性依据 |
|---|---|---|
| `:underline="false"` | `underline="never"` | ✅ 官方弃用提示直接给出目标取值 |
| `:underline="true"` | `underline="always"` | ✅ 同上 |
> ⚠️ **不迁移动态表达式**(如 `:underline="someVar"`)——
> 无法静态判定等价,留给人工判断。
> ⚠️ 同理 `el-pagination` 的弃用项若涉及动态值,脚本不处理(见下方 RULES 注释)。
## 用法
python3 scripts/migrate_ep_deprecations.py # 预览(默认,不写盘)
python3 scripts/migrate_ep_deprecations.py --apply # 写盘
⚠️ 仅做文本替换;写盘后请跑 eslint / build 验证。
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "web" / "src"
# (编译后的正则, 替换文本, 说明)
RULES: list[tuple[re.Pattern, str, str]] = [
# el-link: 布尔 underline → 枚举 underline
(re.compile(r':underline="false"'), 'underline="never"',
'el-link :underline="false" → underline="never"'),
(re.compile(r':underline="true"'), 'underline="always"',
'el-link :underline="true" → underline="always"'),
]
# el-pagination: 裸 `small` 属性 → `size="small"`
# 官方弃用提示:`[el-pagination] [API] small is about to be deprecated in
# version 3.0.0, please use size instead.`
# ⚠️ 只在 <el-pagination ...> 标签内替换,避免误伤其它组件的 small 用法
PAGINATION_TAG = re.compile(r'<el-pagination\b[^>]*?/?>', re.DOTALL)
PAGINATION_SMALL = re.compile(r'(?<![-\w:])small(?=\s|/|>)')
def _fix_pagination(tag: str) -> tuple[str, int]:
"""把 el-pagination 标签内的裸 small 换成 size="small"(幂等)。"""
if 'size="small"' in tag:
return tag, 0
return PAGINATION_SMALL.subn('size="small"', tag)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="写盘(默认仅预览)")
args = ap.parse_args()
files = sorted(SRC.rglob("*.vue"))
report: list[tuple[Path, str, int]] = []
total = 0
for f in files:
text = f.read_text(encoding="utf-8")
new = text
per_rule: list[tuple[str, int]] = []
# ① 纯文本规则(el-link)
for pat, rep, desc in RULES:
new, n = pat.subn(rep, new)
if n:
per_rule.append((desc, n))
# ② el-pagination 裸 small → size="small"(限定标签内,避免误伤)
n_pag = 0
def _sub(m: re.Match) -> str:
nonlocal n_pag
tag, n = _fix_pagination(m.group(0))
n_pag += n
return tag
new = PAGINATION_TAG.sub(_sub, new)
if n_pag:
per_rule.append(('el-pagination 裸 small → size="small"', n_pag))
if new != text:
for desc, n in per_rule:
report.append((f, desc, n))
total += n
if args.apply:
f.write_text(new, encoding="utf-8")
mode = "已写盘" if args.apply else "预览(未写盘)"
print(f"=== Element Plus 弃用项迁移 [{mode}] ===\n")
cur = None
for f, desc, n in report:
if f != cur:
print(f" {f.relative_to(ROOT)}")
cur = f
print(f" {n:>3} 处 {desc}")
print(f"\n合计 {total} 处")
if not args.apply and total:
print("\n→ 确认无误后加 --apply 执行")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -144,13 +144,13 @@
<el-table-column prop="problem" label="投诉内容" min-width="220" show-overflow-tooltip />
<el-table-column prop="device_sn" label="设备SN" width="130">
<template #default="{ row }">
<el-link v-if="row.device_sn" type="primary" :underline="false" @click="$emit('device', row.device_sn)">{{ row.device_sn }}</el-link>
<el-link v-if="row.device_sn" type="primary" underline="never" @click="$emit('device', row.device_sn)">{{ row.device_sn }}</el-link>
<span v-else class="dim">—</span>
</template>
</el-table-column>
<el-table-column v-if="!meta.independent" label="处理人" width="100">
<template #default="{ row }">
<el-link v-if="row.handler && row.handler !== '未分配'" type="primary" :underline="false" @click="$emit('person', row.handler)">{{ row.handler }}</el-link>
<el-link v-if="row.handler && row.handler !== '未分配'" type="primary" underline="never" @click="$emit('person', row.handler)">{{ row.handler }}</el-link>
<span v-else class="dim">未分配</span>
</template>
</el-table-column>

View File

@@ -191,7 +191,7 @@
<span class="snapshot-rank">{{ i + 1 }}</span>
<el-tag :type="sourceTagType(it.source)" size="small" effect="plain">{{ it.source }}</el-tag>
<span class="snapshot-no">{{ it.ticket_no }}</span>
<el-link v-if="it.device_sn" type="primary" :underline="false" class="snapshot-sn" @click="openDeviceAudit(it.device_sn)">{{ it.device_sn }}</el-link>
<el-link v-if="it.device_sn" type="primary" underline="never" class="snapshot-sn" @click="openDeviceAudit(it.device_sn)">{{ it.device_sn }}</el-link>
<el-tag v-if="it.aging_days != null" size="small" :type="it.aging_days >= 8 ? 'danger' : it.aging_days >= 4 ? 'warning' : 'info'">{{ it.aging_days }}天</el-tag>
</div>
</div>
@@ -200,6 +200,112 @@
</el-col>
</el-row>
<!-- ══════════════ A5: 投诉登记时段热力(#72) ══════════════
设计说明(Design Rationale)
目的:**排班决策** —— 让班组长看清"什么时候来投诉最多",据此安排人手。
逻辑:投诉登记量随时段呈明显节律(实测双峰:09–12 时、19–21 时;
02–05 时谷底)。只看总量会漏掉节律 → 高峰期必然积压。
峰谷由后端按 P75/P25 **分位**判定(非固定阈值,自适应业务量级)。
口径:按「登记时间」的北京时间小时×星期聚合(排班关心"何时有人来",
不是"投诉属于哪天");不含 (外)卡商(#53 独立体系)。
详见 docs/standards/数据时间字段使用规范.md
取舍:❌ 不做"建议排班人数" —— 需要人均处理产能数据(接待+投诉混合),
数据未齐;当前只给"量级分布"这一事实,由班组长自行判断。
❌ 不做按周/月切换 —— 时段节律是**日周期**,跨周聚合会把节律抹平。
工单:#72 · 设计:docs/arch/_pending/A-20260911-001-投诉分析整体重构.md §四点五 -->
<el-row v-if="ovHourOption || ovRepeatRows.length" :gutter="12" class="mb-16">
<el-col :xs="24" :md="12">
<div class="chart-card">
<div class="flex-between">
<div class="chart-title">
投诉登记时段热力
<span class="dim-text"> · 小时 × 星期(北京时间)· 服务排班</span>
</div>
<el-popover placement="bottom-end" :width="400" trigger="click">
<template #reference>
<el-icon style="color:#909399;cursor:help;font-size:14px"><QuestionFilled /></el-icon>
</template>
<div class="dr-body">
<div class="dr-title">时段热力 · 设计说明</div>
<div class="dr-sec"><b>给谁用</b><div>班组长 / 客服主管,用于排班与人力调配。</div></div>
<div class="dr-sec"><b>为什么用「登记时间」</b>
<div>排班关心的是"什么时候有人来投诉"(接待压力),而不是"投诉属于哪一天"。</div></div>
<div class="dr-sec"><b>峰谷怎么判</b>
<div>按 P75 / P25 分位(自适应业务量级,而非固定阈值)。<b>高峰</b>=需确保有人值守;<b>谷底</b>=可减配。</div></div>
<div class="dr-sec"><b>我们不做什么</b>
<div>· 不做"建议排班人数":需人均处理产能数据,当前未齐 —— 只给量级事实,由班组长判断。<br>
· 不做周/月切换:时段节律是日周期,跨周聚合会抹平节律。</div></div>
<div class="dr-sec"><b>口径</b><div>北京时间(Asia/Shanghai)· 不含 (外)卡商</div></div>
</div>
</el-popover>
</div>
<BaseChart v-if="ovHourOption" :option="ovHourOption" height="230px" />
<el-empty v-else description="窗口内无登记数据" :image-size="36" />
<div v-if="peakHint" class="peak-hint">{{ peakHint }}</div>
</div>
</el-col>
<!-- ══════════════ A2: 反复投诉(#72) ══════════════
设计说明(Design Rationale)
目的:识别**「第一次没解决好」的设备**,比投诉总量更接近真相。
逻辑:总量会被一次性事件稀释,反复投诉不会 ——
同一设备多次投诉 = 处理质量问题的直接证据;
其中"又反复、又已升级到外部"的设备是**最高优先级**跟进对象。
口径:★★ 计数单位是「投诉事件(SN + 北京日期去重)」,**不是原始记录数**。
实测差异 3 倍(237 台 vs 78 台)—— 同一天多渠道登记属同一笔投诉,
不算反复(#55 口径)。用记录数会把"一天多渠道"误判为反复,方向性错误。
不含 (外)卡商(#53)。
取舍:❌ 不做"自动判定是否同一问题" —— 需问题分类语义,当前 remark 质量不足;
❌ 不做阈值自定义 UI —— 阈值由后端常量统一(避免每人一套口径)。
工单:#72 · 设计:docs/arch/_pending/A-20260911-001-投诉分析整体重构.md §四点五 -->
<el-col v-if="ovRepeatRows.length" :xs="24" :md="12">
<div class="chart-card">
<div class="flex-between">
<div class="chart-title">
反复投诉
<span class="dim-text"> · 同一设备 ≥{{ repeatThreshold }} 次投诉事件</span>
</div>
<el-popover placement="bottom-end" :width="400" trigger="click">
<template #reference>
<el-icon style="color:#909399;cursor:help;font-size:14px"><QuestionFilled /></el-icon>
</template>
<div class="dr-body">
<div class="dr-title">反复投诉 · 设计说明</div>
<div class="dr-sec"><b>给谁用</b><div>客服主管,用于找出"处理没到位"的设备并定向跟进。</div></div>
<div class="dr-sec"><b>为什么要看这个</b>
<div>投诉<b>总量</b>会被一次性事件稀释;<b>反复投诉</b>不会 —— 同一设备多次投诉是"第一次没解决好"的直接证据。</div></div>
<div class="dr-sec"><b>★ 口径(关键)</b>
<div>计数单位是<b>投诉事件</b>(同一设备 + 同一北京日期算 1 次),<b>不是原始记录数</b>。
同一天多渠道登记的同一笔投诉只算 1 次(否则会把"一天多渠道"误判为反复,方向性错误)。</div></div>
<div class="dr-sec"><b>红色「已升级」是什么</b>
<div>该设备既有内部/商户号投诉、又有外部投诉 → 既反复、又已升级,<b>最高优先级</b>。</div></div>
<div class="dr-sec"><b>我们不做什么</b>
<div>· 不自动判定"是否为同一问题":需问题分类语义,当前 remark 质量不足。<br>
· 不做阈值自定义:阈值由后端统一,避免每人一套口径。</div></div>
</div>
</el-popover>
</div>
<div class="repeat-summary">
<span class="repeat-kpi" style="color:#dc2626">{{ formatNum(ovRepeat.repeat_units) }}</span>
<span class="dim">台设备反复投诉(占 {{ ovRepeat.rate ?? '—' }}%)· 贡献 {{ formatNum(ovRepeat.repeat_events) }} 次事件</span>
<el-tag v-if="ovEscalatedCount" size="small" type="danger" effect="plain" style="margin-left:6px">
{{ ovEscalatedCount }} 台已升级外部
</el-tag>
</div>
<div class="repeat-list">
<div v-for="(it, i) in ovRepeatRows" :key="it.device_sn" class="repeat-row">
<span class="snapshot-rank">{{ i + 1 }}</span>
<el-link type="primary" underline="never" class="snapshot-sn"
@click="openDeviceAudit(it.device_sn)">{{ it.device_sn }}</el-link>
<el-tag size="small" :type="it.events >= 5 ? 'danger' : 'warning'" effect="plain">{{ it.events }} 次</el-tag>
<el-tag v-if="it.escalated" size="small" type="danger">已升级</el-tag>
<span class="dim-text repeat-days">{{ it.first_day }} ~ {{ it.last_day }}</span>
</div>
</div>
</div>
</el-col>
</el-row>
<!-- ★ 比率指标占位: 数据完整度不足, 本期不渲染(见 #68 专项研究)
此处保留注释而非代码 —— 数据成熟后由 #68 决定是否加回 -->
</section>
@@ -367,14 +473,14 @@
</el-table-column>
<el-table-column label="设备SN" width="130">
<template #default="{ row }">
<el-link v-if="row.device_sn" type="primary" :underline="false" @click="openDeviceAudit(row.device_sn)">{{ row.device_sn }}</el-link>
<el-link v-if="row.device_sn" type="primary" underline="never" @click="openDeviceAudit(row.device_sn)">{{ row.device_sn }}</el-link>
<span v-else class="dim">—</span>
</template>
</el-table-column>
<el-table-column prop="problem" label="投诉内容" min-width="180" show-overflow-tooltip />
<el-table-column label="处理人" width="100">
<template #default="{ row }">
<el-link v-if="row.handler !== '未分配'" type="primary" :underline="false" @click="openPersonAudit(row.handler)">{{ row.handler }}</el-link>
<el-link v-if="row.handler !== '未分配'" type="primary" underline="never" @click="openPersonAudit(row.handler)">{{ row.handler }}</el-link>
<span v-else class="dim">未分配</span>
</template>
</el-table-column>
@@ -404,7 +510,7 @@
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
small
size="small"
@current-change="loadBacklog"
@size-change="backlogPage = 1; loadBacklog()"
/>
@@ -618,7 +724,7 @@
<el-table-column label="排名" width="60" type="index" />
<el-table-column label="被投诉主体" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" :underline="false" class="subject-link" @click="openSubjectDetail(row.name)">
<el-link type="primary" underline="never" class="subject-link" @click="openSubjectDetail(row.name)">
<span class="subject-name">{{ row.name }}</span>
</el-link>
<el-tag v-if="row.escalated" size="small" type="danger" effect="plain" style="margin-left:6px">升级</el-tag>
@@ -707,7 +813,7 @@
</el-table-column>
<el-table-column label="设备SN" width="140" show-overflow-tooltip>
<template #default="{ row }">
<el-link v-if="row.device_sn" type="primary" :underline="false" @click="openDeviceAudit(row.device_sn)">{{ row.device_sn }}</el-link>
<el-link v-if="row.device_sn" type="primary" underline="never" @click="openDeviceAudit(row.device_sn)">{{ row.device_sn }}</el-link>
<span v-else class="dim">—</span>
</template>
</el-table-column>
@@ -716,7 +822,7 @@
</el-table-column>
<el-table-column label="处理人" width="100" show-overflow-tooltip>
<template #default="{ row }">
<el-link v-if="row.handler && row.handler !== '未分配'" type="primary" :underline="false" @click="openPersonAudit(row.handler)">{{ row.handler }}</el-link>
<el-link v-if="row.handler && row.handler !== '未分配'" type="primary" underline="never" @click="openPersonAudit(row.handler)">{{ row.handler }}</el-link>
<span v-else class="dim">未分配</span>
</template>
</el-table-column>
@@ -1421,6 +1527,68 @@ const ovAgingOption = computed(() => {
}
})
// ── A5 · 投诉登记时段热力图(#72) ──
// x = 小时(0-23),y = 星期(周一~周日);色深 = 登记量
// ★ 用 heatmap 而非折线:时段节律是"二维"的(小时 × 星期),
// 折线只能表一维,会丢掉"周末/工作日差异"这一半信息。
const ovHourOption = computed(() => {
const h = overviewData.value?.hour_heatmap
if (!h || !h.total) return null
const hmap = {}
for (const c of h.by_hour || []) hmap[c.hour] = c.count
const data = [] // echarts heatmap: [x(小时), y(星期索引), value]
let max = 1
for (const c of h.cells || []) {
if (c.count > max) max = c.count
}
for (const c of h.cells || []) {
data.push([c.hour, c.weekday - 1, c.count]) // weekday 1..7 → 0..6
}
const WD = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return {
tooltip: {
position: 'top',
formatter: (p) => `${WD[p.value[1]]} ${String(p.value[0]).padStart(2, '0')}时<br/>登记 ${p.value[2]} 条`,
},
grid: { left: 44, right: 16, top: 10, bottom: 46 },
xAxis: {
type: 'category', data: Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')),
splitArea: { show: true }, axisLabel: { fontSize: 9, interval: 1 },
name: '小时', nameLocation: 'middle', nameGap: 26, nameTextStyle: { fontSize: 10 },
},
yAxis: {
type: 'category', data: WD, splitArea: { show: true }, axisLabel: { fontSize: 10 },
},
visualMap: {
min: 0, max, calculable: false, orient: 'horizontal', left: 'center', bottom: 0,
itemWidth: 10, itemHeight: 60, textStyle: { fontSize: 9 },
inRange: { color: ['#f1f5f9', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a'] },
},
series: [{
type: 'heatmap', data, label: { show: false },
emphasis: { itemStyle: { shadowBlur: 6, shadowColor: 'rgba(0,0,0,0.3)' } },
}],
}
})
/** 峰谷提示文案(把"热力图"翻译成"结论" —— 否则使用者要自己看色块推断) */
const peakHint = computed(() => {
const h = overviewData.value?.hour_heatmap
if (!h || !h.total) return ''
const fmt = (xs) => (xs || []).map(x => `${String(x).padStart(2, '0')}时`).join(' ')
const parts = []
if (h.peak_hours?.length) parts.push(`高峰(需值守):${fmt(h.peak_hours)}`)
if (h.valley_hours?.length) parts.push(`谷底(可减配):${fmt(h.valley_hours)}`)
return parts.join(' · ')
})
// ── A2 · 反复投诉(#72) ──
// ★ 后端已按「事件口径」算好(SN + 北京日期去重),前端只渲染,不做二次计数。
const ovRepeat = computed(() => overviewData.value?.repeat || {})
const ovRepeatRows = computed(() => (ovRepeat.value.items || []).slice(0, 6))
const repeatThreshold = computed(() => ovRepeat.value.threshold ?? 3)
const ovEscalatedCount = computed(() => (ovRepeat.value.items || []).filter(i => i.escalated).length)
// ── 来源分布饼图(含兜底: source_distribution 为空时用 summary 三源数) ──
const sourcePieColors = ['#2563eb', '#7c3aed', '#dc2626']
@@ -1753,6 +1921,13 @@ watch(analysisTab, () => {
.snapshot-source-fill { height: 100%; border-radius: 5px; transition: width 0.3s; }
.snapshot-source-cnt { width: 36px; text-align: right; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
.snapshot-list { max-height: 190px; overflow-y: auto; }
/* ── A5/A2 (#72) 新增区块样式 ── */
.peak-hint { font-size: 11px; color: #6b7280; margin-top: 6px; line-height: 1.6; }
.repeat-summary { display: flex; align-items: baseline; gap: 6px; font-size: 12px; margin-bottom: 8px; flex-wrap: wrap; }
.repeat-kpi { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
.repeat-list { max-height: 168px; overflow-y: auto; }
.repeat-row { display: flex; align-items: center; gap: 6px; padding: 5px 2px; border-bottom: 1px dashed #f1f3f5; }
.repeat-days { font-size: 10px; }
.snapshot-row { display: flex; align-items: center; gap: 6px; padding: 5px 2px; border-bottom: 1px dashed #f1f3f5; }
.snapshot-rank { width: 16px; font-size: 11px; color: #9ca3af; text-align: center; }
.snapshot-no { flex: 1; font-size: 12px; color: #374151; font-family: ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

View File

@@ -152,7 +152,7 @@
</el-table-column>
<el-table-column v-if="staffFilter.aggregateBy === 'person'" label="人员" width="140">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openPersonAudit(row.name, row.person_id)">{{ row.name }}</el-link>
<el-link type="primary" underline="never" @click="openPersonAudit(row.name, row.person_id)">{{ row.name }}</el-link>
</template>
</el-table-column>
<el-table-column v-else-if="staffFilter.aggregateBy === 'group'" label="分组" width="140">
@@ -325,7 +325,7 @@
<el-table :data="recoveryStaffList" size="small" border stripe>
<el-table-column label="人员" width="140">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openPersonAudit(row.name)">{{ row.name }}</el-link>
<el-link type="primary" underline="never" @click="openPersonAudit(row.name)">{{ row.name }}</el-link>
</template>
</el-table-column>
<el-table-column prop="group" label="分组" width="120">
@@ -472,7 +472,7 @@
</el-table-column>
<el-table-column label="设备SN" width="140" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openDeviceAudit(row.device_sn)">{{ row.device_sn || '—' }}</el-link>
<el-link type="primary" underline="never" @click="openDeviceAudit(row.device_sn)">{{ row.device_sn || '—' }}</el-link>
</template>
</el-table-column>
<el-table-column label="被投诉主体" width="150" show-overflow-tooltip>
@@ -503,7 +503,7 @@
<el-table-column prop="problem" label="投诉内容" min-width="180" show-overflow-tooltip />
<el-table-column label="处理/跟进" width="110" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openPersonAudit(row.handler)">{{ row.handler || '—' }}</el-link>
<el-link type="primary" underline="never" @click="openPersonAudit(row.handler)">{{ row.handler || '—' }}</el-link>
</template>
</el-table-column>
<el-table-column prop="status" label="结果/状态" width="110" show-overflow-tooltip :filters="complaintStatusFilters" :filter-method="(v,row)=>row.status===v" filter-placement="bottom-end" />

View File

@@ -135,7 +135,7 @@
:page-sizes="[30, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
small
size="small"
@current-change="loadSleeping"
@size-change="sleepPage = 1; loadSleeping()"
/>

View File

@@ -75,7 +75,7 @@
<span v-if="inc.is_recurring" class="impact-recur-mark">周期</span>
{{ inc.title }}
</el-tag>
<el-link v-if="activeIncidents.length > 4" type="primary" :underline="false" style="margin-left:4px" @click="goToImpactSection">
<el-link v-if="activeIncidents.length > 4" type="primary" underline="never" style="margin-left:4px" @click="goToImpactSection">
+{{ activeIncidents.length - 4 }} 更多
</el-link>
</div>
@@ -185,7 +185,7 @@
<el-icon style="color:#f59e0b; margin-right:4px"><Bell /></el-icon>
我的待办
</h3>
<el-link type="primary" :underline="false" @click="$router.push('/todos')">全部 →</el-link>
<el-link type="primary" underline="never" @click="$router.push('/todos')">全部 →</el-link>
</div>
<div v-if="queueData.todos && queueData.todos.length" class="todo-list">
<div v-for="(t, i) in queueData.todos.slice(0, 3)" :key="i" class="todo-item">
@@ -208,7 +208,7 @@
<el-icon style="color:#7c3aed; margin-right:4px"><List /></el-icon>
进行中专项
</h3>
<el-link type="primary" :underline="false" @click="$router.push('/tasks')">全部 →</el-link>
<el-link type="primary" underline="never" @click="$router.push('/tasks')">全部 →</el-link>
</div>
<div v-if="queueData.tasks && queueData.tasks.length" class="task-list">
<div v-for="(t, i) in queueData.tasks.slice(0, 3)" :key="i" class="task-item">
@@ -229,7 +229,7 @@
<section v-if="hasRecovery" class="section-card mb-12">
<div class="section-header-row">
<h3 class="section-title">{{ viewMode === 'week' ? '退款挽回本周快照' : '退款挽回今日快照' }}</h3>
<el-link type="primary" :underline="false" @click="$router.push('/dashboards/refund')">详细分析 →</el-link>
<el-link type="primary" underline="never" @click="$router.push('/dashboards/refund')">详细分析 →</el-link>
</div>
<el-row :gutter="10" class="mb-8">
<!-- 左侧: 4个紧凑数字 -->
@@ -295,7 +295,7 @@
</el-table-column>
<el-table-column label="设备SN" min-width="140">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openDeviceAudit(row.sn)">{{ row.sn }}</el-link>
<el-link type="primary" underline="never" @click="openDeviceAudit(row.sn)">{{ row.sn }}</el-link>
</template>
</el-table-column>
<el-table-column label="结果" width="80" align="center">
@@ -308,7 +308,7 @@
<el-table-column prop="reason" label="退款原因" min-width="120" show-overflow-tooltip />
<el-table-column label="处理人" width="90">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openPersonAudit(row.staff)">{{ row.staff || '—' }}</el-link>
<el-link type="primary" underline="never" @click="openPersonAudit(row.staff)">{{ row.staff || '—' }}</el-link>
</template>
</el-table-column>
</el-table>
@@ -323,7 +323,7 @@
<el-icon style="color:#7c3aed; margin-right:4px"><Bell /></el-icon>
投诉专项
</h3>
<el-link type="primary" :underline="false" @click="$router.push('/dashboards/complaint-analysis')">投诉分析 →</el-link>
<el-link type="primary" underline="never" @click="$router.push('/dashboards/complaint-analysis')">投诉分析 →</el-link>
</div>
<!-- 第一行: 今日/本周投诉来源(3个, 每个展示 登记/处理 双值) + 积压水位(独立维度) -->
@@ -482,7 +482,7 @@
</el-table-column>
<el-table-column label="设备SN" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
<el-link v-if="row.device_sn" type="primary" :underline="false" @click="openDeviceAudit(row.device_sn)">{{ row.device_sn }}</el-link>
<el-link v-if="row.device_sn" type="primary" underline="never" @click="openDeviceAudit(row.device_sn)">{{ row.device_sn }}</el-link>
<span v-else class="text-muted">—</span>
</template>
</el-table-column>
@@ -531,7 +531,7 @@
</el-table-column>
<el-table-column label="处理/跟进" width="90" show-overflow-tooltip>
<template #default="{ row }">
<el-link v-if="row.handler && row.handler !== '未分配'" type="primary" :underline="false" @click="openPersonAudit(row.handler)">{{ row.handler }}</el-link>
<el-link v-if="row.handler && row.handler !== '未分配'" type="primary" underline="never" @click="openPersonAudit(row.handler)">{{ row.handler }}</el-link>
<span v-else class="text-muted">未分配</span>
</template>
</el-table-column>
@@ -546,7 +546,7 @@
<h3 class="section-title">{{ viewMode === 'week' ? '本周工作效率值' : '今日工作效率值' }}</h3>
<div class="section-header-actions">
<el-checkbox v-model="showAllStaff" size="small">显示所有人(含非企微)</el-checkbox>
<el-link type="primary" :underline="false" @click="$router.push('/dashboards/operation')">运营看板 →</el-link>
<el-link type="primary" underline="never" @click="$router.push('/dashboards/operation')">运营看板 →</el-link>
</div>
</div>
<el-table :data="topStaffList" size="small" border stripe max-height="420"
@@ -562,7 +562,7 @@
</el-table-column>
<el-table-column label="人员" width="100">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openPersonAudit(row.name, row.person_id)">{{ row.name }}</el-link>
<el-link type="primary" underline="never" @click="openPersonAudit(row.name, row.person_id)">{{ row.name }}</el-link>
</template>
</el-table-column>
<el-table-column label="专项/小组" width="140" show-overflow-tooltip>

View File

@@ -328,7 +328,7 @@
:page-sizes="[30, 50, 100, 200]"
layout="total, sizes, prev, pager, next, jumper"
background
small
size="small"
@current-change="loadDetail"
@size-change="detailPage = 1; loadDetail()"
/>

View File

@@ -192,7 +192,7 @@
<el-pagination
v-model:current-page="detailPage"
:page-size="50" :total="detailTotal"
layout="total, prev, pager, next" background small
layout="total, prev, pager, next" background size="small"
@current-change="loadDetail"
/>
</div>

View File

@@ -101,7 +101,7 @@
</el-table>
<div class="pager-row">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total"
layout="total, prev, pager, next" small background @current-change="load" />
layout="total, prev, pager, next" size="small" background @current-change="load" />
</div>
<!-- 备注输入 dialog -->

View File

@@ -110,7 +110,7 @@
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
small
size="small"
@current-change="loadData"
@size-change="page = 1; loadData()"
/>

View File

@@ -69,7 +69,7 @@
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
small
size="small"
@current-change="load"
@size-change="page = 1; load()"
/>

View File

@@ -78,7 +78,7 @@
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
small
size="small"
@current-change="load"
@size-change="page = 1; load()"
/>

View File

@@ -9,7 +9,7 @@
<span style="margin-left: 8px">{{ formatTime(report.time_start) }} ~ {{ formatTime(report.time_end) }}</span>
<span v-if="report.assignee_id" style="margin-left: 8px; color: #909399">· 负责人 {{ userNameMap[report.assignee_id] || report.assignee_id }}</span>
<span v-if="report.due_date" style="margin-left: 8px; color: #909399">· 期限 {{ formatDate(report.due_date) }}</span>
<el-link v-if="report.source_weekly_id" type="primary" :underline="false" style="margin-left: 8px" @click="$router.push(`/management/weekly/${report.source_weekly_id}`)">
<el-link v-if="report.source_weekly_id" type="primary" underline="never" style="margin-left: 8px" @click="$router.push(`/management/weekly/${report.source_weekly_id}`)">
· 来源周报看板 #{{ report.source_weekly_id }}
</el-link>
</div>

View File

@@ -180,7 +180,7 @@
v-for="rid in section.related_report_ids || []"
:key="rid"
type="primary"
:underline="false"
underline="never"
style="margin-right: 12px"
@click="goReport(rid)"
>

View File

@@ -13,7 +13,7 @@
<div class="alert-body">
导出质量取决于上游数据视图的成熟度。当前分析视图体系、组合条件与报告内容编排尚未完善,
待沉淀面向领导的标准预设视图后,将按设计文档依次落地三种导出能力。
详见 <el-link type="primary" :underline="false" @click="showDesign = true">导出功能设计文档</el-link>。
详见 <el-link type="primary" underline="never" @click="showDesign = true">导出功能设计文档</el-link>。
</div>
</el-alert>

View File

@@ -95,7 +95,7 @@
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
small
size="small"
@current-change="load"
@size-change="page = 1; load()"
/>