详情使用共享主表字典,稀疏修改所选字段并保留原始问答及旧复核审计。 月周统计提供文本和下拉双入口;每租户每周一份持久草稿,按技能六节双语模板真实预填。 加入幂等自动保存、冲突保留、未知响应重试、切周及离开保护和schema58非破坏降级保护。 验证:完整make verify、隔离PostgreSQL全mirror竞态回归、真实恢复库浏览器验收通过。 浏览器实测字段精确读回、月周双输入、模板预填、MD/CSV、自动保存、冲突、失败重试与API重启恢复。 未连接或改写DobotAI生产源库,未运行付费模型;提交时尚未部署。
This commit is contained in:
@@ -76,6 +76,19 @@
|
||||
> `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯,
|
||||
> 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。
|
||||
|
||||
## 开发快照 snapshot/v0.1.41 - 2026-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- 周报生成升级为按北京时间自然周保存的 Markdown 编辑器,提供编辑、预览、对照、自动保存、立即保存及 MD/本周明细 CSV 下载;每个租户每周一份共享草稿,切周或离开前保存未提交输入,刷新与重新打开恢复正文。
|
||||
- 按周报模板预填英文在上、中文在下的各六节正文,复用正式总表和已保存区域统计;历史周优先使用相应上报/冻结质量指标,缺失才使用当周实时指标。统计建议可人工修改,重点保留待填写,不编造 FAQ 调整或英文事项,不自动发送或归档。
|
||||
- 治理库新增迁移 58:独立稀疏字段覆盖、不可变字段改动审计、周草稿及逐版保存收据。保存绑定输入与编辑版本,同 nonce 重试不重复留档,冲突和读回失败保留输入;已有草稿重新预填须明确确认,存在人工数据时拒绝删除式降级迁移。
|
||||
|
||||
### Changed
|
||||
|
||||
- 详情顶部五个质量字段可直接下拉修改,范围取源主表实际使用的覆盖/正确性选项及与模型共享的产品、问题、根因字典;只保存明确选择的字段,不补齐其他空白、不把模型值冒充整条人工复核。移除单独人工复核/反馈展示,保留原始问答、旧人工历史和后台留痕。
|
||||
- 区域统计的月份及 ISO 周同时支持下拉选择和直接输入,合法值在回车或失焦后应用;未完成、非法及未来输入在本地提示,不发送错误统计请求,保留原自然月/周和缓存口径。
|
||||
|
||||
## 开发快照 snapshot/v0.1.40 - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/v1/qa-ops/master/field-options": {"get":{"operationId":"getQAMasterFieldOptions","tags":["QA operations"],"summary":"只读主表五字段的正式可选范围,与模型共享同一分类字典;不返回业务问答","parameters":[],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}}}},
|
||||
"/api/v1/qa-ops/master/records/{id}/fields": {"post":{"operationId":"patchQAMasterFields","tags":["QA operations"],"summary":"只更新明确选择的字段并留幂等审计;不改原始正文、其他空白、旧复核或冻结证据","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-master-fields"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["input_hash","input_epoch","base_revision","base_review_revision","nonce","fields"],"properties":{"input_hash":{"type":"string","pattern":"^[a-f0-9]{64}$"},"input_epoch":{"type":"integer","minimum":1},"base_revision":{"type":"integer","minimum":0},"base_review_revision":{"type":"integer","minimum":0},"nonce":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9_.:-]{15,127}$"},"fields":{"type":"object","additionalProperties":false,"minProperties":1,"maxProperties":5,"properties":{"is_correct":{"type":"string","enum":["是","否","待确认"]},"product_category":{"type":"string","enum":["CR/CRA/H/AP","CRV","CR配件","Nova及系列","Nova配件","MG400","MG400配件","M1 Pro","VX500","新示教器","DobotStudio Pro","仿真软件","维护工具","教育所有产品","人形机器人","四六足机器人","X-Trainer","码垛解决方案","码垛插件","焊接解决方案","焊接插件","移动复合机器人","生态插件","生态合作","安全皮肤","未知"]},"problem_type":{"type":"string","enum":["参数","故障","使用配置","二次开发","资料获取","咨询","认证","案例","售后","培训与学习","其他"]},"root_cause":{"type":"string","enum":["知识缺失","检索不到","结果不清","答案生成错误","问题质量差","非知识范围","知识过期"]},"coverage":{"type":"string","enum":["是","否","否但不计入"]}}}}}}}}}},
|
||||
"/api/v1/qa-ops/master/weekly-drafts/{week}": {"get":{"operationId":"getQAWeeklyDraft","tags":["QA operations"],"summary":"读取租户指定北京时间ISO周的持久草稿;不存在时revision=0且不创建记录","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}}},"post":{"operationId":"saveQAWeeklyDraft","tags":["QA operations"],"summary":"防抖编辑保存:版本锁、逐版历史及同nonce重放;正文不会触发外发/归档","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-weekly-draft"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["base_revision","nonce","markdown"],"properties":{"base_revision":{"type":"integer","minimum":0},"nonce":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9_.:-]{15,127}$"},"markdown":{"type":"string","maxLength":262144,"description":"UTF-8正文最多256KiB;允许空正文,保存草稿不等于发布"}}}}}}}},
|
||||
"/api/v1/qa-ops/master/weekly-drafts/{week}/prefill": {"post":{"operationId":"prefillQAWeeklyDraft","tags":["QA operations"],"summary":"从同一正式总表截止和保存区域生成中英各六节草稿;优先匹配已上报/冻结历史指标,其余用当周实时指标;不编造FAQ调整或重点。须带当前草稿版本,防止覆盖旧编辑","parameters":[{"name":"week","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9]{4}-W[0-9]{2}$"}},{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-weekly-draft"}}],"responses":{"200":{"$ref":"#/components/responses/Envelope"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"description":"需要登录"},"403":{"description":"仅管理员且写入须同源操作意图"},"409":{"description":"版本、输入或nonce冲突,未覆盖现有数据"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"description":"独立治理存储不可用,不将失败伪装为已保存"}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["base_revision","nonce"],"properties":{"base_revision":{"type":"integer","minimum":0},"nonce":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9_.:-]{15,127}$"}}}}}}}},
|
||||
"/api/v1/qa-ops/departments/statistics": {
|
||||
"post": {
|
||||
"operationId": "qaDepartmentStatistics",
|
||||
|
||||
@@ -69,6 +69,7 @@ type RecordFeedback struct {
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
FieldEditRevision int64 `json:"field_edit_revision,omitempty"`
|
||||
BackgroundTasks json.RawMessage `json:"background_tasks,omitempty"`
|
||||
RecordOrigin string `json:"record_origin,omitempty"`
|
||||
MasterFields map[string]string `json:"master_fields,omitempty"`
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/qaopsfields"
|
||||
"knowledge-governance/internal/qaopsmirror"
|
||||
"knowledge-governance/internal/qaopsquality"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type masterFieldWriter interface {
|
||||
PatchMasterFields(context.Context, string, qaopsmirror.MasterFieldPatch, string) (qaopsmirror.MasterFieldReceipt, error)
|
||||
}
|
||||
|
||||
func qaMasterFieldOptions(w http.ResponseWriter, r *http.Request, c Config) {
|
||||
if !authorizeQAOps(w, r) {
|
||||
return
|
||||
}
|
||||
if r.URL.RawQuery != "" {
|
||||
qaOpsError(w, dobotaiops.ErrInvalidQuery)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, 200, map[string]any{"data": map[string]any{"version": "qa-master-options-v1", "fields": qaopsfields.EditableOptions()}})
|
||||
}
|
||||
func qaMasterFieldPatch(w http.ResponseWriter, r *http.Request, c Config) {
|
||||
if !authorizeQAOps(w, r) || !authorizeQAIntent(w, r, "qa-master-fields") {
|
||||
return
|
||||
}
|
||||
var q qaopsmirror.MasterFieldPatch
|
||||
if decodeQualityJSON(w, r, &q, "input_hash", "input_epoch", "base_revision", "base_review_revision", "nonce", "fields") != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "字段选项或编辑参数无效"})
|
||||
return
|
||||
}
|
||||
writer, ok := c.QAOps.(masterFieldWriter)
|
||||
if !ok || c.QAOpsTenantKey == "" {
|
||||
qaOpsError(w, dobotaiops.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
identity, _ := sessionFrom(r.Context())
|
||||
result, err := writer.PatchMasterFields(r.Context(), r.PathValue("id"), q, identity.Username)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, qaopsquality.ErrConflict):
|
||||
writeJSON(w, 409, map[string]string{"error": "字段或问答版本已变化,请重新读取后修改;未覆盖其他人的内容"})
|
||||
case errors.Is(err, dobotaiops.ErrInvalidQuery):
|
||||
writeJSON(w, 400, map[string]string{"error": "字段选项无效,未保存;请选择主表允许的内容"})
|
||||
default:
|
||||
qaOpsError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"data": result})
|
||||
}
|
||||
@@ -98,10 +98,13 @@ func enrichQAQualityPage(ctx context.Context, c Config, page *dobotaiops.RecordP
|
||||
}
|
||||
func walkQualityJSON(d *json.Decoder) error { return strictjson.Walk(d) }
|
||||
func decodeQualityJSON(w http.ResponseWriter, r *http.Request, target any, required ...string) error {
|
||||
return decodeGovernanceJSON(w, r, target, 64<<10, required...)
|
||||
}
|
||||
func decodeGovernanceJSON(w http.ResponseWriter, r *http.Request, target any, maxBytes int64, required ...string) error {
|
||||
if r.URL.RawQuery != "" {
|
||||
return qaopsquality.ErrInvalid
|
||||
}
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBytes))
|
||||
if err != nil || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return qaopsquality.ErrInvalid
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/qaopsmirror"
|
||||
"knowledge-governance/internal/qaopsquality"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type weeklyDraftStore interface {
|
||||
ReadWeeklyDraft(context.Context, string, time.Time) (qaopsmirror.WeeklyDraft, error)
|
||||
SaveWeeklyDraft(context.Context, string, qaopsmirror.WeeklyDraftRequest, string, time.Time) (qaopsmirror.WeeklyDraftReceipt, error)
|
||||
PrefillWeeklyDraft(context.Context, string, qaopsmirror.WeeklyDraftPrefillRequest, string, time.Time) (qaopsmirror.WeeklyDraftReceipt, error)
|
||||
}
|
||||
|
||||
func weeklyDraftError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, qaopsquality.ErrConflict):
|
||||
writeJSON(w, 409, map[string]string{"error": "本周草稿已被其他窗口修改;你的文字未被覆盖,请先下载或核对服务器版本", "code": "weekly_draft_conflict"})
|
||||
case errors.Is(err, dobotaiops.ErrInvalidQuery):
|
||||
writeJSON(w, 400, map[string]string{"error": "周次或草稿内容无效,请使用已到达的合法ISO周,正文不超过256KiB", "code": "weekly_draft_invalid"})
|
||||
default:
|
||||
writeJSON(w, 503, map[string]string{"error": "草稿存储暂不可用,请保留文字并重试;未标记为已保存", "code": "weekly_draft_unavailable"})
|
||||
}
|
||||
}
|
||||
func qaWeeklyDraftRead(w http.ResponseWriter, r *http.Request, c Config) {
|
||||
if !authorizeQAOps(w, r) {
|
||||
return
|
||||
}
|
||||
if r.URL.RawQuery != "" {
|
||||
weeklyDraftError(w, dobotaiops.ErrInvalidQuery)
|
||||
return
|
||||
}
|
||||
store, ok := c.QAOps.(weeklyDraftStore)
|
||||
if !ok || c.QAOpsTenantKey == "" {
|
||||
weeklyDraftError(w, dobotaiops.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
out, err := store.ReadWeeklyDraft(r.Context(), r.PathValue("week"), c.Now())
|
||||
if err != nil {
|
||||
weeklyDraftError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"data": out})
|
||||
}
|
||||
func qaWeeklyDraftWrite(w http.ResponseWriter, r *http.Request, c Config, prefill bool) {
|
||||
if !authorizeQAOps(w, r) || !authorizeQAIntent(w, r, "qa-weekly-draft") {
|
||||
return
|
||||
}
|
||||
store, ok := c.QAOps.(weeklyDraftStore)
|
||||
if !ok || c.QAOpsTenantKey == "" {
|
||||
weeklyDraftError(w, dobotaiops.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
identity, _ := sessionFrom(r.Context())
|
||||
var out qaopsmirror.WeeklyDraftReceipt
|
||||
var err error
|
||||
if prefill {
|
||||
var q qaopsmirror.WeeklyDraftPrefillRequest
|
||||
if decodeQualityJSON(w, r, &q, "base_revision", "nonce") != nil {
|
||||
weeklyDraftError(w, dobotaiops.ErrInvalidQuery)
|
||||
return
|
||||
}
|
||||
out, err = store.PrefillWeeklyDraft(r.Context(), r.PathValue("week"), q, identity.Username, c.Now())
|
||||
} else {
|
||||
var q qaopsmirror.WeeklyDraftRequest
|
||||
if decodeGovernanceJSON(w, r, &q, 512<<10, "base_revision", "nonce", "markdown") != nil {
|
||||
weeklyDraftError(w, dobotaiops.ErrInvalidQuery)
|
||||
return
|
||||
}
|
||||
out, err = store.SaveWeeklyDraft(r.Context(), r.PathValue("week"), q, identity.Username, c.Now())
|
||||
}
|
||||
if err != nil {
|
||||
weeklyDraftError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"data": out})
|
||||
}
|
||||
@@ -200,6 +200,11 @@ func NewHandler(config Config) (http.Handler, error) {
|
||||
mux.HandleFunc("GET /api/v1/qa-ops/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
qaOpsStatus(w, r, config)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/qa-ops/master/weekly-drafts/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyDraftRead(w, r, config) })
|
||||
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-drafts/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyDraftWrite(w, r, config, false) })
|
||||
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-drafts/{week}/prefill", func(w http.ResponseWriter, r *http.Request) { qaWeeklyDraftWrite(w, r, config, true) })
|
||||
mux.HandleFunc("GET /api/v1/qa-ops/master/field-options", func(w http.ResponseWriter, r *http.Request) { qaMasterFieldOptions(w, r, config) })
|
||||
mux.HandleFunc("POST /api/v1/qa-ops/master/records/{id}/fields", func(w http.ResponseWriter, r *http.Request) { qaMasterFieldPatch(w, r, config) })
|
||||
mux.HandleFunc("GET /api/v1/qa-ops/master/records", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, listQAOpsRecords) })
|
||||
mux.HandleFunc("GET /api/v1/qa-ops/master/records/{id}", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, getQAOpsRecord) })
|
||||
mux.HandleFunc("POST /api/v1/qa-ops/master/quality/query", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, qaQualityQuery) })
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM qa_ops_weekly_draft_revisions) OR EXISTS(SELECT 1 FROM qa_ops_master_field_edits) THEN
|
||||
RAISE EXCEPTION 'Saved human edits or weekly drafts exist; retain the additive schema for image rollback';
|
||||
END IF;
|
||||
END $$;
|
||||
DROP TABLE qa_ops_weekly_draft_revisions;
|
||||
DROP TABLE qa_ops_weekly_drafts;
|
||||
DROP VIEW qa_ops_master_read_model;
|
||||
CREATE VIEW qa_ops_master_read_model AS
|
||||
SELECT m.*,a.ai_fields,t.background_tasks,qa_ops_fill_empty_fields(m.local_fields||jsonb_build_object('question_chinese',m.chinese_question),a.ai_fields) AS effective_fields
|
||||
FROM qa_ops_master_records m LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(jsonb_object_agg(f.key,f.value ORDER BY o.created_at,o.work_id),'{}'::jsonb) AS ai_fields
|
||||
FROM qa_ops_ai_outputs o CROSS JOIN LATERAL jsonb_each(o.applied_fields) f
|
||||
WHERE o.tenant_key=m.tenant_key AND o.record_id=m.id AND o.input_hash=CASE WHEN o.kind='evaluation' THEN m.quality_hash ELSE encode(sha256(convert_to(m.question,'UTF8')),'hex') END
|
||||
) a ON true LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object('id',w.id,'record_id',w.record_id,'kind',w.kind,'state',w.state,'stage',w.stage,'attempt',w.attempt,'error_code',w.error_code,'skip_reason',w.skip_reason,'started_at',w.started_at,'updated_at',w.updated_at)),'[]'::jsonb) AS background_tasks
|
||||
FROM (SELECT DISTINCT ON (kind) * FROM qa_ops_ai_work WHERE tenant_key=m.tenant_key AND record_id=m.id ORDER BY kind,created_at DESC,id DESC) w
|
||||
) t ON true;
|
||||
DROP TABLE qa_ops_master_field_edits;
|
||||
DROP TABLE qa_ops_master_field_overrides;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Sparse human overrides keep untouched stored/AI fields and original payload immutable.
|
||||
CREATE TABLE qa_ops_master_field_overrides (
|
||||
tenant_key text NOT NULL,record_id text NOT NULL,revision bigint NOT NULL CHECK(revision>0),input_hash text NOT NULL,review_revision bigint NOT NULL CHECK(review_revision>=0),fields jsonb NOT NULL CHECK(jsonb_typeof(fields)='object'),updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(tenant_key,record_id),FOREIGN KEY(tenant_key,record_id) REFERENCES qa_ops_master_records(tenant_key,id)
|
||||
);
|
||||
CREATE TABLE qa_ops_master_field_edits (
|
||||
tenant_key text NOT NULL,record_id text NOT NULL,revision bigint NOT NULL CHECK(revision>0),input_hash text NOT NULL,review_revision bigint NOT NULL,changed_fields jsonb NOT NULL,before_fields jsonb NOT NULL,fields jsonb NOT NULL,actor text NOT NULL,nonce text NOT NULL,request_hash text NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(tenant_key,record_id,revision),UNIQUE(tenant_key,record_id,nonce),FOREIGN KEY(tenant_key,record_id) REFERENCES qa_ops_master_records(tenant_key,id)
|
||||
);
|
||||
CREATE OR REPLACE VIEW qa_ops_master_read_model AS
|
||||
SELECT m.*,a.ai_fields,t.background_tasks,qa_ops_fill_empty_fields(m.local_fields||jsonb_build_object('question_chinese',m.chinese_question),a.ai_fields)||COALESCE(CASE WHEN e.input_hash=m.quality_hash AND e.review_revision=COALESCE(h.review_revision,0) THEN e.fields END,'{}'::jsonb) AS effective_fields,COALESCE(e.revision,0) AS field_edit_revision,COALESCE(CASE WHEN e.input_hash=m.quality_hash AND e.review_revision=COALESCE(h.review_revision,0) THEN e.fields END,'{}'::jsonb) AS inline_fields
|
||||
FROM qa_ops_master_records m LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(jsonb_object_agg(f.key,f.value ORDER BY o.created_at,o.work_id),'{}'::jsonb) AS ai_fields
|
||||
FROM qa_ops_ai_outputs o CROSS JOIN LATERAL jsonb_each(o.applied_fields) f
|
||||
WHERE o.tenant_key=m.tenant_key AND o.record_id=m.id AND o.input_hash=CASE WHEN o.kind='evaluation' THEN m.quality_hash ELSE encode(sha256(convert_to(m.question,'UTF8')),'hex') END
|
||||
) a ON true LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object('id',w.id,'record_id',w.record_id,'kind',w.kind,'state',w.state,'stage',w.stage,'attempt',w.attempt,'error_code',w.error_code,'skip_reason',w.skip_reason,'started_at',w.started_at,'updated_at',w.updated_at)),'[]'::jsonb) AS background_tasks
|
||||
FROM (SELECT DISTINCT ON (kind) * FROM qa_ops_ai_work WHERE tenant_key=m.tenant_key AND record_id=m.id ORDER BY kind,created_at DESC,id DESC) w
|
||||
) t ON true LEFT JOIN qa_ops_quality_records h ON h.tenant_key=m.tenant_key||'::master' AND h.record_id=m.id LEFT JOIN qa_ops_master_field_overrides e ON e.tenant_key=m.tenant_key AND e.record_id=m.id;
|
||||
|
||||
-- One shared editable document per tenant/week, with durable idempotent save receipts.
|
||||
CREATE TABLE qa_ops_weekly_drafts (
|
||||
tenant_key text NOT NULL,week text NOT NULL CHECK(week ~ '^[0-9]{4}-W[0-9]{2}$'),revision bigint NOT NULL CHECK(revision>0),markdown text NOT NULL CHECK(octet_length(markdown)<=262144),seed jsonb NOT NULL CHECK(jsonb_typeof(seed)='object'),updated_by text NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(tenant_key,week)
|
||||
);
|
||||
CREATE TABLE qa_ops_weekly_draft_revisions (
|
||||
tenant_key text NOT NULL,week text NOT NULL,revision bigint NOT NULL CHECK(revision>0),operation text NOT NULL CHECK(operation IN ('edit','prefill')),markdown text NOT NULL CHECK(octet_length(markdown)<=262144),seed jsonb NOT NULL CHECK(jsonb_typeof(seed)='object'),actor text NOT NULL,nonce text NOT NULL,request_hash text NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(tenant_key,week,revision),UNIQUE(tenant_key,week,nonce),FOREIGN KEY(tenant_key,week) REFERENCES qa_ops_weekly_drafts(tenant_key,week)
|
||||
);
|
||||
@@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("loadMigrations() error = %v", err)
|
||||
}
|
||||
if len(migrations) != 57 {
|
||||
t.Fatalf("migration count = %d, want 57", len(migrations))
|
||||
if len(migrations) != 58 {
|
||||
t.Fatalf("migration count = %d, want 58", 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,30 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInlineFieldsWeeklyDraftMigrationIsAdditiveTenantScopedAndRollbackProtected(t *testing.T) {
|
||||
migrations, e := loadMigrations()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
m := migrations[len(migrations)-1]
|
||||
if m.Version != 58 {
|
||||
t.Fatal("missing migration 58")
|
||||
}
|
||||
for _, marker := range []string{"CREATE TABLE qa_ops_master_field_overrides", "CREATE TABLE qa_ops_master_field_edits", "CREATE TABLE qa_ops_weekly_drafts", "CREATE TABLE qa_ops_weekly_draft_revisions", "UNIQUE(tenant_key,record_id,nonce)", "UNIQUE(tenant_key,week,nonce)", "e.input_hash=m.quality_hash AND e.review_revision=COALESCE(h.review_revision,0)", "field_edit_revision", "inline_fields"} {
|
||||
if !strings.Contains(m.UpSQL, marker) {
|
||||
t.Fatalf("migration lost sparse tenant/input/review/idempotency contract: %s", marker)
|
||||
}
|
||||
}
|
||||
if strings.Contains(m.UpSQL, "UPDATE qa_ops_master_records SET payload") || strings.Contains(m.UpSQL, "UPDATE qa_ops_quality_reviews") {
|
||||
t.Fatal("migration rewrites original records or human history")
|
||||
}
|
||||
for _, marker := range []string{"RAISE EXCEPTION", "SELECT 1 FROM qa_ops_weekly_draft_revisions", "SELECT 1 FROM qa_ops_master_field_edits"} {
|
||||
if !strings.Contains(m.DownSQL, marker) {
|
||||
t.Fatalf("down migration must refuse to discard saved human data: %s", marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package qaopsai
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"knowledge-governance/internal/qaopsfields"
|
||||
"knowledge-governance/internal/strictjson"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -12,12 +13,7 @@ const DictionaryVersion = "qa-approved-labels-v1"
|
||||
const EvaluationPromptVersion = "qa-evaluate-evidence-v1"
|
||||
const TranslationPromptVersion = "qa-question-zh-v1"
|
||||
|
||||
var Taxonomy = map[string][]string{
|
||||
"is_correct": {"是", "否", "待确认"},
|
||||
"product_category": {"CR/CRA/H/AP", "CRV", "CR配件", "Nova及系列", "Nova配件", "MG400", "MG400配件", "M1 Pro", "VX500", "新示教器", "DobotStudio Pro", "仿真软件", "维护工具", "教育所有产品", "人形机器人", "四六足机器人", "X-Trainer", "码垛解决方案", "码垛插件", "焊接解决方案", "焊接插件", "移动复合机器人", "生态插件", "生态合作", "安全皮肤", "未知"},
|
||||
"problem_type": {"参数", "故障", "使用配置", "二次开发", "资料获取", "咨询", "认证", "案例", "售后", "培训与学习", "其他"},
|
||||
"root_cause": {"知识缺失", "检索不到", "结果不清", "答案生成错误", "问题质量差", "非知识范围", "知识过期"},
|
||||
}
|
||||
var Taxonomy = qaopsfields.ClassificationTaxonomy
|
||||
|
||||
func allowedLabel(field, value string) bool {
|
||||
for _, label := range Taxonomy[field] {
|
||||
|
||||
@@ -53,6 +53,7 @@ type recordMeta struct {
|
||||
ID, Hash, Status, Question string
|
||||
Revision int64
|
||||
Fields map[string]string
|
||||
InlineFields map[string]string
|
||||
Coverage dobotaiops.CoverageCheck
|
||||
View qaopsquality.View
|
||||
}
|
||||
@@ -81,7 +82,7 @@ func promptVersion(kind string) string {
|
||||
return EvaluationPromptVersion
|
||||
}
|
||||
func (s *Store) readMetas(ctx context.Context, tx pgx.Tx, ids []string, scope *Scope) ([]recordMeta, error) {
|
||||
query := `SELECT m.id,m.quality_hash,m.row_revision,m.status,m.question,m.effective_fields,m.coverage_result,EXISTS(SELECT 1 FROM qa_ops_ai_outputs o JOIN qa_ops_ai_work w ON w.tenant_key=o.tenant_key AND w.id=o.work_id WHERE o.tenant_key=m.tenant_key AND o.record_id=m.id AND o.kind='evaluation' AND o.input_hash=m.quality_hash AND w.state='succeeded' AND w.prompt_version='` + EvaluationPromptVersion + `' AND o.payload->'fields'?'root_cause' AND o.payload->'fields'->'root_cause'->>'value'='') FROM qa_ops_master_read_model m WHERE tenant_key=$1`
|
||||
query := `SELECT m.id,m.quality_hash,m.row_revision,m.status,m.question,m.effective_fields,m.coverage_result,EXISTS(SELECT 1 FROM qa_ops_ai_outputs o JOIN qa_ops_ai_work w ON w.tenant_key=o.tenant_key AND w.id=o.work_id WHERE o.tenant_key=m.tenant_key AND o.record_id=m.id AND o.kind='evaluation' AND o.input_hash=m.quality_hash AND w.state='succeeded' AND w.prompt_version='` + EvaluationPromptVersion + `' AND o.payload->'fields'?'root_cause' AND o.payload->'fields'->'root_cause'->>'value'=''),m.inline_fields FROM qa_ops_master_read_model m WHERE tenant_key=$1`
|
||||
args := []any{s.tenant}
|
||||
if scope != nil && len(scope.IDs) == 0 {
|
||||
window, err := dobotaiops.NewWindow(s.tenant, scope.Start, scope.End, 90*24*time.Hour, time.Now())
|
||||
@@ -103,8 +104,8 @@ func (s *Store) readMetas(ctx context.Context, tx pgx.Tx, ids []string, scope *S
|
||||
keys := []dobotaiops.QualityKey{}
|
||||
for rows.Next() {
|
||||
var m recordMeta
|
||||
var fields, coverage []byte
|
||||
if rows.Scan(&m.ID, &m.Hash, &m.Revision, &m.Status, &m.Question, &fields, &coverage, &m.RootHandled) != nil || json.Unmarshal(fields, &m.Fields) != nil || json.Unmarshal(coverage, &m.Coverage) != nil {
|
||||
var fields, coverage, edits []byte
|
||||
if rows.Scan(&m.ID, &m.Hash, &m.Revision, &m.Status, &m.Question, &fields, &coverage, &m.RootHandled, &edits) != nil || json.Unmarshal(fields, &m.Fields) != nil || json.Unmarshal(coverage, &m.Coverage) != nil || json.Unmarshal(edits, &m.InlineFields) != nil {
|
||||
rows.Close()
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
@@ -139,7 +140,7 @@ func (s *Store) readMetas(ctx context.Context, tx pgx.Tx, ids []string, scope *S
|
||||
if !ok {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
metas[i].View = qaopsquality.ProjectMaster(v, metas[i].Fields, metas[i].Coverage)
|
||||
metas[i].View = qaopsquality.ProjectMasterWithEdits(v, metas[i].Fields, metas[i].Coverage, metas[i].InlineFields)
|
||||
}
|
||||
return metas, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package qaopsfields
|
||||
|
||||
var ClassificationTaxonomy = map[string][]string{
|
||||
"is_correct": {"是", "否", "待确认"},
|
||||
"product_category": {"CR/CRA/H/AP", "CRV", "CR配件", "Nova及系列", "Nova配件", "MG400", "MG400配件", "M1 Pro", "VX500", "新示教器", "DobotStudio Pro", "仿真软件", "维护工具", "教育所有产品", "人形机器人", "四六足机器人", "X-Trainer", "码垛解决方案", "码垛插件", "焊接解决方案", "焊接插件", "移动复合机器人", "生态插件", "生态合作", "安全皮肤", "未知"},
|
||||
"problem_type": {"参数", "故障", "使用配置", "二次开发", "资料获取", "咨询", "认证", "案例", "售后", "培训与学习", "其他"},
|
||||
"root_cause": {"知识缺失", "检索不到", "结果不清", "答案生成错误", "问题质量差", "非知识范围", "知识过期"},
|
||||
}
|
||||
|
||||
func EditableOptions() map[string][]string {
|
||||
out := make(map[string][]string, len(ClassificationTaxonomy)+1)
|
||||
for key, values := range ClassificationTaxonomy {
|
||||
out[key] = append([]string(nil), values...)
|
||||
}
|
||||
out["coverage"] = []string{"是", "否", "否但不计入"}
|
||||
return out
|
||||
}
|
||||
func ValidEditableValue(field, value string) bool {
|
||||
for _, label := range EditableOptions()[field] {
|
||||
if value == label {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -69,6 +69,22 @@ func TestReportedHistoryIsIndependentExactScopedAndImmutable(t *testing.T) {
|
||||
t.Fatal("reported history could be overwritten or deleted")
|
||||
}
|
||||
}
|
||||
// The assertion belongs to migration 57, not whichever newer empty schema is last.
|
||||
for {
|
||||
var version int64
|
||||
if e := p.QueryRow(ctx, `SELECT max(version)FROM schema_migrations`).Scan(&version); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if version == 57 {
|
||||
break
|
||||
}
|
||||
if version < 57 {
|
||||
t.Fatal("reported-history migration missing")
|
||||
}
|
||||
if e := pg.RollbackLatestMigration(ctx, p); e != nil {
|
||||
t.Fatalf("could not reach the historical-data rollback guard: %v", e)
|
||||
}
|
||||
}
|
||||
e := pg.RollbackLatestMigration(ctx, p)
|
||||
var failure *pgconn.PgError
|
||||
if !errors.As(e, &failure) || failure.Code != "55000" {
|
||||
|
||||
@@ -18,6 +18,7 @@ type dashboardRecord struct {
|
||||
at time.Time
|
||||
identity dobotaiops.ActorIdentity
|
||||
fields map[string]string
|
||||
edits map[string]string
|
||||
coverage dobotaiops.CoverageCheck
|
||||
}
|
||||
|
||||
@@ -118,7 +119,7 @@ func (s *Store) readDashboard(ctx context.Context, q dobotaiops.RecordQuery, mas
|
||||
rowsSQL := `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`
|
||||
if master {
|
||||
rowsSQL = strings.ReplaceAll(rowsSQL, "qa_ops_mirror_records", "qa_ops_master_read_model")
|
||||
rowsSQL = strings.ReplaceAll(rowsSQL, "SELECT id,asked_at,identity,quality_hash FROM", "SELECT id,asked_at,identity,quality_hash,effective_fields,coverage_result FROM")
|
||||
rowsSQL = strings.ReplaceAll(rowsSQL, "SELECT id,asked_at,identity,quality_hash FROM", "SELECT id,asked_at,identity,quality_hash,effective_fields,coverage_result,inline_fields FROM")
|
||||
rowsSQL = strings.ReplaceAll(rowsSQL, "lower(question)", "lower(question||E'\\n'||chinese_question||E'\\n'||COALESCE(ai_fields->>'question_chinese',''))")
|
||||
}
|
||||
rows, e := tx.Query(ctx, rowsSQL, s.tenant, lower, q.Window.End, q.Keyword)
|
||||
@@ -132,10 +133,13 @@ func (s *Store) readDashboard(ctx context.Context, q dobotaiops.RecordQuery, mas
|
||||
var identity []byte
|
||||
var scanErr error
|
||||
if master {
|
||||
var fields, coverage []byte
|
||||
scanErr = rows.Scan(&r.id, &r.at, &identity, &r.hash, &fields, &coverage)
|
||||
var fields, coverage, edits []byte
|
||||
scanErr = rows.Scan(&r.id, &r.at, &identity, &r.hash, &fields, &coverage, &edits)
|
||||
if scanErr == nil {
|
||||
scanErr = json.Unmarshal(fields, &r.fields)
|
||||
if scanErr == nil {
|
||||
scanErr = json.Unmarshal(edits, &r.edits)
|
||||
}
|
||||
}
|
||||
if scanErr == nil {
|
||||
scanErr = json.Unmarshal(coverage, &r.coverage)
|
||||
@@ -183,7 +187,7 @@ func (s *Store) readDashboard(ctx context.Context, q dobotaiops.RecordQuery, mas
|
||||
if master {
|
||||
for _, r := range records {
|
||||
if v, ok := views[r.id]; ok {
|
||||
views[r.id] = qaopsquality.ProjectMaster(v, r.fields, r.coverage)
|
||||
views[r.id] = qaopsquality.ProjectMasterWithEdits(v, r.fields, r.coverage, r.edits)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func (s *Store) MasterQualityStore() *qaopsquality.Store {
|
||||
return qaopsquality.NewStore(s.pool, s.tenant+"::master")
|
||||
}
|
||||
|
||||
const masterBaseSQL = `WITH records AS (SELECT id,asked_at,question||E'\n'||chinese_question||E'\n'||COALESCE(ai_fields->>'question_chinese','') AS question,status,identity,quality_hash,jsonb_set(payload,'{master_fields}',qa_ops_fill_empty_fields(COALESCE(payload->'master_fields','{}'::jsonb),ai_fields))||jsonb_build_object('record_origin',origin,'background_tasks',background_tasks) AS payload FROM qa_ops_master_read_model WHERE tenant_key=$1)`
|
||||
const masterBaseSQL = `WITH records AS (SELECT id,asked_at,question||E'\n'||chinese_question||E'\n'||COALESCE(ai_fields->>'question_chinese','') AS question,status,identity,quality_hash,jsonb_set(payload,'{master_fields}',effective_fields)||jsonb_build_object('record_origin',origin,'background_tasks',background_tasks,'field_edit_revision',field_edit_revision) AS payload FROM qa_ops_master_read_model WHERE tenant_key=$1)`
|
||||
|
||||
var masterMetadataBase = strings.NewReplacer("qa_ops_mirror_records", "qa_ops_master_read_model", "h.tenant_key=m.tenant_key", "h.tenant_key=m.tenant_key||'::master'", "m.question", "(m.question||E'\\n'||m.chinese_question||E'\\n'||COALESCE(m.ai_fields->>'question_chinese','')) AS question").Replace(localMetadataBase)
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/qaopsfields"
|
||||
"knowledge-governance/internal/qaopsquality"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type MasterFieldPatch struct {
|
||||
InputHash string `json:"input_hash"`
|
||||
InputEpoch int64 `json:"input_epoch"`
|
||||
BaseRevision int64 `json:"base_revision"`
|
||||
BaseReviewRevision int64 `json:"base_review_revision"`
|
||||
Nonce string `json:"nonce"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
type MasterFieldReceipt struct {
|
||||
SavedRevision int64 `json:"saved_revision"`
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
var inlineHash = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
var inlineNonce = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.:-]{15,127}$`)
|
||||
|
||||
func validInlineID(value string) bool {
|
||||
return value != "" && len(value) <= 256 && utf8.ValidString(value) && !strings.ContainsAny(value, "\x00\x1f")
|
||||
}
|
||||
func (s *Store) PatchMasterFields(ctx context.Context, id string, q MasterFieldPatch, actor string) (MasterFieldReceipt, error) {
|
||||
if !s.valid() {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if !validInlineID(id) || !validInlineID(actor) || !inlineHash.MatchString(q.InputHash) || !inlineNonce.MatchString(q.Nonce) || q.InputEpoch < 1 || q.BaseRevision < 0 || q.BaseReviewRevision < 0 || len(q.Fields) < 1 || len(q.Fields) > 5 {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
for field, value := range q.Fields {
|
||||
if !qaopsfields.ValidEditableValue(field, value) {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1||chr(31)||$2,258))`, s.tenant, id); err != nil {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
payload, _ := json.Marshal(q)
|
||||
sum := sha256.Sum256(payload)
|
||||
requestHash := hex.EncodeToString(sum[:])
|
||||
var savedRevision int64
|
||||
var savedHash, savedActor string
|
||||
err = tx.QueryRow(ctx, `SELECT revision,request_hash,actor FROM qa_ops_master_field_edits WHERE tenant_key=$1 AND record_id=$2 AND nonce=$3`, s.tenant, id, q.Nonce).Scan(&savedRevision, &savedHash, &savedActor)
|
||||
if err == nil {
|
||||
if savedHash != requestHash || savedActor != actor {
|
||||
return MasterFieldReceipt{}, qaopsquality.ErrConflict
|
||||
}
|
||||
return MasterFieldReceipt{SavedRevision: savedRevision, Replayed: true}, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
var hash string
|
||||
var reviewRevision, epoch int64
|
||||
err = tx.QueryRow(ctx, `SELECT input_hash,review_revision,input_epoch FROM qa_ops_quality_records WHERE tenant_key=$1 AND record_id=$2 FOR UPDATE`, s.tenant+"::master", id).Scan(&hash, &reviewRevision, &epoch)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrRecordNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if hash != q.InputHash || epoch != q.InputEpoch || reviewRevision != q.BaseReviewRevision {
|
||||
return MasterFieldReceipt{}, qaopsquality.ErrConflict
|
||||
}
|
||||
var currentRevision, previousReview int64
|
||||
var previousHash string
|
||||
var raw []byte
|
||||
overrides := map[string]string{}
|
||||
err = tx.QueryRow(ctx, `SELECT revision,input_hash,review_revision,fields FROM qa_ops_master_field_overrides WHERE tenant_key=$1 AND record_id=$2`, s.tenant, id).Scan(¤tRevision, &previousHash, &previousReview, &raw)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if currentRevision != q.BaseRevision {
|
||||
return MasterFieldReceipt{}, qaopsquality.ErrConflict
|
||||
}
|
||||
if previousHash == hash && previousReview == reviewRevision && json.Unmarshal(raw, &overrides) != nil {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
var masterHash string
|
||||
var fieldsRaw, coverageRaw, editsRaw []byte
|
||||
err = tx.QueryRow(ctx, `SELECT quality_hash,effective_fields,coverage_result,inline_fields FROM qa_ops_master_read_model WHERE tenant_key=$1 AND id=$2`, s.tenant, id).Scan(&masterHash, &fieldsRaw, &coverageRaw, &editsRaw)
|
||||
if err != nil {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if masterHash != q.InputHash {
|
||||
return MasterFieldReceipt{}, qaopsquality.ErrConflict
|
||||
}
|
||||
var fields, edits map[string]string
|
||||
var coverage dobotaiops.CoverageCheck
|
||||
if json.Unmarshal(fieldsRaw, &fields) != nil || json.Unmarshal(editsRaw, &edits) != nil || json.Unmarshal(coverageRaw, &coverage) != nil {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
views, err := s.MasterQualityStore().ReadViewsInTx(ctx, tx, []dobotaiops.QualityKey{{ID: id, InputHash: masterHash}})
|
||||
if err != nil {
|
||||
return MasterFieldReceipt{}, err
|
||||
}
|
||||
current := qaopsquality.ProjectMasterWithEdits(views[id], fields, coverage, edits).Effective
|
||||
beforeAll := map[string]string{"coverage": map[string]string{"covered": "是", "uncovered": "否", "partial": "部分覆盖", "pending": "待确认", "excluded": "否但不计入"}[current.Coverage], "is_correct": map[string]string{"correct": "是", "incorrect": "否", "pending": "待确认"}[current.Correctness], "product_category": current.ProductCategory, "problem_type": current.ProblemType, "root_cause": current.RootCause}
|
||||
correctness := beforeAll["is_correct"]
|
||||
if value, ok := q.Fields["is_correct"]; ok {
|
||||
correctness = value
|
||||
}
|
||||
if correctness == "是" && q.Fields["root_cause"] != "" {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
before := map[string]string{}
|
||||
for key, value := range q.Fields {
|
||||
before[key] = beforeAll[key]
|
||||
overrides[key] = value
|
||||
}
|
||||
encoded, _ := json.Marshal(overrides)
|
||||
changed, _ := json.Marshal(q.Fields)
|
||||
previous, _ := json.Marshal(before)
|
||||
revision := currentRevision + 1
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO qa_ops_master_field_overrides(tenant_key,record_id,revision,input_hash,review_revision,fields) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(tenant_key,record_id) DO UPDATE SET revision=EXCLUDED.revision,input_hash=EXCLUDED.input_hash,review_revision=EXCLUDED.review_revision,fields=EXCLUDED.fields,updated_at=now()`, s.tenant, id, revision, hash, reviewRevision, encoded); err != nil {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO qa_ops_master_field_edits(tenant_key,record_id,revision,input_hash,review_revision,changed_fields,before_fields,fields,actor,nonce,request_hash) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, s.tenant, id, revision, hash, reviewRevision, changed, previous, encoded, actor, q.Nonce, requestHash); err != nil {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `UPDATE qa_ops_master_records SET row_revision=row_revision+1 WHERE tenant_key=$1 AND id=$2 AND quality_hash=$3`, s.tenant, id, hash)
|
||||
if err != nil || tag.RowsAffected() != 1 {
|
||||
return MasterFieldReceipt{}, qaopsquality.ErrConflict
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return MasterFieldReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
return MasterFieldReceipt{SavedRevision: revision}, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
pg "knowledge-governance/internal/platform/postgres"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMasterInlineFieldPatchPersistsOnlyChosenField(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
ctx := context.Background()
|
||||
s := NewStore(p, "fixture-inline-fields")
|
||||
queue := pg.NewStore(p)
|
||||
source := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("inline-field", "first")}}
|
||||
job := claimMirrorJob(t, s, queue, "inline-initial")
|
||||
if e := s.HandleSync(ctx, *job, func(context.Context) (Source, error) { return source, nil }); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := queue.Complete(ctx, job.ID, job.LeaseOwner, time.Now()); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
rec, e := s.GetMasterRecord(ctx, s.tenant, "inline-field")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
keys := []dobotaiops.QualityKey{{ID: rec.ID, InputHash: rec.QualityInputHash}}
|
||||
before, e := s.ReadMasterQualityViews(ctx, keys)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var original string
|
||||
if e = p.QueryRow(ctx, `SELECT md5(payload::text||local_fields::text) FROM qa_ops_master_records WHERE tenant_key=$1 AND id=$2`, s.tenant, rec.ID).Scan(&original); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
q := MasterFieldPatch{InputHash: rec.QualityInputHash, InputEpoch: before[rec.ID].InputEpoch, BaseReviewRevision: before[rec.ID].Revision, Nonce: "fixture_inline_field_001", Fields: map[string]string{"product_category": "MG400"}}
|
||||
receipt, e := s.PatchMasterFields(ctx, rec.ID, q, "fixture-admin")
|
||||
if e != nil {
|
||||
t.Fatalf("single-field persistent edit rejected: %v", e)
|
||||
}
|
||||
if receipt.SavedRevision != 1 || receipt.Replayed {
|
||||
t.Fatalf("invalid receipt: %+v", receipt)
|
||||
}
|
||||
after, e := NewStore(p, s.tenant).ReadMasterQualityViews(ctx, keys)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
fresh, e := s.GetMasterRecord(ctx, s.tenant, rec.ID)
|
||||
if e != nil || fresh.FieldEditRevision != 1 || fresh.MasterFields["product_category"] != "MG400" {
|
||||
t.Fatalf("detail/list read model did not expose saved field version and value: %+v error=%v", fresh, e)
|
||||
}
|
||||
b, a := before[rec.ID], after[rec.ID]
|
||||
b.Effective.ProductCategory = "MG400"
|
||||
rawB, _ := json.Marshal(b)
|
||||
rawA, _ := json.Marshal(a)
|
||||
if string(rawB) != string(rawA) {
|
||||
t.Fatalf("unselected conclusions/review provenance changed: before=%s after=%s", rawB, rawA)
|
||||
}
|
||||
var current string
|
||||
var audits int
|
||||
if e = p.QueryRow(ctx, `SELECT md5(payload::text||local_fields::text) FROM qa_ops_master_records WHERE tenant_key=$1 AND id=$2`, s.tenant, rec.ID).Scan(¤t); e != nil || original != current {
|
||||
t.Fatal("source/master payload or stored labels were rewritten", e)
|
||||
}
|
||||
if e = p.QueryRow(ctx, `SELECT count(*) FROM qa_ops_master_field_edits WHERE tenant_key=$1 AND record_id=$2`, s.tenant, rec.ID).Scan(&audits); e != nil || audits != 1 {
|
||||
t.Fatal("field edit audit missing", e)
|
||||
}
|
||||
}
|
||||
@@ -27,17 +27,17 @@ func (s *Store) ReadMasterQualityViews(ctx context.Context, keys []dobotaiops.Qu
|
||||
ids = append(ids, key.ID)
|
||||
hashes[key.ID] = key.InputHash
|
||||
}
|
||||
rows, err := tx.Query(ctx, `SELECT id,quality_hash,effective_fields,coverage_result FROM qa_ops_master_read_model WHERE tenant_key=$1 AND id=ANY($2::text[])`, s.tenant, ids)
|
||||
rows, err := tx.Query(ctx, `SELECT id,quality_hash,effective_fields,coverage_result,inline_fields FROM qa_ops_master_read_model WHERE tenant_key=$1 AND id=ANY($2::text[])`, s.tenant, ids)
|
||||
if err != nil {
|
||||
return nil, dobotaiops.ErrUnavailable
|
||||
}
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var id, hash string
|
||||
var rawFields, rawCoverage []byte
|
||||
var fields map[string]string
|
||||
var rawFields, rawCoverage, rawEdits []byte
|
||||
var fields, edits map[string]string
|
||||
var coverage dobotaiops.CoverageCheck
|
||||
if rows.Scan(&id, &hash, &rawFields, &rawCoverage) != nil || json.Unmarshal(rawFields, &fields) != nil || json.Unmarshal(rawCoverage, &coverage) != nil {
|
||||
if rows.Scan(&id, &hash, &rawFields, &rawCoverage, &rawEdits) != nil || json.Unmarshal(rawFields, &fields) != nil || json.Unmarshal(rawCoverage, &coverage) != nil || json.Unmarshal(rawEdits, &edits) != nil {
|
||||
rows.Close()
|
||||
return nil, dobotaiops.ErrUnavailable
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func (s *Store) ReadMasterQualityViews(ctx context.Context, keys []dobotaiops.Qu
|
||||
rows.Close()
|
||||
return nil, qaopsquality.ErrUnavailable
|
||||
}
|
||||
views[id] = qaopsquality.ProjectMaster(view, fields, coverage)
|
||||
views[id] = qaopsquality.ProjectMasterWithEdits(view, fields, coverage, edits)
|
||||
count++
|
||||
}
|
||||
err = rows.Err()
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/qaopsquality"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func (s *Store) PrefillWeeklyDraft(ctx context.Context, week string, q WeeklyDraftPrefillRequest, actor string, now time.Time) (WeeklyDraftReceipt, error) {
|
||||
if !s.valid() {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
start, end, err := weeklyDraftBounds(week, now)
|
||||
if err != nil {
|
||||
return WeeklyDraftReceipt{}, err
|
||||
}
|
||||
if q.BaseRevision < 0 || !inlineNonce.MatchString(q.Nonce) || !validInlineID(actor) {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
request := WeeklyDraftRequest{BaseRevision: q.BaseRevision, Nonce: q.Nonce}
|
||||
saved := WeeklyDraft{Week: week}
|
||||
var hash, owner string
|
||||
err = s.pool.QueryRow(ctx, `SELECT revision,markdown,seed,created_at,actor,request_hash FROM qa_ops_weekly_draft_revisions WHERE tenant_key=$1 AND week=$2 AND nonce=$3`, s.tenant, week, q.Nonce).Scan(&saved.Revision, &saved.Markdown, &saved.Seed, &saved.UpdatedAt, &owner, &hash)
|
||||
if err == nil {
|
||||
if hash != weeklyRequestHash(request, "prefill") || owner != actor {
|
||||
return WeeklyDraftReceipt{}, qaopsquality.ErrConflict
|
||||
}
|
||||
return WeeklyDraftReceipt{Draft: saved, Replayed: true}, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
data, err := s.ReadMasterDashboard(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: start, End: end}})
|
||||
if err != nil {
|
||||
return WeeklyDraftReceipt{}, err
|
||||
}
|
||||
markdown, seed, err := renderWeeklyTemplate(week, data, now)
|
||||
if err != nil {
|
||||
return WeeklyDraftReceipt{}, err
|
||||
}
|
||||
request.Markdown = markdown
|
||||
return s.saveWeeklyDraft(ctx, week, request, actor, now, "prefill", seed)
|
||||
}
|
||||
|
||||
const maxWeeklyDraftBytes = 256 << 10
|
||||
|
||||
type WeeklyDraft struct {
|
||||
Week string `json:"week"`
|
||||
Revision int64 `json:"revision"`
|
||||
Markdown string `json:"markdown"`
|
||||
Seed json.RawMessage `json:"seed"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
}
|
||||
type WeeklyDraftRequest struct {
|
||||
BaseRevision int64 `json:"base_revision"`
|
||||
Nonce string `json:"nonce"`
|
||||
Markdown string `json:"markdown"`
|
||||
}
|
||||
type WeeklyDraftPrefillRequest struct {
|
||||
BaseRevision int64 `json:"base_revision"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
type WeeklyDraftReceipt struct {
|
||||
Draft WeeklyDraft `json:"draft"`
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
func weeklyDraftBounds(week string, now time.Time) (time.Time, time.Time, error) {
|
||||
start, end, err := (DepartmentStatisticsQuery{Period: "week", Week: week}).Bounds(now)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
if start == nil {
|
||||
return time.Time{}, time.Time{}, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
return *start, end, nil
|
||||
}
|
||||
func (s *Store) ReadWeeklyDraft(ctx context.Context, week string, now time.Time) (WeeklyDraft, error) {
|
||||
out := WeeklyDraft{Week: week, Seed: json.RawMessage(`{}`)}
|
||||
if !s.valid() {
|
||||
return out, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if _, _, e := weeklyDraftBounds(week, now); e != nil {
|
||||
return out, e
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
e := s.pool.QueryRow(ctx, `SELECT revision,markdown,seed,updated_at FROM qa_ops_weekly_drafts WHERE tenant_key=$1 AND week=$2`, s.tenant, week).Scan(&out.Revision, &out.Markdown, &out.Seed, &out.UpdatedAt)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return out, nil
|
||||
}
|
||||
if e != nil {
|
||||
return WeeklyDraft{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func weeklyRequestHash(q WeeklyDraftRequest, operation string) string {
|
||||
if operation == "prefill" {
|
||||
q.Markdown = ""
|
||||
}
|
||||
raw, _ := json.Marshal(struct {
|
||||
Request WeeklyDraftRequest
|
||||
Operation string
|
||||
}{q, operation})
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
func (s *Store) SaveWeeklyDraft(ctx context.Context, week string, q WeeklyDraftRequest, actor string, now time.Time) (WeeklyDraftReceipt, error) {
|
||||
return s.saveWeeklyDraft(ctx, week, q, actor, now, "edit", nil)
|
||||
}
|
||||
func (s *Store) saveWeeklyDraft(ctx context.Context, week string, q WeeklyDraftRequest, actor string, now time.Time, operation string, seed json.RawMessage) (WeeklyDraftReceipt, error) {
|
||||
if !s.valid() {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if _, _, e := weeklyDraftBounds(week, now); e != nil {
|
||||
return WeeklyDraftReceipt{}, e
|
||||
}
|
||||
if q.BaseRevision < 0 || !inlineNonce.MatchString(q.Nonce) || !validInlineID(actor) || !utf8.ValidString(q.Markdown) || len(q.Markdown) > maxWeeklyDraftBytes || strings.ContainsRune(q.Markdown, 0) || (operation != "edit" && operation != "prefill") {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
tx, e := s.pool.Begin(ctx)
|
||||
if e != nil {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if _, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1||chr(31)||$2,259))`, s.tenant, week); e != nil {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
requestHash := weeklyRequestHash(q, operation)
|
||||
saved := WeeklyDraft{Week: week}
|
||||
var savedActor, savedHash string
|
||||
e = tx.QueryRow(ctx, `SELECT revision,markdown,seed,created_at,actor,request_hash FROM qa_ops_weekly_draft_revisions WHERE tenant_key=$1 AND week=$2 AND nonce=$3`, s.tenant, week, q.Nonce).Scan(&saved.Revision, &saved.Markdown, &saved.Seed, &saved.UpdatedAt, &savedActor, &savedHash)
|
||||
if e == nil {
|
||||
if savedActor != actor || savedHash != requestHash {
|
||||
return WeeklyDraftReceipt{}, qaopsquality.ErrConflict
|
||||
}
|
||||
return WeeklyDraftReceipt{Draft: saved, Replayed: true}, nil
|
||||
}
|
||||
if !errors.Is(e, pgx.ErrNoRows) {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
current := WeeklyDraft{Week: week, Seed: json.RawMessage(`{}`)}
|
||||
e = tx.QueryRow(ctx, `SELECT revision,markdown,seed,updated_at FROM qa_ops_weekly_drafts WHERE tenant_key=$1 AND week=$2 FOR UPDATE`, s.tenant, week).Scan(¤t.Revision, ¤t.Markdown, ¤t.Seed, ¤t.UpdatedAt)
|
||||
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if current.Revision != q.BaseRevision {
|
||||
return WeeklyDraftReceipt{}, qaopsquality.ErrConflict
|
||||
}
|
||||
if seed == nil {
|
||||
seed = current.Seed
|
||||
}
|
||||
if len(seed) == 0 || !json.Valid(seed) {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
var created time.Time
|
||||
next := current.Revision + 1
|
||||
e = tx.QueryRow(ctx, `INSERT INTO qa_ops_weekly_drafts(tenant_key,week,revision,markdown,seed,updated_by) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(tenant_key,week) DO UPDATE SET revision=EXCLUDED.revision,markdown=EXCLUDED.markdown,seed=EXCLUDED.seed,updated_by=EXCLUDED.updated_by,updated_at=now() RETURNING updated_at`, s.tenant, week, next, q.Markdown, seed, actor).Scan(&created)
|
||||
if e != nil {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if _, e = tx.Exec(ctx, `INSERT INTO qa_ops_weekly_draft_revisions(tenant_key,week,revision,operation,markdown,seed,actor,nonce,request_hash,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, s.tenant, week, next, operation, q.Markdown, seed, actor, q.Nonce, requestHash, created); e != nil {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if e = tx.Commit(ctx); e != nil {
|
||||
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
return WeeklyDraftReceipt{Draft: WeeklyDraft{Week: week, Revision: next, Markdown: q.Markdown, Seed: seed, UpdatedAt: &created}}, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWeeklyDraftDurablyRoundTripsOneDraftPerWeekWithoutReadSideEffects(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
s := NewStore(p, "fixture-weekly-editor")
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
|
||||
empty, e := s.ReadWeeklyDraft(ctx, "2026-W41", now)
|
||||
if e != nil || empty.Revision != 0 || empty.Week != "2026-W41" || empty.Markdown != "" {
|
||||
t.Fatalf("missing week must be a read-only empty draft, not unavailable/fabricated content: %+v %v", empty, e)
|
||||
}
|
||||
var rows int
|
||||
if e = p.QueryRow(ctx, `SELECT count(*) FROM qa_ops_weekly_drafts WHERE tenant_key=$1`, s.tenant).Scan(&rows); e != nil || rows != 0 {
|
||||
t.Fatal("viewing a week created a draft", e)
|
||||
}
|
||||
q := WeeklyDraftRequest{BaseRevision: 0, Nonce: "fixture_weekly_editor_0001", Markdown: "# Edited report\n\n中文正文,English text\n<script>not executable</script>\n"}
|
||||
receipt, e := s.SaveWeeklyDraft(ctx, "2026-W41", q, "fixture-admin", now)
|
||||
if e != nil || receipt.Draft.Revision != 1 || receipt.Replayed {
|
||||
t.Fatalf("autosave rejected: %+v %v", receipt, e)
|
||||
}
|
||||
fresh, e := NewStore(p, s.tenant).ReadWeeklyDraft(ctx, "2026-W41", now)
|
||||
if e != nil || fresh.Markdown != q.Markdown || fresh.Revision != 1 || fresh.UpdatedAt == nil {
|
||||
t.Fatalf("reopened editor lost exact content: %+v %v", fresh, e)
|
||||
}
|
||||
other, e := s.ReadWeeklyDraft(ctx, "2026-W40", now)
|
||||
if e != nil || other.Revision != 0 || other.Markdown != "" {
|
||||
t.Fatal("week contents contaminated", e)
|
||||
}
|
||||
tenant, e := NewStore(p, "fixture-other-tenant").ReadWeeklyDraft(ctx, "2026-W41", now)
|
||||
if e != nil || tenant.Revision != 0 {
|
||||
t.Fatal("tenant isolation failed", e)
|
||||
}
|
||||
replay, e := s.SaveWeeklyDraft(ctx, "2026-W41", q, "fixture-admin", now)
|
||||
if e != nil || !replay.Replayed || replay.Draft.Revision != 1 {
|
||||
t.Fatal("same autosave retry must recover its existing receipt", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
pg "knowledge-governance/internal/platform/postgres"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWeeklyPrefillUsesRealMasterDashboardAndDoesNotOverwriteExistingDraft(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
ctx := context.Background()
|
||||
s := NewStore(p, "fixture-weekly-prefill")
|
||||
queue := pg.NewStore(p)
|
||||
source := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("prefill-event", "existing answer")}}
|
||||
job := claimMirrorJob(t, s, queue, "prefill-source")
|
||||
if e := s.HandleSync(ctx, *job, func(context.Context) (Source, error) { return source, nil }); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := queue.Complete(ctx, job.ID, job.LeaseOwner, time.Now()); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
record, e := s.GetMasterRecord(ctx, s.tenant, "prefill-event")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
year, weekNumber := record.AskedAt.In(dashboardLocation).ISOWeek()
|
||||
week := fmt.Sprintf("%04d-W%02d", year, weekNumber)
|
||||
now := time.Now()
|
||||
start, end, e := weeklyDraftBounds(week, now)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
data, e := s.ReadMasterDashboard(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: start, End: end}})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
q := WeeklyDraftPrefillRequest{Nonce: "fixture_prefill_week_0001"}
|
||||
receipt, e := s.PrefillWeeklyDraft(ctx, week, q, "fixture-admin", now)
|
||||
if e != nil {
|
||||
t.Fatalf("real-source prefill unavailable: %v", e)
|
||||
}
|
||||
if receipt.Draft.Revision != 1 || !strings.Contains(receipt.Draft.Markdown, fmt.Sprintf("问题总数:%d", data.TotalIssues)) || !strings.Contains(receipt.Draft.Markdown, fmt.Sprintf("New This Week: %d", data.NewThisWeek)) {
|
||||
t.Fatal("prefill was not generated from matching master cutoff")
|
||||
}
|
||||
var seed map[string]any
|
||||
if json.Unmarshal(receipt.Draft.Seed, &seed) != nil || seed["source"] != "governance-master" || seed["week"] != week {
|
||||
t.Fatal("prefill source provenance missing")
|
||||
}
|
||||
edit := WeeklyDraftRequest{BaseRevision: 1, Nonce: "fixture_prefill_user_0002", Markdown: receipt.Draft.Markdown + "\n用户亲写内容,不许替换\n"}
|
||||
if _, e = s.SaveWeeklyDraft(ctx, week, edit, "fixture-admin", now); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e = s.PrefillWeeklyDraft(ctx, week, WeeklyDraftPrefillRequest{BaseRevision: 1, Nonce: "fixture_prefill_stale_0003"}, "fixture-admin", now); e == nil {
|
||||
t.Fatal("stale regeneration silently replaced edited content")
|
||||
}
|
||||
fresh, e := s.ReadWeeklyDraft(ctx, week, now)
|
||||
if e != nil || fresh.Revision != 2 || fresh.Markdown != edit.Markdown {
|
||||
t.Fatal("prefill or stale request overwrote user input", e)
|
||||
}
|
||||
replay, e := s.PrefillWeeklyDraft(ctx, week, q, "fixture-admin", now)
|
||||
if e != nil || !replay.Replayed || replay.Draft.Revision != 1 {
|
||||
t.Fatal("prefill retry must return its exact historical receipt", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func weeklyMarkdownCell(value string) string {
|
||||
return strings.NewReplacer("&", "&", "<", "<", ">", ">", "\\", "\\\\", "*", "\\*", "_", "\\_", "[", "\\[", "]", "\\]", "|", "\\|", "`", "\\`", "\r", " ", "\n", " ").Replace(value)
|
||||
}
|
||||
func weeklyPercent(value *float64) string {
|
||||
if value == nil {
|
||||
return "—"
|
||||
}
|
||||
return fmt.Sprintf("%.2f%%", *value)
|
||||
}
|
||||
func weeklyNumber(value *float64) string {
|
||||
if value == nil {
|
||||
return "—"
|
||||
}
|
||||
return fmt.Sprintf("%.2f", *value)
|
||||
}
|
||||
func validWeeklyValue(v *float64, percent bool) bool {
|
||||
return v == nil || (!math.IsNaN(*v) && !math.IsInf(*v, 0) && *v >= 0 && (!percent || *v <= 100))
|
||||
}
|
||||
func weeklyRegionRanking(regions []DashboardRegion, coverage, english bool) string {
|
||||
rows := append([]DashboardRegion(nil), regions...)
|
||||
metric := func(r DashboardRegion) *float64 {
|
||||
if coverage {
|
||||
return r.UsageCoverage.Percent
|
||||
}
|
||||
return r.Frequency
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a, b := metric(rows[i]), metric(rows[j])
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
if b == nil {
|
||||
return true
|
||||
}
|
||||
if *a == *b {
|
||||
return rows[i].ID < rows[j].ID
|
||||
}
|
||||
return *a > *b
|
||||
})
|
||||
values := make([]string, 0, len(rows))
|
||||
for i, r := range rows {
|
||||
name := r.Name
|
||||
if english {
|
||||
if translated := map[string]string{"国内工业": "China Industrial", "America": "Americas", "APAC": "APAC", "EMEA": "EMEA"}[name]; translated != "" {
|
||||
name = translated
|
||||
}
|
||||
}
|
||||
value := weeklyNumber(metric(r))
|
||||
if coverage {
|
||||
value = weeklyPercent(metric(r))
|
||||
}
|
||||
values = append(values, fmt.Sprintf("%d. %s: %s", i+1, weeklyMarkdownCell(name), value))
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return "—"
|
||||
}
|
||||
return strings.Join(values, "\n")
|
||||
}
|
||||
func renderWeeklyTemplate(week string, data Dashboard, now time.Time) (string, json.RawMessage, error) {
|
||||
start, end, err := weeklyDraftBounds(week, now)
|
||||
if err != nil || !data.Start.Equal(start) || !data.End.Equal(end) || data.TotalIssues < 0 || data.NewThisWeek < 0 || data.NewThisWeek > data.TotalIssues || data.MonthStart.IsZero() {
|
||||
return "", nil, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
coverage, accuracy := data.Quality.Rates.Coverage.Percent, data.Quality.Rates.Correctness.Percent
|
||||
qualitySource := "live-week"
|
||||
qualitySourceWeekKey := ""
|
||||
for _, f := range data.FrozenWeeks {
|
||||
if f.WeekStart.Equal(start) && f.FrozenAt != nil {
|
||||
coverage, accuracy = f.Coverage.Percent, f.Correctness.Percent
|
||||
qualitySource = "frozen-week"
|
||||
}
|
||||
}
|
||||
for _, p := range data.ReportedWeeks {
|
||||
if p.WeekStart.Equal(start) && p.WeekEnd.Equal(start.AddDate(0, 0, 7)) && !p.WeekEnd.After(end) {
|
||||
c, a := p.CoveragePercent, p.CorrectnessPercent
|
||||
coverage, accuracy = &c, &a
|
||||
qualitySource = "reported-history"
|
||||
qualitySourceWeekKey = p.Week
|
||||
}
|
||||
}
|
||||
if !validWeeklyValue(coverage, true) || !validWeeklyValue(accuracy, true) {
|
||||
return "", nil, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
for _, r := range data.Regions {
|
||||
if !validWeeklyValue(r.Frequency, false) || !validWeeklyValue(r.UsageCoverage.Percent, true) {
|
||||
return "", nil, dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
}
|
||||
month := data.MonthStart.In(dashboardLocation)
|
||||
replacements := []string{
|
||||
"{{YEAR}}", week[:4], "{{WEEK}}", week[6:],
|
||||
"{{TOTAL_QUESTIONS}}", fmt.Sprint(data.TotalIssues), "{{NEW_THIS_WEEK}}", fmt.Sprint(data.NewThisWeek),
|
||||
"{{COVERAGE_RATE}}", weeklyPercent(coverage), "{{ACCURACY_RATE}}", weeklyPercent(accuracy),
|
||||
"{{MONTH_EN}}", month.Format("January 2006"), "{{MONTH_ZH}}", fmt.Sprintf("%d年%d月", month.Year(), month.Month()),
|
||||
"{{AVERAGE_USAGE_RANKING_EN}}", weeklyRegionRanking(data.Regions, false, true), "{{AVERAGE_USAGE_RANKING_ZH}}", weeklyRegionRanking(data.Regions, false, false),
|
||||
"{{USAGE_COVERAGE_RANKING_EN}}", weeklyRegionRanking(data.Regions, true, true), "{{USAGE_COVERAGE_RANKING_ZH}}", weeklyRegionRanking(data.Regions, true, false),
|
||||
"{{WEEKLY_HIGHLIGHTS_EN}}", "[To be completed]", "{{NEXT_WEEK_PRIORITIES_EN}}", "[To be completed]",
|
||||
"{{WEEKLY_HIGHLIGHTS_ZH}}", "[待填写]", "{{NEXT_WEEK_PRIORITIES_ZH}}", "[待填写]"}
|
||||
markdown := strings.NewReplacer(replacements...).Replace(weeklySkillTemplate)
|
||||
seed, _ := json.Marshal(map[string]any{"template_version": "dobot-weekly-six-sections-v1", "week": week, "start": start, "end": end, "generated_at": now, "source": "governance-master", "local_revision": data.LocalRevision, "source_observed_at": data.AsOf, "organization": data.Organization, "quality_source": qualitySource, "quality_source_week_key": qualitySourceWeekKey, "total_issues": data.TotalIssues, "new_this_week": data.NewThisWeek, "coverage_percent": coverage, "correctness_percent": accuracy, "month": month.Format("2006-01"), "regions": data.Regions})
|
||||
return markdown, seed, nil
|
||||
}
|
||||
|
||||
const weeklySkillTemplate = `# Knowledge Base Weekly Report - {{YEAR}} WK{{WEEK}}
|
||||
|
||||
## 1. Weekly Summary
|
||||
|
||||
Total Questions: {{TOTAL_QUESTIONS}}
|
||||
|
||||
New This Week: {{NEW_THIS_WEEK}}
|
||||
|
||||
## 2. Key Metrics
|
||||
|
||||
Coverage Rate: {{COVERAGE_RATE}}
|
||||
|
||||
Accuracy Rate: {{ACCURACY_RATE}}
|
||||
|
||||
## 3. {{MONTH_EN}} Regional Average Usage Ranking
|
||||
|
||||
{{AVERAGE_USAGE_RANKING_EN}}
|
||||
|
||||
## 4. {{MONTH_EN}} Regional Usage Coverage Ranking
|
||||
|
||||
{{USAGE_COVERAGE_RANKING_EN}}
|
||||
|
||||
## 5. Weekly Highlights
|
||||
|
||||
{{WEEKLY_HIGHLIGHTS_EN}}
|
||||
|
||||
## 6. Next Week Priorities
|
||||
|
||||
{{NEXT_WEEK_PRIORITIES_EN}}
|
||||
|
||||
# 知识库周报 - {{YEAR}}年第{{WEEK}}周
|
||||
|
||||
## 一、本周概况
|
||||
|
||||
问题总数:{{TOTAL_QUESTIONS}}
|
||||
|
||||
本周新增:{{NEW_THIS_WEEK}}
|
||||
|
||||
## 二、核心指标
|
||||
|
||||
覆盖率:{{COVERAGE_RATE}}
|
||||
|
||||
正确率:{{ACCURACY_RATE}}
|
||||
|
||||
## 三、{{MONTH_ZH}}区域人均使用次数排名
|
||||
|
||||
{{AVERAGE_USAGE_RANKING_ZH}}
|
||||
|
||||
## 四、{{MONTH_ZH}}区域使用覆盖率排名
|
||||
|
||||
{{USAGE_COVERAGE_RANKING_ZH}}
|
||||
|
||||
## 五、本周重点
|
||||
|
||||
{{WEEKLY_HIGHLIGHTS_ZH}}
|
||||
|
||||
## 六、下周重点
|
||||
|
||||
{{NEXT_WEEK_PRIORITIES_ZH}}
|
||||
`
|
||||
@@ -0,0 +1,61 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"knowledge-governance/internal/qaopsquality"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWeeklyTemplateHistoricalRatesKeepSourceWeekKeyAndUseMatchingBoundaries(t *testing.T) {
|
||||
now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
|
||||
start, end, e := weeklyDraftBounds("2026-W40", now)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
data := Dashboard{Start: start, End: end, MonthStart: dashboardDay(end.Add(-time.Second)), ReportedWeeks: []DashboardReported{{Week: "2026WK40", WeekStart: start, WeekEnd: end, CoveragePercent: 57.23, CorrectnessPercent: 92.34, Source: "user_provided_history"}}}
|
||||
md, seed, e := renderWeeklyTemplate("2026-W40", data, now)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var meta map[string]any
|
||||
if json.Unmarshal(seed, &meta) != nil {
|
||||
t.Fatal("invalid seed")
|
||||
}
|
||||
if !strings.Contains(md, "Coverage Rate: 57.23%") || !strings.Contains(md, "Accuracy Rate: 92.34%") || meta["quality_source"] != "reported-history" || meta["quality_source_week_key"] != "2026WK40" {
|
||||
t.Fatalf("historical source key must remain verbatim and match by real week boundaries: %s", md)
|
||||
}
|
||||
if data.ReportedWeeks[0].Week != "2026WK40" {
|
||||
t.Fatal("rewrote source week identifier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeeklyTemplateUsesExactBilingualSixSectionsAndRealMetricValues(t *testing.T) {
|
||||
now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
|
||||
start, end, e := weeklyDraftBounds("2026-W41", now)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
p := func(v float64) *float64 { return &v }
|
||||
data := Dashboard{Start: start, End: end, MonthStart: time.Date(2026, 10, 1, 0, 0, 0, 0, dashboardLocation), TotalIssues: 1234, NewThisWeek: 12, Quality: qaopsquality.Summary{Rates: qaopsquality.Rates{Coverage: qaopsquality.Rate{Percent: p(0)}, Correctness: qaopsquality.Rate{}}}, Regions: []DashboardRegion{{ID: "a", Name: "APAC", Frequency: p(0), UsageCoverage: qaopsquality.Rate{Percent: p(10)}}, {ID: "b", Name: "America", Frequency: p(2), UsageCoverage: qaopsquality.Rate{Percent: p(5)}}, {ID: "c", Name: "unassigned\n## fake", Frequency: nil}}}
|
||||
md, seed, e := renderWeeklyTemplate("2026-W41", data, now)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if strings.Count(md, "\n## ") != 12 || !strings.HasPrefix(md, "# Knowledge Base Weekly Report - 2026 WK41") || strings.Index(md, "## 6. Next Week Priorities") > strings.Index(md, "# 知识库周报") || !strings.Contains(md, "问题总数:1234") || !strings.Contains(md, "New This Week: 12") || !strings.Contains(md, "Coverage Rate: 0.00%") || !strings.Contains(md, "Accuracy Rate: —") || strings.Contains(md, "{{") || strings.Contains(md, "\n## fake") {
|
||||
t.Fatalf("skill template, trusted values or escaping missing: %s", md)
|
||||
}
|
||||
if !strings.Contains(md, "[待填写]") || !strings.Contains(md, "[To be completed]") {
|
||||
t.Fatal("invented authored highlights instead of leaving placeholders")
|
||||
}
|
||||
average := md[strings.Index(md, "Regional Average"):strings.Index(md, "## 4.")]
|
||||
coverage := md[strings.Index(md, "Regional Usage Coverage"):strings.Index(md, "## 5.")]
|
||||
if strings.Index(average, "Americas") > strings.Index(average, "APAC") || strings.Index(coverage, "APAC") > strings.Index(coverage, "Americas") {
|
||||
t.Fatal("regional rankings not sorted independently")
|
||||
}
|
||||
var meta map[string]any
|
||||
if json.Unmarshal(seed, &meta) != nil || meta["quality_source"] != "live-week" {
|
||||
t.Fatal("prefill provenance missing")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,33 @@ package qaopsquality
|
||||
|
||||
import "knowledge-governance/internal/dobotaiops"
|
||||
|
||||
func ProjectMasterWithEdits(v View, stored map[string]string, derived dobotaiops.CoverageCheck, edits map[string]string) View {
|
||||
v = ProjectMaster(v, stored, derived)
|
||||
if value, ok := edits["coverage"]; ok {
|
||||
v.Effective.Coverage = map[string]string{"是": "covered", "否": "uncovered", "否但不计入": "excluded"}[value]
|
||||
v.Effective.ExclusionReason = ""
|
||||
if value == "否但不计入" {
|
||||
v.Effective.ExclusionReason = "详情字段选择:否但不计入"
|
||||
}
|
||||
}
|
||||
if value, ok := edits["is_correct"]; ok {
|
||||
v.Effective.Correctness = map[string]string{"是": "correct", "否": "incorrect", "待确认": "pending"}[value]
|
||||
}
|
||||
if value, ok := edits["product_category"]; ok {
|
||||
v.Effective.ProductCategory = value
|
||||
}
|
||||
if value, ok := edits["problem_type"]; ok {
|
||||
v.Effective.ProblemType = value
|
||||
}
|
||||
if value, ok := edits["root_cause"]; ok {
|
||||
v.Effective.RootCause = value
|
||||
}
|
||||
if v.Effective.Correctness == "correct" {
|
||||
v.Effective.RootCause = ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Stored master labels remain their original provenance, not synthetic reviews.
|
||||
func ProjectMaster(v View, stored map[string]string, derived dobotaiops.CoverageCheck) View {
|
||||
v.EffectiveSource = "master-effective-fields-v1"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package qaopsquality
|
||||
|
||||
import (
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMasterInlineEditsOverrideLegacyReviewOnlyForSelectedFields(t *testing.T) {
|
||||
v := View{State: "reviewed", Revision: 7, Effective: Fields{Coverage: "covered", Correctness: "incorrect", ProductCategory: "legacy", ProblemType: "故障", RootCause: "知识过期"}, LatestReview: &Review{Revision: 7}}
|
||||
edits := map[string]string{"coverage": "否但不计入", "is_correct": "是", "root_cause": "检索不到"}
|
||||
got := ProjectMasterWithEdits(v, map[string]string{}, dobotaiops.CoverageCheck{}, edits)
|
||||
if got.Effective.Coverage != "excluded" || got.Effective.ExclusionReason == "" || got.Effective.Correctness != "correct" || got.Effective.RootCause != "" || got.Effective.ProductCategory != "legacy" || got.Effective.ProblemType != "故障" || got.LatestReview.Revision != 7 {
|
||||
t.Fatalf("inline override/reset dependency/legacy preservation failed: %+v", got)
|
||||
}
|
||||
reverted := ProjectMasterWithEdits(got, nil, dobotaiops.CoverageCheck{}, map[string]string{"coverage": "否", "is_correct": "待确认", "problem_type": "咨询", "root_cause": "知识缺失"})
|
||||
if reverted.Effective.Coverage != "uncovered" || reverted.Effective.ExclusionReason != "" || reverted.Effective.Correctness != "pending" || reverted.Effective.ProblemType != "咨询" || reverted.Effective.RootCause != "知识缺失" {
|
||||
t.Fatalf("canonical labels/reset failed: %+v", reverted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterInlineEditChangesOnlyChosenFieldWithoutFabricatingReview(t *testing.T) {
|
||||
v := View{State: "unreviewed"}
|
||||
stored := map[string]string{"coverage": "是", "product_category": "CRV"}
|
||||
got := ProjectMasterWithEdits(v, stored, dobotaiops.CheckMasterCoverage("existing answer", true), map[string]string{"product_category": "MG400"})
|
||||
if got.Effective.ProductCategory != "MG400" || got.Effective.Correctness != "" || got.Effective.ProblemType != "" || got.State != "unreviewed" || got.LatestReview != nil {
|
||||
t.Fatalf("single-field edit lost or implicitly created other conclusions: %+v", got)
|
||||
}
|
||||
if stored["product_category"] != "CRV" {
|
||||
t.Fatal("mutated source labels")
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { DepartmentStatisticsSnapshot, DepartmentStatisticsQuery } from './qaDepartmentStatistics'
|
||||
import { fetchDepartmentStatistics } from './qaDepartmentStatistics'
|
||||
import { departmentTree, selectDepartmentBranch, subtreeIDs, defaultDepartmentCollapsed } from './qaRegionSelection'
|
||||
import { currentQAWeek, qaWeek } from './qaWeeks'
|
||||
import { currentQAWeekValue } from './qaWeeks'
|
||||
import { bindOrganizationDepartments } from './qaOrg'
|
||||
import type { RegionDefinition } from './qaOrg'
|
||||
import { QARegions } from './QARegions'
|
||||
import './qaDepartmentStatistics.css'
|
||||
import { QAPeriodInput } from './QAPeriodInput'
|
||||
export interface DepartmentStatisticsViewProps {
|
||||
data: DepartmentStatisticsSnapshot
|
||||
selected?: string[]
|
||||
@@ -165,10 +166,7 @@ export function DepartmentRegionBinding({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const currentWeekValue = () => {
|
||||
const w = currentQAWeek()
|
||||
return `${w.year}-W${String(w.week).padStart(2, '0')}`
|
||||
}
|
||||
const currentWeekValue = currentQAWeekValue
|
||||
export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {}) {
|
||||
const [period, setPeriod] = useState<'all' | 'month' | 'week'>('all'),
|
||||
[month, setMonth] = useState(currentMonth),
|
||||
@@ -325,39 +323,24 @@ export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {})
|
||||
</select>
|
||||
</label>
|
||||
{period === 'month' ? (
|
||||
<label>
|
||||
统计月份
|
||||
<input
|
||||
aria-label="部门统计月份"
|
||||
type="month"
|
||||
min="2000-01"
|
||||
max={currentMonth()}
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<QAPeriodInput
|
||||
kind="month"
|
||||
label="统计月份"
|
||||
ariaLabel="部门统计月份"
|
||||
value={month}
|
||||
max={currentMonth()}
|
||||
onChange={setMonth}
|
||||
/>
|
||||
) : null}
|
||||
{period === 'week' ? (
|
||||
<label>
|
||||
统计周次(北京时间周一至周日)
|
||||
<input
|
||||
aria-label="部门统计周次"
|
||||
type="week"
|
||||
min="2000-W01"
|
||||
max={currentWeekValue()}
|
||||
value={week}
|
||||
onChange={(e) => setWeek(e.target.value)}
|
||||
/>
|
||||
<small>
|
||||
{(() => {
|
||||
try {
|
||||
return qaWeek(Number(week.slice(0, 4)), Number(week.slice(6))).label
|
||||
} catch {
|
||||
return '请选择有效自然周'
|
||||
}
|
||||
})()}
|
||||
</small>
|
||||
</label>
|
||||
<QAPeriodInput
|
||||
kind="week"
|
||||
label="统计周次(北京时间周一至周日)"
|
||||
ariaLabel="部门统计周次"
|
||||
value={week}
|
||||
max={currentWeekValue()}
|
||||
onChange={setWeek}
|
||||
/>
|
||||
) : null}
|
||||
<label>
|
||||
部门
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import './qaPeriodInput.css'
|
||||
import type { QARecord } from './qaOps'
|
||||
import { qaColumns } from './qaOps'
|
||||
import { editableFieldKeys, editableFieldValue, fetchFieldOptions, saveInlineField } from './qaInlineFields'
|
||||
import type { EditableField, FieldOptions } from './qaInlineFields'
|
||||
import { newOperationID } from './operationKey'
|
||||
import { qaFieldColors } from './qaFieldColors'
|
||||
export function QAInlineFields({
|
||||
record,
|
||||
onSaved,
|
||||
onReload,
|
||||
}: {
|
||||
record: QARecord
|
||||
onSaved: (record: QARecord) => void
|
||||
onReload: () => void
|
||||
}) {
|
||||
const [options, setOptions] = useState<FieldOptions | null>(null),
|
||||
[error, setError] = useState(''),
|
||||
[notice, setNotice] = useState(''),
|
||||
[saving, setSaving] = useState(false),
|
||||
[reloadOptions, setReloadOptions] = useState(0)
|
||||
const [pending, setPending] = useState<{
|
||||
record: QARecord
|
||||
field: EditableField
|
||||
value: string
|
||||
nonce: string
|
||||
} | null>(null)
|
||||
const writing = useRef<AbortController | null>(null)
|
||||
useEffect(() => {
|
||||
const c = new AbortController()
|
||||
setError('')
|
||||
void fetchFieldOptions(c.signal)
|
||||
.then(setOptions)
|
||||
.catch((e) => {
|
||||
if (!c.signal.aborted) setError(e instanceof Error ? e.message : '主表选项读取失败')
|
||||
})
|
||||
return () => c.abort()
|
||||
}, [reloadOptions])
|
||||
useEffect(() => () => writing.current?.abort(), [])
|
||||
const save = async (p: NonNullable<typeof pending>) => {
|
||||
if (writing.current) return
|
||||
const c = new AbortController()
|
||||
writing.current = c
|
||||
setSaving(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const fresh = await saveInlineField(p.record, p.field, p.value, p.nonce, c.signal)
|
||||
if (!c.signal.aborted) {
|
||||
setPending(null)
|
||||
onSaved(fresh)
|
||||
setNotice('已保存')
|
||||
}
|
||||
} catch (e) {
|
||||
if (!c.signal.aborted) setError(e instanceof Error ? e.message : '字段保存失败,选择尚未确认')
|
||||
} finally {
|
||||
writing.current = null
|
||||
if (!c.signal.aborted) setSaving(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="qa-inline-fields">
|
||||
<div className="qa-detail-quality" aria-label="固定质量字段">
|
||||
{editableFieldKeys.map((field) => {
|
||||
const value = pending?.field === field ? pending.value : editableFieldValue(record, field)
|
||||
const rootNotNeeded = field === 'root_cause' && editableFieldValue(record, 'is_correct') === '是'
|
||||
return (
|
||||
<div key={field} data-quality-field={field}>
|
||||
<label>
|
||||
<span>{qaColumns.find((c) => c.key === field)!.label}</span>
|
||||
<select
|
||||
aria-label={`修改${qaColumns.find((c) => c.key === field)!.label}`}
|
||||
title="选择后自动保存"
|
||||
value={rootNotNeeded ? '' : value}
|
||||
disabled={
|
||||
saving || !!pending || !options || !record.record_origin || !record.quality || rootNotNeeded
|
||||
}
|
||||
style={qaFieldColors(field, value || '—')}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === editableFieldValue(record, field)) return
|
||||
try {
|
||||
const next = { record, field, value: e.target.value, nonce: newOperationID() }
|
||||
setPending(next)
|
||||
void save(next)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '无法生成保存标识')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{rootNotNeeded ? '无需根因' : '未设置'}
|
||||
</option>
|
||||
{value && !options?.[field].includes(value) ? (
|
||||
<option value={value} disabled>
|
||||
{value}
|
||||
</option>
|
||||
) : null}
|
||||
{options?.[field].map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{saving || notice ? (
|
||||
<p className="qa-inline-save" role="status" aria-live="polite">
|
||||
{saving ? '保存中…' : notice}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="error-banner">
|
||||
{error}{' '}
|
||||
{pending ? (
|
||||
<>
|
||||
<button type="button" disabled={saving} onClick={() => void save(pending)}>
|
||||
重试保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setPending(null)
|
||||
setError('')
|
||||
onReload()
|
||||
}}
|
||||
>
|
||||
放弃并重读
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={() => setReloadOptions((v) => v + 1)}>
|
||||
重试读取选项
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,15 @@ const props = {
|
||||
}
|
||||
|
||||
describe('QA operations workbench', () => {
|
||||
it('edits five fields directly in the first row without a separate review or feedback form', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QAOperationsView {...props} detail={{ ...item, record_origin: 'historical_excel' }} />
|
||||
)
|
||||
const drawer = html.slice(html.indexOf('<aside'))
|
||||
expect((drawer.match(/<select/g) ?? []).length).toBe(5)
|
||||
for (const copy of ['人工复核与留档', '保存复核', '复核理由', '用户反馈', '质量留档/人工复核未配置'])
|
||||
expect(drawer).not.toContain(copy)
|
||||
})
|
||||
it('shows each persisted AI task in the existing operation cell and explains positive master coverage', () => {
|
||||
const withTasks = {
|
||||
...item,
|
||||
|
||||
+22
-35
@@ -8,7 +8,7 @@ import { ledgerWindow, ledgerCellPreview } from './qaLedgerRendering'
|
||||
import { QARegionalStatistics } from './QADepartmentStatistics'
|
||||
import { QADashboard } from './QADashboard'
|
||||
import { QAWeekly } from './QAWeekly'
|
||||
import { QAQualityEditor } from './QAQuality'
|
||||
import { QAInlineFields } from './QAInlineFields'
|
||||
import { qaFieldColors, qaTagColumns } from './qaFieldColors'
|
||||
import { qualityLabels } from './qaQuality'
|
||||
import { QAWeekPicker } from './QAWeekPicker'
|
||||
@@ -272,16 +272,12 @@ export function QAOperationsView(props: ViewProps) {
|
||||
</button>
|
||||
</header>
|
||||
{detail ? (
|
||||
<div className="qa-detail-quality" aria-label="固定质量字段">
|
||||
{(['coverage', 'is_correct', 'product_category', 'problem_type', 'root_cause'] as const).map((key) => (
|
||||
<div key={key} data-quality-field={key}>
|
||||
<span>{qaColumns.find((column) => column.key === key)!.label}</span>
|
||||
<strong className="qa-field-tag" style={qaFieldColors(key, qaCellValue(detail, key) ?? '—')}>
|
||||
{qaCellValue(detail, key) ?? '—'}
|
||||
</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<QAInlineFields
|
||||
key={detail.id}
|
||||
record={detail}
|
||||
onSaved={props.onQualitySaved ?? (() => {})}
|
||||
onReload={props.onReloadDetail ?? (() => {})}
|
||||
/>
|
||||
) : null}
|
||||
{props.detailLoading ? <p role="status">正在读取完整问答…</p> : null}
|
||||
{props.detailError ? (
|
||||
@@ -334,28 +330,6 @@ export function QAOperationsView(props: ViewProps) {
|
||||
?.map((task) => task.updated_at)
|
||||
.join(':')}
|
||||
/>
|
||||
{detail.quality ? (
|
||||
<QAQualityEditor
|
||||
key={detail.id}
|
||||
recordId={detail.id}
|
||||
dataset={detail.record_origin ? 'master' : undefined}
|
||||
quality={detail.quality}
|
||||
onSaved={props.onQualitySaved ?? (() => {})}
|
||||
onReload={props.onReloadDetail ?? (() => {})}
|
||||
/>
|
||||
) : (
|
||||
<p className="qa-ops-muted">质量留档/人工复核未配置。</p>
|
||||
)}
|
||||
<h4>用户反馈</h4>
|
||||
<p>
|
||||
提问人:
|
||||
{detail.feedback.asker_value === 'up'
|
||||
? '点赞'
|
||||
: detail.feedback.asker_value === 'down'
|
||||
? '点踩'
|
||||
: '无反馈'}
|
||||
;全部反馈:赞 {detail.feedback.up} / 踩 {detail.feedback.down}
|
||||
</p>
|
||||
<h4>引用知识</h4>
|
||||
<pre>{JSON.stringify(detail.references, null, 2)}</pre>
|
||||
<details>
|
||||
@@ -379,6 +353,10 @@ function initialDraft() {
|
||||
|
||||
export function QAOperations() {
|
||||
const [view, setView] = useState<QAOpsView>('records')
|
||||
const weeklyBeforeLeave = useRef<(() => Promise<boolean>) | null>(null)
|
||||
const registerWeeklyLeave = useCallback((guard: (() => Promise<boolean>) | null) => {
|
||||
weeklyBeforeLeave.current = guard
|
||||
}, [])
|
||||
const auth = useOptionalAuth()
|
||||
const canRead = auth?.identity?.role === 'admin'
|
||||
const [draft, setDraft] = useState(initialDraft)
|
||||
@@ -528,7 +506,16 @@ export function QAOperations() {
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<QAOperationsTabs view={view} onChange={setView} />
|
||||
<QAOperationsTabs
|
||||
view={view}
|
||||
onChange={(next) => {
|
||||
if (view === 'weekly' && next !== 'weekly' && weeklyBeforeLeave.current)
|
||||
void weeklyBeforeLeave.current().then((ok) => {
|
||||
if (ok) setView(next)
|
||||
})
|
||||
else setView(next)
|
||||
}}
|
||||
/>
|
||||
{queryView ? (
|
||||
<>
|
||||
{mirrorChanged ? <p className="qa-ops-warning">数据已更新,关闭详情后刷新。</p> : null}
|
||||
@@ -608,7 +595,7 @@ export function QAOperations() {
|
||||
}}
|
||||
/>
|
||||
) : view === 'weekly' ? (
|
||||
<QAWeekly query={query} />
|
||||
<QAWeekly onBeforeLeave={registerWeeklyLeave} />
|
||||
) : view === 'dashboard' ? (
|
||||
<QADashboard
|
||||
query={query}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { QAPeriodInput } from './QAPeriodInput'
|
||||
describe('one period value with two input methods', () => {
|
||||
it('offers a text input and dropdown for both months and ISO weeks', () => {
|
||||
for (const kind of ['month', 'week'] as const) {
|
||||
const value = kind === 'month' ? '2026-10' : '2026-W43'
|
||||
const html = renderToStaticMarkup(
|
||||
<QAPeriodInput kind={kind} label="统计周期" ariaLabel="测试周期" value={value} max={value} onChange={vi.fn()} />
|
||||
)
|
||||
expect(html).toContain('type="text"')
|
||||
expect(html).toContain('<select')
|
||||
expect(html).toContain(`value="${value}"`)
|
||||
expect(html).toContain('测试周期下拉选择')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import type { QAPeriodKind } from './qaPeriodInput'
|
||||
import { periodInputOptions, validPeriodInput } from './qaPeriodInput'
|
||||
import { qaWeek } from './qaWeeks'
|
||||
import './qaPeriodInput.css'
|
||||
export interface QAPeriodInputProps {
|
||||
kind: QAPeriodKind
|
||||
label: string
|
||||
ariaLabel: string
|
||||
value: string
|
||||
max: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
export function QAPeriodInput(p: QAPeriodInputProps) {
|
||||
const id = useId(),
|
||||
[text, setText] = useState(p.value),
|
||||
[error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
setText(p.value)
|
||||
setError('')
|
||||
}, [p.value, p.kind])
|
||||
const options = useMemo(() => {
|
||||
const values = periodInputOptions(p.kind, p.max)
|
||||
if (validPeriodInput(p.kind, p.value, p.max) && !values.includes(p.value)) values.push(p.value)
|
||||
return values
|
||||
}, [p.kind, p.max, p.value])
|
||||
const apply = () => {
|
||||
if (text === p.value) return
|
||||
if (!validPeriodInput(p.kind, text, p.max)) {
|
||||
setError(
|
||||
`请输入有效的${p.kind === 'month' ? 'YYYY-MM月份' : 'YYYY-Www自然周'},最早2000年、不晚于${p.max};仍显示${p.value}的结果`
|
||||
)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
p.onChange(text)
|
||||
}
|
||||
return (
|
||||
<div className="qa-period-input">
|
||||
<label htmlFor={id}>{p.label}</label>
|
||||
<div className="qa-period-combo">
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
aria-label={p.ariaLabel}
|
||||
aria-describedby={`${id}-hint`}
|
||||
aria-invalid={!!error}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={text}
|
||||
disabled={p.disabled}
|
||||
placeholder={p.kind === 'month' ? 'YYYY-MM' : 'YYYY-Www'}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
onBlur={apply}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
apply()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setText(p.value)
|
||||
setError('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
aria-label={`${p.ariaLabel}下拉选择`}
|
||||
value=""
|
||||
disabled={p.disabled}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (!value) return
|
||||
setText(value)
|
||||
setError('')
|
||||
p.onChange(value)
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
选择
|
||||
</option>
|
||||
{options.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{p.kind === 'week' ? `${v} ${qaWeek(Number(v.slice(0, 4)), Number(v.slice(6))).label}` : v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<small id={`${id}-hint`} role={error ? 'alert' : undefined}>
|
||||
{error ||
|
||||
(text !== p.value
|
||||
? '尚未应用,按回车或移开输入框确认'
|
||||
: p.kind === 'week'
|
||||
? qaWeek(Number(p.value.slice(0, 4)), Number(p.value.slice(6))).label
|
||||
: '可下拉选择,也可直接输入')}
|
||||
</small>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+255
-79
@@ -1,97 +1,273 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDeferredValue, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import type { QAQuery } from './qaOps'
|
||||
import { fetchAllQARecords } from './qaOps'
|
||||
import { fetchQADashboardQuality } from './qaDashboard'
|
||||
import { fetchQAMirrorStatus } from './qaMirror'
|
||||
import { buildQAWeeklyDraft, downloadQAWeekly } from './qaWeekly'
|
||||
import type { QAWeeklyDraft } from './qaWeekly'
|
||||
import { currentQAWeekValue, qaDateTimeToISO, qaWeekQueryRange } from './qaWeeks'
|
||||
import { WeeklyDraftEditorModel } from './qaWeeklyDrafts'
|
||||
import { weeklyTransport } from './qaWeeklyDraftAPI'
|
||||
import { buildQAWeeklyCSV, downloadQAWeekly } from './qaWeekly'
|
||||
import { QAAnswerMarkdown } from './QAAnswerMarkdown'
|
||||
export function QAWeekly({ query }: { query: QAQuery | null }) {
|
||||
const [draft, setDraft] = useState<QAWeeklyDraft | null>(null),
|
||||
[loading, setLoading] = useState(false),
|
||||
[error, setError] = useState('')
|
||||
const request = useRef<AbortController | null>(null)
|
||||
import { QAPeriodInput } from './QAPeriodInput'
|
||||
import './qaWeeklyEditor.css'
|
||||
type LeaveGuard = (() => Promise<boolean>) | null
|
||||
interface Props {
|
||||
query?: QAQuery | null
|
||||
onBeforeLeave?: (guard: LeaveGuard) => void
|
||||
}
|
||||
export function QAWeekly({ onBeforeLeave }: Props) {
|
||||
const [model] = useState(() => new WeeklyDraftEditorModel(currentQAWeekValue(), weeklyTransport))
|
||||
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot)
|
||||
const [mode, setMode] = useState<'edit' | 'preview' | 'both'>('both'),
|
||||
[replaceConfirm, setReplaceConfirm] = useState(false),
|
||||
[reloadConfirm, setReloadConfirm] = useState(false),
|
||||
[periodReset, setPeriodReset] = useState(0),
|
||||
[exporting, setExporting] = useState(false),
|
||||
[exportError, setExportError] = useState('')
|
||||
const preview = useDeferredValue(state.text)
|
||||
useEffect(() => {
|
||||
request.current?.abort()
|
||||
setDraft(null)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
return () => request.current?.abort()
|
||||
}, [query])
|
||||
const generate = async () => {
|
||||
if (!query || loading) return
|
||||
request.current?.abort()
|
||||
const controller = new AbortController()
|
||||
request.current = controller
|
||||
setLoading(true)
|
||||
setDraft(null)
|
||||
setError('')
|
||||
void model.initialize()
|
||||
const unload = (e: BeforeUnloadEvent) => {
|
||||
if (model.dirty || model.uncertainPrefill) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', unload)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', unload)
|
||||
if (model.dirty && !model.uncertainPrefill) void model.beforeLeave().finally(() => model.dispose())
|
||||
else model.dispose()
|
||||
}
|
||||
}, [model])
|
||||
useEffect(() => {
|
||||
onBeforeLeave?.(() => model.beforeLeave())
|
||||
return () => onBeforeLeave?.(null)
|
||||
}, [model, onBeforeLeave])
|
||||
const blocked = state.busy || state.loading || !state.document || model.uncertainPrefill
|
||||
const status = state.loading
|
||||
? 'loading'
|
||||
: state.saving
|
||||
? 'saving'
|
||||
: state.busy
|
||||
? 'working'
|
||||
: state.error
|
||||
? 'error'
|
||||
: model.dirty
|
||||
? 'dirty'
|
||||
: state.document?.revision
|
||||
? 'saved'
|
||||
: 'empty'
|
||||
const source = state.document?.seed
|
||||
const generate = () => {
|
||||
setReplaceConfirm(false)
|
||||
setReloadConfirm(false)
|
||||
void model.prefill()
|
||||
}
|
||||
const csv = async () => {
|
||||
if (exporting) return
|
||||
setExporting(true)
|
||||
setExportError('')
|
||||
try {
|
||||
const before = await fetchQAMirrorStatus(controller.signal)
|
||||
if (!before.ready || before.blocked) throw Error('本地同步未就绪,暂不能生成周报')
|
||||
const [page, quality] = await Promise.all([
|
||||
fetchAllQARecords(query, controller.signal),
|
||||
fetchQADashboardQuality(query, controller.signal),
|
||||
])
|
||||
const after = await fetchQAMirrorStatus(controller.signal)
|
||||
if (!after.ready || after.blocked || before.revision !== after.revision || page.local_revision !== after.revision)
|
||||
throw Error('本地副本在生成期间已更新,请重新生成')
|
||||
const result = buildQAWeeklyDraft(page, query, quality, new Date().toISOString())
|
||||
if (!controller.signal.aborted) setDraft(result)
|
||||
const range = qaWeekQueryRange(Number(state.week.slice(0, 4)), Number(state.week.slice(6)))
|
||||
const start = typeof source?.start === 'string' ? source.start : qaDateTimeToISO(range.start),
|
||||
end = typeof source?.end === 'string' ? source.end : qaDateTimeToISO(range.end)
|
||||
const page = await fetchAllQARecords({ dataset: 'master', start, end, keyword: '', cursor: '' })
|
||||
downloadQAWeekly(buildQAWeeklyCSV(page), `DobotAI-Questions-${state.week}.csv`, 'text/csv;charset=utf-8')
|
||||
} catch (e) {
|
||||
if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '周报生成失败')
|
||||
setExportError(e instanceof Error ? e.message : '明细导出失败')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
const suffix = query ? new Date(Date.parse(query.start) + 8 * 60 * 60 * 1000).toISOString().slice(0, 10) : 'draft'
|
||||
return (
|
||||
<section className="qa-weekly" aria-label="周报生成">
|
||||
<header className="qa-weekly-toolbar">
|
||||
<div>
|
||||
<h3>周报生成</h3>
|
||||
{query ? (
|
||||
<span>
|
||||
{new Date(query.start).toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' })} 至{' '}
|
||||
{new Date(Date.parse(query.end) - 1).toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' })}
|
||||
</span>
|
||||
) : null}
|
||||
<section className="qa-weekly qa-weekly-editor" aria-label="周报生成" data-weekly-week={state.week}>
|
||||
<header className="qa-weekly-editor-header">
|
||||
<h3>周报草稿</h3>
|
||||
<QAPeriodInput
|
||||
key={`${state.week}:${periodReset}`}
|
||||
kind="week"
|
||||
label="周报周次"
|
||||
ariaLabel="周报周次"
|
||||
value={state.week}
|
||||
max={currentQAWeekValue()}
|
||||
disabled={state.busy || state.loading}
|
||||
onChange={(week) => {
|
||||
setReplaceConfirm(false)
|
||||
setReloadConfirm(false)
|
||||
setExportError('')
|
||||
void model.switchWeek(week).then((ok) => {
|
||||
if (!ok) setPeriodReset((v) => v + 1)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<div className="qa-weekly-editor-actions">
|
||||
{replaceConfirm ? (
|
||||
<>
|
||||
<button type="button" className="qa-weekly-prefill" onClick={() => setReplaceConfirm(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" onClick={generate}>
|
||||
确认替换预填
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="qa-weekly-prefill"
|
||||
disabled={blocked || state.errorKind === 'conflict'}
|
||||
onClick={() => {
|
||||
if (state.document?.revision || state.text) setReplaceConfirm(true)
|
||||
else generate()
|
||||
}}
|
||||
>
|
||||
{state.document?.revision ? '重新预填数据' : '生成并预填草稿'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={blocked || !model.dirty || state.saving || state.errorKind === 'conflict'}
|
||||
onClick={() => void model.beforeLeave()}
|
||||
>
|
||||
立即保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!state.document}
|
||||
onClick={() =>
|
||||
downloadQAWeekly(state.text, `DobotAI_KB_Weekly_Report_${state.week}.md`, 'text/markdown;charset=utf-8')
|
||||
}
|
||||
>
|
||||
下载 MD
|
||||
</button>
|
||||
<button type="button" disabled={!state.document || exporting || state.busy} onClick={() => void csv()}>
|
||||
{exporting ? '导出中…' : '下载明细 CSV'}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => void generate()} disabled={loading || !query}>
|
||||
{loading ? '正在生成…' : '生成周报草稿'}
|
||||
</button>
|
||||
{draft ? (
|
||||
<>
|
||||
<span className="qa-weekly-badge">草稿 · {draft.questions} 条</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
downloadQAWeekly(draft.markdown, `DobotAI-问答周报-${suffix}.md`, 'text/markdown;charset=utf-8')
|
||||
}
|
||||
>
|
||||
下载 MD
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadQAWeekly(draft.csv, `DobotAI-问题明细-${suffix}.csv`, 'text/csv;charset=utf-8')}
|
||||
>
|
||||
下载明细 CSV
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</header>
|
||||
{error ? (
|
||||
<div className="qa-weekly-editor-toolbar">
|
||||
<div className="qa-weekly-mode" aria-label="编辑与预览">
|
||||
{(['edit', 'preview', 'both'] as const).map((value) => (
|
||||
<button type="button" key={value} aria-pressed={mode === value} onClick={() => setMode(value)}>
|
||||
{{ edit: '编辑', preview: '预览', both: '对照' }[value]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span role="status" aria-live="polite" className="qa-weekly-editor-status" data-weekly-save-state={status}>
|
||||
自动保存 ·{' '}
|
||||
{
|
||||
{
|
||||
loading: '正在读取草稿…',
|
||||
saving: '保存中…',
|
||||
working: '正在处理…',
|
||||
error: '未保存或尚未确认',
|
||||
dirty: '待保存',
|
||||
saved: '已保存',
|
||||
empty: '尚未创建',
|
||||
}[status]
|
||||
}
|
||||
{status === 'saved' && state.document?.updated_at
|
||||
? ` · ${new Date(state.document.updated_at).toLocaleTimeString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
{replaceConfirm ? (
|
||||
<p className="qa-ops-warning">将先保存当前文字,再用最新统计替换正文;旧保存版本保留。请确认后继续。</p>
|
||||
) : null}
|
||||
{state.error ? (
|
||||
<div role="alert" className="error-banner">
|
||||
{state.error}
|
||||
{state.errorKind === 'load' ? (
|
||||
<button type="button" disabled={state.busy} onClick={() => void model.reload()}>
|
||||
重试读取
|
||||
</button>
|
||||
) : state.errorKind === 'save' ? (
|
||||
<button type="button" disabled={state.saving || state.busy} onClick={() => void model.beforeLeave()}>
|
||||
重试保存
|
||||
</button>
|
||||
) : state.errorKind === 'prefill' ? (
|
||||
<button type="button" disabled={state.busy} onClick={generate}>
|
||||
重试确认预填
|
||||
</button>
|
||||
) : null}
|
||||
{reloadConfirm ? (
|
||||
<>
|
||||
<button type="button" className="qa-weekly-reload" onClick={() => setReloadConfirm(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={state.saving || state.busy}
|
||||
onClick={() => {
|
||||
setReloadConfirm(false)
|
||||
void model.reload()
|
||||
}}
|
||||
>
|
||||
放弃本地修改并读取
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="qa-weekly-reload"
|
||||
disabled={state.busy || state.saving}
|
||||
onClick={() => setReloadConfirm(true)}
|
||||
>
|
||||
重新读取服务器版本
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{exportError ? (
|
||||
<p role="alert" className="error-banner">
|
||||
{error}
|
||||
{exportError}
|
||||
</p>
|
||||
) : null}
|
||||
{draft ? (
|
||||
<article className="qa-weekly-preview">
|
||||
<QAAnswerMarkdown answer={draft.markdown} />
|
||||
</article>
|
||||
) : (
|
||||
<div className="qa-weekly-empty">{loading ? '正在汇总本地问答…' : '尚未生成周报。'}</div>
|
||||
)}
|
||||
<div className="qa-weekly-editor-layout" data-mode={mode}>
|
||||
{mode !== 'preview' ? (
|
||||
<div className="qa-weekly-writing">
|
||||
<label htmlFor="qa-weekly-body">
|
||||
周报草稿正文 <small>Markdown</small>
|
||||
</label>
|
||||
<textarea
|
||||
id="qa-weekly-body"
|
||||
aria-label="周报草稿正文"
|
||||
value={state.text}
|
||||
disabled={blocked}
|
||||
spellCheck={false}
|
||||
placeholder="生成并预填草稿,或直接输入。英文完整部分在上,中文在下;输入后自动保存。"
|
||||
onChange={(e) => model.setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
void model.beforeLeave()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{mode !== 'edit' ? (
|
||||
<article className="qa-weekly-preview" aria-label="周报预览">
|
||||
{preview ? <QAAnswerMarkdown answer={preview} /> : <p className="qa-ops-muted">尚无草稿正文。</p>}
|
||||
</article>
|
||||
) : null}
|
||||
</div>
|
||||
{source && source.source === 'governance-master' ? (
|
||||
<details className="qa-weekly-source">
|
||||
<summary>预填来源</summary>
|
||||
<p>
|
||||
治理正式总表 · {String(source.week)} · 截至{' '}
|
||||
{typeof source.end === 'string'
|
||||
? new Date(source.end).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
|
||||
: '—'}{' '}
|
||||
·{' '}
|
||||
{{ 'live-week': '当周实时质量指标', 'frozen-week': '已冻结周指标', 'reported-history': '历史上报指标' }[
|
||||
String(source.quality_source)
|
||||
] ?? '指标来源未确认'}
|
||||
</p>
|
||||
<p>
|
||||
问题总数与新增是可编辑的系统统计建议;区域采用已保存配置及当前通讯录。未做新的FAQ调整,重点与英文文字请自行填写。草稿不会自动发送。
|
||||
</p>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { QAWeekly } from './QAWeekly'
|
||||
describe('weekly report editor surface', () => {
|
||||
it('provides a weekly document editor, automatic-save status and a preview without an external-send action', () => {
|
||||
const html = renderToStaticMarkup(<QAWeekly query={null} />)
|
||||
expect(html).toContain('<textarea')
|
||||
expect(html).toContain('周报草稿正文')
|
||||
expect(html).toContain('自动保存')
|
||||
expect(html).toContain('周报周次下拉选择')
|
||||
expect(html).toContain('预览')
|
||||
expect(html).not.toContain('发送周报')
|
||||
expect(html).not.toContain('发布周报')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { fetchQARecord, getPayload, qaCellValue } from './qaOps'
|
||||
import type { QARecord } from './qaOps'
|
||||
export const editableFieldKeys = ['coverage', 'is_correct', 'product_category', 'problem_type', 'root_cause'] as const
|
||||
export type EditableField = (typeof editableFieldKeys)[number]
|
||||
export type FieldOptions = Record<EditableField, string[]>
|
||||
export function parseFieldOptions(raw: unknown): FieldOptions {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw Error('主表选项读取失败')
|
||||
const v = raw as Record<string, unknown>
|
||||
if (v.version !== 'qa-master-options-v1' || !v.fields || typeof v.fields !== 'object' || Array.isArray(v.fields))
|
||||
throw Error('主表选项版本无效')
|
||||
const fields = v.fields as Record<string, unknown>
|
||||
if (Object.keys(fields).length !== editableFieldKeys.length) throw Error('主表选项不完整')
|
||||
const result = {} as FieldOptions
|
||||
for (const key of editableFieldKeys) {
|
||||
const values = fields[key]
|
||||
if (
|
||||
!Array.isArray(values) ||
|
||||
!values.length ||
|
||||
values.some((v) => typeof v !== 'string' || !v.trim()) ||
|
||||
new Set(values).size !== values.length
|
||||
)
|
||||
throw Error('主表选项不完整')
|
||||
result[key] = values as string[]
|
||||
}
|
||||
return result
|
||||
}
|
||||
export async function fetchFieldOptions(signal?: AbortSignal) {
|
||||
return parseFieldOptions(await getPayload('/api/v1/qa-ops/master/field-options', signal))
|
||||
}
|
||||
export function editableFieldValue(record: QARecord, field: EditableField): string {
|
||||
if (field === 'root_cause' && qaCellValue(record, 'is_correct') === '是') return ''
|
||||
const value = qaCellValue(record, field) ?? ''
|
||||
return field === 'coverage' && value === '不计入' ? '否但不计入' : value
|
||||
}
|
||||
export async function saveInlineField(
|
||||
record: QARecord,
|
||||
field: EditableField,
|
||||
value: string,
|
||||
nonce: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<QARecord> {
|
||||
if (!record.record_origin || !record.quality) throw Error('此记录暂不可修改,请重新读取')
|
||||
const q = record.quality
|
||||
const raw = await getPayload(`/api/v1/qa-ops/master/records/${encodeURIComponent(record.id)}/fields`, signal, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-master-fields' },
|
||||
body: JSON.stringify({
|
||||
input_hash: q.input_hash,
|
||||
input_epoch: q.input_epoch,
|
||||
base_revision: record.field_edit_revision ?? 0,
|
||||
base_review_revision: q.revision,
|
||||
nonce,
|
||||
fields: { [field]: value },
|
||||
}),
|
||||
})
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw Error('字段保存回执无效,请重试读取')
|
||||
const receipt = raw as Record<string, unknown>
|
||||
if (
|
||||
!Number.isSafeInteger(receipt.saved_revision) ||
|
||||
Number(receipt.saved_revision) < 1 ||
|
||||
typeof receipt.replayed !== 'boolean'
|
||||
)
|
||||
throw Error('字段保存回执无效,请重试读取')
|
||||
const fresh = await fetchQARecord(record.id, signal, 'master')
|
||||
if (
|
||||
fresh.field_edit_revision !== receipt.saved_revision ||
|
||||
fresh.quality?.input_hash !== q.input_hash ||
|
||||
fresh.quality.revision !== q.revision ||
|
||||
editableFieldValue(fresh, field) !== value
|
||||
)
|
||||
throw Error('保存后字段再次变化,未将旧结果标为成功,请重新读取核对')
|
||||
return fresh
|
||||
}
|
||||
+18
-1
@@ -84,6 +84,7 @@ export function mergeQAPages(current: QARecordPage, next: QARecordPage): QARecor
|
||||
}
|
||||
}
|
||||
export interface QARecord {
|
||||
field_edit_revision?: number
|
||||
background_tasks?: AIWorkState[]
|
||||
record_origin?: 'dobotai_2_0' | 'historical_excel'
|
||||
master_fields?: Partial<Record<QAColumn, string>>
|
||||
@@ -215,6 +216,7 @@ export function parseQARecord(value: unknown): QARecord {
|
||||
if (typeof row.is_correct !== 'boolean' && row.is_correct !== null) return invalid()
|
||||
item.is_correct = row.is_correct
|
||||
}
|
||||
if (row.field_edit_revision !== undefined) item.field_edit_revision = count(row.field_edit_revision)
|
||||
if (row.record_origin !== undefined) {
|
||||
if (row.record_origin !== 'dobotai_2_0' && row.record_origin !== 'historical_excel') return invalid()
|
||||
item.record_origin = row.record_origin
|
||||
@@ -291,6 +293,16 @@ export function parseQARecordPage(value: unknown): QARecordPage {
|
||||
}
|
||||
}
|
||||
|
||||
export class QARequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'QARequestError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPayload(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
@@ -300,7 +312,10 @@ export async function getPayload(
|
||||
const raw: unknown = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
const payload = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : null
|
||||
throw new Error(typeof payload?.error === 'string' ? payload.error : `问答数据读取失败(HTTP ${response.status})`)
|
||||
throw new QARequestError(
|
||||
typeof payload?.error === 'string' ? payload.error : `问答数据读取失败(HTTP ${response.status})`,
|
||||
response.status
|
||||
)
|
||||
}
|
||||
return object(raw).data
|
||||
}
|
||||
@@ -399,6 +414,8 @@ const qaAskedAtFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
})
|
||||
|
||||
export function qaCellValue(item: QARecord, column: QAColumn): string | null {
|
||||
if (column === 'root_cause' && item.field_edit_revision && item.quality?.effective.correctness === 'correct')
|
||||
return null
|
||||
if (column === 'trigger_person') return triggerPersonName(item.master_fields?.trigger_person ?? item.trigger_person)
|
||||
|
||||
const reviewedQuality =
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
.qa-period-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
.qa-period-combo {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
width: 240px;
|
||||
max-width: 100%;
|
||||
border: 1px solid #d7e0eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qa-period-combo input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
}
|
||||
.qa-period-combo select {
|
||||
width: 74px;
|
||||
min-width: 74px;
|
||||
border: 0 !important;
|
||||
border-left: 1px solid #e3e9f0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: #f5f8fc;
|
||||
padding: 7px 5px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.qa-period-combo:focus-within {
|
||||
outline: 2px solid #83b6f4;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.qa-period-input small {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
max-width: 360px;
|
||||
white-space: normal;
|
||||
}
|
||||
.qa-period-input small[role='alert'] {
|
||||
color: #b42318;
|
||||
}
|
||||
.qa-period-combo input[aria-invalid='true'] {
|
||||
background: #fff4f2;
|
||||
}
|
||||
.qa-inline-fields {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
}
|
||||
.qa-inline-fields .qa-detail-quality label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.qa-inline-fields .qa-detail-quality select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border: 1px solid #d7e0eb;
|
||||
border-radius: 6px;
|
||||
padding: 6px 4px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.qa-inline-fields .qa-detail-quality select:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.qa-inline-save {
|
||||
margin: 2px 18px 6px;
|
||||
font-size: 12px;
|
||||
color: #35634c;
|
||||
}
|
||||
.qa-inline-fields > .error-banner {
|
||||
margin: 4px 18px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { validPeriodInput, periodInputOptions } from './qaPeriodInput'
|
||||
describe('typed or dropdown natural periods', () => {
|
||||
it('accepts canonical months and real cross-year weeks and rejects incomplete, future or impossible inputs', () => {
|
||||
expect(validPeriodInput('month', '2026-02', '2026-10')).toBe(true)
|
||||
expect(validPeriodInput('week', '2020-W53', '2021-W01')).toBe(true)
|
||||
for (const value of ['', '2026-1', '2026-00', '2026-13', '2027-01', ' 2026-02', '1999-12'])
|
||||
expect(validPeriodInput('month', value, '2026-10')).toBe(false)
|
||||
for (const value of ['', '2021-W53', '2021-W1', '2021-W00', '2026-W44', '2020-W+1', '2020-W53 '])
|
||||
expect(validPeriodInput('week', value, '2026-W43')).toBe(false)
|
||||
})
|
||||
it('offers selectable periods across year boundaries without replacing the typed value with an invalid date', () => {
|
||||
expect(periodInputOptions('week', '2021-W01').slice(0, 2)).toEqual(['2021-W01', '2020-W53'])
|
||||
expect(periodInputOptions('month', '2026-01').slice(0, 2)).toEqual(['2026-01', '2025-12'])
|
||||
expect(periodInputOptions('week', '2021-W53')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { qaWeek, currentQAWeek, qaWeekValue } from './qaWeeks'
|
||||
export type QAPeriodKind = 'month' | 'week'
|
||||
export function validPeriodInput(kind: QAPeriodKind, value: string, max?: string): boolean {
|
||||
const pattern = kind === 'month' ? /^[0-9]{4}-[0-9]{2}$/ : /^[0-9]{4}-W[0-9]{2}$/
|
||||
if (!pattern.test(value)) return false
|
||||
const year = Number(value.slice(0, 4))
|
||||
if (year < 2000 || year > 9998) return false
|
||||
if (kind === 'month') {
|
||||
const month = Number(value.slice(5))
|
||||
if (month < 1 || month > 12) return false
|
||||
} else {
|
||||
try {
|
||||
qaWeek(year, Number(value.slice(6)))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return !max || (validPeriodInput(kind, max) && value <= max)
|
||||
}
|
||||
export function periodInputOptions(kind: QAPeriodKind, max: string): string[] {
|
||||
if (!validPeriodInput(kind, max)) return []
|
||||
const result: string[] = []
|
||||
if (kind === 'month') {
|
||||
let year = Number(max.slice(0, 4)),
|
||||
month = Number(max.slice(5))
|
||||
for (let i = 0; i < 36 && year >= 2000; i++) {
|
||||
result.push(`${year}-${String(month).padStart(2, '0')}`)
|
||||
month--
|
||||
if (month === 0) {
|
||||
year--
|
||||
month = 12
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const start = qaWeek(Number(max.slice(0, 4)), Number(max.slice(6))).start
|
||||
const monday = Date.parse(`${start}:00Z`)
|
||||
for (let i = 0; i < 156; i++) {
|
||||
const p = currentQAWeek(new Date(monday - i * 7 * 86400000))
|
||||
if (p.year < 2000) break
|
||||
result.push(qaWeekValue(p))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
+13
-6
@@ -24,6 +24,18 @@ function csvCell(value: unknown) {
|
||||
const safe = /^[\t\r\n]|^\s*[=+\-@]/.test(v) ? "'" + v : v
|
||||
return '"' + safe.replace(/"/g, '""') + '"'
|
||||
}
|
||||
export function buildQAWeeklyCSV(page: QARecordPage): string {
|
||||
if (page.has_more || page.total !== page.items.length || new Set(page.items.map((r) => r.id)).size !== page.total)
|
||||
throw Error('明细未完整读取,未导出部分数据')
|
||||
const csv =
|
||||
'\ufeff' +
|
||||
[
|
||||
qaColumns.map((c) => csvCell(c.label)).join(','),
|
||||
...page.items.map((row) => qaColumns.map((c) => csvCell(qaCellValue(row, c.key))).join(',')),
|
||||
].join('\r\n')
|
||||
return csv
|
||||
}
|
||||
|
||||
export function buildQAWeeklyDraft(
|
||||
page: QARecordPage,
|
||||
query: QAQuery,
|
||||
@@ -106,12 +118,7 @@ export function buildQAWeeklyDraft(
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n') + '\n'
|
||||
const csv =
|
||||
'\ufeff' +
|
||||
[
|
||||
qaColumns.map((c) => csvCell(c.label)).join(','),
|
||||
...page.items.map((row) => qaColumns.map((c) => csvCell(qaCellValue(row, c.key))).join(',')),
|
||||
].join('\r\n')
|
||||
const csv = buildQAWeeklyCSV(page)
|
||||
return {
|
||||
markdown: md.replace(/\|\n\n\|/g, '|\n|'),
|
||||
csv,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { parseWeeklyDocument, weeklyTransport } from './qaWeeklyDraftAPI'
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
const empty = { week: '2026-W41', revision: 0, markdown: '', seed: {}, updated_at: null }
|
||||
describe('weekly document protocol', () => {
|
||||
it('verifies an exact GET after POST and refuses a newer conflicting head', async () => {
|
||||
const document = { ...empty, revision: 1, markdown: '用户正文', updated_at: '2026-10-19T10:00:00Z' }
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { draft: document, replayed: false } }), { status: 200 })
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: document }), { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
expect(
|
||||
await weeklyTransport.write(empty.week, {
|
||||
base_revision: 0,
|
||||
nonce: 'fixture_client_weekly_0001',
|
||||
markdown: document.markdown,
|
||||
})
|
||||
).toEqual(document)
|
||||
expect(fetch.mock.calls).toHaveLength(2)
|
||||
expect(fetch.mock.calls[0][1].method).toBe('POST')
|
||||
expect(fetch.mock.calls[1][1].method ?? 'GET').toBe('GET')
|
||||
fetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { draft: document, replayed: true } }), { status: 200 })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { ...document, revision: 2, markdown: 'another editor' } }), {
|
||||
status: 200,
|
||||
})
|
||||
)
|
||||
await expect(
|
||||
weeklyTransport.write(empty.week, {
|
||||
base_revision: 0,
|
||||
nonce: 'fixture_client_weekly_0001',
|
||||
markdown: document.markdown,
|
||||
})
|
||||
).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
it('refuses foreign weeks, fabricated saved content and invalid revisions rather than showing false success', () => {
|
||||
expect(parseWeeklyDocument(empty, empty.week)).toEqual(empty)
|
||||
for (const value of [
|
||||
{ ...empty, week: '2026-W40' },
|
||||
{ ...empty, revision: -1 },
|
||||
{ ...empty, revision: 0, markdown: 'invented persisted content' },
|
||||
{ ...empty, revision: 1, updated_at: null },
|
||||
{ ...empty, seed: [] },
|
||||
{ ...empty, markdown: 9 },
|
||||
])
|
||||
expect(() => parseWeeklyDocument(value, empty.week)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { WeeklyDocument, WeeklyTransport } from './qaWeeklyDrafts'
|
||||
import { validPeriodInput } from './qaPeriodInput'
|
||||
import { getPayload, QARequestError } from './qaOps'
|
||||
import type { WeeklyWriteRequest, WeeklyPrefillRequest } from './qaWeeklyDrafts'
|
||||
const invalid = (): never => {
|
||||
throw Error('草稿响应无效,未展示或标记为已保存')
|
||||
}
|
||||
export function parseWeeklyDocument(value: unknown, week: string): WeeklyDocument {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return invalid()
|
||||
const v = value as Record<string, unknown>
|
||||
if (
|
||||
v.week !== week ||
|
||||
!validPeriodInput('week', week) ||
|
||||
typeof v.revision !== 'number' ||
|
||||
!Number.isSafeInteger(v.revision) ||
|
||||
v.revision < 0 ||
|
||||
typeof v.markdown !== 'string' ||
|
||||
new TextEncoder().encode(v.markdown).length > 256 * 1024 ||
|
||||
!v.seed ||
|
||||
typeof v.seed !== 'object' ||
|
||||
Array.isArray(v.seed)
|
||||
)
|
||||
return invalid()
|
||||
if (v.revision === 0) {
|
||||
if (v.markdown !== '' || v.updated_at !== null || Object.keys(v.seed).length) return invalid()
|
||||
} else if (typeof v.updated_at !== 'string' || !Number.isFinite(Date.parse(v.updated_at))) return invalid()
|
||||
return {
|
||||
week,
|
||||
revision: v.revision,
|
||||
markdown: v.markdown,
|
||||
seed: v.seed as Record<string, unknown>,
|
||||
updated_at: v.updated_at as string | null,
|
||||
}
|
||||
}
|
||||
const endpoint = (week: string) => `/api/v1/qa-ops/master/weekly-drafts/${encodeURIComponent(week)}`
|
||||
async function read(week: string) {
|
||||
return parseWeeklyDocument(await getPayload(endpoint(week)), week)
|
||||
}
|
||||
async function save(week: string, q: WeeklyWriteRequest | WeeklyPrefillRequest, prefill: boolean) {
|
||||
const raw = await getPayload(endpoint(week) + (prefill ? '/prefill' : ''), undefined, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-weekly-draft' },
|
||||
body: JSON.stringify(q),
|
||||
})
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return invalid()
|
||||
const receipt = raw as Record<string, unknown>,
|
||||
draft = parseWeeklyDocument(receipt.draft, week)
|
||||
if (
|
||||
typeof receipt.replayed !== 'boolean' ||
|
||||
draft.revision !== q.base_revision + 1 ||
|
||||
(!prefill && draft.markdown !== (q as WeeklyWriteRequest).markdown)
|
||||
)
|
||||
return invalid()
|
||||
const current = await read(week)
|
||||
if (current.revision !== draft.revision || current.markdown !== draft.markdown)
|
||||
throw new QARequestError('本周草稿在保存后又发生变化;你的文字仍保留,请核对服务器版本', 409)
|
||||
return current
|
||||
}
|
||||
export const weeklyTransport: WeeklyTransport = {
|
||||
read,
|
||||
write: (week, q) => save(week, q, false),
|
||||
prefill: (week, q) => save(week, q, true),
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { WeeklyDraftEditorModel } from './qaWeeklyDrafts'
|
||||
import type { WeeklyDocument, WeeklyWriteRequest } from './qaWeeklyDrafts'
|
||||
const empty: WeeklyDocument = { week: '2026-W41', revision: 0, markdown: '', seed: {}, updated_at: null }
|
||||
const saved = (q: WeeklyWriteRequest): WeeklyDocument => ({
|
||||
...empty,
|
||||
revision: q.base_revision + 1,
|
||||
markdown: q.markdown,
|
||||
updated_at: '2026-10-19T10:00:00Z',
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
describe('weekly draft autosave model', () => {
|
||||
it('retains input and reports a safe failure if an operation identifier cannot be created', async () => {
|
||||
const write = vi.fn()
|
||||
const model = new WeeklyDraftEditorModel(empty.week, { read: async () => empty, write, prefill: vi.fn() })
|
||||
await model.initialize()
|
||||
model.setText('不可丢失的输入')
|
||||
vi.stubGlobal('crypto', undefined)
|
||||
expect(await model.flush()).toBe(false)
|
||||
expect(model.state.text).toBe('不可丢失的输入')
|
||||
expect(model.state.errorKind).toBe('save')
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
model.dispose()
|
||||
})
|
||||
|
||||
it('does not regenerate an existing draft on opening and saves edited text before explicit replacement', async () => {
|
||||
const initial = { ...empty, revision: 1, markdown: '原草稿', updated_at: '2026-10-19T10:00:00Z' }
|
||||
const write = vi.fn(async (_week: string, q: WeeklyWriteRequest) => saved(q)),
|
||||
prefill = vi.fn(async (week: string, q: { base_revision: number; nonce: string }) => ({
|
||||
...initial,
|
||||
week,
|
||||
revision: q.base_revision + 1,
|
||||
markdown: '新预填模板',
|
||||
}))
|
||||
const model = new WeeklyDraftEditorModel(empty.week, { read: async () => initial, write, prefill })
|
||||
await model.initialize()
|
||||
expect(prefill).not.toHaveBeenCalled()
|
||||
model.setText('用户原稿的最后输入')
|
||||
expect(await model.prefill()).toBe(true)
|
||||
expect(write.mock.calls[0][1].markdown).toBe('用户原稿的最后输入')
|
||||
expect(prefill.mock.calls[0][1].base_revision).toBe(2)
|
||||
expect(model.state.text).toBe('新预填模板')
|
||||
expect(model.state.document?.revision).toBe(3)
|
||||
model.dispose()
|
||||
})
|
||||
it('saves outstanding text before switching weeks and restores each week independently', async () => {
|
||||
const documents = new Map<string, WeeklyDocument>([
|
||||
[empty.week, empty],
|
||||
[
|
||||
'2026-W40',
|
||||
{ ...empty, week: '2026-W40', revision: 4, markdown: '上周原稿', updated_at: '2026-10-19T10:00:00Z' },
|
||||
],
|
||||
])
|
||||
const read = vi.fn(async (week: string) => documents.get(week)!),
|
||||
write = vi.fn(async (week: string, q: WeeklyWriteRequest) => {
|
||||
const value = { ...saved(q), week }
|
||||
documents.set(week, value)
|
||||
return value
|
||||
})
|
||||
const model = new WeeklyDraftEditorModel(empty.week, { read, write, prefill: vi.fn() })
|
||||
await model.initialize()
|
||||
model.setText('本周未保存输入')
|
||||
expect(await model.switchWeek('2026-W40')).toBe(true)
|
||||
expect(write.mock.calls[0][1].markdown).toBe('本周未保存输入')
|
||||
expect(model.state.text).toBe('上周原稿')
|
||||
expect(await model.switchWeek(empty.week)).toBe(true)
|
||||
expect(model.state.text).toBe('本周未保存输入')
|
||||
model.dispose()
|
||||
})
|
||||
it('retries the identical uncertain request before sending newer keystrokes and blocks week changes on failure', async () => {
|
||||
const write = vi
|
||||
.fn<(_: string, q: WeeklyWriteRequest) => Promise<WeeklyDocument>>()
|
||||
.mockRejectedValueOnce(Error('网络断开'))
|
||||
.mockImplementation(async (_week, q) => saved(q))
|
||||
const model = new WeeklyDraftEditorModel(empty.week, { read: async () => empty, write, prefill: vi.fn() })
|
||||
await model.initialize()
|
||||
model.setText('original')
|
||||
expect(await model.flush()).toBe(false)
|
||||
const request = write.mock.calls[0][1]
|
||||
expect(model.state.errorKind).toBe('save')
|
||||
model.setText('newer input after failure')
|
||||
expect(await model.flush()).toBe(true)
|
||||
expect(write.mock.calls[1][1]).toEqual(request)
|
||||
expect(write.mock.calls[2][1].base_revision).toBe(1)
|
||||
expect(write.mock.calls[2][1].markdown).toBe('newer input after failure')
|
||||
expect(write.mock.calls[2][1].nonce).not.toBe(request.nonce)
|
||||
expect(model.state.document?.revision).toBe(2)
|
||||
model.dispose()
|
||||
})
|
||||
it('debounces continuous typing and persists the latest exact text, not the initial keystroke', async () => {
|
||||
vi.useFakeTimers()
|
||||
const write = vi.fn(async (_week: string, q: WeeklyWriteRequest) => saved(q))
|
||||
const model = new WeeklyDraftEditorModel(empty.week, { read: async () => empty, write, prefill: vi.fn() })
|
||||
await model.initialize()
|
||||
model.setText('first')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
model.setText('最后输入 / last edit')
|
||||
await vi.advanceTimersByTimeAsync(799)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0][1].markdown).toBe('最后输入 / last edit')
|
||||
expect(model.state.document?.markdown).toBe('最后输入 / last edit')
|
||||
expect(model.state.error).toBe('')
|
||||
model.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { newOperationID } from './operationKey'
|
||||
export interface WeeklyDocument {
|
||||
week: string
|
||||
revision: number
|
||||
markdown: string
|
||||
seed: Record<string, unknown>
|
||||
updated_at: string | null
|
||||
}
|
||||
export interface WeeklyWriteRequest {
|
||||
base_revision: number
|
||||
nonce: string
|
||||
markdown: string
|
||||
}
|
||||
export interface WeeklyPrefillRequest {
|
||||
base_revision: number
|
||||
nonce: string
|
||||
}
|
||||
export interface WeeklyTransport {
|
||||
read: (week: string) => Promise<WeeklyDocument>
|
||||
write: (week: string, q: WeeklyWriteRequest) => Promise<WeeklyDocument>
|
||||
prefill: (week: string, q: WeeklyPrefillRequest) => Promise<WeeklyDocument>
|
||||
}
|
||||
export interface WeeklyEditorState {
|
||||
week: string
|
||||
document: WeeklyDocument | null
|
||||
text: string
|
||||
loading: boolean
|
||||
busy: boolean
|
||||
saving: boolean
|
||||
error: string
|
||||
errorKind: '' | 'load' | 'save' | 'conflict' | 'prefill'
|
||||
}
|
||||
const errorText = (e: unknown) => (e instanceof Error ? e.message : '草稿操作失败,请保留文字并重试')
|
||||
const conflict = (e: unknown) => !!e && typeof e === 'object' && 'status' in e && e.status === 409
|
||||
export class WeeklyDraftEditorModel {
|
||||
private snapshot: WeeklyEditorState
|
||||
private listeners = new Set<() => void>()
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private pending: WeeklyWriteRequest | null = null
|
||||
private prefillPending: WeeklyPrefillRequest | null = null
|
||||
get uncertainPrefill() {
|
||||
return !!this.prefillPending
|
||||
}
|
||||
private flight: Promise<boolean> | null = null
|
||||
private generation = 0
|
||||
private disposed = false
|
||||
constructor(
|
||||
week: string,
|
||||
private transport: WeeklyTransport
|
||||
) {
|
||||
this.snapshot = {
|
||||
week,
|
||||
document: null,
|
||||
text: '',
|
||||
loading: false,
|
||||
busy: false,
|
||||
saving: false,
|
||||
error: '',
|
||||
errorKind: '',
|
||||
}
|
||||
}
|
||||
get state() {
|
||||
return this.snapshot
|
||||
}
|
||||
get dirty() {
|
||||
return !!this.state.document && (this.state.text !== this.state.document.markdown || !!this.pending)
|
||||
}
|
||||
getSnapshot = () => this.snapshot
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
private update(change: Partial<WeeklyEditorState>) {
|
||||
this.snapshot = { ...this.snapshot, ...change }
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
private stopTimer() {
|
||||
if (this.timer !== null) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
private schedule() {
|
||||
this.stopTimer()
|
||||
if (this.disposed || !this.dirty || this.state.error || this.state.loading || this.state.busy || this.state.saving)
|
||||
return
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
void this.saveOne()
|
||||
}, 800)
|
||||
}
|
||||
async initialize() {
|
||||
const generation = ++this.generation
|
||||
this.disposed = false
|
||||
this.update({ loading: true, error: '', errorKind: '' })
|
||||
try {
|
||||
const document = await this.transport.read(this.state.week)
|
||||
if (generation !== this.generation) return
|
||||
this.pending = null
|
||||
this.update({ document, text: document.markdown })
|
||||
} catch (e) {
|
||||
if (generation === this.generation) this.update({ error: errorText(e), errorKind: 'load' })
|
||||
} finally {
|
||||
if (generation === this.generation) this.update({ loading: false })
|
||||
}
|
||||
}
|
||||
setText(text: string) {
|
||||
if (this.state.busy || this.state.loading || this.prefillPending || !this.state.document) return
|
||||
this.update({ text })
|
||||
this.schedule()
|
||||
}
|
||||
private async saveOne(): Promise<boolean> {
|
||||
if (this.flight) return this.flight
|
||||
if (this.state.errorKind === 'conflict') return false
|
||||
const document = this.state.document
|
||||
if (!document || !this.dirty) return true
|
||||
if (!this.pending) {
|
||||
try {
|
||||
this.pending = { base_revision: document.revision, nonce: newOperationID(), markdown: this.state.text }
|
||||
} catch (e) {
|
||||
this.update({ error: errorText(e), errorKind: 'save' })
|
||||
return false
|
||||
}
|
||||
}
|
||||
const request = this.pending,
|
||||
week = this.state.week
|
||||
this.flight = Promise.resolve().then(async () => {
|
||||
this.update({ saving: true, error: '', errorKind: '' })
|
||||
try {
|
||||
const fresh = await this.transport.write(week, request)
|
||||
if (fresh.week !== week || fresh.markdown !== request.markdown || fresh.revision !== request.base_revision + 1)
|
||||
throw Error('保存版本或正文不匹配,请重新读取核对')
|
||||
this.pending = null
|
||||
this.update({ document: fresh })
|
||||
return true
|
||||
} catch (e) {
|
||||
this.update({ error: errorText(e), errorKind: conflict(e) ? 'conflict' : 'save' })
|
||||
return false
|
||||
} finally {
|
||||
this.update({ saving: false })
|
||||
this.flight = null
|
||||
this.schedule()
|
||||
}
|
||||
})
|
||||
return this.flight
|
||||
}
|
||||
async prefill() {
|
||||
if (this.state.busy || this.state.loading || !this.state.document) return false
|
||||
this.update({ busy: true })
|
||||
this.stopTimer()
|
||||
try {
|
||||
if (!this.prefillPending) {
|
||||
if (!(await this.flush())) return false
|
||||
this.prefillPending = { base_revision: this.state.document!.revision, nonce: newOperationID() }
|
||||
}
|
||||
const request = this.prefillPending,
|
||||
week = this.state.week
|
||||
this.update({ error: '', errorKind: '' })
|
||||
const document = await this.transport.prefill(week, request)
|
||||
if (document.week !== week || document.revision !== request.base_revision + 1)
|
||||
throw Error('预填版本无效,尚未确认保存')
|
||||
this.prefillPending = null
|
||||
this.pending = null
|
||||
this.update({ document, text: document.markdown })
|
||||
return true
|
||||
} catch (e) {
|
||||
if (conflict(e)) this.prefillPending = null
|
||||
this.update({ error: errorText(e), errorKind: conflict(e) ? 'conflict' : 'prefill' })
|
||||
return false
|
||||
} finally {
|
||||
this.update({ busy: false })
|
||||
}
|
||||
}
|
||||
async beforeLeave() {
|
||||
if (this.state.busy) return false
|
||||
this.update({ busy: true })
|
||||
try {
|
||||
return await this.flush()
|
||||
} finally {
|
||||
this.update({ busy: false })
|
||||
}
|
||||
}
|
||||
async switchWeek(week: string) {
|
||||
if (week === this.state.week) return true
|
||||
if (this.state.busy) return false
|
||||
this.update({ busy: true })
|
||||
try {
|
||||
if (!(await this.flush())) return false
|
||||
this.stopTimer()
|
||||
this.pending = null
|
||||
this.prefillPending = null
|
||||
this.update({ week, document: null, text: '' })
|
||||
await this.initialize()
|
||||
return !this.state.error
|
||||
} finally {
|
||||
this.update({ busy: false })
|
||||
}
|
||||
}
|
||||
async reload() {
|
||||
if (this.state.busy) return false
|
||||
this.update({ busy: true })
|
||||
try {
|
||||
this.stopTimer()
|
||||
if (this.flight) await this.flight
|
||||
this.pending = null
|
||||
this.prefillPending = null
|
||||
await this.initialize()
|
||||
return !this.state.error
|
||||
} finally {
|
||||
this.update({ busy: false })
|
||||
}
|
||||
}
|
||||
async flush() {
|
||||
this.stopTimer()
|
||||
if (this.prefillPending || this.state.errorKind === 'conflict') return false
|
||||
do {
|
||||
if (!(await this.saveOne())) return false
|
||||
} while (this.dirty)
|
||||
return true
|
||||
}
|
||||
dispose() {
|
||||
this.disposed = true
|
||||
this.generation++
|
||||
this.stopTimer()
|
||||
this.listeners.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
.qa-weekly.qa-weekly-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qa-weekly-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.qa-weekly-editor-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #17324d;
|
||||
}
|
||||
.qa-weekly-editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.qa-weekly-reload {
|
||||
min-width: 168px;
|
||||
}
|
||||
.qa-weekly-prefill {
|
||||
min-width: 144px;
|
||||
}
|
||||
.qa-weekly-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.qa-weekly-mode {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.qa-weekly-mode button[aria-pressed='true'] {
|
||||
background: #eaf3ff;
|
||||
color: #155cb5;
|
||||
border-color: #a3c7f3;
|
||||
}
|
||||
.qa-weekly-editor-status {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
.qa-weekly-editor-status[data-weekly-save-state='saved'] {
|
||||
color: #2d704d;
|
||||
}
|
||||
.qa-weekly-editor-status[data-weekly-save-state='error'] {
|
||||
color: #b42318;
|
||||
}
|
||||
.qa-weekly-editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dce4ed;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.qa-weekly-editor-layout[data-mode='edit'],
|
||||
.qa-weekly-editor-layout[data-mode='preview'] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.qa-weekly-writing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.qa-weekly-writing > label {
|
||||
padding: 10px 16px;
|
||||
background: #f5f8fc;
|
||||
border-bottom: 1px solid #e3e9f0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #344b66;
|
||||
}
|
||||
.qa-weekly-writing > label small {
|
||||
font-weight: 400;
|
||||
color: #728298;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.qa-weekly-writing textarea {
|
||||
flex: 1;
|
||||
min-height: 260px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline-offset: -3px;
|
||||
border-radius: 0;
|
||||
resize: none;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
font:
|
||||
13px/1.8 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Consolas,
|
||||
monospace;
|
||||
color: #263c52;
|
||||
background: #fff;
|
||||
tab-size: 2;
|
||||
}
|
||||
.qa-weekly-writing textarea:disabled {
|
||||
background: #f6f8fb;
|
||||
color: #6c7a8a;
|
||||
}
|
||||
.qa-weekly-editor-layout .qa-weekly-preview {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-left: 1px solid #e3e9f0;
|
||||
border-radius: 0;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
}
|
||||
.qa-weekly-editor-layout[data-mode='preview'] .qa-weekly-preview {
|
||||
border-left: 0;
|
||||
}
|
||||
.qa-weekly-source {
|
||||
font-size: 12px;
|
||||
color: #66778c;
|
||||
}
|
||||
.qa-weekly-source p {
|
||||
margin: 7px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.qa-weekly-source summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.qa-weekly-editor > .error-banner {
|
||||
margin: 0;
|
||||
}
|
||||
.qa-weekly-editor > .error-banner button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.qa-weekly-editor .qa-period-input {
|
||||
min-width: 210px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.qa-weekly-editor-header {
|
||||
gap: 10px;
|
||||
}
|
||||
.qa-weekly-editor-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
.qa-weekly-editor-layout[data-mode='both'] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
.qa-weekly-editor-layout[data-mode='both'] .qa-weekly-writing {
|
||||
min-height: 360px;
|
||||
flex: none;
|
||||
}
|
||||
.qa-weekly-editor-layout[data-mode='both'] .qa-weekly-preview {
|
||||
min-height: 260px;
|
||||
overflow: visible;
|
||||
border-left: 0;
|
||||
border-top: 1px solid #e3e9f0;
|
||||
}
|
||||
.qa-weekly-editor-header .qa-period-input {
|
||||
width: 100%;
|
||||
}
|
||||
.qa-weekly-editor-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,13 @@ export interface QAWeek {
|
||||
const day = 86_400_000
|
||||
const dateOnly = (value: number) => new Date(value).toISOString().slice(0, 10)
|
||||
|
||||
export function qaWeekValue(value: Pick<QAWeek, 'year' | 'week'>): string {
|
||||
return `${value.year}-W${String(value.week).padStart(2, '0')}`
|
||||
}
|
||||
export function currentQAWeekValue(now = new Date()): string {
|
||||
return qaWeekValue(currentQAWeek(now))
|
||||
}
|
||||
|
||||
export function currentQAWeek(now = new Date()): QAWeek {
|
||||
const beijing = new Date(now.getTime() + 8 * 3_600_000)
|
||||
beijing.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
Reference in New Issue
Block a user