@marsken/dsh-studio 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +148 -0
- package/LICENSE +202 -0
- package/README.md +1367 -3
- package/client.js +952 -0
- package/client.src.js +2466 -0
- package/cordis.patch.yml +9 -0
- package/dist/client.js +952 -0
- package/docs/compatibility.md +101 -0
- package/docs/images/board.png +0 -0
- package/docs/images/cases.png +0 -0
- package/docs/images/quality.png +0 -0
- package/docs/publish.md +123 -0
- package/docs/quickstart.md +53 -0
- package/icon.svg +6 -0
- package/index.js +2674 -0
- package/locale/en.json +11 -0
- package/locale/zh.json +11 -0
- package/package.json +64 -4
- package/scripts/smoke-store.mjs +76 -0
- package/src/host/cases.js +135 -0
- package/src/host/design.js +87 -0
- package/src/host/dispatcher.js +936 -0
- package/src/host/domain.js +1966 -0
- package/src/host/executor.js +1396 -0
- package/src/host/gitlab-facts.js +22 -0
- package/src/host/impact.js +32 -0
- package/src/host/integrations.js +587 -0
- package/src/host/quality.js +106 -0
- package/src/host/routes/admin.js +100 -0
- package/src/host/routes/agents.js +71 -0
- package/src/host/routes/auth.js +155 -0
- package/src/host/routes/coding.js +203 -0
- package/src/host/routes/connect.js +166 -0
- package/src/host/routes/exports.js +122 -0
- package/src/host/routes/insights.js +97 -0
- package/src/host/routes/releases.js +335 -0
- package/src/host/routes/requirements.js +430 -0
- package/src/host/routes/skills.js +75 -0
- package/src/host/routes/tasks.js +573 -0
- package/src/host/store.js +522 -0
- package/src/host/team-memory.js +46 -0
- package/src/host/tools.js +1211 -0
package/locale/en.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Studio",
|
|
4
|
+
"description": "Product / architect / developer / QA multi-agent collaboration with a live work board; GitLab and Feishu connections are configured in the GUI."
|
|
5
|
+
},
|
|
6
|
+
"shared": {
|
|
7
|
+
"entry": {
|
|
8
|
+
"label": "Studio"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marsken/dsh-studio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "DSH Studio: role-based multi-agent workspace (product / architect / developer / QA) with a live work board, GitLab + Feishu connection settings, all configurable from the web GUI.",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"keywords": ["dsh", "dsh-plugin", "ai-agents", "kanban", "multi-agent", "requirements", "regression"],
|
|
8
|
+
"icon": "./icon.svg",
|
|
9
|
+
"repository": { "type": "git", "url": "git+https://github.com/marsken88/dsh-studio.git" },
|
|
10
|
+
"homepage": "https://github.com/marsken88/dsh-studio#readme",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test tests/repo.test.mjs tests/store-boot.test.mjs tests/client-units.test.mjs tests/notify.test.mjs",
|
|
13
|
+
"check": "node --check index.js && node --check client.src.js && node --test tests/repo.test.mjs tests/store-boot.test.mjs tests/client-units.test.mjs tests/notify.test.mjs",
|
|
14
|
+
"smoke:store": "node scripts/smoke-store.mjs"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./index.js",
|
|
18
|
+
"./client": "./client.js",
|
|
19
|
+
"./locale/*.json": "./locale/*.json",
|
|
20
|
+
"./icon": "./icon.svg",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"index.js",
|
|
25
|
+
"src",
|
|
26
|
+
"client.js",
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"locale/*.json",
|
|
29
|
+
"icon.svg",
|
|
30
|
+
"README.md",
|
|
31
|
+
"dist/client.js",
|
|
32
|
+
"client.src.js",
|
|
33
|
+
"AGENTS.md",
|
|
34
|
+
"docs/*.md",
|
|
35
|
+
"docs/images/*.png",
|
|
36
|
+
"scripts/*.mjs"
|
|
37
|
+
],
|
|
38
|
+
"dsh": {
|
|
39
|
+
"bundle": {
|
|
40
|
+
"patch": "./cordis.patch.yml"
|
|
41
|
+
},
|
|
42
|
+
"client": {
|
|
43
|
+
"platform": "web",
|
|
44
|
+
"immediately": true,
|
|
45
|
+
"inject": [
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"compatibility": {
|
|
50
|
+
"dshReleases": {
|
|
51
|
+
"0.2.2-rc.1+": "compatible"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=22",
|
|
57
|
+
"dsh": ">=0.2.2-rc.1"
|
|
58
|
+
},
|
|
59
|
+
"license": "Apache-2.0",
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"pixelmatch": "^8.0.0",
|
|
62
|
+
"pngjs": "^7.0.0",
|
|
63
|
+
"puppeteer-core": "^25.17.0",
|
|
64
|
+
"ssh2": "^1.17.0"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Boot a copy of a real Studio database locally.
|
|
3
|
+
|
|
4
|
+
Motivated by a stretch of deploys where the service 502'd only on the real
|
|
5
|
+
data path (`sanitizeRelease` / `canonicalAuthor` / `ROLE_LABELS` were used
|
|
6
|
+
before they were imported). `node --check` cannot see that class of bug, and
|
|
7
|
+
an empty temp database does not exercise `#load()` deeply enough — this
|
|
8
|
+
script copies the production `.db`/`-wal`/`-shm` trio into a scratch
|
|
9
|
+
directory and runs the exact startup path against it.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
node scripts/smoke-store.mjs ~/.dsh/dsh-studio.db
|
|
13
|
+
npm run smoke:store -- /tmp/dsh-studio-backup-20261111-231911.db
|
|
14
|
+
|
|
15
|
+
Exits non-zero when the store cannot boot or reports no state. */
|
|
16
|
+
|
|
17
|
+
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
|
|
18
|
+
import { existsSync } from 'node:fs'
|
|
19
|
+
import { homedir, tmpdir } from 'node:os'
|
|
20
|
+
import { join, resolve } from 'node:path'
|
|
21
|
+
import { SecretStore, StudioStore } from '../src/host/store.js'
|
|
22
|
+
|
|
23
|
+
const given = process.argv[2]
|
|
24
|
+
if (!given) {
|
|
25
|
+
console.error('usage: node scripts/smoke-store.mjs <path-to-dsh-studio.db|.json>')
|
|
26
|
+
process.exit(2)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const source = resolve(given.replace(/^~(?=$|[\\/])/, homedir()))
|
|
30
|
+
const dbSource = source.endsWith('.json') ? source.replace(/\.json$/, '.db') : source
|
|
31
|
+
if (!existsSync(dbSource)) {
|
|
32
|
+
console.error(`✖ database not found: ${dbSource}`)
|
|
33
|
+
process.exit(2)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-smoke-'))
|
|
37
|
+
const copied = []
|
|
38
|
+
try {
|
|
39
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
40
|
+
const from = `${dbSource}${suffix}`
|
|
41
|
+
if (!existsSync(from)) continue
|
|
42
|
+
await copyFile(from, join(dir, `smoke.db${suffix}`))
|
|
43
|
+
copied.push(`smoke.db${suffix}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const store = new StudioStore(join(dir, 'smoke.json'), {
|
|
47
|
+
dbFile: join(dir, 'smoke.db'),
|
|
48
|
+
secrets: new SecretStore(join(dir, 'smoke-secrets.json')),
|
|
49
|
+
})
|
|
50
|
+
await store.ready
|
|
51
|
+
await store.chain
|
|
52
|
+
|
|
53
|
+
const state = store.snapshot()
|
|
54
|
+
const summary = {
|
|
55
|
+
engine: typeof store.describe === 'function' ? store.describe().engine : store.engine,
|
|
56
|
+
revision: state.revision,
|
|
57
|
+
tasks: (state.tasks ?? []).length,
|
|
58
|
+
requirements: (state.requirements ?? []).length,
|
|
59
|
+
releases: (state.releases ?? []).length,
|
|
60
|
+
audit: (state.audit ?? []).length,
|
|
61
|
+
experts: (state.config?.experts ?? []).length,
|
|
62
|
+
columns: Object.keys(state.config?.columns ?? {}).length,
|
|
63
|
+
files: copied,
|
|
64
|
+
}
|
|
65
|
+
console.log(JSON.stringify(summary))
|
|
66
|
+
|
|
67
|
+
if (!summary.tasks && !summary.requirements) {
|
|
68
|
+
console.error('✖ store booted but carries no tasks or requirements — wrong database?')
|
|
69
|
+
process.exitCode = 1
|
|
70
|
+
} else {
|
|
71
|
+
console.log(`✓ ${summary.engine} store booted from ${dbSource}`)
|
|
72
|
+
}
|
|
73
|
+
store.db?.close?.()
|
|
74
|
+
} finally {
|
|
75
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
76
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// dsh-studio · 跨卡用例的聚合与回归汇总(纯函数,不碰台账)
|
|
2
|
+
// 从 domain.js 拆出来:那边有 2000 行的硬上限(tests/repo.test.mjs 会拦)。
|
|
3
|
+
|
|
4
|
+
/** 一批回归跑完后的汇总记录(写给「最近回归」用,不占 state、不涨 revision)。 */
|
|
5
|
+
export function summarizeRun(results, by, at = Date.now()) {
|
|
6
|
+
const rows = Array.isArray(results) ? results : []
|
|
7
|
+
const sum = key => rows.reduce((n, item) => n + (Number(item[key]) || 0), 0)
|
|
8
|
+
return {
|
|
9
|
+
at,
|
|
10
|
+
by: String(by ?? '').slice(0, 40),
|
|
11
|
+
cards: rows.length,
|
|
12
|
+
failedCards: rows.filter(item => item.ok === false).length,
|
|
13
|
+
total: sum('total'),
|
|
14
|
+
passed: sum('passed'),
|
|
15
|
+
failed: sum('failed'),
|
|
16
|
+
bugs: sum('bugs'),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 把 "taskId:caseId" 的键按卡分组(跨卡回归就是这么拆成一轮一轮跑的);坏键直接丢掉。 */
|
|
21
|
+
export function groupCaseKeys(keys) {
|
|
22
|
+
const byTask = new Map()
|
|
23
|
+
for (const key of Array.isArray(keys) ? keys : []) {
|
|
24
|
+
const raw = String(key ?? '')
|
|
25
|
+
const at = raw.indexOf(':')
|
|
26
|
+
if (at <= 0 || at === raw.length - 1) continue
|
|
27
|
+
const taskId = raw.slice(0, at)
|
|
28
|
+
const caseId = raw.slice(at + 1)
|
|
29
|
+
if (!byTask.has(taskId)) byTask.set(taskId, [])
|
|
30
|
+
byTask.get(taskId).push(caseId)
|
|
31
|
+
}
|
|
32
|
+
return byTask
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 跨卡用例总览:把每张卡上的用例摊成一张表,带上卡片与需求的上下文。纯函数,方便单测。 */
|
|
36
|
+
export function collectCases(state) {
|
|
37
|
+
const requirementById = new Map((state?.requirements ?? []).map(item => [item.id, item]))
|
|
38
|
+
const out = []
|
|
39
|
+
for (const task of state?.tasks ?? []) {
|
|
40
|
+
const requirement = requirementById.get(task.requirementId)
|
|
41
|
+
for (const item of task.cases ?? []) {
|
|
42
|
+
out.push({
|
|
43
|
+
key: `${task.id}:${item.id}`,
|
|
44
|
+
id: item.id,
|
|
45
|
+
title: item.title ?? '',
|
|
46
|
+
expected: item.expected ?? '',
|
|
47
|
+
result: item.result ?? 'pending',
|
|
48
|
+
type: item.type ?? 'functional',
|
|
49
|
+
priority: item.priority ?? 'p2',
|
|
50
|
+
pointId: item.pointId ?? '',
|
|
51
|
+
http: item.http !== null && item.http !== undefined,
|
|
52
|
+
lastRunAt: item.lastRun?.at ?? 0,
|
|
53
|
+
// 失败时最想看的三样:错在哪、返回了什么、上一次的结论文案。
|
|
54
|
+
lastError: String(item.lastRun?.error ?? '').slice(0, 300),
|
|
55
|
+
lastStatus: Number(item.lastRun?.status) || 0,
|
|
56
|
+
lastPreview: String(item.lastRun?.preview ?? '').slice(0, 400),
|
|
57
|
+
note: String(item.note ?? '').slice(0, 300),
|
|
58
|
+
taskId: task.id,
|
|
59
|
+
taskTitle: task.title ?? '',
|
|
60
|
+
taskCode: task.code ?? '',
|
|
61
|
+
taskStatus: task.status ?? '',
|
|
62
|
+
requirementId: task.requirementId ?? '',
|
|
63
|
+
requirementTitle: requirement?.title ?? '',
|
|
64
|
+
requirementCode: requirement?.code ?? '',
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------- 回归套件
|
|
73
|
+
// 套件 = 一组规则(需求 / 优先级 / 是否接口用例)+ 手工挑的用例键。
|
|
74
|
+
// 规则在跑的时候才解析成具体用例,所以新加的用例会自动进套件,不用回来改。
|
|
75
|
+
|
|
76
|
+
const SUITES_KEY = 'suites'
|
|
77
|
+
|
|
78
|
+
/** 建套件时的默认规则:只跑有 http 定义的,按卡串行。 */
|
|
79
|
+
export const SUITE_DEFAULTS = { rules: { requirementIds: [], priorities: [], httpOnly: true }, keys: [] }
|
|
80
|
+
|
|
81
|
+
/** 套件清洗:名字必有、规则白名单、手工键只留 "taskId:caseId" 形状的。 */
|
|
82
|
+
export function sanitizeSuite(raw) {
|
|
83
|
+
const source = raw !== null && typeof raw === 'object' ? raw : {}
|
|
84
|
+
const rules = source.rules !== null && typeof source.rules === 'object' ? source.rules : {}
|
|
85
|
+
const id = String(source.id ?? '').trim() || `suite-${Math.random().toString(36).slice(2, 8)}`
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
name: String(source.name ?? '').trim().slice(0, 40) || '未命名套件',
|
|
89
|
+
note: String(source.note ?? '').slice(0, 200),
|
|
90
|
+
rules: {
|
|
91
|
+
requirementIds: (Array.isArray(rules.requirementIds) ? rules.requirementIds : []).map(String).filter(Boolean).slice(0, 20),
|
|
92
|
+
priorities: (Array.isArray(rules.priorities) ? rules.priorities : [])
|
|
93
|
+
.map(item => String(item).toLowerCase())
|
|
94
|
+
.filter(item => ['p0', 'p1', 'p2', 'p3'].includes(item))
|
|
95
|
+
.slice(0, 4),
|
|
96
|
+
httpOnly: rules.httpOnly !== false,
|
|
97
|
+
},
|
|
98
|
+
keys: (Array.isArray(source.keys) ? source.keys : []).map(String).filter(key => key.includes(':')).slice(0, 200),
|
|
99
|
+
isDefault: source.isDefault === true,
|
|
100
|
+
at: Number(source.at) || Date.now(),
|
|
101
|
+
by: String(source.by ?? '').slice(0, 40),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 套件解析成具体的用例键(去重)。 */
|
|
106
|
+
export function resolveSuiteKeys(suite, cases) {
|
|
107
|
+
const rules = suite?.rules ?? {}
|
|
108
|
+
const requirementIds = Array.isArray(rules.requirementIds) ? rules.requirementIds.map(String) : []
|
|
109
|
+
const priorities = Array.isArray(rules.priorities) ? rules.priorities.map(String) : []
|
|
110
|
+
const httpOnly = rules.httpOnly !== false
|
|
111
|
+
const picked = new Set((Array.isArray(suite?.keys) ? suite.keys : []).map(String))
|
|
112
|
+
const out = new Set()
|
|
113
|
+
for (const row of Array.isArray(cases) ? cases : []) {
|
|
114
|
+
if (picked.has(row.key)) { out.add(row.key); continue }
|
|
115
|
+
if (httpOnly && row.http !== true) continue
|
|
116
|
+
if (requirementIds.length > 0 && !requirementIds.includes(row.requirementId)) continue
|
|
117
|
+
if (priorities.length > 0 && !priorities.includes(row.priority)) continue
|
|
118
|
+
out.add(row.key)
|
|
119
|
+
}
|
|
120
|
+
return [...out]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function loadSuites(store) {
|
|
124
|
+
const saved = await store.readSide(SUITES_KEY).catch(() => null)
|
|
125
|
+
return Array.isArray(saved) ? saved.map(sanitizeSuite) : []
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 存整套:同一个时刻只允许一个默认套件(夜间跑的就是它)。 */
|
|
129
|
+
export async function saveSuites(store, list) {
|
|
130
|
+
const suites = (Array.isArray(list) ? list : []).map(sanitizeSuite)
|
|
131
|
+
const firstDefault = suites.findIndex(item => item.isDefault === true)
|
|
132
|
+
if (firstDefault >= 0) suites.forEach((item, index) => { item.isDefault = index === firstDefault })
|
|
133
|
+
await store.writeSide(SUITES_KEY, suites).catch(() => {})
|
|
134
|
+
return suites
|
|
135
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// 设计评审(一期):卡上的设计状态 + 「进开发前必须评审过」的闸门。纯函数,方便单测。
|
|
2
|
+
|
|
3
|
+
export const DESIGN_STATUS = ['pending', 'approved', 'changes_requested']
|
|
4
|
+
|
|
5
|
+
/** 设计区:链接、评审状态、评审意见。没填过就是 null。 */
|
|
6
|
+
export function sanitizeDesign(raw) {
|
|
7
|
+
if (raw === null || raw === undefined || typeof raw !== 'object') return null
|
|
8
|
+
const status = DESIGN_STATUS.includes(String(raw.status ?? '')) ? String(raw.status) : 'pending'
|
|
9
|
+
return {
|
|
10
|
+
link: String(raw.link ?? '').trim().slice(0, 500),
|
|
11
|
+
status,
|
|
12
|
+
note: String(raw.note ?? '').slice(0, 500),
|
|
13
|
+
by: String(raw.by ?? '').slice(0, 40),
|
|
14
|
+
at: Number(raw.at) || 0,
|
|
15
|
+
// 设计还原度:tools/visual/design-diff.mjs 算出来的差异百分比(0 = 一模一样)。
|
|
16
|
+
diff: raw.diff === null || raw.diff === undefined || typeof raw.diff !== 'object'
|
|
17
|
+
? null
|
|
18
|
+
: {
|
|
19
|
+
percent: Math.min(100, Math.max(0, Number(raw.diff.percent) || 0)),
|
|
20
|
+
url: String(raw.diff.url ?? '').slice(0, 500),
|
|
21
|
+
at: Number(raw.diff.at) || 0,
|
|
22
|
+
by: String(raw.diff.by ?? '').slice(0, 40),
|
|
23
|
+
},
|
|
24
|
+
// 设计规范体检(design-audit.mjs)的结果摘要:查了多少元素、几处野值、最扎眼的前三条。
|
|
25
|
+
spec: raw.spec === null || raw.spec === undefined || typeof raw.spec !== 'object'
|
|
26
|
+
? null
|
|
27
|
+
: {
|
|
28
|
+
checked: Math.max(0, Number(raw.spec.checked) || 0),
|
|
29
|
+
violations: Math.max(0, Number(raw.spec.violations) || 0),
|
|
30
|
+
at: Number(raw.spec.at) || 0,
|
|
31
|
+
by: String(raw.spec.by ?? '').slice(0, 40),
|
|
32
|
+
top: (Array.isArray(raw.spec.top) ? raw.spec.top : []).slice(0, 5).map(item => ({
|
|
33
|
+
kind: String(item?.kind ?? '').slice(0, 20),
|
|
34
|
+
value: String(item?.value ?? '').slice(0, 40),
|
|
35
|
+
count: Math.max(0, Number(item?.count) || 0),
|
|
36
|
+
})),
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 还原度低于这个值就算没还原。 */
|
|
42
|
+
export const FIDELITY_TARGET = 95
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 把还原度写进验收清单:找到「设计还原度」那一行就更新,没有就补一行。
|
|
46
|
+
* 低于 FIDELITY_TARGET 就留成未勾——验收时人一眼能看到差在哪。
|
|
47
|
+
*/
|
|
48
|
+
export function applyFidelityItem(checklist, percent) {
|
|
49
|
+
const list = Array.isArray(checklist) ? checklist : []
|
|
50
|
+
const score = Math.max(0, 100 - (Number(percent) || 0))
|
|
51
|
+
const text = `设计还原度 ≥ ${FIDELITY_TARGET}%(当前 ${score.toFixed(1)}%)`
|
|
52
|
+
const index = list.findIndex(item => String(item?.text ?? '').startsWith('设计还原度'))
|
|
53
|
+
const item = { id: index >= 0 ? list[index].id : `k-fidelity-${Date.now().toString(36)}`, text, done: score >= FIDELITY_TARGET }
|
|
54
|
+
if (index >= 0) list[index] = { ...list[index], ...item }
|
|
55
|
+
else list.push(item)
|
|
56
|
+
return list
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 把规范体检结果写进验收清单:0 处才勾,有野值就留成待复核并列出前两条。 */
|
|
60
|
+
export function applySpecItem(checklist, spec) {
|
|
61
|
+
const list = Array.isArray(checklist) ? checklist : []
|
|
62
|
+
const count = Math.max(0, Number(spec?.violations) || 0)
|
|
63
|
+
const top = (Array.isArray(spec?.top) ? spec.top : []).slice(0, 2).map(item => `${item.value}×${item.count}`).join('、')
|
|
64
|
+
const text = count === 0
|
|
65
|
+
? '设计规范体检:没有野值'
|
|
66
|
+
: `设计规范体检:${count} 处待复核${top === '' ? '' : `(${top})`}`
|
|
67
|
+
const index = list.findIndex(item => String(item?.text ?? '').startsWith('设计规范体检'))
|
|
68
|
+
const item = { id: index >= 0 ? list[index].id : `k-spec-${Date.now().toString(36)}`, text, done: count === 0 }
|
|
69
|
+
if (index >= 0) list[index] = { ...list[index], ...item }
|
|
70
|
+
else list.push(item)
|
|
71
|
+
return list
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 进开发前的闸门:只有开了开关、且目标列在 columns 里才拦。
|
|
76
|
+
* 返回 { needed, ready, blockers };override 在调用方处理(填了原因就放行)。
|
|
77
|
+
*/
|
|
78
|
+
export function designGate(config, task, nextStatus) {
|
|
79
|
+
const design = config?.design ?? {}
|
|
80
|
+
const columns = Array.isArray(design.columns) && design.columns.length > 0 ? design.columns : ['dev']
|
|
81
|
+
const needed = design.enabled === true && columns.includes(String(nextStatus ?? ''))
|
|
82
|
+
if (needed !== true) return { needed: false, ready: true, blockers: [] }
|
|
83
|
+
const state = String(task?.design?.status ?? '')
|
|
84
|
+
if (state === 'approved') return { needed: true, ready: true, blockers: [] }
|
|
85
|
+
const label = { pending: '还没评审', changes_requested: '评审要求修改' }[state] ?? '还没有设计记录'
|
|
86
|
+
return { needed: true, ready: false, blockers: [`设计${label}:先把设计稿放进卡片并让评审通过(或填 override 说明原因)`] }
|
|
87
|
+
}
|