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:
@@ -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],
|
||||
}
|
||||
|
||||
305
backend/tests/test_complaint_overview_a2a5.py
Normal file
305
backend/tests/test_complaint_overview_a2a5.py
Normal 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}"
|
||||
)
|
||||
@@ -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)。
|
||||
|
||||
---
|
||||
|
||||
@@ -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 化);前端右上角抽屉。
|
||||
|
||||
121
scripts/migrate_ep_deprecations.py
Normal file
121
scripts/migrate_ep_deprecations.py
Normal 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())
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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()"
|
||||
/>
|
||||
|
||||
@@ -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()"
|
||||
/>
|
||||
|
||||
@@ -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()"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user