feat(qa-ops): 交付十一项统计看板与完整图片导出
build / build (push) Successful in 1m10s

以治理库单一只读事务分开聚合累计、自然周、截止月及当前通讯录使用分母;浏览器不再读取全量长答案来构建看板。

新增迁移52与普通worker周质量冻结,保留真实冻结时点、规则和标量贡献;缺少当时快照留空。提交后冻结头与贡献不可修改、删除或追加;只读接口保留管理员、固定租户、严格JSON与输入冲突保护。

已验证:make verify退出0,686个Go用例和305个前端用例通过,必需跳过0;镜像与组织隔离PostgreSQL竞态回归退出0。真实治理副本浏览器核对第41周累计1885与新增471、十一项和完整PNG下载,390px无外溢、JS异常0;明确模拟503后不伪零且重试恢复。没有修改DobotAI原始问答、真实区域规则或生产发布门禁。
This commit is contained in:
姚章浩
2026-10-18 23:26:55 +08:00
parent 76671e0784
commit 6e67034fbd
32 changed files with 2103 additions and 356 deletions
+17
View File
@@ -76,6 +76,23 @@
> `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯,
> 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。
## 开发快照 snapshot/v0.1.32 - 2026-10-18
### Added
- 问题统计看板按参考布局提供完整十一项:2.0 累计问题、本周新增、覆盖率与正确率半环、问题类型饼图、产品横条排行、所选自然周逐日折线、区域平均使用频率与使用覆盖率、两条历史质量周趋势。未知分类保留为未分类,未知分母及历史缺失点不伪造为零。
- 支持导出当前完整看板 PNG,包含所有图形、中文、图例与数值,独立于屏幕滚动高度;不包含导航、筛选控件、按钮或个人月明细。桌面与窄屏共享同一份数据及响应式图形。
- 治理侧保存自然周质量冻结快照与逐条标量贡献;普通 worker 在符合采集条件的下一周一自动冻结上一周,保存实际时点、源观测、版本和分子分母。首次启用前、错过采集或没有完整观测的历史周保持空白,后续人工改判不回写已冻结比率。
### Changed
- 看板通过单一只读一致性接口聚合治理副本、有效质量与组织版本,不连接源端、不下载全部长答案;累计、所选自然周和截止月分别计算,原问题关键词按字面筛选。区域分母使用配置后的通讯录去重人数,仅用于使用统计,个人月明细与四页共享查询保留。
- 新增加表迁移 52。应用回退保留加表;发现冻结历史时拒绝有损 down。未接入翻译/AI 评估模型、绩效名单、1.0 归档或自动发送。
### Security
- 统计接口维持固定租户、管理员会话、同源动作头、严格 JSON 和 no-store;输入指纹变化返回冲突,不混合新旧结论。数据库拒绝修改、删除或追加已提交的冻结贡献;浏览器查询不写指标,PNG 使用自包含 SVG,不加载外部图片或执行原始 HTML。
## 开发快照 snapshot/v0.1.31 - 2026-10-18
### Added
+1
View File
@@ -48,6 +48,7 @@
"get": {"tags":["QA operations"],"operationId":"getQAOrganizationSync","summary":"管理员读取通讯录同步执行器状态及最近任务的安全汇总,不返回人员名单或运行配置","responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"状态存储不可用,不伪装为空记录"}}},
"post": {"tags":["QA operations"],"operationId":"startQAOrganizationSync","summary":"管理员同源发起持久化通讯录同步;重复请求重放同一任务,进行中任务合并;失败保留现有快照","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-organization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false}}}},"responses":{"202":{"$ref":"#/components/responses/OperationEnvelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员同源操作"},"409":{"description":"操作标识与原请求冲突"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"同步未接线、执行器离线或入队失败;未创建新任务"}}}
},
"/api/v1/qa-ops/dashboard": {"post":{"operationId":"readQAOperationsDashboard","tags":["QA operations"],"summary":"管理员只读本地一致性看板:2.0累计、所选周、新增/质量/类别及截止月区域统计;历史质量仅真实冻结快照,缺失留空","description":"同一只读可重复读事务读取副本、有效质量及组织版本。不访问源端,不冻结或写入指标;区域分母为当前配置通讯录去重人数而非在职/绩效名单。冻结记录按保存的原始标量分子分母及问题文字支持字面关键词筛选,不用当前评估回填历史。","parameters":[{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-dashboard"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["start","end"],"properties":{"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"},"keyword":{"type":"string","maxLength":200}}}}}},"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员同源读取"},"409":{"description":"质量输入指纹变化,拒绝混合数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"本地副本或一致性投影不可用,不返回伪零"}}}},
"/api/v1/qa-ops/organization": {
"get": {
"tags": ["QA operations"],
+23
View File
@@ -0,0 +1,23 @@
package main
import (
"context"
"errors"
"time"
)
type recordsSyncSchedulerStore interface {
EnqueueDue(context.Context) (bool, error)
TrackQualityWeeks(context.Context, time.Time) error
FreezePreviousQualityWeek(context.Context, time.Time) (bool, error)
}
func runRecordsSyncCycle(ctx context.Context, store recordsSyncSchedulerStore, at time.Time) error {
_, syncErr := store.EnqueueDue(ctx)
trackingErr := store.TrackQualityWeeks(ctx, at)
if trackingErr != nil {
return errors.Join(syncErr, trackingErr)
}
_, freezeErr := store.FreezePreviousQualityWeek(ctx, at)
return errors.Join(syncErr, freezeErr)
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"context"
"errors"
"reflect"
"testing"
"time"
)
type frozenScheduleSpy struct {
calls []string
trackErr error
}
func (s *frozenScheduleSpy) EnqueueDue(context.Context) (bool, error) {
s.calls = append(s.calls, "sync")
return true, nil
}
func (s *frozenScheduleSpy) TrackQualityWeeks(context.Context, time.Time) error {
s.calls = append(s.calls, "track")
return s.trackErr
}
func (s *frozenScheduleSpy) FreezePreviousQualityWeek(context.Context, time.Time) (bool, error) {
s.calls = append(s.calls, "freeze")
return true, nil
}
func TestRecordsSchedulerActuallyCapturesFrozenWeeklyMetrics(t *testing.T) {
s := &frozenScheduleSpy{}
if e := runRecordsSyncCycle(context.Background(), s, time.Now()); e != nil || !reflect.DeepEqual(s.calls, []string{"sync", "track", "freeze"}) {
t.Fatal("weekly capture is not on the real scheduler invocation path", e, s.calls)
}
broken := &frozenScheduleSpy{trackErr: errors.New("isolated tracking unavailable")}
if e := runRecordsSyncCycle(context.Background(), broken, time.Now()); e == nil || !reflect.DeepEqual(broken.calls, []string{"sync", "track"}) {
t.Fatal("snapshot failure must not suppress source sync or freeze untracked history")
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ func runRecordsSyncScheduler(ctx context.Context, store *qaopsmirror.Store, onEr
tick := time.NewTicker(15 * time.Second)
defer tick.Stop()
for {
if _, e := store.EnqueueDue(ctx); e != nil && ctx.Err() == nil && onError != nil {
if e := runRecordsSyncCycle(ctx, store, time.Now()); e != nil && ctx.Err() == nil && onError != nil {
onError(e)
}
select {
+50
View File
@@ -0,0 +1,50 @@
package httpapi
import (
"context"
"errors"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsmirror"
"knowledge-governance/internal/qaopsquality"
"net/http"
"time"
)
type qaDashboardReader interface {
ReadDashboard(context.Context, dobotaiops.RecordQuery) (qaopsmirror.Dashboard, error)
}
func qaOperationsDashboard(w http.ResponseWriter, r *http.Request, c Config) {
if !authorizeQAOps(w, r) || !authorizeQAIntent(w, r, "qa-dashboard") {
return
}
var request struct {
Start string `json:"start"`
End string `json:"end"`
Keyword string `json:"keyword"`
}
if decodeQualityJSON(w, r, &request, "start", "end") != nil {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
return
}
reader, ok := c.QAOps.(qaDashboardReader)
if !ok || c.QAOpsTenantKey == "" {
qaOpsError(w, dobotaiops.ErrUnavailable)
return
}
window, e := dobotaiops.NewWindow(c.QAOpsTenantKey, request.Start, request.End, 90*24*time.Hour, c.Now())
if e != nil {
qaOpsError(w, e)
return
}
dashboard, e := reader.ReadDashboard(r.Context(), dobotaiops.RecordQuery{Window: window, Keyword: request.Keyword})
if e != nil {
if errors.Is(e, qaopsquality.ErrConflict) {
writeQualityError(w, e)
return
}
qaOpsError(w, e)
return
}
writeJSON(w, 200, map[string]any{"data": dashboard})
}
+73
View File
@@ -0,0 +1,73 @@
package httpapi
import (
"context"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsmirror"
"knowledge-governance/internal/qaopsquality"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type dashboardReaderSpy struct {
attributedReader
calls int
query dobotaiops.RecordQuery
err error
}
func (s *dashboardReaderSpy) ReadDashboard(_ context.Context, q dobotaiops.RecordQuery) (qaopsmirror.Dashboard, error) {
s.calls++
s.query = q
return qaopsmirror.Dashboard{TotalIssues: 7, NewThisWeek: 2}, s.err
}
func TestQADashboardIsFixedTenantAdminAndReadOnly(t *testing.T) {
reader := &dashboardReaderSpy{}
auth := newFakeAuthStore()
auth.sessions["dash-admin"] = "usr-boss"
auth.sessions["dash-viewer"] = "usr-eye"
h, e := NewHandler(Config{Store: &fakeJobStore{}, QAOps: reader, QAOpsTenantKey: "fixed-dashboard-tenant", Now: func() time.Time { return time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) }, Auth: &AuthConfig{Store: auth}})
if e != nil {
t.Fatal(e)
}
good := `{"start":"2026-10-05T00:00:00+08:00","end":"2026-10-12T00:00:00+08:00","keyword":"100%_"}`
for _, c := range []struct {
token, action, origin, body string
status int
}{
{"", "qa-dashboard", "", good, 401}, {"dash-viewer", "qa-dashboard", "", good, 403}, {"dash-admin", "", "", good, 403}, {"dash-admin", "qa-dashboard", "https://untrusted.invalid", good, 403},
{"dash-admin", "qa-dashboard", "", `null`, 400}, {"dash-admin", "qa-dashboard", "", `{"start":null,"end":"2026-10-12T00:00:00+08:00"}`, 400},
{"dash-admin", "qa-dashboard", "", `{"tenant":"other","start":"2026-10-05T00:00:00+08:00","end":"2026-10-12T00:00:00+08:00"}`, 400},
{"dash-admin", "qa-dashboard", "", good, 200},
} {
r := httptest.NewRequest(http.MethodPost, "/api/v1/qa-ops/dashboard", strings.NewReader(c.body))
if c.token != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: c.token})
}
r.Header.Set("X-Governance-Action", c.action)
r.Header.Set("Origin", c.origin)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != c.status {
t.Fatalf("dashboard status=%d want=%d", w.Code, c.status)
}
if w.Code == 200 && w.Header().Get("Cache-Control") != "no-store" {
t.Fatal("private dashboard response may be cached")
}
}
if reader.calls != 1 || reader.query.Window.TenantKey != "fixed-dashboard-tenant" || reader.query.Keyword != "100%_" {
t.Fatal("rejected requests reached the reader or browser controlled the tenant")
}
reader.err = qaopsquality.ErrConflict
r := httptest.NewRequest(http.MethodPost, "/api/v1/qa-ops/dashboard", strings.NewReader(good))
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "dash-admin"})
r.Header.Set("X-Governance-Action", "qa-dashboard")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusConflict {
t.Fatalf("quality input drift status=%d want409", w.Code)
}
}
+1
View File
@@ -177,6 +177,7 @@ func NewHandler(config Config) (http.Handler, error) {
config.NewID = newID
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/qa-ops/dashboard", func(w http.ResponseWriter, r *http.Request) { qaOperationsDashboard(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/quality/query", func(w http.ResponseWriter, r *http.Request) { qaQualityQuery(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/records/{id}/reviews", func(w http.ResponseWriter, r *http.Request) { qaQualityReview(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/records/{id}/quality/history", func(w http.ResponseWriter, r *http.Request) { qaQualityHistory(w, r, config) })
@@ -0,0 +1,10 @@
DO $$ BEGIN
IF EXISTS(SELECT 1 FROM qa_ops_weekly_quality_snapshots) THEN
RAISE EXCEPTION 'frozen weekly quality history exists; keep additive schema during application rollback';
END IF;
END $$;
DROP TABLE qa_ops_weekly_quality_snapshot_records;
DROP TABLE qa_ops_weekly_quality_snapshots;
DROP TABLE qa_ops_weekly_quality_tracking;
DROP FUNCTION qa_ops_require_new_weekly_quality_header();
DROP FUNCTION qa_ops_reject_frozen_quality_mutation();
@@ -0,0 +1,50 @@
CREATE TABLE qa_ops_weekly_quality_tracking (
tenant_key TEXT PRIMARY KEY CHECK(length(tenant_key) BETWEEN 1 AND 128),
started_at TIMESTAMPTZ NOT NULL, first_data_at TIMESTAMPTZ
);
CREATE TABLE qa_ops_weekly_quality_snapshots (
tenant_key TEXT NOT NULL, week_start TIMESTAMPTZ NOT NULL, week_end TIMESTAMPTZ NOT NULL,
frozen_at TIMESTAMPTZ NOT NULL, mirror_revision BIGINT NOT NULL CHECK(mirror_revision>=0),
source_observed_at TIMESTAMPTZ NOT NULL, policy_version TEXT NOT NULL,
record_count INTEGER NOT NULL CHECK(record_count>=0),
coverage_numerator INTEGER NOT NULL CHECK(coverage_numerator>=0), coverage_denominator INTEGER NOT NULL CHECK(coverage_denominator>=coverage_numerator),
correctness_numerator INTEGER NOT NULL CHECK(correctness_numerator>=0), correctness_denominator INTEGER NOT NULL CHECK(correctness_denominator>=correctness_numerator),
PRIMARY KEY(tenant_key,week_start), CHECK(week_end=week_start+interval '7 days'),
CHECK(date_trunc('day',week_start AT TIME ZONE 'Asia/Shanghai')=week_start AT TIME ZONE 'Asia/Shanghai'),
CHECK(EXTRACT(ISODOW FROM week_start AT TIME ZONE 'Asia/Shanghai')=1),
CHECK(frozen_at>=week_end), CHECK(source_observed_at>=week_end AND source_observed_at<=frozen_at),
CHECK(coverage_denominator<=record_count AND correctness_denominator<=record_count)
);
-- Frozen scalar contributions preserve the original formula under future policy
-- changes and allow literal keyword filtering without recalculating old reviews.
-- No answers, staff IDs, names or organization memberships are duplicated here.
CREATE TABLE qa_ops_weekly_quality_snapshot_records (
tenant_key TEXT NOT NULL, week_start TIMESTAMPTZ NOT NULL, record_id TEXT NOT NULL,
question TEXT NOT NULL,
coverage_numerator SMALLINT NOT NULL CHECK(coverage_numerator BETWEEN 0 AND 1),
coverage_denominator SMALLINT NOT NULL CHECK(coverage_denominator BETWEEN coverage_numerator AND 1),
correctness_numerator SMALLINT NOT NULL CHECK(correctness_numerator BETWEEN 0 AND 1),
correctness_denominator SMALLINT NOT NULL CHECK(correctness_denominator BETWEEN correctness_numerator AND 1),
PRIMARY KEY(tenant_key,week_start,record_id),
FOREIGN KEY(tenant_key,week_start) REFERENCES qa_ops_weekly_quality_snapshots(tenant_key,week_start) ON DELETE CASCADE
);
CREATE FUNCTION qa_ops_reject_frozen_quality_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'committed weekly quality snapshots are immutable' USING ERRCODE='55000';
END $$;
CREATE TRIGGER immutable_weekly_quality_header BEFORE UPDATE OR DELETE ON qa_ops_weekly_quality_snapshots FOR EACH ROW EXECUTE FUNCTION qa_ops_reject_frozen_quality_mutation();
CREATE TRIGGER immutable_weekly_quality_records BEFORE UPDATE OR DELETE ON qa_ops_weekly_quality_snapshot_records FOR EACH ROW EXECUTE FUNCTION qa_ops_reject_frozen_quality_mutation();
-- Contributions may only be inserted in the SAME transaction as their header.
-- This rejects a later append while allowing the initial atomic CopyFrom batch.
CREATE FUNCTION qa_ops_require_new_weekly_quality_header() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE parent_xid bigint;
BEGIN
SELECT xmin::text::bigint INTO parent_xid FROM qa_ops_weekly_quality_snapshots WHERE tenant_key=NEW.tenant_key AND week_start=NEW.week_start;
IF parent_xid IS NULL OR parent_xid <> mod(pg_current_xact_id()::text::numeric,4294967296)::bigint THEN
RAISE EXCEPTION 'cannot append to a committed weekly quality snapshot' USING ERRCODE='55000';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER append_only_new_weekly_quality BEFORE INSERT ON qa_ops_weekly_quality_snapshot_records FOR EACH ROW EXECUTE FUNCTION qa_ops_require_new_weekly_quality_header();
@@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) {
if err != nil {
t.Fatalf("loadMigrations() error = %v", err)
}
if len(migrations) != 51 {
t.Fatalf("migration count = %d, want 51", len(migrations))
if len(migrations) != 52 {
t.Fatalf("migration count = %d, want 52", len(migrations))
}
if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") {
t.Fatalf("migration 1 = %#v", migrations[0])
@@ -0,0 +1,33 @@
package postgres
import (
"strings"
"testing"
)
func TestWeeklyQualityMigrationPreservesFrozenHistory(t *testing.T) {
migrations, e := loadMigrations()
if e != nil {
t.Fatal(e)
}
var up, down string
for _, m := range migrations {
if m.Version == 52 {
up = m.UpSQL
down = m.DownSQL
}
}
for _, part := range []string{"CREATE TABLE qa_ops_weekly_quality_tracking", "first_data_at TIMESTAMPTZ", "CREATE TABLE qa_ops_weekly_quality_snapshots", "CREATE TABLE qa_ops_weekly_quality_snapshot_records", "AT TIME ZONE 'Asia/Shanghai'", "BEFORE UPDATE OR DELETE", "BEFORE INSERT", "pg_current_xact_id()", "coverage_denominator<=record_count"} {
if !strings.Contains(up, part) {
t.Fatal("missing frozen weekly quality contract", part)
}
}
if !strings.Contains(down, "IF EXISTS(SELECT 1 FROM qa_ops_weekly_quality_snapshots)") || !strings.Contains(down, "RAISE EXCEPTION") || !strings.Contains(down, "DROP FUNCTION qa_ops_require_new_weekly_quality_header()") {
t.Fatal("lossy history rollback or stale trigger functions")
}
for _, forbidden := range []string{"ALTER TABLE qa_ops_mirror_records", "UPDATE qa_ops_quality_reviews", "answer TEXT", "staff_id TEXT"} {
if strings.Contains(up, forbidden) {
t.Fatal("snapshot migration widened into original records or employee identity")
}
}
}
+82
View File
@@ -0,0 +1,82 @@
package qaopsmirror
import (
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsquality"
"time"
)
type DashboardCount struct {
Label string `json:"label"`
Count int `json:"count"`
}
type DashboardDaily struct {
Date string `json:"date"`
Count int `json:"count"`
}
type DashboardFrozen struct {
WeekStart time.Time `json:"week_start"`
WeekEnd time.Time `json:"week_end"`
FrozenAt *time.Time `json:"frozen_at"`
PolicyVersion string `json:"policy_version"`
Records int `json:"records"`
Coverage qaopsquality.Rate `json:"coverage"`
Correctness qaopsquality.Rate `json:"correctness"`
}
type DashboardRegion struct {
ID string `json:"id"`
Name string `json:"name"`
Questions int `json:"questions"`
Users *int `json:"users"`
RosterPeople *int `json:"roster_people"`
Frequency *float64 `json:"frequency"`
UsageCoverage qaopsquality.Rate `json:"usage_coverage"`
}
type Dashboard struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
Keyword string `json:"keyword"`
AsOf time.Time `json:"as_of"`
LocalRevision int64 `json:"local_revision"`
Organization dobotaiops.OrganizationVersion `json:"organization"`
TotalIssues int `json:"total_issues"`
NewThisWeek int `json:"new_this_week"`
WeekStart time.Time `json:"week_start"`
MonthStart time.Time `json:"month_start"`
Quality qaopsquality.Summary `json:"quality"`
IssueTypes []DashboardCount `json:"issue_types"`
Products []DashboardCount `json:"products"`
Daily []DashboardDaily `json:"daily"`
Regions []DashboardRegion `json:"regions"`
FrozenWeeks []DashboardFrozen `json:"frozen_weeks"`
}
var dashboardLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
func dashboardDay(at time.Time) time.Time {
t := at.In(dashboardLocation)
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, dashboardLocation)
}
func dashboardWeek(at time.Time) time.Time {
d := dashboardDay(at)
return d.AddDate(0, 0, -((int(d.Weekday()) + 6) % 7))
}
func dashboardPeriods(end time.Time) (time.Time, time.Time) {
cutoff := end.Add(-time.Nanosecond).In(dashboardLocation)
return time.Date(cutoff.Year(), cutoff.Month(), 1, 0, 0, 0, 0, dashboardLocation), dashboardWeek(cutoff)
}
func dashboardFrozenSeries(first, end time.Time, snapshots map[string]DashboardFrozen) []DashboardFrozen {
out := []DashboardFrozen{}
if first.IsZero() || !first.Before(end) {
return out
}
for at := dashboardWeek(first); at.Before(end) && len(out) < 5200; at = at.AddDate(0, 0, 7) {
key := at.Format("2006-01-02")
point, ok := snapshots[key]
if !ok {
point = DashboardFrozen{WeekStart: at, WeekEnd: at.AddDate(0, 0, 7)}
}
out = append(out, point)
}
return out
}
+170
View File
@@ -0,0 +1,170 @@
package qaopsmirror
import (
"context"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsquality"
"time"
)
func (s *Store) TrackQualityWeeks(ctx context.Context, at time.Time) error {
if !s.valid() || at.IsZero() {
return dobotaiops.ErrUnavailable
}
_, e := s.pool.Exec(ctx, `INSERT INTO qa_ops_weekly_quality_tracking(tenant_key,started_at,first_data_at) VALUES($1,$2,(SELECT min(asked_at) FROM qa_ops_mirror_records WHERE tenant_key=$1 AND asked_at<=$2)) ON CONFLICT(tenant_key) DO UPDATE SET first_data_at=LEAST(qa_ops_weekly_quality_tracking.first_data_at,EXCLUDED.first_data_at) WHERE EXCLUDED.first_data_at IS NOT NULL AND (qa_ops_weekly_quality_tracking.first_data_at IS NULL OR EXCLUDED.first_data_at<qa_ops_weekly_quality_tracking.first_data_at)`, s.tenant, at)
if e != nil {
return dobotaiops.ErrUnavailable
}
return nil
}
// Capture only during the following Monday, after a complete post-boundary
// source observation. A missed capture remains a gap, never retrospective data.
// Scheduling is local worker-owned; browser reads never freeze or write metrics.
func (s *Store) FreezePreviousQualityWeek(ctx context.Context, at time.Time) (bool, error) {
if !s.valid() || at.IsZero() {
return false, dobotaiops.ErrUnavailable
}
if at.In(dashboardLocation).Weekday() != time.Monday {
return false, nil
}
end := dashboardWeek(at)
start := end.AddDate(0, 0, -7)
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
tx, e := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead})
if e != nil {
return false, dobotaiops.ErrUnavailable
}
defer func() { _ = tx.Rollback(context.Background()) }()
var locked bool
if tx.QueryRow(ctx, `SELECT pg_try_advisory_xact_lock(hashtextextended($1,17))`, s.tenant+":qa-quality-freeze:"+start.Format("2006-01-02")).Scan(&locked) != nil {
return false, dobotaiops.ErrUnavailable
}
if !locked {
return false, nil
}
var tracked time.Time
if e = tx.QueryRow(ctx, `SELECT started_at FROM qa_ops_weekly_quality_tracking WHERE tenant_key=$1`, s.tenant).Scan(&tracked); e == pgx.ErrNoRows {
return false, nil
} else if e != nil {
return false, dobotaiops.ErrUnavailable
}
if !tracked.Before(end) {
return false, nil
}
var exists bool
if tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM qa_ops_weekly_quality_snapshots WHERE tenant_key=$1 AND week_start=$2)`, s.tenant, start).Scan(&exists) != nil {
return false, dobotaiops.ErrUnavailable
}
if exists {
return false, nil
}
state, e := s.readState(ctx, txQuery{tx})
if e != nil {
return false, e
}
if state.Blocked || !state.Ready || state.SourceObservedAt == nil || state.LastSuccessAt == nil || state.SourceObservedAt.Before(end) || state.SourceObservedAt.After(at) || state.LastSuccessAt.Before(end) || state.LastSuccessAt.After(at) {
return false, nil
}
rows, e := tx.Query(ctx, `SELECT id,question,quality_hash FROM qa_ops_mirror_records WHERE tenant_key=$1 AND asked_at>=$2 AND asked_at<$3 ORDER BY id LIMIT 100001`, s.tenant, start, end)
if e != nil {
return false, dobotaiops.ErrUnavailable
}
type frozenRecord struct {
id, question, hash string
coverage, correctness qaopsquality.Rate
}
records := []frozenRecord{}
keys := []dobotaiops.QualityKey{}
for rows.Next() {
var r frozenRecord
if rows.Scan(&r.id, &r.question, &r.hash) != nil {
rows.Close()
return false, dobotaiops.ErrUnavailable
}
records = append(records, r)
keys = append(keys, dobotaiops.QualityKey{ID: r.id, InputHash: r.hash})
if len(records) > dobotaiops.MaxMirrorRecords {
rows.Close()
return false, dobotaiops.ErrInvalidQuery
}
}
e = rows.Err()
rows.Close()
if e != nil {
return false, dobotaiops.ErrUnavailable
}
views := map[string]qaopsquality.View{}
quality := qaopsquality.NewStore(s.pool, s.tenant)
for begin := 0; begin < len(keys); begin += 5000 {
stop := begin + 5000
if stop > len(keys) {
stop = len(keys)
}
batch, err := quality.ReadViewsInTx(ctx, tx, keys[begin:stop])
if err != nil {
return false, err
}
for id, v := range batch {
views[id] = v
}
}
rates := qaopsquality.ComputeRates(views)
for i := range records {
r := &records[i]
v, ok := views[r.id]
if !ok {
return false, qaopsquality.ErrUnavailable
}
if v.Automatic.CreatedAt.After(at) || (v.LatestReview != nil && v.LatestReview.CreatedAt.After(at)) {
return false, qaopsquality.ErrConflict
}
one := qaopsquality.ComputeRates(map[string]qaopsquality.View{r.id: v})
r.coverage = one.Coverage
r.correctness = one.Correctness
}
tag, e := tx.Exec(ctx, `INSERT INTO qa_ops_weekly_quality_snapshots(tenant_key,week_start,week_end,frozen_at,mirror_revision,source_observed_at,policy_version,record_count,coverage_numerator,coverage_denominator,correctness_numerator,correctness_denominator) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT(tenant_key,week_start) DO NOTHING`, s.tenant, start, end, at, state.Revision, *state.SourceObservedAt, rates.PolicyVersion, len(records), rates.Coverage.Numerator, rates.Coverage.Denominator, rates.Correctness.Numerator, rates.Correctness.Denominator)
if e != nil || tag.RowsAffected() != 1 {
return false, dobotaiops.ErrUnavailable
}
count, e := tx.CopyFrom(ctx, pgx.Identifier{"qa_ops_weekly_quality_snapshot_records"}, []string{"tenant_key", "week_start", "record_id", "question", "coverage_numerator", "coverage_denominator", "correctness_numerator", "correctness_denominator"}, pgx.CopyFromSlice(len(records), func(i int) ([]any, error) {
r := records[i]
return []any{s.tenant, start, r.id, r.question, r.coverage.Numerator, r.coverage.Denominator, r.correctness.Numerator, r.correctness.Denominator}, nil
}))
if e != nil || count != int64(len(records)) {
return false, dobotaiops.ErrUnavailable
}
var verified int
if tx.QueryRow(ctx, `SELECT count(*) FROM qa_ops_weekly_quality_snapshot_records WHERE tenant_key=$1 AND week_start=$2`, s.tenant, start).Scan(&verified) != nil || verified != len(records) {
return false, dobotaiops.ErrUnavailable
}
if tx.Commit(ctx) != nil {
return false, dobotaiops.ErrUnavailable
}
return true, nil
}
func (s *Store) readFrozenDashboard(ctx context.Context, tx pgx.Tx, keyword string, end time.Time) (map[string]DashboardFrozen, error) {
rows, e := tx.Query(ctx, `SELECT h.week_start,h.week_end,h.frozen_at,h.policy_version,h.record_count,count(r.record_id),count(r.record_id) FILTER(WHERE $2='' OR position(lower($2) IN lower(r.question))>0),COALESCE(sum(r.coverage_numerator) FILTER(WHERE $2='' OR position(lower($2) IN lower(r.question))>0),0),COALESCE(sum(r.coverage_denominator) FILTER(WHERE $2='' OR position(lower($2) IN lower(r.question))>0),0),COALESCE(sum(r.correctness_numerator) FILTER(WHERE $2='' OR position(lower($2) IN lower(r.question))>0),0),COALESCE(sum(r.correctness_denominator) FILTER(WHERE $2='' OR position(lower($2) IN lower(r.question))>0),0) FROM qa_ops_weekly_quality_snapshots h LEFT JOIN qa_ops_weekly_quality_snapshot_records r ON r.tenant_key=h.tenant_key AND r.week_start=h.week_start WHERE h.tenant_key=$1 AND h.week_start<$3 GROUP BY h.week_start,h.week_end,h.frozen_at,h.policy_version,h.record_count ORDER BY h.week_start`, s.tenant, keyword, end)
if e != nil {
return nil, dobotaiops.ErrUnavailable
}
defer rows.Close()
out := map[string]DashboardFrozen{}
for rows.Next() {
var p DashboardFrozen
var total, actual, cn, cd, an, ad int
if rows.Scan(&p.WeekStart, &p.WeekEnd, &p.FrozenAt, &p.PolicyVersion, &total, &actual, &p.Records, &cn, &cd, &an, &ad) != nil || actual != total || cn > cd || an > ad || cd > p.Records || ad > p.Records {
return nil, dobotaiops.ErrUnavailable
}
p.Coverage = dashboardRate(cn, cd)
p.Correctness = dashboardRate(an, ad)
out[p.WeekStart.In(dashboardLocation).Format("2006-01-02")] = p
}
if rows.Err() != nil {
return nil, dobotaiops.ErrUnavailable
}
return out, nil
}
@@ -0,0 +1,156 @@
package qaopsmirror
import (
"context"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5/pgconn"
"knowledge-governance/internal/dobotaiops"
pg "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/qaopsorg"
"knowledge-governance/internal/qaopsquality"
"strings"
"testing"
"time"
)
func TestDashboardUsesLifetimeWeekMonthAndDeduplicatedRegionalRoster(t *testing.T) {
p := mirrorFixture(t)
ctx := context.Background()
s := NewStore(p, "fixture-dashboard")
queue := pg.NewStore(p)
dates := []string{"2026-09-28T08:00:00+08:00", "2026-10-02T08:00:00+08:00", "2026-10-06T08:00:00+08:00", "2026-10-10T08:00:00+08:00"}
source := &fixtureSource{}
for i, date := range dates {
entry := fixtureEntry([]string{"older", "month", "covered", "uncovered"}[i], "暂无已发布")
var wire map[string]any
_ = json.Unmarshal(entry.Payload, &wire)
wire["asked_at"] = date
wire["question"] = "needle 100%_ fixture"
entry.Payload, _ = json.Marshal(wire)
source.rows = append(source.rows, entry)
}
job := claimMirrorJob(t, s, queue, "dashboard-seed")
if e := s.HandleSync(ctx, *job, func(context.Context) (Source, error) { return source, nil }); e != nil {
t.Fatal(e)
}
org := qaopsorg.NewStore(p, s.tenant)
state, e := org.ReplaceDirectory(ctx, qaopsorg.Import{Source: "weekly_report_snapshot", SyncedAt: time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC), Complete: true, Directory: qaopsorg.Directory{Departments: []qaopsorg.Department{{ID: "root", Name: "示例组织"}}, People: []qaopsorg.Person{{ID: "s", DepartmentIDs: []string{"root"}}, {ID: "zero-use", DepartmentIDs: []string{"root"}}}}})
if e != nil {
t.Fatal(e)
}
_, e = org.SaveRules(ctx, qaopsorg.SaveRequest{DirectoryID: state.DirectoryID, Regions: []qaopsorg.RegionDefinition{{ID: "education", Name: "国内教育"}}, Rules: []qaopsorg.Rule{{DepartmentID: "root", Region: "education"}}}, "fixture-admin")
if e != nil {
t.Fatal(e)
}
record, e := s.GetRecord(ctx, s.tenant, "covered")
if e != nil {
t.Fatal(e)
}
quality := qaopsquality.NewStore(p, s.tenant)
views, e := quality.Observe(ctx, []dobotaiops.Record{record})
if e != nil {
t.Fatal(e)
}
v := views[record.ID]
_, e = quality.Save(ctx, record, qaopsquality.SaveRequest{InputHash: v.InputHash, InputEpoch: v.InputEpoch, BaseRevision: v.Revision, Nonce: strings.Repeat("d", 32), Fields: qaopsquality.Fields{Coverage: "covered", Correctness: "correct", ProductCategory: "CR", ProblemType: "使用咨询"}, Reason: "explicit isolated test review"}, "fixture-admin")
if e != nil {
t.Fatal(e)
}
start, _ := time.Parse(time.RFC3339, "2026-10-05T00:00:00+08:00")
end := start.AddDate(0, 0, 7)
data, e := s.ReadDashboard(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: start, End: end}, Keyword: "100%_"})
if e != nil {
t.Fatal("dashboard must read one local snapshot without a source client", e)
}
if data.TotalIssues != 4 || data.NewThisWeek != 2 || data.Quality.Total != 2 || data.Quality.Rates.Coverage.Numerator != 1 || data.Quality.Rates.Coverage.Denominator != 2 {
t.Fatal("lifetime/selected-week quality were mixed")
}
if len(data.Daily) != 7 || len(data.Regions) != 1 {
t.Fatal("daily range or custom region catalog missing")
}
r := data.Regions[0]
if r.Questions != 3 || r.Users == nil || *r.Users != 1 || r.RosterPeople == nil || *r.RosterPeople != 2 || r.Frequency == nil || *r.Frequency != 1.5 || r.UsageCoverage.Percent == nil || *r.UsageCoverage.Percent != 50 {
t.Fatal("monthly regional scope or full-roster denominator was replaced by active users")
}
if len(data.FrozenWeeks) != 2 {
t.Fatal("historical weeks did not start at first data week")
}
for _, point := range data.FrozenWeeks {
if point.FrozenAt != nil || point.Coverage.Percent != nil || point.Correctness.Percent != nil {
t.Fatal("current quality was backfilled into absent frozen history")
}
}
t.Run("historical quality is frozen once and never backfilled", func(t *testing.T) {
tracked := start.AddDate(0, 0, 2)
if e := s.TrackQualityWeeks(ctx, tracked); e != nil {
t.Fatal(e)
}
freezeAt := end.Add(6 * time.Minute)
// Explicit fake-clock fixture: the source snapshot and review existed before the closing capture.
if _, e = p.Exec(ctx, "UPDATE qa_ops_mirror_state SET source_observed_at=$2,synced_at=$2 WHERE tenant_key=$1", s.tenant, freezeAt.Add(-time.Minute)); e != nil {
t.Fatal(e)
}
if _, e = p.Exec(ctx, "UPDATE qa_ops_quality_reviews SET created_at=$2 WHERE tenant_key=$1", s.tenant, end.Add(-time.Hour)); e != nil {
t.Fatal(e)
}
if _, e = p.Exec(ctx, "UPDATE qa_ops_quality_checks SET created_at=$2 WHERE tenant_key=$1", s.tenant, end.Add(-time.Hour)); e != nil {
t.Fatal(e)
}
frozen, e := s.FreezePreviousQualityWeek(ctx, freezeAt)
if e != nil || !frozen {
t.Fatal("first eligible Monday capture was not frozen", e)
}
for _, statement := range []string{
`UPDATE qa_ops_weekly_quality_snapshots SET policy_version=policy_version||'-changed' WHERE tenant_key=$1`,
`UPDATE qa_ops_weekly_quality_snapshot_records SET question=question||' changed' WHERE tenant_key=$1`,
`DELETE FROM qa_ops_weekly_quality_snapshots WHERE tenant_key=$1`,
`DELETE FROM qa_ops_weekly_quality_snapshot_records WHERE tenant_key=$1`,
`INSERT INTO qa_ops_weekly_quality_snapshot_records SELECT tenant_key,week_start,record_id||'-late',question,coverage_numerator,coverage_denominator,correctness_numerator,correctness_denominator FROM qa_ops_weekly_quality_snapshot_records WHERE tenant_key=$1 LIMIT 1`,
} {
mutation, err := p.Begin(ctx)
if err != nil {
t.Fatal(err)
}
_, err = mutation.Exec(ctx, statement, s.tenant)
_ = mutation.Rollback(ctx)
var dbError *pgconn.PgError
if !errors.As(err, &dbError) || dbError.Code != "55000" {
t.Fatal("committed snapshot mutation was not rejected by the immutable guard", err)
}
}
historical, e := s.ReadDashboard(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: start, End: end}, Keyword: "100%_"})
if e != nil {
t.Fatal(e)
}
last := historical.FrozenWeeks[len(historical.FrozenWeeks)-1]
if last.FrozenAt == nil || last.Coverage.Percent == nil || *last.Coverage.Percent != 50 || last.Correctness.Percent == nil || *last.Correctness.Percent != 100 {
t.Fatal("frozen ratios or literal keyword projection were lost")
}
latest, e := quality.ReadViews(ctx, []dobotaiops.QualityKey{{ID: record.ID, InputHash: v.InputHash}})
if e != nil {
t.Fatal(e)
}
current := latest[record.ID]
if _, e = quality.Save(ctx, record, qaopsquality.SaveRequest{InputHash: current.InputHash, InputEpoch: current.InputEpoch, BaseRevision: current.Revision, Nonce: strings.Repeat("e", 32), Fields: qaopsquality.Fields{Coverage: "uncovered", Correctness: "incorrect"}, Reason: "later isolated correction, must not rewrite frozen history"}, "fixture-admin"); e != nil {
t.Fatal(e)
}
repeated, e := s.FreezePreviousQualityWeek(ctx, freezeAt.Add(time.Minute))
if e != nil || repeated {
t.Fatal("immutable weekly snapshot was replaced")
}
changed, e := s.ReadDashboard(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: start, End: end}, Keyword: "100%_"})
if e != nil {
t.Fatal(e)
}
point := changed.FrozenWeeks[len(changed.FrozenWeeks)-1]
if changed.Quality.Rates.Coverage.Numerator != 0 || point.Coverage.Percent == nil || *point.Coverage.Percent != 50 || changed.FrozenWeeks[0].FrozenAt != nil {
t.Fatal("current correction rewrote frozen history or backfilled pre-tracking weeks")
}
})
raw, _ := json.Marshal(data)
if strings.Contains(string(raw), "zero-use") || strings.Contains(string(raw), "needle") {
t.Fatal("dashboard leaked identity or original question rows")
}
}
+215
View File
@@ -0,0 +1,215 @@
package qaopsmirror
import (
"context"
"encoding/json"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsorg"
"knowledge-governance/internal/qaopsquality"
"sort"
"strings"
"time"
"unicode/utf8"
)
type dashboardRecord struct {
id, hash string
at time.Time
identity dobotaiops.ActorIdentity
}
func dashboardRate(n, d int) qaopsquality.Rate {
r := qaopsquality.Rate{Numerator: n, Denominator: d}
if d > 0 {
p := 100 * float64(n) / float64(d)
r.Percent = &p
}
return r
}
func dashboardCategories(counts map[string]int) []DashboardCount {
out := []DashboardCount{}
for label, count := range counts {
out = append(out, DashboardCount{Label: label, Count: count})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count == out[j].Count {
return out[i].Label < out[j].Label
}
return out[i].Count > out[j].Count
})
return out
}
func dashboardCategory(value string) string {
if value = strings.TrimSpace(value); value != "" {
return value
}
return "未分类"
}
// ReadDashboard never fetches answer payloads or opens a source connection. All
// scopes, quality inputs and organizational attribution use ONE local snapshot.
func (s *Store) ReadDashboard(ctx context.Context, q dobotaiops.RecordQuery) (Dashboard, error) {
out := Dashboard{Start: q.Window.Start, End: q.Window.End, Keyword: q.Keyword, IssueTypes: []DashboardCount{}, Products: []DashboardCount{}, Daily: []DashboardDaily{}, Regions: []DashboardRegion{}, FrozenWeeks: []DashboardFrozen{}}
if !s.valid() {
return out, dobotaiops.ErrUnavailable
}
if q.Window.TenantKey != s.tenant || !utf8.ValidString(q.Keyword) || utf8.RuneCountInString(q.Keyword) > 200 || strings.ContainsRune(q.Keyword, 0) {
return out, dobotaiops.ErrInvalidQuery
}
if _, e := dobotaiops.NewWindow(s.tenant, q.Window.Start.Format(time.RFC3339Nano), q.Window.End.Format(time.RFC3339Nano), 90*24*time.Hour, time.Now()); e != nil {
return out, e
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
tx, e := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if e != nil {
return out, dobotaiops.ErrUnavailable
}
defer func() { _ = tx.Rollback(context.Background()) }()
state, e := s.readState(ctx, txQuery{tx})
if e != nil {
return out, e
}
if state.Blocked {
return out, dobotaiops.ErrNotReadOnly
}
if !state.Ready || state.SourceObservedAt == nil {
return out, dobotaiops.ErrUnavailable
}
out.AsOf = *state.SourceObservedAt
out.LocalRevision = state.Revision
attribution, e := qaopsorg.NewStore(s.pool, s.tenant).GetAttributionInTx(ctx, tx)
if e != nil {
return out, e
}
meta := attribution.Metadata
out.Organization = dobotaiops.OrganizationVersion{State: meta.State, DirectoryID: meta.DirectoryID, DirectorySource: meta.DirectorySource, SyncedAt: meta.DirectorySyncedAt, RevisionID: meta.RevisionID, Basis: "current_directory", RegionNames: meta.RegionLabels()}
out.MonthStart, out.WeekStart = dashboardPeriods(q.Window.End)
var first *time.Time
if e = tx.QueryRow(ctx, `SELECT count(*),LEAST((SELECT min(asked_at) FROM qa_ops_mirror_records WHERE tenant_key=$1 AND asked_at<$2),(SELECT first_data_at FROM qa_ops_weekly_quality_tracking WHERE tenant_key=$1),(SELECT min(week_start) FROM qa_ops_weekly_quality_snapshots WHERE tenant_key=$1 AND week_start<$2)) FROM qa_ops_mirror_records WHERE tenant_key=$1 AND asked_at<$2 AND ($3='' OR position(lower($3) IN lower(question))>0)`, s.tenant, q.Window.End, q.Keyword).Scan(&out.TotalIssues, &first); e != nil {
return out, dobotaiops.ErrUnavailable
}
lower := q.Window.Start
if out.MonthStart.Before(lower) {
lower = out.MonthStart
}
if out.WeekStart.Before(lower) {
lower = out.WeekStart
}
rows, e := tx.Query(ctx, `SELECT id,asked_at,identity,quality_hash FROM qa_ops_mirror_records WHERE tenant_key=$1 AND asked_at>=$2 AND asked_at<$3 AND ($4='' OR position(lower($4) IN lower(question))>0) ORDER BY id LIMIT 100001`, s.tenant, lower, q.Window.End, q.Keyword)
if e != nil {
return out, dobotaiops.ErrUnavailable
}
records := []dashboardRecord{}
keys := []dobotaiops.QualityKey{}
for rows.Next() {
var r dashboardRecord
var identity []byte
if rows.Scan(&r.id, &r.at, &identity, &r.hash) != nil || json.Unmarshal(identity, &r.identity) != nil {
rows.Close()
return out, dobotaiops.ErrUnavailable
}
records = append(records, r)
if len(records) > dobotaiops.MaxMirrorRecords {
rows.Close()
return out, dobotaiops.ErrInvalidQuery
}
if !r.at.Before(q.Window.Start) {
keys = append(keys, dobotaiops.QualityKey{ID: r.id, InputHash: r.hash})
}
}
e = rows.Err()
rows.Close()
if e != nil {
return out, dobotaiops.ErrUnavailable
}
views := map[string]qaopsquality.View{}
quality := qaopsquality.NewStore(s.pool, s.tenant)
for begin := 0; begin < len(keys); begin += 5000 {
end := begin + 5000
if end > len(keys) {
end = len(keys)
}
batch, err := quality.ReadViewsInTx(ctx, tx, keys[begin:end])
if err != nil {
return out, err
}
for id, v := range batch {
views[id] = v
}
}
out.Quality = qaopsquality.Summarize(views)
products, types, daily := map[string]int{}, map[string]int{}, map[string]int{}
for day := out.WeekStart; day.Before(q.Window.End); day = day.AddDate(0, 0, 1) {
daily[day.Format("2006-01-02")] = 0
}
regionIndex := map[string]int{}
users := map[string]map[string]bool{}
for _, region := range meta.RegionDefinitions() {
r := DashboardRegion{ID: region.ID, Name: region.Name}
if meta.State == "ready" {
n := meta.View.Regions[region.ID]
r.RosterPeople = &n
r.Users = new(int)
}
regionIndex[region.Name] = len(out.Regions)
users[region.Name] = map[string]bool{}
out.Regions = append(out.Regions, r)
}
for _, r := range records {
if !r.at.Before(out.WeekStart) {
out.NewThisWeek++
daily[r.at.In(dashboardLocation).Format("2006-01-02")]++
}
if !r.at.Before(q.Window.Start) {
v, ok := views[r.id]
if !ok {
return out, qaopsquality.ErrUnavailable
}
products[dashboardCategory(v.Effective.ProductCategory)]++
types[dashboardCategory(v.Effective.ProblemType)]++
}
if !r.at.Before(out.MonthStart) {
person := attribution.Resolve(r.identity.StaffID, r.identity.UnionID)
if index, ok := regionIndex[person.Region]; ok && person.PersonID != "" {
out.Regions[index].Questions++
users[person.Region][person.PersonID] = true
}
}
}
out.Products = dashboardCategories(products)
out.IssueTypes = dashboardCategories(types)
for day := out.WeekStart; day.Before(q.Window.End); day = day.AddDate(0, 0, 1) {
date := day.Format("2006-01-02")
out.Daily = append(out.Daily, DashboardDaily{Date: date, Count: daily[date]})
}
for i := range out.Regions {
r := &out.Regions[i]
if r.Users != nil {
*r.Users = len(users[r.Name])
if *r.Users > *r.RosterPeople {
return out, dobotaiops.ErrUnavailable
}
r.UsageCoverage = dashboardRate(*r.Users, *r.RosterPeople)
if *r.RosterPeople > 0 {
frequency := float64(r.Questions) / float64(*r.RosterPeople)
r.Frequency = &frequency
}
}
}
if first != nil {
snapshots, err := s.readFrozenDashboard(ctx, tx, q.Keyword, q.Window.End)
if err != nil {
return out, err
}
out.FrozenWeeks = dashboardFrozenSeries(*first, q.Window.End, snapshots)
if len(out.FrozenWeeks) > 0 && out.FrozenWeeks[len(out.FrozenWeeks)-1].WeekEnd.Before(q.Window.End) {
return out, dobotaiops.ErrInvalidQuery
}
}
if tx.Commit(ctx) != nil {
return out, dobotaiops.ErrUnavailable
}
return out, nil
}
+27
View File
@@ -0,0 +1,27 @@
package qaopsmirror
import (
"testing"
"time"
)
func TestDashboardPeriodsUseBeijingAndPreviousSundayAtExclusiveEnd(t *testing.T) {
end := time.Date(2026, 11, 2, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
month, week := dashboardPeriods(end)
if month.Format(time.RFC3339) != "2026-11-01T00:00:00+08:00" || week.Format(time.RFC3339) != "2026-10-26T00:00:00+08:00" {
t.Fatal("exclusive Monday end was assigned to the wrong source week/month")
}
}
func TestDashboardFrozenGapsNeverUseCurrentRates(t *testing.T) {
start := time.Date(2026, 9, 28, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
end := start.AddDate(0, 0, 21)
points := dashboardFrozenSeries(start, end, nil)
if len(points) != 3 {
t.Fatal("must include each source natural week including missing snapshots")
}
for _, point := range points {
if point.FrozenAt != nil || point.Coverage.Percent != nil || point.Correctness.Percent != nil {
t.Fatal("missing historical snapshots were fabricated from current data")
}
}
}
+11
View File
@@ -2,6 +2,7 @@ package qaopsorg
import (
"context"
"github.com/jackc/pgx/v5"
"sort"
)
@@ -111,6 +112,16 @@ func (p *Attribution) Resolve(staff, union string) PersonAttribution {
}
return none
}
func (s *Store) GetAttributionInTx(ctx context.Context, tx pgx.Tx) (*Attribution, error) {
if !s.available() {
return nil, ErrUnavailable
}
state, directory, err := s.read(ctx, tx, false)
if err != nil {
return nil, err
}
return BuildAttribution(state, directory)
}
func (s *Store) GetAttribution(ctx context.Context) (*Attribution, error) {
if !s.available() {
return nil, ErrUnavailable
@@ -181,6 +181,19 @@ func TestOrganizationStoreRoundTripAndOptimisticConflict(t *testing.T) {
if e != nil || repeated.RevisionID != renamed.RevisionID {
t.Fatal("unchanged catalog appended another revision")
}
// Pin the catalog down test to migration 51, not a later additive feature.
for {
var latest int64
if err := pool.QueryRow(ctx, "SELECT max(version) FROM schema_migrations").Scan(&latest); err != nil {
t.Fatal(err)
}
if latest <= 51 {
break
}
if err := postgresstore.RollbackLatestMigration(ctx, pool); err != nil {
t.Fatal(err)
}
}
if e = postgresstore.RollbackLatestMigration(ctx, pool); e == nil {
t.Fatal("down migration discarded a custom catalog")
}
+14 -1
View File
@@ -3,12 +3,25 @@ package qaopsquality
import (
"context"
"encoding/json"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/dobotaiops"
)
// ReadViews projects quality already observed by mirror activation. It performs
// one SELECT, never inserts inputs/checks or advances an observation epoch.
type viewQuerier interface {
Query(context.Context, string, ...any) (pgx.Rows, error)
}
func (s *Store) ReadViews(ctx context.Context, keys []dobotaiops.QualityKey) (map[string]View, error) {
return s.readViews(ctx, s.pool, keys)
}
// ReadViewsInTx shares the caller's repeatable-read snapshot with mirror and organization metadata.
func (s *Store) ReadViewsInTx(ctx context.Context, tx pgx.Tx, keys []dobotaiops.QualityKey) (map[string]View, error) {
return s.readViews(ctx, tx, keys)
}
func (s *Store) readViews(ctx context.Context, q viewQuerier, keys []dobotaiops.QualityKey) (map[string]View, error) {
if !s.available() {
return nil, ErrUnavailable
}
@@ -34,7 +47,7 @@ func (s *Store) ReadViews(ctx context.Context, keys []dobotaiops.QualityKey) (ma
if len(ids) == 0 {
return out, nil
}
rows, e := s.pool.Query(ctx, `SELECT h.record_id,h.input_hash,h.input_epoch,h.review_revision,`+checkObject+`::text,COALESCE((SELECT `+reviewObject+` FROM qa_ops_quality_reviews r WHERE r.tenant_key=h.tenant_key AND r.record_id=h.record_id AND r.revision=h.review_revision),'null'::jsonb)::text FROM qa_ops_quality_records h JOIN qa_ops_quality_inputs i ON i.tenant_key=h.tenant_key AND i.record_id=h.record_id AND i.input_epoch=h.input_epoch JOIN qa_ops_quality_checks c ON c.tenant_key=h.tenant_key AND c.record_id=h.record_id AND c.input_epoch=h.input_epoch AND c.rule_version=$3 WHERE h.tenant_key=$1 AND h.record_id=ANY($2::text[])`, s.tenant, ids, dobotaiops.CoverageRuleVersion)
rows, e := q.Query(ctx, `SELECT h.record_id,h.input_hash,h.input_epoch,h.review_revision,`+checkObject+`::text,COALESCE((SELECT `+reviewObject+` FROM qa_ops_quality_reviews r WHERE r.tenant_key=h.tenant_key AND r.record_id=h.record_id AND r.revision=h.review_revision),'null'::jsonb)::text FROM qa_ops_quality_records h JOIN qa_ops_quality_inputs i ON i.tenant_key=h.tenant_key AND i.record_id=h.record_id AND i.input_epoch=h.input_epoch JOIN qa_ops_quality_checks c ON c.tenant_key=h.tenant_key AND c.record_id=h.record_id AND c.input_epoch=h.input_epoch AND c.rule_version=$3 WHERE h.tenant_key=$1 AND h.record_id=ANY($2::text[])`, s.tenant, ids, dobotaiops.CoverageRuleVersion)
if e != nil {
return nil, ErrUnavailable
}
+5 -11
View File
@@ -2,6 +2,8 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, it, expect, vi } from 'vitest'
import { qaDailySeries, qaCategorySeries } from './qaDashboard'
import { QADashboardView } from './QADashboard'
import { parseQADashboard } from './qaDashboardData'
import { dashboardFixture } from './qaDashboard.fixture'
import type { QARecord, QAQuery } from './qaOps'
const query: QAQuery = { start: '2026-10-11T16:00:00Z', end: '2026-10-13T16:00:00Z', keyword: '', cursor: '' }
const records = [
@@ -24,19 +26,11 @@ describe('question dashboard projections', () => {
})
it('renders the real dashboard panels and escaped category text', () => {
const html = renderToStaticMarkup(
<QADashboardView
page={{ items: records, columns: [], total: 2, has_more: false, next_cursor: '', as_of: query.end }}
query={query}
quality={null}
usage={null}
loading={false}
error=""
onRetry={vi.fn()}
/>
<QADashboardView data={parseQADashboard(dashboardFixture)} loading={false} error="" onRetry={vi.fn()} />
)
for (const title of ['问题总量', '每日提问趋势', '质量分布', '区域分布', '产品分类', '问题类型'])
for (const title of ['问题总数', '本周新增', '覆盖率趋势', '正确率趋势', '产品问题排行', '导出看板图片'])
expect(html).toContain(title)
expect(html).not.toContain('<img src=x')
expect(html).not.toContain('0.0%')
expect((html.match(/data-panel=/g) || []).length).toBe(11)
})
})
+74 -172
View File
@@ -1,219 +1,121 @@
import { useEffect, useState } from 'react'
import type { QARecordPage, QAQuery } from './qaOps'
import type { QualitySummary, QualityRate } from './qaQuality'
import type { QAUsage } from './qaUsage'
import { fetchQAUsage, usageLabels } from './qaUsage'
import { qaDailySeries, qaCategorySeries, fetchQADashboardQuality } from './qaDashboard'
import type { QACountPoint } from './qaDashboard'
import { useEffect, useRef, useState } from 'react'
import type { QAQuery } from './qaOps'
import { fetchQADashboard } from './qaDashboardData'
import type { QADashboardData } from './qaDashboardData'
import { QADashboardDrawing } from './QADashboardDrawing'
import { createDashboardPNG } from './qaDashboardExport'
import { currentQAWeek } from './qaWeeks'
import { QAMonthly } from './QAMonthly'
import './qaDashboard.css'
export interface QADashboardProps {
page: QARecordPage | null
query: QAQuery | null
quality: QualitySummary | null
usage: QAUsage | null
data: QADashboardData | null
loading: boolean
error: string
onRetry: () => void
}
const percent = (r: QualityRate | undefined) => (r?.percent == null ? '—' : `${r.percent.toFixed(1)}%`)
function Bars({ points }: { points: QACountPoint[] }) {
const max = Math.max(1, ...points.map((p) => p.count))
return points.length ? (
<div className="qa-chart-bars">
{points.map((p) => (
<div className="qa-chart-bar" key={p.label}>
<span title={p.label}>{p.label}</span>
<div className="qa-chart-track">
<i style={{ width: `${(p.count / max) * 100}%` }} />
</div>
<b>{p.count}</b>
</div>
))}
</div>
) : (
<p className="qa-chart-empty">暂无数据</p>
)
}
export function QADashboardView(p: QADashboardProps) {
const page = p.loading ? null : p.page,
items = page?.items || [],
q = page && p.quality?.summary.total === page.total ? p.quality : null
const b = q?.summary.rates.breakdown
const daily = page && p.query ? qaDailySeries(items, p.query) : []
const max = Math.max(1, ...daily.map((d) => d.count))
const outcomes = b
? [
['已覆盖', b.covered],
['未覆盖', b.uncovered],
['部分覆盖', b.partial],
['待确认', b.pending],
['未评估', b.unassessed],
['不计入', b.excluded],
['待重新核对', b.stale],
].map(([label, count]) => ({ label: String(label), count: Number(count) }))
: []
export function QADashboardView({ data, loading, error, onRetry }: QADashboardProps) {
const host = useRef<HTMLDivElement>(null),
svg = useRef<SVGSVGElement>(null)
const [width, setWidth] = useState(1280),
[exporting, setExporting] = useState(false),
[exportError, setExportError] = useState('')
useEffect(() => {
const element = host.current
if (!element) return
const update = () => setWidth(Math.max(240, Math.floor(element.clientWidth)))
update()
const observer = new ResizeObserver(update)
observer.observe(element)
return () => observer.disconnect()
}, [])
useEffect(() => setExportError(''), [data])
async function download() {
if (!svg.current || !data || loading || error || exporting) return
setExporting(true)
setExportError('')
try {
const blob = await createDashboardPNG(svg.current),
url = URL.createObjectURL(blob),
link = document.createElement('a'),
week = currentQAWeek(new Date(data.week_start))
link.href = url
link.download = `DobotAI_QA_Dashboard_${week.year}_W${String(week.week).padStart(2, '0')}.png`
document.body.append(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (e) {
setExportError(e instanceof Error ? e.message : '看板图片生成失败')
} finally {
setExporting(false)
}
}
return (
<section className="qa-dashboard" aria-label="问题统计看板">
{p.error ? (
<div className="qa-dashboard-toolbar">
<button type="button" disabled={!data || loading || !!error || exporting} onClick={() => void download()}>
{exporting ? '正在生成图片…' : '导出看板图片'}
</button>
</div>
{error ? (
<div role="alert" className="error-banner">
{p.error}{' '}
<button type="button" onClick={p.onRetry}>
{error}{' '}
<button type="button" onClick={onRetry}>
重新读取
</button>
</div>
) : null}
{p.loading ? <p role="status">正在读取完整统计范围…</p> : null}
<div className="qa-dashboard-metrics" aria-label="问题概览">
<div>
<span>问题总量</span>
<strong>{page?.total ?? '—'}</strong>
<small>条</small>
{exportError ? (
<div role="alert" className="error-banner">
{exportError}
</div>
<div>
<span>知识覆盖率</span>
<strong>{percent(q?.summary.rates.coverage)}</strong>
<small>
{q ? `${q.summary.rates.coverage.numerator} / ${q.summary.rates.coverage.denominator}` : '分子 / 分母'}
</small>
</div>
<div>
<span>回答正确率</span>
<strong>{percent(q?.summary.rates.correctness)}</strong>
<small>
{q
? `${q.summary.rates.correctness.numerator} / ${q.summary.rates.correctness.denominator}`
: '分子 / 分母'}
</small>
</div>
<div>
<span>已人工复核</span>
<strong>{q?.summary.reviewed ?? '—'}</strong>
<small>{q ? `待复核 ${q.summary.unreviewed}` : '条'}</small>
</div>
</div>
<div className="qa-dashboard-grid">
<section className="qa-chart qa-chart-wide">
<h3>每日提问趋势</h3>
{daily.length ? (
<div className="qa-daily-chart" role="img" aria-label="北京时间每日提问量">
{daily.map((d) => (
<div key={d.label} title={`${d.label}:${d.count} 条`}>
<b>{d.count}</b>
<div>
<i style={{ height: `${(d.count / max) * 100}%` }} />
</div>
<span>{d.label.slice(5)}</span>
</div>
))}
</div>
) : (
<p className="qa-chart-empty">暂无数据</p>
)}
</section>
<section className="qa-chart">
<h3>质量分布</h3>
<Bars points={outcomes} />
</section>
<section className="qa-chart qa-chart-wide">
<h3>区域分布</h3>
<Bars
points={
p.usage?.rows
.filter((r) => r.questions > 0)
.map((r) => ({ label: usageLabels[r.key] || r.key, count: r.questions })) || []
}
/>
{p.usage ? (
<div className="qa-dashboard-region-table">
<table>
<thead>
<tr>
<th>区域</th>
<th>提问量</th>
<th>已关联使用人数</th>
</tr>
</thead>
<tbody>
{p.usage.rows
.filter((r) => r.questions > 0 || r.roster_people !== null)
.map((r) => (
<tr key={r.key}>
<td>{usageLabels[r.key] || r.key}</td>
<td>{r.questions}</td>
<td>{r.users ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</section>
<section className="qa-chart">
<h3>问题类型</h3>
<Bars points={qaCategorySeries(items, 'problem_type')} />
</section>
<section className="qa-chart qa-chart-full">
<h3>产品分类</h3>
<Bars points={qaCategorySeries(items, 'product_category')} />
</section>
) : null}
{loading ? <p role="status">正在读取完整统计范围…</p> : null}
<div className="qa-dashboard-canvas" ref={host}>
{!loading && !error && data ? <QADashboardDrawing data={data} width={width} svgRef={svg} /> : null}
</div>
</section>
)
}
export function QADashboard({
page,
query,
loading,
error,
revision,
onRetry,
}: {
page: QARecordPage | null
query: QAQuery | null
loading: boolean
error: string | null
revision: number
onRetry: () => void
}) {
const [quality, setQuality] = useState<QualitySummary | null>(null),
[usage, setUsage] = useState<QAUsage | null>(null),
const [data, setData] = useState<QADashboardData | null>(null),
[failure, setFailure] = useState(''),
[loading, setLoading] = useState(false),
[monthly, setMonthly] = useState(false)
useEffect(() => {
setQuality(null)
setUsage(null)
setData(null)
setFailure('')
if (!query) return
if (!query) {
setLoading(false)
return
}
const abort = new AbortController()
void fetchQADashboardQuality(query, abort.signal)
setLoading(true)
void fetchQADashboard(query, abort.signal)
.then((v) => {
if (!abort.signal.aborted) setQuality(v)
if (!abort.signal.aborted) setData(v)
})
.catch((e) => {
if (!abort.signal.aborted) setFailure(e instanceof Error ? e.message : '质量统计读取失败')
if (!abort.signal.aborted) setFailure(e instanceof Error ? e.message : '统计看板读取失败')
})
void fetchQAUsage(query, abort.signal)
.then((v) => {
if (!abort.signal.aborted) setUsage(v)
})
.catch((e) => {
if (!abort.signal.aborted) setFailure(e instanceof Error ? e.message : '区域统计读取失败')
.finally(() => {
if (!abort.signal.aborted) setLoading(false)
})
return () => abort.abort()
}, [query, revision])
const mismatch = !!page && !!quality && page.total !== quality.summary.total
return (
<div className="qa-dashboard-scroll">
<QADashboardView
page={page}
query={query}
quality={quality}
usage={usage}
loading={loading}
error={error || failure || (mismatch ? '统计范围已更新,请重新查询' : '')}
onRetry={onRetry}
/>
<QADashboardView data={data} loading={loading} error={error || failure} onRetry={onRetry} />
<details className="qa-monthly-disclosure" onToggle={(e) => setMonthly(e.currentTarget.open)}>
<summary>个人月明细</summary>
{monthly ? <QAMonthly /> : null}
+33
View File
@@ -0,0 +1,33 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { it, expect } from 'vitest'
import { QADashboardDrawing } from './QADashboardDrawing'
import { dashboardFixture } from './qaDashboard.fixture'
import { parseQADashboard } from './qaDashboardData'
it('draws all eleven panels as a self-contained exportable SVG without HTML execution', () => {
const html = renderToStaticMarkup(<QADashboardDrawing data={parseQADashboard(dashboardFixture)} width={1280} />)
expect((html.match(/data-panel=/g) || []).length).toBe(11)
for (const title of [
'问题总数',
'本周新增',
'覆盖率',
'正确率',
'问题类型分布',
'产品问题排行',
'问题趋势',
'区域平均使用频率',
'区域使用覆盖率',
'覆盖率趋势',
'正确率趋势',
])
expect(html).toContain(title)
expect(html).toContain('&lt;img src=x')
expect(html).not.toContain('<foreignObject')
expect(html).not.toContain('<img src=x')
expect(html).toContain('data-frozen="missing"')
expect(html).toContain('data-frozen="available"')
})
it('reflows the same complete eleven panels at mobile width rather than clipping the canvas', () => {
const html = renderToStaticMarkup(<QADashboardDrawing data={parseQADashboard(dashboardFixture)} width={350} />)
expect((html.match(/data-panel=/g) || []).length).toBe(11)
expect(html).toContain('viewBox="0 0 350 ')
})
+589
View File
@@ -0,0 +1,589 @@
import type { ReactNode, Ref } from 'react'
import type { QADashboardData, DashboardCount, DashboardRegion } from './qaDashboardData'
import type { QualityRate } from './qaQuality'
import { currentQAWeek } from './qaWeeks'
interface Box {
x: number
y: number
w: number
h: number
}
const blue = '#3274e6',
ink = '#23344d',
muted = '#7a8799',
line = '#eaf0f7'
const colors = [blue, '#59a9f3', '#69c6bc', '#f3c25a', '#f39b6c', '#9b8ade', '#86b8a1', '#d6a3cf', '#8aacc9', '#c4cb76']
const number = (n: number) => new Intl.NumberFormat('zh-CN').format(n)
const pct = (n: number | null) => (n === null ? '—' : `${n.toFixed(1)}%`)
function wrap(value: string, width: number, size = 12): string[] {
const max = Math.max(2, width / size),
out: string[] = []
let row = '',
units = 0
for (const c of Array.from(value)) {
const next = c.charCodeAt(0) < 128 ? 0.57 : 1
if (row && units + next > max) {
out.push(row)
row = ''
units = 0
}
row += c
units += next
}
if (row || !out.length) out.push(row)
return out
}
function Text({
value,
x,
y,
width,
size = 12,
fill = ink,
weight = 400,
anchor = 'start',
}: {
value: string
x: number
y: number
width: number
size?: number
fill?: string
weight?: number
anchor?: 'start' | 'middle' | 'end'
}) {
return (
<text x={x} y={y} fontSize={size} fill={fill} fontWeight={weight} textAnchor={anchor}>
{wrap(value, width, size).map((row, i) => (
<tspan key={i} x={x} dy={i ? size * 1.35 : 0}>
{row}
</tspan>
))}
</text>
)
}
function Panel({
box,
title,
en,
id,
children,
tint = '#fff',
}: {
box: Box
title: string
en: string
id: string
children: ReactNode
tint?: string
}) {
return (
<g data-panel={id} transform={`translate(${box.x} ${box.y})`}>
<rect width={box.w} height={box.h} rx={7} fill={tint} />
<text x={16} y={25} fontSize={14} fontWeight={650} fill={ink}>
{title}
</text>
<text x={16} y={43} fontSize={10.5} fill={muted}>
{en}
</text>
{children}
</g>
)
}
function Metric({
box,
value,
title,
en,
id,
tint,
color,
subtitle,
}: {
box: Box
value: number
title: string
en: string
id: string
tint: string
color: string
subtitle: string
}) {
const text = number(value),
size = Math.min(box.w < 220 ? 40 : 54, (box.w - 32) / (text.length * 0.62))
return (
<Panel box={box} title={title} en={en} id={id} tint={tint}>
<text x={16} y={box.h - 43} fontSize={size} fill={color} fontWeight={700} data-value={value}>
{text}
</text>
<text x={16} y={box.h - 17} fill={muted} fontSize={11}>
{subtitle}
</text>
</Panel>
)
}
function polar(cx: number, cy: number, r: number, degrees: number) {
const a = (degrees * Math.PI) / 180
return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }
}
function arc(cx: number, cy: number, r: number, start: number, end: number) {
const a = polar(cx, cy, r, start),
b = polar(cx, cy, r, end)
return `M ${a.x} ${a.y} A ${r} ${r} 0 ${end - start > 180 ? 1 : 0} 1 ${b.x} ${b.y}`
}
function Gauge({ box, rate, title, en, id }: { box: Box; rate: QualityRate; title: string; en: string; id: string }) {
const r = Math.min(42, box.w * 0.27),
cx = box.w / 2,
cy = 94,
d = arc(cx, cy, r, 135, 405)
return (
<Panel box={box} title={title} en={en} id={id}>
<title>{`${title}:${rate.numerator} / ${rate.denominator};分母为有效已评估记录`}</title>
<path d={d} fill="none" stroke="#e8ecf2" strokeWidth={8} strokeLinecap="round" />
{rate.percent !== null && rate.percent > 0 ? (
<path
d={d}
fill="none"
stroke={blue}
strokeWidth={8}
strokeLinecap="round"
pathLength={100}
strokeDasharray={`${rate.percent} 100`}
/>
) : null}
<text
x={cx}
y={cy + 6}
textAnchor="middle"
fontSize={box.w < 220 ? 22 : 27}
fontWeight={650}
fill={ink}
data-value={rate.percent ?? 'unknown'}
>
{pct(rate.percent)}
</text>
<text x={cx} y={box.h - 14} fontSize={11} fill={muted} textAnchor="middle">
{rate.denominator ? `${number(rate.numerator)} / ${number(rate.denominator)}` : '暂无有效分母'}
</text>
</Panel>
)
}
function pieHeight(points: DashboardCount[], w: number) {
const mobile = w < 500,
legendW = mobile ? w - 60 : w * 0.45
return Math.max(
mobile ? 270 : 300,
(mobile ? 225 : 65) + points.reduce((n, p) => n + Math.max(24, wrap(p.label, legendW - 85).length * 16 + 9), 0)
)
}
function Pie({ box, points }: { box: Box; points: DashboardCount[] }) {
const mobile = box.w < 500,
total = points.reduce((n, p) => n + p.count, 0),
legendW = mobile ? box.w - 32 : box.w * 0.47,
cx = mobile ? box.w / 2 : box.w * 0.76,
cy = mobile ? 135 : Math.max(155, box.h / 2),
radius = Math.min(mobile ? 73 : 112, box.w * (mobile ? 0.25 : 0.21))
let angle = -90,
legendY = mobile ? 236 : 76
return (
<Panel box={box} title="问题类型分布" en="Issue Type Distribution" id="issue-types">
{!total ? (
<text x={box.w / 2} y={box.h / 2} textAnchor="middle" fill={muted} fontSize={13}>
暂无分类记录
</text>
) : null}
{points.map((point, i) => {
const start = angle
angle += (point.count / total) * 360
const a = polar(cx, cy, radius, start),
b = polar(cx, cy, radius, angle)
const y = legendY
legendY += Math.max(24, wrap(point.label, legendW - 85).length * 16 + 9)
return (
<g key={point.label}>
{points.length === 1 ? (
<circle cx={cx} cy={cy} r={radius} fill={colors[i % colors.length]} />
) : (
<path
d={`M ${cx} ${cy} L ${a.x} ${a.y} A ${radius} ${radius} 0 ${angle - start > 180 ? 1 : 0} 1 ${b.x} ${b.y} Z`}
fill={colors[i % colors.length]}
stroke="#fff"
strokeWidth={1}
/>
)}
<title>{`${point.label}:${point.count}(${((point.count / total) * 100).toFixed(1)}%)`}</title>
<circle cx={22} cy={y - 4} r={4} fill={colors[i % colors.length]} />
<Text x={34} y={y} width={legendW - 91} value={point.label} size={11.5} />
<text x={legendW + 8} y={y} textAnchor="end" fill={ink} fontSize={11.5}>
{number(point.count)}
</text>
</g>
)
})}
</Panel>
)
}
function productRows(points: DashboardCount[], w: number) {
return points.map((p) => ({ point: p, lines: wrap(p.label, Math.max(70, w * 0.3), 11) }))
}
function productHeight(points: DashboardCount[], w: number) {
return Math.max(300, 75 + productRows(points, w).reduce((n, r) => n + Math.max(30, r.lines.length * 15 + 10), 0))
}
function Products({ box, points }: { box: Box; points: DashboardCount[] }) {
const left = Math.max(85, box.w * 0.32),
right = box.w - 58,
max = Math.max(1, ...points.map((p) => p.count))
let y = 78
return (
<Panel box={box} title="产品问题排行" en="Product Issue Ranking" id="products">
{points.length ? (
productRows(points, box.w).map(({ point, lines }) => {
const at = y
y += Math.max(30, lines.length * 15 + 10)
return (
<g key={point.label}>
<title>{`${point.label}:${point.count}`}</title>
<Text x={16} y={at + 2} width={left - 26} value={point.label} size={11} />
<rect
x={left}
y={at - 11}
width={Math.max(1, (point.count / max) * (right - left))}
height={15}
rx={2}
fill={blue}
/>
<text x={left + (point.count / max) * (right - left) + 5} y={at + 1} fontSize={11} fill={ink}>
{number(point.count)}
</text>
</g>
)
})
) : (
<text x={16} y={100} fill={muted} fontSize={13}>
暂无分类记录
</text>
)}
</Panel>
)
}
interface PlotPoint {
label: string
value: number | null
hint: string
frozen?: boolean
}
function Plot({
box,
title,
en,
id,
points,
percent = false,
}: {
box: Box
title: string
en: string
id: string
points: PlotPoint[]
percent?: boolean
}) {
const left = 44,
right = box.w - 22,
top = 74,
bottom = box.h - 47,
max = percent ? 100 : Math.max(4, Math.ceil(Math.max(0, ...points.map((p) => p.value ?? 0)) / 4) * 4),
x = (i: number) =>
points.length === 1 ? (left + right) / 2 : left + (i * (right - left)) / Math.max(1, points.length - 1),
y = (n: number) => bottom - (n / max) * (bottom - top)
let path = '',
open = false
for (let i = 0; i < points.length; i++) {
const value = points[i].value
if (value === null) {
open = false
continue
}
path += `${open ? 'L' : 'M'} ${x(i)} ${y(value)} `
open = true
}
const every = Math.max(1, Math.ceil(points.length / (box.w < 420 ? 5 : 8)))
return (
<Panel box={box} title={title} en={en} id={id}>
{Array.from({ length: 5 }, (_, i) => (
<g key={i}>
<line x1={left} x2={right} y1={y((max * i) / 4)} y2={y((max * i) / 4)} stroke={line} />
<text x={left - 9} y={y((max * i) / 4) + 4} textAnchor="end" fill={muted} fontSize={10}>
{(max * i) / 4}
{percent ? '%' : ''}
</text>
</g>
))}
{path ? <path d={path} fill="none" stroke={blue} strokeWidth={2} data-series={id} /> : null}
{points.map((point, i) => (
<g
key={point.label}
data-frozen={point.frozen === undefined ? undefined : point.frozen ? 'available' : 'missing'}
>
<title>{point.hint}</title>
{point.value !== null ? (
<>
<circle cx={x(i)} cy={y(point.value)} r={3} fill="#fff" stroke={blue} strokeWidth={1.5} />
{points.length <= 14 || i % every === 0 || i === points.length - 1 ? (
<text x={x(i)} y={Math.max(top - 9, y(point.value) - 9)} fill={blue} fontSize={10} textAnchor="middle">
{percent ? `${point.value.toFixed(1)}%` : number(point.value)}
</text>
) : null}
</>
) : null}
{i % every === 0 || i === points.length - 1 ? (
<text x={x(i)} y={bottom + 22} textAnchor="middle" fill={muted} fontSize={10}>
{point.label}
</text>
) : null}
</g>
))}
{!points.some((p) => p.value !== null) ? (
<text x={(left + right) / 2} y={(top + bottom) / 2} fill={muted} textAnchor="middle" fontSize={12}>
{percent ? '暂无冻结快照' : '暂无数据'}
</text>
) : null}
</Panel>
)
}
function regionalHeight(regions: DashboardRegion[], w: number) {
return Math.max(
290,
130 + regions.reduce((n, r) => n + Math.max(34, wrap(r.name, Math.max(70, w - 125), 11.5).length * 16 + 12), 0)
)
}
function RegionTable({
box,
regions,
frequency,
month,
}: {
box: Box
regions: DashboardRegion[]
frequency: boolean
month: string
}) {
const rows = [...regions].sort((a, b) => {
const av = frequency ? a.frequency : a.usage_coverage.percent,
bv = frequency ? b.frequency : b.usage_coverage.percent
return (bv ?? -1) - (av ?? -1) || a.name.localeCompare(b.name, 'zh-CN')
})
let y = 104
return (
<Panel
box={box}
title={frequency ? '区域平均使用频率' : '区域使用覆盖率'}
en={frequency ? 'Average usage frequency' : 'Usage coverage'}
id={frequency ? 'frequency' : 'usage-coverage'}
>
<rect x={10} y={57} width={box.w - 20} height={box.h - 68} rx={5} fill="#fff" stroke="#e4eaf2" />
<text x={19} y={77} fontSize={10.5} fill={muted}>
排名
</text>
<text x={58} y={77} fontSize={10.5} fill={muted}>
区域
</text>
<text x={box.w - 19} y={77} textAnchor="end" fontSize={10.5} fill={muted}>
{frequency ? '人均次数' : '使用覆盖率'}
</text>
<line x1={15} x2={box.w - 15} y1={85} y2={85} stroke={line} />
{rows.map((r, i) => {
const at = y,
value = frequency ? r.frequency : r.usage_coverage.percent
const h = Math.max(34, wrap(r.name, Math.max(70, box.w - 125), 11.5).length * 16 + 12)
y += h
return (
<g key={r.id}>
<title>{`${r.name};${month}月内提问 ${r.questions} 次;去重使用 ${r.users ?? '未知'} 人 / 通讯录归属 ${r.roster_people ?? '未知'} 人`}</title>
{i % 2 === 0 ? <rect x={11} y={at - 15} width={box.w - 22} height={h} fill="#f7faff" /> : null}
{value !== null && i < 3 ? (
<circle cx={30} cy={at - 4} r={9} fill={['#e9bd54', '#a8b6c9', '#dc9c73'][i]} />
) : null}
<text x={30} y={at} textAnchor="middle" fontSize={10.5} fill={value !== null && i < 3 ? '#fff' : muted}>
{value === null ? '—' : i + 1}
</text>
<Text x={53} y={at} width={Math.max(70, box.w - 125)} value={r.name} size={11.5} />
<text x={box.w - 19} y={at} textAnchor="end" fontSize={12} fill={ink}>
{value === null ? '—' : frequency ? value.toFixed(2) : `${value.toFixed(1)}%`}
</text>
</g>
)
})}
<text x={box.w - 17} y={box.h - 17} textAnchor="end" fontSize={9.5} fill={muted}>
{month}月累计
</text>
</Panel>
)
}
export function QADashboardDrawing({
data,
width,
svgRef,
}: {
data: QADashboardData
width: number
svgRef?: Ref<SVGSVGElement>
}) {
const w = Math.max(240, Math.round(width)),
pad = w < 700 ? 10 : 16,
gap = w < 700 ? 12 : 18,
inner = w - pad * 2,
mobile = w < 700,
unit = (inner - gap * 3) / 4,
week = currentQAWeek(new Date(data.week_start)),
filterLines = wrap(data.keyword ? `关键词:${data.keyword}` : '', inner - 16, 11),
header = 62 + (data.keyword ? filterLines.length * 15 : 0),
metricH = mobile ? 154 : 166
const metrics: Box[] = Array.from({ length: 4 }, (_, i) =>
mobile
? {
x: pad + ((i % 2) * (inner + gap)) / 2,
y: header + Math.floor(i / 2) * (metricH + gap),
w: (inner - gap) / 2,
h: metricH,
}
: { x: pad + i * (unit + gap), y: header, w: unit, h: metricH }
)
let cursor = header + (mobile ? 2 * metricH + gap : metricH) + gap
const pieW = mobile ? inner : (inner - gap) * 0.54,
productW = mobile ? inner : inner - gap - pieW,
pieH = pieHeight(data.issue_types, pieW),
productsH = productHeight(data.products, productW),
row2H = Math.max(pieH, productsH)
const pie: Box = { x: pad, y: cursor, w: pieW, h: mobile ? pieH : row2H },
products: Box = {
x: mobile ? pad : pad + pieW + gap,
y: mobile ? cursor + pieH + gap : cursor,
w: productW,
h: mobile ? productsH : row2H,
}
cursor += mobile ? pieH + productsH + gap * 2 : row2H + gap
const trendW = mobile ? inner : unit * 2 + gap,
regionW = mobile ? inner : unit,
regionH = regionalHeight(data.regions, regionW),
row3H = Math.max(290, regionH)
const trend: Box = { x: pad, y: cursor, w: trendW, h: mobile ? 270 : row3H },
freq: Box = {
x: mobile ? pad : pad + trendW + gap,
y: mobile ? cursor + trend.h + gap : cursor,
w: regionW,
h: mobile ? regionH : row3H,
},
usage: Box = {
x: mobile ? pad : pad + trendW + regionW + gap * 2,
y: mobile ? cursor + trend.h + regionH + gap * 2 : cursor,
w: regionW,
h: mobile ? regionH : row3H,
}
cursor += mobile ? trend.h + regionH * 2 + gap * 3 : row3H + gap
const historyW = mobile ? inner : (inner - gap) / 2,
coverage: Box = { x: pad, y: cursor, w: historyW, h: 270 },
accuracy: Box = {
x: mobile ? pad : pad + historyW + gap,
y: mobile ? cursor + 270 + gap : cursor,
w: historyW,
h: 270,
},
height = mobile ? cursor + 540 + gap + pad : cursor + 270 + pad
const frozen = (correctness: boolean): PlotPoint[] =>
data.frozen_weeks.map((p) => {
const rate = correctness ? p.correctness : p.coverage,
week = currentQAWeek(new Date(p.week_start))
return {
label: `${week.year}W${String(week.week).padStart(2, '0')}`,
value: rate.percent,
frozen: p.frozen_at !== null,
hint: p.frozen_at
? `${week.year}年第${week.week}周:${rate.numerator}/${rate.denominator};冻结于 ${p.frozen_at}`
: `${week.year}年第${week.week}周:缺少当时冻结快照`,
}
})
const month = new Date(Date.parse(data.month_start) + 8 * 3600000).toISOString().slice(0, 7)
return (
<svg
ref={svgRef}
className="qa-dashboard-svg"
xmlns="http://www.w3.org/2000/svg"
width="100%"
viewBox={`0 0 ${w} ${height}`}
role="img"
aria-label="完整十一项问题统计看板"
style={{
display: 'block',
fontFamily: 'Arial, "Noto Sans CJK SC", "Microsoft YaHei", sans-serif',
fontVariantNumeric: 'tabular-nums',
background: '#fff',
}}
>
<title>
问题统计看板 · {week.year}年第{week.week}周
</title>
<rect width={w} height={height} fill="#fff" />
<text x={pad + 6} y={27} fontSize={mobile ? 17 : 21} fontWeight={700} fill={ink}>
问题统计看板
</text>
<text x={w - pad - 6} y={27} textAnchor="end" fontSize={12} fill={muted}>
{week.year} · W{String(week.week).padStart(2, '0')}
</text>
<text x={pad + 6} y={46} fontSize={10.5} fill={muted}>
{data.week_start.slice(0, 10)} 起的自然周 · 区域统计 {month} 月累计
</text>
{data.keyword ? (
<Text x={pad + 6} y={62} width={inner - 16} value={`关键词:${data.keyword}`} size={11} fill={muted} />
) : null}
<Metric
box={metrics[0]}
title="问题总数"
en="Total Issues"
id="total"
value={data.total_issues}
tint="#fff4e2"
color="#c69b00"
subtitle="所选周截止前累计"
/>
<Metric
box={metrics[1]}
title="本周新增"
en="New This week"
id="new-week"
value={data.new_this_week}
tint="#ececff"
color="#4056d6"
subtitle="所选自然周"
/>
<Gauge box={metrics[2]} title="覆盖率" en="Coverage Rate" id="coverage" rate={data.quality.rates.coverage} />
<Gauge box={metrics[3]} title="正确率" en="Accuracy Rate" id="accuracy" rate={data.quality.rates.correctness} />
<Pie box={pie} points={data.issue_types} />
<Products box={products} points={data.products} />
<Plot
box={trend}
title="问题趋势"
en="Issue Trend"
id="issue-trend"
points={data.daily.map((p) => ({ label: p.date.slice(5), value: p.count, hint: `${p.date}:${p.count} 条` }))}
/>
<RegionTable box={freq} regions={data.regions} frequency month={month} />
<RegionTable box={usage} regions={data.regions} frequency={false} month={month} />
<Plot
box={coverage}
title="覆盖率趋势"
en="Coverage Rate Trend"
id="coverage-trend"
points={frozen(false)}
percent
/>
<Plot
box={accuracy}
title="正确率趋势"
en="Accuracy Rate Trend"
id="accuracy-trend"
points={frozen(true)}
percent
/>
</svg>
)
}
+2 -3
View File
@@ -334,8 +334,9 @@ export function QAOperations() {
useEffect(() => {
if (!query || !canRead) return
if (view !== 'records' && view !== 'dashboard') {
if (view !== 'records') {
setLoading(false)
setError(null)
detailRequest.current?.abort()
setDetail(null)
return
@@ -504,9 +505,7 @@ export function QAOperations() {
<QAWeekly query={query} />
) : view === 'dashboard' ? (
<QADashboard
page={page}
query={query}
loading={loading}
error={error}
revision={qualityRevision}
onRetry={() => {
+7 -166
View File
@@ -10,146 +10,19 @@
flex-direction: column;
gap: 16px;
}
.qa-dashboard-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border: 1px solid var(--border-base);
background: var(--bg-panel);
}
.qa-dashboard-metrics > div {
padding: 18px 22px;
.qa-dashboard-toolbar {
display: flex;
flex-direction: column;
gap: 8px;
border-right: 1px solid var(--border-base);
justify-content: flex-end;
}
.qa-dashboard-metrics > div:last-child {
border-right: 0;
}
.qa-dashboard-metrics span {
color: var(--ink-mute);
font-size: 12px;
}
.qa-dashboard-metrics strong {
color: var(--ink-strong);
font-size: 30px;
font-weight: 650;
font-variant-numeric: tabular-nums;
letter-spacing: -0.04em;
}
.qa-dashboard-metrics small {
font-size: 11px;
color: var(--ink-mute);
}
.qa-dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(260px, 1fr);
gap: 14px;
}
.qa-chart {
.qa-dashboard-canvas {
min-width: 0;
padding: 18px 20px;
background: var(--bg-panel);
border: 1px solid var(--border-base);
}
.qa-chart-full {
grid-column: 1/-1;
}
.qa-chart h3 {
font-size: 14px;
font-weight: 600;
margin: 0 0 20px;
color: var(--ink-strong);
}
.qa-chart-bars {
display: grid;
gap: 13px;
}
.qa-chart-bar {
display: grid;
grid-template-columns: minmax(70px, 120px) minmax(0, 1fr) 40px;
gap: 12px;
align-items: center;
font-size: 12px;
}
.qa-chart-bar > span {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: #fff;
}
.qa-chart-bar b {
text-align: right;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.qa-chart-track {
height: 7px;
background: var(--bg-canvas, #f1f5f9);
overflow: hidden;
border-radius: 2px;
}
.qa-chart-track i {
display: block;
height: 100%;
background: #377bc9;
border-radius: 2px;
}
.qa-chart-empty {
padding: 28px 0;
text-align: center;
color: var(--ink-mute);
font-size: 12px;
}
.qa-daily-chart {
display: flex;
gap: clamp(6px, 1.5vw, 22px);
height: 180px;
align-items: stretch;
overflow-x: auto;
}
.qa-daily-chart > div {
flex: 1;
min-width: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
font-size: 11px;
color: var(--ink-mute);
}
.qa-daily-chart > div > div {
height: 125px;
.qa-dashboard-svg {
width: 100%;
display: flex;
align-items: flex-end;
border-bottom: 1px solid var(--border-base);
}
.qa-daily-chart i {
display: block;
width: 100%;
background: #377bc9;
border-radius: 2px 2px 0 0;
min-height: 0;
}
.qa-daily-chart b {
font-size: 11px;
color: var(--ink-strong);
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.qa-dashboard-region-table {
margin-top: 18px;
overflow-x: auto;
}
.qa-dashboard-region-table table {
width: 100%;
font-size: 12px;
}
.qa-dashboard-region-table th,
.qa-dashboard-region-table td {
padding: 8px 10px;
text-align: left;
white-space: normal;
height: auto;
}
.qa-monthly-disclosure {
margin-top: 14px;
@@ -166,38 +39,6 @@
margin-top: 12px;
overflow: auto;
}
@media (max-width: 900px) {
.qa-dashboard-grid {
grid-template-columns: minmax(0, 1fr);
}
.qa-chart-full {
grid-column: auto;
}
.qa-dashboard-metrics > div {
padding: 14px;
}
.qa-dashboard-metrics strong {
font-size: 25px;
}
}
@media (max-width: 560px) {
.qa-dashboard-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.qa-dashboard-metrics > div:nth-child(2) {
border-right: 0;
}
.qa-dashboard-metrics > div:nth-child(-n + 2) {
border-bottom: 1px solid var(--border-base);
}
.qa-chart {
padding: 16px 12px;
}
.qa-chart-bar {
grid-template-columns: minmax(65px, 100px) minmax(0, 1fr) 30px;
}
}
.qa-weekly {
display: flex;
flex-direction: column;
+89
View File
@@ -0,0 +1,89 @@
export const dashboardFixture = {
start: '2026-10-05T00:00:00+08:00',
end: '2026-10-12T00:00:00+08:00',
keyword: '',
as_of: '2026-10-18T10:00:00Z',
local_revision: 1,
organization: {
state: 'ready',
directory_id: 'd',
directory_source: 'weekly_report_live',
synced_at: '2026-10-17T10:00:00Z',
revision_id: 'r',
basis: 'current_directory',
region_names: ['国内教育'],
},
total_issues: 4,
new_this_week: 2,
week_start: '2026-10-05T00:00:00+08:00',
month_start: '2026-10-01T00:00:00+08:00',
quality: {
total: 2,
reviewed: 1,
unreviewed: 1,
stale: 0,
pending_confirmation: 0,
rule_uncovered: 1,
rates: {
policy_version: 'quality-rates-human-first-v1',
coverage: { numerator: 1, denominator: 2, percent: 50 },
correctness: { numerator: 1, denominator: 1, percent: 100 },
breakdown: {
covered: 1,
uncovered: 1,
partial: 0,
pending: 0,
unassessed: 0,
excluded: 0,
stale: 0,
covered_correct: 1,
covered_incorrect: 0,
covered_pending: 0,
covered_unassessed: 0,
},
},
},
issue_types: [
{ label: '使用咨询', count: 1 },
{ label: '未分类', count: 1 },
],
products: [
{ label: '<img src=x onerror=alert(1)>', count: 1 },
{ label: '未分类', count: 1 },
],
daily: Array.from({ length: 7 }, (_, i) => ({
date: `2026-10-${String(i + 5).padStart(2, '0')}`,
count: i === 1 || i === 5 ? 1 : 0,
})),
regions: [
{
id: 'education',
name: '国内教育',
questions: 3,
users: 1,
roster_people: 2,
frequency: 1.5,
usage_coverage: { numerator: 1, denominator: 2, percent: 50 },
},
],
frozen_weeks: [
{
week_start: '2026-09-28T00:00:00+08:00',
week_end: '2026-10-05T00:00:00+08:00',
frozen_at: null,
policy_version: '',
records: 0,
coverage: { numerator: 0, denominator: 0, percent: null },
correctness: { numerator: 0, denominator: 0, percent: null },
},
{
week_start: '2026-10-05T00:00:00+08:00',
week_end: '2026-10-12T00:00:00+08:00',
frozen_at: '2026-10-12T00:06:00+08:00',
policy_version: 'quality-rates-human-first-v1',
records: 2,
coverage: { numerator: 1, denominator: 2, percent: 50 },
correctness: { numerator: 1, denominator: 1, percent: 100 },
},
],
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { parseQADashboard } from './qaDashboardData'
import { dashboardFixture } from './qaDashboard.fixture'
describe('complete dashboard data contract', () => {
it('keeps distinct cumulative, week, month and genuinely frozen periods', () => {
const data = parseQADashboard(dashboardFixture)
expect([data.total_issues, data.new_this_week, data.regions[0].questions]).toEqual([4, 2, 3])
expect(data.frozen_weeks[0].coverage.percent).toBeNull()
expect(data.frozen_weeks[1].coverage.percent).toBe(50)
})
it('rejects fabricated percentages, category omissions and historical backfill', () => {
for (const bad of [
{ ...dashboardFixture, new_this_week: 5 },
{ ...dashboardFixture, products: [] },
{ ...dashboardFixture, regions: [{ ...dashboardFixture.regions[0], frequency: 3 }] },
{
...dashboardFixture,
frozen_weeks: [
{ ...dashboardFixture.frozen_weeks[0], coverage: { numerator: 1, denominator: 2, percent: 50 } },
],
},
])
expect(() => parseQADashboard(bad)).toThrow()
})
})
+230
View File
@@ -0,0 +1,230 @@
import { parseQAOrganizationVersion } from './qaOps'
import type { QAOrganizationVersion, QAQuery } from './qaOps'
import { parseQualitySummary, parseQualityRate } from './qaQuality'
import type { QualitySummary, QualityRate } from './qaQuality'
export interface DashboardCount {
label: string
count: number
}
export interface DashboardFrozen {
week_start: string
week_end: string
frozen_at: string | null
policy_version: string
records: number
coverage: QualityRate
correctness: QualityRate
}
export interface DashboardRegion {
id: string
name: string
questions: number
users: number | null
roster_people: number | null
frequency: number | null
usage_coverage: QualityRate
}
export interface QADashboardData {
start: string
end: string
keyword: string
as_of: string
local_revision: number
organization: QAOrganizationVersion
total_issues: number
new_this_week: number
week_start: string
month_start: string
quality: QualitySummary['summary']
issue_types: DashboardCount[]
products: DashboardCount[]
daily: { date: string; count: number }[]
regions: DashboardRegion[]
frozen_weeks: DashboardFrozen[]
}
const bad = (): never => {
throw new Error('统计看板响应无效,未展示可能失真的指标')
}
const obj = (v: unknown): Record<string, unknown> =>
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : bad()
const text = (v: unknown): string => (typeof v === 'string' ? v : bad())
const count = (v: unknown): number => (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : bad())
const stamp = (v: unknown): string => {
const s = text(v)
return Number.isFinite(Date.parse(s)) ? s : bad()
}
const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : bad())
const dayMS = 86400000,
offset = 8 * 3600000
const day = (at: number) => Math.floor((at + offset) / dayMS) * dayMS - offset
const monday = (at: number) => {
const d = day(at)
return d - ((new Date(d + offset).getUTCDay() + 6) % 7) * dayMS
}
const month = (at: number) => {
const d = new Date(at + offset)
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) - offset
}
function categories(value: unknown, total: number): DashboardCount[] {
const seen = new Set<string>()
const out = list(value).map((raw) => {
const v = obj(raw),
label = text(v.label),
n = count(v.count)
if (!label || seen.has(label) || !n) return bad()
seen.add(label)
return { label, count: n }
})
if (out.reduce((n, r) => n + r.count, 0) !== total) return bad()
return out
}
export function parseQADashboard(value: unknown): QADashboardData {
const v = obj(value),
start = stamp(v.start),
end = stamp(v.end),
keyword = text(v.keyword),
as_of = stamp(v.as_of),
week_start = stamp(v.week_start),
month_start = stamp(v.month_start)
if (
Date.parse(start) >= Date.parse(end) ||
Date.parse(week_start) !== monday(Date.parse(end) - 1) ||
Date.parse(month_start) !== month(Date.parse(end) - 1)
)
return bad()
const quality = parseQualitySummary({
summary: v.quality,
start,
end,
keyword,
as_of,
scope: 'whole_filter',
ai_state: 'not_configured',
}).summary
const organization = parseQAOrganizationVersion(v.organization),
total_issues = count(v.total_issues),
new_this_week = count(v.new_this_week)
if (new_this_week > total_issues || quality.total > total_issues) return bad()
const regionIDs = new Set<string>(),
regionNames = new Set<string>()
const regions = list(v.regions).map((raw) => {
const r = obj(raw),
id = text(r.id),
name = text(r.name),
questions = count(r.questions),
users = r.users === null ? null : count(r.users),
roster_people = r.roster_people === null ? null : count(r.roster_people),
usage_coverage = parseQualityRate(r.usage_coverage)
if (!id || !name || regionIDs.has(id) || regionNames.has(name) || !organization.region_names?.includes(name))
return bad()
regionIDs.add(id)
regionNames.add(name)
if (
(users === null) !== (roster_people === null) ||
(users !== null && roster_people !== null && users > roster_people)
)
return bad()
if (usage_coverage.numerator !== (users ?? 0) || usage_coverage.denominator !== (roster_people ?? 0)) return bad()
let frequency: number | null = null
if (roster_people !== null && roster_people > 0) {
if (
typeof r.frequency !== 'number' ||
!Number.isFinite(r.frequency) ||
r.frequency < 0 ||
Math.abs(r.frequency - questions / roster_people) > 1e-8
)
return bad()
frequency = r.frequency
} else if (r.frequency !== null) return bad()
return { id, name, questions, users, roster_people, frequency, usage_coverage }
})
if (regions.length !== (organization.region_names?.length ?? 0)) return bad()
const daily = list(v.daily).map((raw, i) => {
const d = obj(raw),
date = text(d.date),
n = count(d.count)
if (
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
Date.parse(date + 'T00:00:00+08:00') !== Date.parse(week_start) + i * dayMS
)
return bad()
return { date, count: n }
})
if (
daily.length < 1 ||
daily.length > 7 ||
daily.reduce((n, r) => n + r.count, 0) !== new_this_week ||
Date.parse(daily[daily.length - 1].date + 'T00:00:00+08:00') >= Date.parse(end)
)
return bad()
const frozen_weeks = list(v.frozen_weeks).map((raw, i) => {
const p = obj(raw),
week_start = stamp(p.week_start),
week_end = stamp(p.week_end),
frozen_at = p.frozen_at === null ? null : stamp(p.frozen_at),
policy_version = text(p.policy_version),
records = count(p.records),
coverage = parseQualityRate(p.coverage),
correctness = parseQualityRate(p.correctness)
if (
monday(Date.parse(week_start)) !== Date.parse(week_start) ||
Date.parse(week_end) - Date.parse(week_start) !== 7 * dayMS ||
Date.parse(week_start) >= Date.parse(end) ||
coverage.denominator > records ||
correctness.denominator > records
)
return bad()
if (frozen_at === null) {
if (policy_version || records || coverage.denominator || correctness.denominator) return bad()
} else if (!policy_version || Date.parse(frozen_at) < Date.parse(week_end)) return bad()
const previous = list(v.frozen_weeks)[i - 1]
if (i > 0 && Date.parse(week_start) !== Date.parse(text(obj(previous).week_end))) return bad()
return { week_start, week_end, frozen_at, policy_version, records, coverage, correctness }
})
if (
frozen_weeks.length > 5200 ||
(frozen_weeks.length && Date.parse(frozen_weeks[frozen_weeks.length - 1].week_end) < Date.parse(end))
)
return bad()
return {
start,
end,
keyword,
as_of,
week_start,
month_start,
local_revision: count(v.local_revision),
organization,
total_issues,
new_this_week,
quality,
issue_types: categories(v.issue_types, quality.total),
products: categories(v.products, quality.total),
daily,
regions,
frozen_weeks,
}
}
export async function fetchQADashboard(query: QAQuery, signal?: AbortSignal): Promise<QADashboardData> {
const response = await fetch('/api/v1/qa-ops/dashboard', {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
signal,
headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-dashboard' },
body: JSON.stringify({ start: query.start, end: query.end, keyword: query.keyword }),
})
const body: unknown = await response.json().catch(() => null)
if (!response.ok) {
const message = body && typeof body === 'object' ? (body as Record<string, unknown>).error : undefined
throw new Error(typeof message === 'string' ? message : `统计看板读取失败(HTTP ${response.status})`)
}
const data = parseQADashboard(obj(body).data)
if (
Date.parse(data.start) !== Date.parse(query.start) ||
Date.parse(data.end) !== Date.parse(query.end) ||
data.keyword !== query.keyword
)
throw new Error('看板返回范围与当前查询不一致,请重新读取')
return data
}
+14
View File
@@ -0,0 +1,14 @@
import { it, expect } from 'vitest'
import { dashboardExportSize } from './qaDashboardExport'
it('exports the complete viewBox at double resolution and refuses unsafe canvas sizes', () => {
expect(dashboardExportSize(1280, 1500)).toEqual({ width: 1280 * 2, height: 1500 * 2, scale: 2 })
expect(dashboardExportSize(2000, 9000)).toEqual({ width: 2000, height: 9000, scale: 1 })
for (const size of [
[0, 100],
[-1, 100],
[Infinity, 100],
[100, 40000],
[20000, 20000],
])
expect(() => dashboardExportSize(...(size as [number, number]))).toThrow()
})
+48
View File
@@ -0,0 +1,48 @@
export function dashboardExportSize(w: number, h: number): { width: number; height: number; scale: number } {
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0 || w > 32760 || h > 32760 || w * h > 64000000)
throw new Error('看板过大,无法完整生成 PNG,请缩小统计范围')
const scale = w * 2 <= 32760 && h * 2 <= 32760 && w * h * 4 <= 64000000 ? 2 : 1
return { width: Math.ceil(w * scale), height: Math.ceil(h * scale), scale }
}
export async function createDashboardPNG(svg: SVGSVGElement): Promise<Blob> {
const box = svg.viewBox.baseVal,
size = dashboardExportSize(box.width, box.height),
copy = svg.cloneNode(true) as SVGSVGElement
if (
copy.querySelectorAll('[data-panel]').length !== 11 ||
copy.querySelector('foreignObject,image,script,iframe,use')
)
throw new Error('看板内容不完整,未生成图片')
copy.setAttribute('width', String(box.width))
copy.setAttribute('height', String(box.height))
copy.style.width = `${box.width}px`
copy.style.height = `${box.height}px`
copy.style.fontFamily = getComputedStyle(svg).fontFamily
if (document.fonts) await document.fonts.ready
const source = new Blob([new XMLSerializer().serializeToString(copy)], { type: 'image/svg+xml;charset=utf-8' }),
url = URL.createObjectURL(source)
try {
const image = new Image()
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve()
image.onerror = () => reject(new Error('看板图片绘制失败'))
image.src = url
})
const canvas = document.createElement('canvas')
canvas.width = size.width
canvas.height = size.height
const context = canvas.getContext('2d', { alpha: false })
if (!context) throw new Error('浏览器无法创建完整图片画布')
context.fillStyle = '#fff'
context.fillRect(0, 0, size.width, size.height)
context.drawImage(image, 0, 0, size.width, size.height)
return await new Promise<Blob>((resolve, reject) =>
canvas.toBlob(
(blob) => (blob && blob.size > 0 ? resolve(blob) : reject(new Error('PNG生成失败,请重试'))),
'image/png'
)
)
} finally {
URL.revokeObjectURL(url)
}
}
+1
View File
@@ -238,6 +238,7 @@ function qualityRate(value: unknown): QualityRate {
return bad()
return { numerator, denominator, percent: v.percent }
}
export { qualityRate as parseQualityRate }
function qualityRates(value: unknown, total: number, stale: number): QualityRates {
const v = obj(value),
r = obj(v.breakdown)