@marsken/dsh-studio 0.0.0-stage → 0.1.0

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/client.src.js ADDED
@@ -0,0 +1,2466 @@
1
+ /**
2
+ * dsh-studio — browser half.
3
+ *
4
+ * Registers the Studio panel (sidebar row + central page) through the official
5
+ * slot service: the host owns visibility, navigation and the row chrome; this
6
+ * module only supplies the glyph and the page body.
7
+ *
8
+ * Plain ESM class-free artifact: the ModuleLoader hands us React from the page's
9
+ * own module table, so no bundler and no second React copy are involved.
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-studio',
13
+ factory(require) {
14
+ const React = require('react')
15
+ const h = React.createElement
16
+ const PANEL_ID = 'studio'
17
+ const API = '/studio-api'
18
+
19
+ // shadcn/ui 基础组件(由打包后的 main.jsx 注入);单文件版本里保持 null,
20
+ // 所有用到它的地方都带 legacy 兜底,所以两种产物行为一致。
21
+ let UIKIT = null
22
+ function useUIKit(next) { UIKIT = next ?? null }
23
+ function kit(name) { return UIKIT === null ? null : (UIKIT[name] ?? null) }
24
+
25
+ /* 关键按钮的悬停说明:有 UIKIT 用 Radix Tooltip,同时保留原生 title 兜底。 */
26
+ function tipOf(label, node) {
27
+ if (node === null || node === undefined) return node
28
+ const props = node.props ?? {}
29
+ const child = props.title !== undefined ? node : h(node.type, { ...props, title: label })
30
+ const Tip = kit('Tip')
31
+ return Tip === null ? child : h(Tip, { key: node.key, label }, child)
32
+ }
33
+
34
+ /* 表单适配层与共享抽屉已经搬到 web/src/studio,这边只负责把 h / kit 递进去。 */
35
+ const { TextField, TextAreaField, ChipField, CheckField, SelectField } = createFields({ h, kit, React })
36
+ const SideDrawer = createSideDrawer({ h, kit })
37
+ const Editor = createEditor({ h, React })
38
+ const Card = createCard({ h, ChipField })
39
+ const Board = createBoard({ h, React, Card, TextField, SelectField, CheckField, toast })
40
+ /* 字段标签的悬停说明:只给「不看说明容易填错」的字段加,鼠标移上去出气泡。 */
41
+ /* Markdown 内容字段:左编辑右预览(切换式),底部写清支持哪些语法。
42
+ 需求正文 / 文档正文 / 交付物正文 / 技能正文 / 任务需求 / 功能点说明 统一走这里。 */
43
+ function MdField({ rows = 6, value, onChange, placeholder, className, ...rest }) {
44
+ const [preview, setPreview] = React.useState(false)
45
+ const text = String(value ?? '')
46
+ return h('div', { className: 'ds-md-field' },
47
+ h('div', { className: 'ds-md-bar' },
48
+ h('span', { className: 'ds-hint' }, '支持 Markdown:标题 / 列表 / 粗体 / 代码块 / 表格'),
49
+ h('button', { className: 'ds-btn ds-btn-ghost', type: 'button', onClick: () => setPreview(!preview) }, preview ? '回到编辑' : '预览渲染'),
50
+ ),
51
+ preview
52
+ ? h('div', { className: 'ds-md-preview ds-req-desc ds-md' }, text.trim() === '' ? h('span', { className: 'ds-hint' }, '还没写内容。') : renderMarkdown(text))
53
+ : h(TextAreaField, { className: className ?? 'ds-textarea', rows, value, onChange, placeholder, ...rest }),
54
+ )
55
+ }
56
+
57
+ function tipLabel(text, hint) {
58
+ const node = h('div', { className: 'ds-label', style: { cursor: 'help' } }, text)
59
+ return hint === undefined || hint === null || hint === '' ? node : tipOf(hint, node)
60
+ }
61
+
62
+ /* 表单控件的 shadcn 适配层。
63
+ legacy 代码全是 h(SelectField, { className:'ds-select', value, onChange(e) }, option…)
64
+ 这类写法;有了 UIKIT 就换成 Radix 版本,没有就原样用原生控件(单文件产物)。 */
65
+
66
+
67
+
68
+
69
+ /* 徽章。legacy 写法是 h(ChipField, { className: 'ds-chip', ...rest }, 内容);
70
+ 有 UIKIT 就渲染成 shadcn Badge(variant 按语义映射),没有就还是 span.ds-chip。 */
71
+
72
+ /* 布尔开关。legacy 写法是 h(CheckField, { checked, onChange(e) }), */
73
+ /* 换了 UIKIT 就渲染成 Radix Switch,没有就还是原生 checkbox。 */
74
+
75
+ /* 进度条:有 UIKIT 用 Radix Progress,没有就回落原来的自绘条。 */
76
+
77
+ /* 确认 / 输入:不用浏览器原生 confirm/prompt,改成页面里的 shadcn 弹窗。
78
+ 没有 UIKIT(单文件产物)时退回原生实现,调用方行为一致。 */
79
+ let DIALOG_STATE = null
80
+ let DIALOG_LISTENER = null
81
+
82
+ function dialogEmit(next) {
83
+ DIALOG_STATE = next
84
+ if (typeof DIALOG_LISTENER === 'function') DIALOG_LISTENER(next)
85
+ }
86
+
87
+ function confirmDialog(message, options) {
88
+ const opts = options ?? {}
89
+ const Box = kit('DialogBox')
90
+ if (Box === null) return Promise.resolve(window.confirm(String(message)))
91
+ return new Promise(resolve => {
92
+ dialogEmit({
93
+ kind: 'confirm',
94
+ title: opts.title ?? '确认操作',
95
+ message: String(message ?? ''),
96
+ danger: opts.danger === true,
97
+ okText: opts.okText ?? '确定',
98
+ cancelText: opts.cancelText ?? '取消',
99
+ resolve,
100
+ })
101
+ })
102
+ }
103
+
104
+ function promptDialog(message, options) {
105
+ const opts = options ?? {}
106
+ const Box = kit('DialogBox')
107
+ if (Box === null) return Promise.resolve(window.prompt(String(message), opts.defaultValue ?? ''))
108
+ return new Promise(resolve => {
109
+ dialogEmit({
110
+ kind: 'prompt',
111
+ title: opts.title ?? '需要填写',
112
+ message: String(message ?? ''),
113
+ value: String(opts.defaultValue ?? ''),
114
+ placeholder: opts.placeholder ?? '',
115
+ okText: opts.okText ?? '确定',
116
+ cancelText: '取消',
117
+ resolve,
118
+ })
119
+ })
120
+ }
121
+
122
+ /* 弹窗宿主:订阅 DIALOG_STATE,渲染成 shadcn Dialog。 */
123
+ function DialogHost() {
124
+ const [request, setRequest] = React.useState(null)
125
+ const [text, setText] = React.useState('')
126
+ React.useEffect(() => {
127
+ const listen = next => {
128
+ setRequest(next)
129
+ setText(next !== null && next.kind === 'prompt' ? next.value : '')
130
+ }
131
+ DIALOG_LISTENER = listen
132
+ if (DIALOG_STATE !== null) listen(DIALOG_STATE)
133
+ return () => { if (DIALOG_LISTENER === listen) DIALOG_LISTENER = null }
134
+ }, [])
135
+ const Box = kit('DialogBox')
136
+ if (request === null || Box === null) return null
137
+ const finish = result => {
138
+ const done = request.resolve
139
+ setRequest(null)
140
+ DIALOG_STATE = null
141
+ done(result)
142
+ }
143
+ return h(Box, {
144
+ open: true,
145
+ title: request.title,
146
+ message: request.message,
147
+ danger: request.danger === true,
148
+ okText: request.okText,
149
+ cancelText: request.cancelText,
150
+ input: request.kind === 'prompt',
151
+ value: text,
152
+ placeholder: request.placeholder,
153
+ onChange: next => setText(next),
154
+ onOk: () => finish(request.kind === 'prompt' ? text.trim() : true),
155
+ onCancel: () => finish(request.kind === 'prompt' ? null : false),
156
+ })
157
+ }
158
+
159
+ // --------------------------------------------------------------- 轻提示
160
+ // 保存/同步这类动作结果原本只写在各面板自己的小字里,面板一滚就看不见了。
161
+ // 这里加一个右下角堆叠的轻提示:3.2 秒自动消失,点 × 可提前关。
162
+ let TOAST_SEQ = 0
163
+ let TOAST_ITEMS = []
164
+ let TOAST_LISTENERS = []
165
+ // 同一动作可能一瞬间连发两条(重复绑定、onBlur 与 onClick 各触发一次),600ms 内同文案吞掉。
166
+ let TOAST_LAST = { text: '', at: 0 }
167
+ function toastEmit() {
168
+ const items = TOAST_ITEMS.slice()
169
+ for (const listen of TOAST_LISTENERS.slice()) listen(items)
170
+ }
171
+ function toast(text, tone = 'success', ttl = 3200) {
172
+ const message = String(text ?? '').trim()
173
+ if (message === '') return null
174
+ const now = Date.now()
175
+ if (TOAST_LAST.text === message && now - TOAST_LAST.at < 600) return null
176
+ TOAST_LAST = { text: message, at: now }
177
+ const id = ++TOAST_SEQ
178
+ TOAST_ITEMS = TOAST_ITEMS.concat([{ id, text: message, tone }])
179
+ toastEmit()
180
+ window.setTimeout(() => { dismissToast(id) }, ttl)
181
+ return id
182
+ }
183
+ function dismissToast(id) {
184
+ const next = TOAST_ITEMS.filter(item => item.id !== id)
185
+ if (next.length === TOAST_ITEMS.length) return
186
+ TOAST_ITEMS = next
187
+ toastEmit()
188
+ }
189
+ const TOAST_ICON = { success: '✓', error: '!', info: 'i' }
190
+ /* 轻提示宿主:挂在 StudioPage 根部,跟 DialogHost 一样只订阅不持有业务状态。 */
191
+ function ToastHost() {
192
+ const [items, setItems] = React.useState(() => TOAST_ITEMS.slice())
193
+ React.useEffect(() => {
194
+ TOAST_LISTENERS.push(setItems)
195
+ return () => { TOAST_LISTENERS = TOAST_LISTENERS.filter(entry => entry !== setItems) }
196
+ }, [])
197
+ if (items.length === 0) return null
198
+ return h('div', { className: 'ds-toasts', role: 'status', 'aria-live': 'polite' },
199
+ items.map(item => h('div', { key: item.id, className: 'ds-toast', 'data-tone': item.tone },
200
+ h('span', { className: 'ds-toast-icon', 'aria-hidden': 'true' }, TOAST_ICON[item.tone] ?? 'i'),
201
+ h('span', { className: 'ds-toast-text' }, item.text),
202
+ h('button', {
203
+ type: 'button', className: 'ds-toast-close', 'aria-label': '关闭提示',
204
+ onClick: () => dismissToast(item.id),
205
+ }, '×'),
206
+ )),
207
+ )
208
+ }
209
+
210
+ // --------------------------------------------------------------- 我是谁
211
+ // 单机时代每个人共用一张 cookie,靠这个名字区分别人。写操作都会带在请求头里。
212
+ const USER_KEY = 'dsh-studio:user'
213
+
214
+ function currentUser() {
215
+ try {
216
+ const value = window.localStorage.getItem(USER_KEY)
217
+ if (typeof value === 'string' && value.trim() !== '') return value.trim().slice(0, 40)
218
+ } catch { /* 隐私模式 */ }
219
+ return '人'
220
+ }
221
+
222
+ function setUserName(next) {
223
+ const name = String(next ?? '').trim().slice(0, 40) || '人'
224
+ try { window.localStorage.setItem(USER_KEY, name) } catch { /* 隐私模式 */ }
225
+ return name
226
+ }
227
+
228
+ // --------------------------------------------------------------- 显示模式
229
+ // 明亮 / 黑暗 / 跟随系统。这套配色本来就是从 currentColor 派生的(线、底、卡片都是
230
+ // color-mix(currentColor N%, transparent)),所以只要在根元素上定住 color 与 background,
231
+ // 再把 --ds-surface、--ds-accent 换掉,整块面板就会跟着换肤,不用逐条改样式。
232
+ const THEME_KEY = 'dsh-studio:theme'
233
+ const THEMES = [['light', '☀ 明亮'], ['dark', '☾ 黑暗'], ['system', '跟随系统']]
234
+
235
+ function systemDarkQuery() {
236
+ return typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-color-scheme: dark)') : null
237
+ }
238
+
239
+ // 「跟随系统」实际跟的是「宿主界面」:面板长在宿主页面里,宿主自己是深色、我们跟操作系统就会出现
240
+ // 一面白一面黑的接缝。宿主没给出明确信号(背景透明/取不到)时再退回操作系统的 prefers-color-scheme。
241
+ function hostScheme() {
242
+ try {
243
+ const color = window.getComputedStyle(document.body).backgroundColor
244
+ const matched = /rgba?\(([^)]+)\)/.exec(String(color ?? ''))
245
+ if (matched === null) return null
246
+ const parts = matched[1].split(',').map(value => Number(value.trim()))
247
+ const [r, g, b, a] = parts
248
+ if (![r, g, b].every(value => Number.isFinite(value))) return null
249
+ if (Number.isFinite(a) && a < 0.2) return null
250
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 128 ? 'dark' : 'light'
251
+ } catch {
252
+ return null
253
+ }
254
+ }
255
+
256
+ function currentTheme() {
257
+ try {
258
+ const value = window.localStorage.getItem(THEME_KEY)
259
+ if (value === 'light' || value === 'dark' || value === 'system') return value
260
+ } catch { /* 隐私模式 */ }
261
+ return 'system'
262
+ }
263
+
264
+ function setThemeName(next) {
265
+ const value = next === 'light' || next === 'dark' ? next : 'system'
266
+ try { window.localStorage.setItem(THEME_KEY, value) } catch { /* 隐私模式 */ }
267
+ return value
268
+ }
269
+
270
+ // --------------------------------------------------------------- 登录态
271
+ // 统一登录(LDAP)开启后,服务端会给一个会话 token;放在 localStorage 里,
272
+ // 每次请求带 x-studio-session。名字也一并记下来,用于「我是谁」和审计。
273
+ const SESSION_KEY = 'dsh-studio:session'
274
+ const AUTH_USER_KEY = 'dsh-studio:auth-user'
275
+
276
+ function sessionToken() {
277
+ try {
278
+ const value = window.localStorage.getItem(SESSION_KEY)
279
+ if (typeof value === 'string' && value.trim() !== '') return value.trim()
280
+ } catch { /* 隐私模式 */ }
281
+ return ''
282
+ }
283
+
284
+ function setSessionToken(next) {
285
+ try {
286
+ if (next === '' || next === null || next === undefined) window.localStorage.removeItem(SESSION_KEY)
287
+ else window.localStorage.setItem(SESSION_KEY, String(next))
288
+ } catch { /* 隐私模式 */ }
289
+ }
290
+
291
+ function authUser() {
292
+ try {
293
+ const raw = window.localStorage.getItem(AUTH_USER_KEY)
294
+ if (typeof raw === 'string' && raw !== '') {
295
+ const parsed = JSON.parse(raw)
296
+ if (parsed !== null && typeof parsed === 'object') return parsed
297
+ }
298
+ } catch { /* 隐私模式 */ }
299
+ return null
300
+ }
301
+
302
+ function setAuthUser(next) {
303
+ try {
304
+ if (next === null || next === undefined) window.localStorage.removeItem(AUTH_USER_KEY)
305
+ else window.localStorage.setItem(AUTH_USER_KEY, JSON.stringify(next))
306
+ } catch { /* 隐私模式 */ }
307
+ return next ?? null
308
+ }
309
+
310
+ // ------------------------------------------------------------------ api
311
+
312
+ async function api(path, body) {
313
+ const headers = { 'x-studio-user': encodeURIComponent(currentUser()) }
314
+ const token = sessionToken()
315
+ if (token !== '') headers['x-studio-session'] = token
316
+ if (body !== undefined) headers['content-type'] = 'application/json'
317
+ const init = body === undefined
318
+ ? { method: 'GET', headers }
319
+ : { method: 'POST', headers, body: JSON.stringify(body) }
320
+ const response = await fetch(API + path, init)
321
+ let payload = {}
322
+ try { payload = await response.json() } catch { /* non-JSON error page */ }
323
+ if (!response.ok || payload.ok === false) {
324
+ throw new Error(payload.error ?? `请求失败(HTTP ${response.status})`)
325
+ }
326
+ return payload
327
+ }
328
+
329
+ // ---------------------------------------------------------------- style
330
+
331
+ const GLOBAL_THEME_CSS = `
332
+ html[data-dsh-theme="dark"] body { background: #0e1014 !important; color: #e6e8ee }
333
+ html[data-dsh-theme="dark"] [class*="frame"],
334
+ html[data-dsh-theme="dark"] [class*="centerCol"],
335
+ html[data-dsh-theme="dark"] [class*="mainPanel"] { background: #0e1014 !important; color: #e6e8ee }
336
+ html[data-dsh-theme="dark"] [class*="sidebarCol"],
337
+ html[data-dsh-theme="dark"] [class*="rightbarCol"] { background: #0a0b0f !important; color: #d7dae0 !important; border-color: #22262e !important }
338
+ html[data-dsh-theme="dark"] [class*="sidebarCol"] a,
339
+ html[data-dsh-theme="dark"] [class*="sidebarCol"] button,
340
+ html[data-dsh-theme="dark"] [class*="sidebarCol"] span,
341
+ html[data-dsh-theme="dark"] [class*="sidebarCol"] div { color: inherit }
342
+ html[data-dsh-theme="dark"] [class*="handle"] { background: #22262e !important }
343
+ html[data-dsh-theme="light"] body { background: #ffffff !important; color: #1f2328 }
344
+ html[data-dsh-theme="light"] [class*="sidebarCol"],
345
+ html[data-dsh-theme="light"] [class*="rightbarCol"] { background: #fafafa !important; color: #1f2328 !important; border-color: #e4e4e7 !important }
346
+ `
347
+ const CSS = `
348
+ [data-dsh-studio] { display:flex; flex-direction:column; height:100%; min-height:0; color:currentColor;
349
+ --ds-line: color-mix(in srgb, currentColor 18%, transparent);
350
+ --ds-line-soft: color-mix(in srgb, currentColor 9%, transparent);
351
+ --ds-soft: color-mix(in srgb, currentColor 5%, transparent);
352
+ --ds-soft2: color-mix(in srgb, currentColor 10%, transparent);
353
+ --ds-bg: #ffffff;
354
+ --ds-surface: #ffffff;
355
+ --ds-surface-2: #ffffff;
356
+ --ds-card: var(--ds-surface);
357
+ --ds-shadow: none;
358
+ --ds-accent: #2f6feb;
359
+ font: 13px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
360
+ /* 三档显示模式:只换根元素的 color / background 与一组表面色,其余靠 color-mix 自动派生。
361
+ color-scheme 必须一起改,否则 <select>/<input>/滚动条仍按操作系统的深浅色画。
362
+ 表面层次:--ds-bg(页面底)< --ds-surface-2(列/次级块)< --ds-surface(面板/卡片)。 */
363
+ [data-dsh-studio][data-theme="light"] {
364
+ color-scheme:light; color:#1f2328; background:var(--ds-bg); --ds-accent:#2f6feb;
365
+ --ds-bg:#eef1f5; --ds-surface:#ffffff; --ds-surface-2:#f6f8fb; --ds-card:#ffffff;
366
+ --ds-line:#dbe1ea; --ds-line-soft:#e9edf3;
367
+ --ds-shadow:0 1px 2px #0f172a0f;
368
+ }
369
+ [data-dsh-studio][data-theme="dark"] {
370
+ color-scheme:dark; color:#e6e8ee; background:var(--ds-bg); --ds-accent:#7ba2ff;
371
+ --ds-bg:#0e1014; --ds-surface:#181b22; --ds-surface-2:#1e222a; --ds-card:#181b22;
372
+ --ds-line:#2c323d; --ds-line-soft:#232830;
373
+ --ds-shadow:0 1px 2px #00000040;
374
+ }
375
+ [data-dsh-studio][data-theme="light"] .ds-chip[data-review="changes_requested"] { color:#b3282c; }
376
+ [data-dsh-studio][data-theme="dark"] .ds-chip[data-review="changes_requested"] { color:#ff9a9d; }
377
+ /* 深色下把几处「深色语义色」换成浅色版本:它们在白底上是 4.6:1,到了 #14161b 上只有 2.5–3.9:1,
378
+ 11–12px 的小字会糊。只改前景色,底色与边框仍由各规则自己算。 */
379
+ [data-dsh-studio][data-theme="dark"] .ds-blocked-line,
380
+ [data-dsh-studio][data-theme="dark"] .ds-blocked-box,
381
+ [data-dsh-studio][data-theme="dark"] .ds-chip-bug,
382
+ [data-dsh-studio][data-theme="dark"] .ds-chip-bug[data-sev="p1"],
383
+ [data-dsh-studio][data-theme="dark"] .ds-error,
384
+ [data-dsh-studio][data-theme="dark"] .ds-gate-item[data-kind="block"] { color:#ff9a9d; }
385
+ [data-dsh-studio][data-theme="dark"] .ds-chip-bug[data-sev="p0"] { color:#fff; }
386
+ [data-dsh-studio][data-theme="dark"] .ds-chip[data-review="approved"],
387
+ [data-dsh-studio][data-theme="dark"] .ds-chip[data-sla="ok"],
388
+ [data-dsh-studio][data-theme="dark"] .ds-ok { color:#6fdca4; }
389
+ [data-dsh-studio][data-theme="dark"] .ds-chip[data-sla="due_soon"],
390
+ [data-dsh-studio][data-theme="dark"] .ds-gate-item[data-kind="warn"] { color:#f0b552; }
391
+ [data-dsh-studio][data-theme="dark"] .ds-skilldesc { color:color-mix(in srgb, currentColor 62%, transparent); }
392
+ /* 键盘焦点:所有可聚焦元素统一用主题色描边(原来是浏览器默认蓝,深色下很跳)。 */
393
+ [data-dsh-studio] :focus-visible { outline:2px solid var(--ds-accent); outline-offset:2px; border-radius:4px; }
394
+ [data-dsh-studio] * { box-sizing:border-box; }
395
+ .ds-head { flex:none; border-bottom:1px solid var(--ds-line); padding:12px 18px 0;
396
+ background:var(--ds-surface); box-shadow:var(--ds-shadow); }
397
+ .ds-head-top { display:flex; align-items:flex-start; gap:14px; padding-bottom:10px; }
398
+ .ds-brand { display:flex; align-items:center; gap:10px; min-width:0; }
399
+ .ds-brand-mark { width:26px; height:26px; border-radius:8px; flex:none; display:flex; align-items:center; justify-content:center;
400
+ background:color-mix(in srgb, var(--ds-accent) 14%, transparent); color:var(--ds-accent); font-size:13px; }
401
+ .ds-head-actions { margin-left:auto; display:flex; align-items:center; gap:8px; flex:none; }
402
+ .ds-head-tabs { display:flex; align-items:center; flex-wrap:wrap; row-gap:6px; gap:10px; padding-bottom:9px; }
403
+ .ds-title { font-size:15px; font-weight:650; letter-spacing:.01em; }
404
+ .ds-sub { font-size:11.5px; opacity:.55; }
405
+ .ds-tabs { display:inline-flex; gap:2px; padding:3px; border-radius:9px; background:var(--ds-soft); border:1px solid var(--ds-line);
406
+ max-width:100%; overflow-x:auto; scrollbar-width:none; }
407
+ .ds-tabs::-webkit-scrollbar { height:0; }
408
+ .ds-seg { display:inline-flex; gap:2px; padding:3px; border-radius:9px; background:var(--ds-soft); border:1px solid var(--ds-line); }
409
+ .ds-seg-btn { border:0; background:transparent; color:inherit; font:inherit; font-size:11.5px; padding:4px 9px; border-radius:6px;
410
+ cursor:pointer; opacity:.7; white-space:nowrap; }
411
+ .ds-seg-btn:hover { opacity:1; background:var(--ds-soft2); }
412
+ .ds-seg-btn[data-on="1"] { opacity:1; font-weight:600; background:color-mix(in srgb, currentColor 14%, transparent); }
413
+ .ds-tab { border:0; background:transparent; color:inherit; font:inherit; font-size:12.5px; padding:4px 15px; border-radius:7px;
414
+ cursor:pointer; opacity:.62; white-space:nowrap; flex:0 0 auto; }
415
+ .ds-tab:hover { opacity:.95; background:var(--ds-soft2); }
416
+ .ds-tab[data-on="1"] { opacity:1; background:var(--ds-surface); font-weight:600; box-shadow:0 1px 3px #00000026; }
417
+ .ds-grow { flex:1; }
418
+ .ds-btn { border:1px solid var(--ds-line); background:var(--ds-soft); color:inherit; font:inherit; padding:5px 12px; border-radius:7px; cursor:pointer; }
419
+ .ds-btn:hover { background:var(--ds-soft2); }
420
+ .ds-btn[disabled] { opacity:.45; cursor:default; }
421
+ .ds-btn-primary { background:var(--ds-accent); border-color:var(--ds-accent); color:#fff; }
422
+ .ds-btn-primary:hover { filter:brightness(1.08); background:var(--ds-accent); }
423
+ .ds-board-bar { display:flex; gap:8px; align-items:center; padding:9px 16px; flex-wrap:wrap; flex:none;
424
+ border-bottom:1px solid var(--ds-line); }
425
+ .ds-board-bar .ds-input, .ds-board-bar .ds-select { height:28px; padding:0 8px; }
426
+ .ds-search { position:relative; display:inline-flex; }
427
+ .ds-search .ds-input { padding-left:24px; }
428
+ .ds-search::before { content:'⌕'; position:absolute; left:7px; top:50%; transform:translateY(-50%);
429
+ opacity:.4; font-size:13px; pointer-events:none; }
430
+ .ds-stat { font-size:11.5px; opacity:.55; }
431
+ .ds-market-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:12px; }
432
+ .ds-market-card { display:flex; flex-direction:column; gap:8px; border:1px solid var(--ds-line); border-radius:12px;
433
+ padding:14px; background:var(--ds-card); min-height:136px; }
434
+ .ds-mc-head { display:flex; align-items:center; gap:8px; }
435
+ .ds-mc-name { font-weight:600; font-size:13px; }
436
+ .ds-mc-desc { font-size:12px; opacity:.72; }
437
+ .ds-market-card code { font-size:11px; opacity:.55; word-break:break-all; }
438
+ .ds-chip[data-on="1"] { color:#0f9d58; border-color:#0f9d5855; }
439
+ .ds-board { display:flex; gap:12px; padding:12px 16px 16px; overflow-x:auto; overflow-y:hidden; flex:1; min-height:0; align-items:stretch; }
440
+ .ds-col { flex:0 0 282px; display:flex; flex-direction:column; min-height:0; border:1px solid var(--ds-line); border-radius:12px;
441
+ background:var(--ds-surface-2); overflow:hidden; }
442
+ .ds-col[data-role="product"] { border-top:2px solid #2f6feb; }
443
+ .ds-col[data-role="architect"] { border-top:2px solid #8b5cf6; }
444
+ .ds-col[data-role="dev"] { border-top:2px solid #0f9d58; }
445
+ .ds-col[data-role="qa"] { border-top:2px solid #e08600; }
446
+ .ds-col-head { display:flex; align-items:center; gap:6px; padding:9px 11px; border-bottom:1px solid var(--ds-line); font-weight:600; font-size:12px; flex:none; background:var(--ds-card); }
447
+ .ds-count { margin-left:auto; min-width:20px; text-align:center; border-radius:999px; padding:0 7px; line-height:17px;
448
+ background:var(--ds-soft2); border:1px solid var(--ds-line); font-weight:600; font-size:10.5px; font-variant-numeric:tabular-nums; }
449
+ .ds-count[data-zero="1"] { opacity:.4; }
450
+ .ds-col-owner { padding:0 11px 7px; font-size:10.5px; opacity:.55; flex:none; }
451
+ .ds-col-body { display:flex; flex-direction:column; gap:8px; padding:9px; overflow-y:auto; flex:1; min-height:0; }
452
+ .ds-card { border:1px solid var(--ds-line); border-radius:9px; background:var(--ds-surface); padding:9px 10px; cursor:pointer;
453
+ box-shadow:var(--ds-shadow);
454
+ display:flex; flex-direction:column; gap:6px; transition:transform .12s ease, border-color .12s ease, box-shadow .12s ease; }
455
+ .ds-card:hover { border-color:var(--ds-accent); transform:translateY(-1px); box-shadow:0 4px 14px #00000014; }
456
+ .ds-card[data-urgent="1"] { border-left:3px solid #e5484d; padding-left:8px; }
457
+ .ds-card[data-running="1"] { border-color:var(--ds-accent); }
458
+ .ds-card[data-blocked="1"] { border-color:#e5484d80; background:color-mix(in srgb, #e5484d 4%, var(--ds-surface)); box-shadow:inset 3px 0 0 #e5484d; }
459
+ .ds-blocked-line { font-size:11px; color:#c0392b; line-height:1.4; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
460
+ .ds-blocked-box { border:1px solid #e5484d55; background:color-mix(in srgb, #e5484d 7%, transparent); border-radius:8px; padding:9px 11px; display:flex; flex-direction:column; gap:3px; font-size:12px; color:#c0392b; }
461
+ .ds-chip[data-proj="1"] { color:var(--ds-accent); border-color:color-mix(in srgb, var(--ds-accent) 40%, transparent); font-weight:600; cursor:pointer; }
462
+ .ds-chip[data-req="1"] { color:#7c4dff; border-color:color-mix(in srgb, #7c4dff 36%, transparent); background:color-mix(in srgb, #7c4dff 8%, transparent); cursor:pointer; }
463
+ .ds-chip[data-req-missing="1"] { border-style:dashed; opacity:.62; cursor:pointer; }
464
+ .ds-chip[data-iter="1"] { opacity:.8; }
465
+ .ds-check-progress { margin:2px 0 4px; }
466
+ .ds-code-box { border:1px solid var(--ds-line); border-radius:8px; padding:10px 12px; background:var(--ds-card); display:flex; flex-direction:column; gap:8px; }
467
+ .ds-code-head { display:flex; align-items:flex-start; gap:8px; }
468
+ .ds-code-line { font-size:12.5px; font-weight:600; }
469
+ .ds-code-list { display:flex; flex-direction:column; gap:3px; }
470
+ .ds-code-item { display:flex; align-items:center; gap:8px; font-size:11.5px; }
471
+ .ds-code-item code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11px; padding:1px 5px; border-radius:4px; background:var(--ds-soft2); color:inherit; }
472
+ .ds-code-files { display:flex; flex-wrap:wrap; gap:4px; }
473
+ .ds-code-file { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11px; padding:2px 6px; border-radius:5px; background:var(--ds-soft2); color:inherit; }
474
+ .ds-code-file[data-change="added"] { box-shadow:inset 2px 0 0 #0f9d58; }
475
+ .ds-code-file[data-change="deleted"] { box-shadow:inset 2px 0 0 #e5484d; }
476
+ .ds-code-file[data-change="modified"] { box-shadow:inset 2px 0 0 #d97706; }
477
+ .ds-code-note { font-size:12px; padding:6px 8px; border-radius:6px; background:color-mix(in srgb, #e5484d 8%, transparent); }
478
+ .ds-chip[data-review="approved"] { background:color-mix(in srgb, #0f9d58 16%, transparent); color:#0f6b3c; }
479
+ .ds-chip[data-review="changes_requested"] { background:color-mix(in srgb, #e5484d 16%, transparent); color:#a3282b; }
480
+ .ds-check-bar { height:6px; border-radius:999px; background:var(--ds-soft2); overflow:hidden; }
481
+ .ds-check-bar > span { display:block; height:100%; background:var(--ds-accent); transition:width .2s ease; }
482
+ .ds-check-bar[data-full="1"] > span { background:#0f9d58; }
483
+ .ds-check-row { display:flex; align-items:flex-start; gap:9px; padding:7px 0; border-bottom:1px solid var(--ds-line); }
484
+ .ds-check-row:last-of-type { border-bottom:none; }
485
+ .ds-check-row input[type="checkbox"] { margin-top:2px; width:14px; height:14px; accent-color:var(--ds-accent); flex:0 0 auto; }
486
+ .ds-check-text { font-size:12px; line-height:1.5; }
487
+ .ds-check-row[data-done="1"] .ds-check-text { text-decoration:line-through; opacity:.55; }
488
+ .ds-art-row { display:flex; align-items:center; gap:9px; padding:8px 0; border-bottom:1px solid var(--ds-line); }
489
+ .ds-art-row:last-of-type { border-bottom:none; }
490
+ .ds-art-row[data-result="fail"] { background: color-mix(in srgb, #e5484d 8%, transparent); border-left:3px solid #e5484d; }
491
+ .ds-art-row[data-result="pass"] { border-left:3px solid #0f9d58; }
492
+ .ds-art-row[data-result="pending"], .ds-art-row[data-result="skip"] { border-left:3px solid color-mix(in srgb, currentColor 22%, transparent); }
493
+ .ds-chip-case { font-size:10.5px; font-weight:600; padding:1px 6px; border-radius:9px; white-space:nowrap; border:1px solid; }
494
+ .ds-chip-case[data-r="pass"] { color:#0f9d58; border-color:#0f9d5855; background:#0f9d5812; }
495
+ .ds-chip-case[data-r="fail"] { color:#e5484d; border-color:#e5484d55; background:#e5484d12; }
496
+ .ds-chip-case[data-r="pending"], .ds-chip-case[data-r="skip"] { opacity:.6; }
497
+ .ds-art-title { font-size:12px; font-weight:600; line-height:1.4; }
498
+ .ds-art-ref { font-size:11px; opacity:.6; word-break:break-all; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
499
+ .ds-chip[data-kind="mr"] { color:#8b5cf6; border-color:#8b5cf655; }
500
+ .ds-chip[data-kind="cases"] { color:#e08600; border-color:#e0860055; }
501
+ .ds-chip[data-kind="design"] { color:#0f9d58; border-color:#0f9d5855; }
502
+ .ds-btn-ghost { background:transparent; border-color:transparent; color:inherit; opacity:.45; padding:2px 6px; }
503
+ .ds-btn-ghost:hover { opacity:.9; }
504
+ .ds-proj-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(330px,1fr)); gap:14px; }
505
+ .ds-proj-card { border:1px solid var(--ds-line); border-radius:10px; background:var(--ds-card); padding:14px 15px; display:flex; flex-direction:column; gap:10px; }
506
+ .ds-proj-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
507
+ .ds-proj-name { font-size:13.5px; font-weight:600; }
508
+ .ds-proj-stats { display:grid; grid-template-columns:repeat(3,1fr); gap:8px 12px; }
509
+ .ds-proj-stats > div { display:flex; flex-direction:column; }
510
+ .ds-proj-stats b { font-size:16px; font-weight:650; line-height:1.2; }
511
+ .ds-proj-stats b[data-warn="1"] { color:#e5484d; }
512
+ .ds-proj-stats span { font-size:11px; opacity:.6; }
513
+ .ds-proj-bar { margin-top:2px; }
514
+ .ds-card-title { font-size:12.5px; font-weight:600; line-height:1.45; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
515
+ .ds-card-foot { display:flex; align-items:center; gap:6px; font-size:10.5px; opacity:.5; }
516
+ .ds-meta { display:flex; flex-wrap:wrap; gap:4px; align-items:center; font-size:10.5px; opacity:.75; }
517
+ .ds-chip { border:1px solid var(--ds-line); border-radius:999px; padding:0 6px; line-height:16px; }
518
+ .ds-chip-soft { background: var(--ds-soft2); border-color: transparent; margin-left: 6px; }
519
+ .ds-audit-row { display:grid; grid-template-columns: 64px 88px 120px minmax(0,1fr) 66px; align-items:center; gap:8px;
520
+ padding:6px 0; border-bottom:1px solid var(--ds-line); font-size:12px; }
521
+ .ds-audit-row:last-child { border-bottom:none; }
522
+ .ds-audit-row[data-ok="0"] { background: color-mix(in srgb, #e5484d 5%, transparent); }
523
+ .ds-audit-time { color: color-mix(in srgb, currentColor 58%, transparent); font-variant-numeric: tabular-nums; }
524
+ .ds-audit-by { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
525
+ .ds-audit-what { color: color-mix(in srgb, currentColor 82%, transparent); }
526
+ .ds-audit-detail { color: color-mix(in srgb, currentColor 56%, transparent); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
527
+ font-family: ui-monospace, Menlo, monospace; font-size:11px; }
528
+ .ds-audit-status { text-align:right; font-size:11px; font-weight:600; color:#0f9d58; }
529
+ .ds-audit-status[data-ok="0"] { color:#e5484d; }
530
+ @media (max-width:760px) { .ds-audit-row { grid-template-columns: 60px 1fr 60px; } .ds-audit-what, .ds-audit-detail { display:none; } }
531
+ .ds-kpi-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
532
+ @media (max-width:900px) { .ds-kpi-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
533
+ @media (max-width:560px) { .ds-kpi-grid { grid-template-columns:minmax(0,1fr); } }
534
+ .ds-kpi { position:relative; overflow:hidden; border:1px solid var(--ds-line); border-radius:10px; padding:11px 13px 12px; background:var(--ds-card); box-shadow:var(--ds-shadow), inset 3px 0 0 color-mix(in srgb, var(--ds-accent) 55%, transparent); }
535
+ .ds-kpi[data-warn="1"] { border-color: color-mix(in srgb, #e5484d 40%, var(--ds-line)); box-shadow:inset 3px 0 0 color-mix(in srgb, #e5484d 70%, transparent); }
536
+ .ds-kpi-value { font-size:23px; font-weight:650; letter-spacing:-.02em; line-height:1.15; }
537
+ .ds-kpi[data-warn="1"] .ds-kpi-value { color:#e5484d; }
538
+ .ds-kpi-label { font-size:12px; font-weight:600; margin-top:1px; }
539
+ .ds-kpi-hint { font-size:11px; opacity:.6; margin-top:3px; line-height:1.45; }
540
+ /* 效能页:12 栅格显式排版。原来用 auto-fit 撑满,四块面板挤在一行、每块只有 290px,
541
+ 柱条被压成发丝、数字挤成一坨;现在每块自己声明占几列,窄屏逐级退成 6 → 12 列。 */
542
+ .ds-an-grid { display:grid; grid-template-columns:repeat(12,minmax(0,1fr)); gap:12px; align-items:start; }
543
+ .ds-an-grid > .ds-panel { display:flex; flex-direction:column; min-width:0; }
544
+ .ds-an-grid > .ds-panel > .ds-panel-body { flex:0 1 auto; }
545
+ .ds-span-3 { grid-column:span 3; }
546
+ .ds-span-4 { grid-column:span 4; }
547
+ .ds-span-5 { grid-column:span 5; }
548
+ .ds-span-6 { grid-column:span 6; }
549
+ .ds-span-7 { grid-column:span 7; }
550
+ .ds-span-8 { grid-column:span 8; }
551
+ .ds-span-9 { grid-column:span 9; }
552
+ .ds-span-12 { grid-column:span 12; }
553
+ @media (max-width:1180px) { .ds-span-3, .ds-span-4, .ds-span-5 { grid-column:span 6; } .ds-span-7, .ds-span-8, .ds-span-9 { grid-column:span 12; } }
554
+ @media (max-width:760px) { .ds-an-grid > .ds-panel { grid-column:span 12; } }
555
+ .ds-an-sec { display:flex; align-items:baseline; gap:10px; margin:6px 0 -2px; padding-bottom:2px; font-size:12px; font-weight:600; letter-spacing:.02em; opacity:.8; }
556
+ .ds-an-sec .ds-hint { margin-left:auto; font-weight:400; opacity:.75; }
557
+ /* 栅格里已由 gap 控制间距,panel 自带的 margin-bottom 会把行高顶歪 */
558
+ .ds-an-grid > .ds-panel { margin-bottom:0; }
559
+ /* 面板底部小注:贴底,用来把「多高都行」的面板补满,不至于下面留一大片空白 */
560
+ .ds-an-foot { margin-top:9px; padding-top:8px; border-top:1px dashed var(--ds-line); font-size:11.5px; opacity:.62; line-height:1.5; }
561
+ .ds-an-row { display:grid; grid-template-columns:106px minmax(0,1fr) 84px; align-items:center; gap:9px; padding:5px 0; font-size:12px; }
562
+ .ds-an-name { opacity:.8; }
563
+ .ds-an-track { height:8px; border-radius:999px; background:var(--ds-soft2); overflow:hidden; }
564
+ .ds-an-fill { height:100%; border-radius:999px; background:var(--ds-accent); }
565
+ .ds-an-fill-warn { background:#d97706; }
566
+ .ds-an-num { text-align:right; font-variant-numeric:tabular-nums; opacity:.75; }
567
+ .ds-an-kv { display:flex; align-items:baseline; justify-content:space-between; gap:10px; padding:4px 0; border-bottom:1px dashed var(--ds-line); font-size:12px; }
568
+ .ds-an-kv:last-child { border-bottom:none; }
569
+ .ds-an-kv b[data-warn="1"] { color:#e5484d; }
570
+ .ds-spark { display:flex; align-items:flex-end; gap:4px; height:78px; padding-top:6px; border-bottom:1px solid var(--ds-line); }
571
+ .ds-spark-col { flex:1 1 0; display:flex; flex-direction:column; align-items:center; justify-content:flex-end; gap:4px; height:100%; }
572
+ .ds-spark-bar { width:70%; min-height:2px; border-radius:3px 3px 0 0; background:color-mix(in srgb, var(--ds-accent) 70%, transparent); }
573
+ .ds-spark-day { font-size:9.5px; opacity:.5; white-space:nowrap; height:12px; }
574
+ .ds-chip[data-role="product"] { color:#2f6feb; border-color:#2f6feb55; }
575
+ .ds-chip[data-role="architect"] { color:#8b5cf6; border-color:#8b5cf655; }
576
+ .ds-chip[data-role="dev"] { color:#0f9d58; border-color:#0f9d5855; }
577
+ .ds-chip[data-role="qa"] { color:#e08600; border-color:#e0860055; }
578
+ .ds-chip-ai { color:var(--ds-accent); border-color:var(--ds-accent); animation: ds-pulse 1.6s ease-in-out infinite; }
579
+ .ds-chip-blocked { color:#fff; background:#e5484d; border-color:#e5484d; font-weight:650; }
580
+ .ds-card-warn { color:#e5484d; font-size:10.5px; font-weight:600; white-space:nowrap; }
581
+ .ds-case-fail { color:#e5484d; font-weight:600; }
582
+ .ds-card[data-nobody="1"] .ds-card-warn { animation: ds-pulse 2.2s ease-in-out infinite; }
583
+ .ds-card[data-nobody="1"] { border-color: color-mix(in srgb, #e5484d 42%, var(--ds-line)); }
584
+ .ds-art-row[data-missing="1"] { background: color-mix(in srgb, #e5484d 5%, transparent); }
585
+ .ds-chip-bug { color:#c0392b; border-color:#c0392b66; background:color-mix(in srgb, #c0392b 8%, transparent); font-weight:650; }
586
+ .ds-chip-bug[data-sev="p0"] { color:#fff; background:#c0392b; border-color:#c0392b; }
587
+ .ds-chip-bug[data-sev="p1"] { color:#c0392b; background:color-mix(in srgb, #c0392b 16%, transparent); }
588
+ .ds-chip[data-sla="overdue"] { color:#fff; background:#e5484d; border-color:#e5484d; font-weight:650; }
589
+ .ds-chip[data-sla="due_soon"] { color:#b45309; border-color:#b4530966; background:color-mix(in srgb, #d97706 12%, transparent); }
590
+ .ds-chip[data-sla="ok"] { color:#137a4b; border-color:#137a4b55; background:color-mix(in srgb, #0f9d58 8%, transparent); }
591
+ .ds-smoke-done { color:color-mix(in srgb, currentColor 55%, transparent); text-decoration:line-through; }
592
+ @keyframes ds-pulse { 0%,100% { opacity:.45 } 50% { opacity:1 } }
593
+ .ds-dot { width:6px; height:6px; border-radius:50%; display:inline-block; }
594
+ .ds-dot[data-p="urgent"] { background:#e5484d; }
595
+ .ds-dot[data-p="normal"] { background:#8a8f98; }
596
+ .ds-dot[data-p="relaxed"] { background:#4b7bec55; }
597
+ .ds-empty { opacity:.4; font-size:11.5px; padding:14px 8px; text-align:center; border:1px dashed var(--ds-line); border-radius:8px; }
598
+ .ds-body { flex:1; min-height:0; overflow:auto; padding:16px 18px; }
599
+ .ds-panel { border:1px solid var(--ds-line); border-radius:10px; overflow:hidden; margin-bottom:14px; background:var(--ds-card);
600
+ box-shadow:var(--ds-shadow); }
601
+ .ds-panel-head { padding:11px 15px; border-bottom:1px solid var(--ds-line); font-weight:600; font-size:12.5px; display:flex; align-items:center; gap:10px; justify-content:space-between; }
602
+ .ds-panel-head > .ds-hint { font-weight:400; }
603
+ /* Panel bodies stack by default: one field or one list row per line, never a mosaic. */
604
+ .ds-panel-body { padding:14px 15px; display:flex; flex-direction:column; gap:12px; min-width:0; }
605
+ .ds-panel-body > .ds-row { min-width:0; }
606
+ .ds-panel-body > .ds-row > .ds-field { min-width:0; }
607
+ /* Two-or-more column body, only where the content really is a set of short fields. */
608
+ .ds-panel-body.ds-grid2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px 18px; align-items:start; }
609
+ .ds-panel-body.ds-grid4 { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px 16px; align-items:start; }
610
+ @media (max-width: 1080px) { .ds-panel-body.ds-grid4 { grid-template-columns:repeat(2,minmax(0,1fr)); } }
611
+ @media (max-width: 660px) { .ds-panel-body.ds-grid2, .ds-panel-body.ds-grid4 { grid-template-columns:1fr; } }
612
+ .ds-panel-body.ds-grid2 > .ds-span, .ds-panel-body.ds-grid4 > .ds-span { grid-column:1 / -1; }
613
+ .ds-list { display:flex; flex-direction:column; gap:0; }
614
+ .ds-listrow { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; padding:11px 0;
615
+ border-bottom:1px solid var(--ds-line); }
616
+ .ds-listrow:last-child { border-bottom:0; padding-bottom:2px; }
617
+ .ds-listrow-main { min-width:0; flex:1 1 auto; display:flex; flex-direction:column; gap:3px; }
618
+ .ds-listrow-main > div:first-child { font-size:12.5px; font-weight:600; display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
619
+ .ds-listrow-actions { display:flex; gap:6px; flex:0 0 auto; align-items:center; }
620
+ /* Edit forms: keep the field grid readable instead of stretching inputs across the pane. */
621
+ .ds-pane-stack { display:flex; flex-direction:column; gap:12px; }
622
+ .ds-pane-stack > .ds-panel { margin-bottom:0; }
623
+ .ds-pane-stack > .ds-form-panel { order:-1; }
624
+ /* Right-side editor drawer: keep the list in view while editing one record. */
625
+ .ds-side-wrap { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; }
626
+ .ds-side-mask { position:absolute; inset:0; background:rgba(15,18,25,.42); }
627
+ .ds-side { position:relative; display:flex; flex-direction:column; width:min(760px,96vw); height:100%; background:var(--ds-surface); color:inherit; border-left:1px solid var(--ds-line); box-shadow:-18px 0 48px rgba(0,0,0,.18); }
628
+ .ds-side-head { flex:0 0 auto; display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:14px 18px 12px; border-bottom:1px solid var(--ds-line); }
629
+ .ds-side-title { font-size:15px; font-weight:600; }
630
+ .ds-side-body { flex:1 1 auto; min-height:0; overflow-y:auto; padding:16px 18px 20px; display:flex; flex-direction:column; gap:16px; }
631
+ .ds-side-foot { flex:0 0 auto; display:flex; align-items:center; gap:8px; padding:12px 18px; border-top:1px solid var(--ds-line); background:var(--ds-card); }
632
+ .ds-side-note { margin-left:auto; }
633
+ .ds-emoji-preview { font-size:30px; line-height:1.2; padding:6px 0; text-align:center; background:var(--ds-soft); border:1px solid var(--ds-line); border-radius:8px; }
634
+ .ds-emojigrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(36px,1fr)); gap:4px; }
635
+ .ds-emoji { font-size:17px; line-height:1; padding:6px 0; border:1px solid var(--ds-line); border-radius:8px; background:var(--ds-soft); cursor:pointer; }
636
+ .ds-emoji:hover { background:var(--ds-soft2); }
637
+ .ds-emoji[data-on="1"] { border-color:var(--ds-accent); background:color-mix(in srgb, var(--ds-accent) 12%, transparent); box-shadow:0 0 0 2px color-mix(in srgb, var(--ds-accent) 22%, transparent); }
638
+ @media (max-width:760px) { .ds-side { width:100%; } }
639
+ .ds-form-panel { max-width:880px; }
640
+ .ds-form-panel .ds-panel-body > .ds-row { align-items:flex-start; }
641
+ .ds-form-panel .ds-panel-body > .ds-row:last-child { border-top:1px solid var(--ds-line); padding-top:12px; margin-top:2px; }
642
+ .ds-form-panel .ds-panel-body > .ds-row:last-child .ds-btn-primary { min-width:88px; }
643
+ /* Settings: a fixed section rail plus one pane, instead of one long scroll. */
644
+ .ds-settings { display:grid; grid-template-columns:186px minmax(0,1fr); gap:18px; align-items:start; width:100%; }
645
+ .ds-settings-nav { position:sticky; top:8px; display:flex; flex-direction:column; gap:1px;
646
+ border-right:1px solid var(--ds-line); padding:0 10px 10px 0; }
647
+ .ds-settings-nav-group { font-size:10px; letter-spacing:.08em; opacity:.45; padding:10px 10px 4px; }
648
+ .ds-nav-item { display:flex; align-items:center; gap:8px; width:100%; text-align:left; font:inherit; color:inherit;
649
+ border:0; background:none; padding:7px 10px; border-radius:8px; cursor:pointer; font-size:12.5px; line-height:1.2; }
650
+ .ds-nav-item:hover { background:var(--ds-soft2); }
651
+ .ds-nav-item[data-active="1"] { background:var(--ds-accent); color:#fff; }
652
+ .ds-nav-item[data-active="1"] .ds-nav-count { opacity:.85; }
653
+ .ds-nav-count { margin-left:auto; font-size:10.5px; opacity:.5; font-variant-numeric:tabular-nums; }
654
+ .ds-settings-pane { min-width:0; width:100%; max-width:1060px; padding-bottom:24px; }
655
+ .ds-pane-head { margin:0 0 14px; display:flex; align-items:flex-end; justify-content:space-between; gap:12px; flex-wrap:wrap; }
656
+ .ds-pane-title { font-size:13.5px; font-weight:650; letter-spacing:.01em; }
657
+ .ds-pane-desc { font-size:11px; opacity:.55; margin-top:3px; max-width:62ch; line-height:1.55; }
658
+ .ds-settings-pane > .ds-panel:last-child { margin-bottom:0; }
659
+ @media (max-width: 760px) {
660
+ .ds-settings { grid-template-columns:1fr; gap:10px; }
661
+ .ds-settings-nav { position:static; flex-direction:row; flex-wrap:wrap; gap:6px; border-right:0;
662
+ border-bottom:1px solid var(--ds-line); padding:0 0 10px; }
663
+ .ds-settings-nav-group { display:none; }
664
+ .ds-nav-item { width:auto; }
665
+ }
666
+ .ds-field { display:flex; flex-direction:column; gap:4px; }
667
+ .ds-label { font-size:11px; opacity:.6; }
668
+ .ds-input, .ds-textarea, .ds-select { width:100%; font:inherit; color:inherit; background:var(--ds-soft); border:1px solid var(--ds-line);
669
+ border-radius:7px; padding:6px 9px; outline:none; }
670
+ .ds-input:focus, .ds-textarea:focus, .ds-select:focus { border-color:var(--ds-accent); }
671
+ .ds-textarea { min-height:70px; resize:vertical; font-size:12px; }
672
+ .ds-hint { font-size:10.5px; opacity:.5; }
673
+ .ds-error { font-size:11px; color:#c0392b; }
674
+ .ds-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
675
+ .ds-drawer-wrap { position:fixed; inset:0; background:#00000055; display:flex; justify-content:flex-end; z-index:60; }
676
+ .ds-modal-wrap { position:fixed; inset:0; background:#00000055; display:flex; align-items:center; justify-content:center;
677
+ z-index:70; padding:24px; }
678
+ .ds-modal { width:min(560px,96vw); max-height:min(86vh,760px); overflow:auto; display:flex; flex-direction:column; gap:14px;
679
+ background:var(--ds-surface); color:inherit; border:1px solid var(--ds-line); border-radius:14px; padding:20px 22px;
680
+ box-shadow:0 24px 60px #0000003d; }
681
+ .ds-modal h3 { margin:0; font-size:15px; font-weight:650; }
682
+ .ds-modal-foot { display:flex; align-items:center; gap:10px; padding-top:2px; }
683
+ .ds-modal-foot .ds-grow { flex:1; }
684
+ .ds-modal-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
685
+ .ds-drawer { width:min(980px,94vw); height:100%; max-height:100%; background:var(--ds-surface); color:inherit; border-left:1px solid var(--ds-line);
686
+ display:flex; flex-direction:column; overflow:hidden; box-shadow:-16px 0 40px #0000002e; }
687
+ .ds-dw-head { display:flex; gap:10px; align-items:flex-start; padding:16px 20px 12px; border-bottom:1px solid var(--ds-line);
688
+ flex:0 0 auto; background:linear-gradient(180deg, color-mix(in srgb, var(--ds-accent) 5%, transparent), transparent); }
689
+ .ds-dw-title { font-size:15.5px; font-weight:600; line-height:1.35; word-break:break-word; }
690
+ .ds-dw-sub { font-size:11.5px; opacity:.6; margin-top:4px; }
691
+ /* min-height:0 是关键:没有它,flex 子项不肯收缩,内容一长就把底部评论框顶到屏幕外 */
692
+ .ds-dw-body { flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain; padding:16px 20px 26px;
693
+ display:flex; flex-direction:column; gap:18px; }
694
+ /* 详情按页签分组,这里只是包裹层,保持单列 */
695
+ .ds-dw-cols, .ds-dw-col { display:block; }
696
+ .ds-dw-tabs { display:flex; gap:4px; flex-wrap:wrap; padding:10px 20px 0; border-bottom:1px solid var(--ds-line); flex:0 0 auto; }
697
+ .ds-dw-tab {
698
+ appearance:none; background:transparent; border:1px solid transparent; border-bottom:none; color:inherit;
699
+ font-size:12.5px; padding:7px 12px 8px; border-radius:8px 8px 0 0; cursor:pointer; display:flex; align-items:center; gap:5px;
700
+ opacity:.72; margin-bottom:-1px;
701
+ }
702
+ .ds-dw-tab:hover { opacity:1; background:color-mix(in srgb, currentColor 6%, transparent); }
703
+ .ds-dw-tab[data-on="1"] {
704
+ opacity:1; font-weight:600; background:var(--ds-surface); border-color:var(--ds-line);
705
+ border-bottom:1px solid var(--ds-surface); color:var(--ds-accent);
706
+ }
707
+ .ds-dw-tab-count {
708
+ font-size:10.5px; line-height:1; padding:2px 5px; border-radius:8px; font-weight:600;
709
+ background:color-mix(in srgb, currentColor 12%, transparent);
710
+ }
711
+ .ds-dw-foot { flex:0 0 auto; border-top:1px solid var(--ds-line); padding:10px 20px 14px; background:var(--ds-surface); position:relative; }
712
+ .ds-mention-bar { display:flex; align-items:center; gap:6px; margin-bottom:6px; flex-wrap:wrap; }
713
+ .ds-mention-pop {
714
+ position:absolute; left:20px; right:20px; bottom:calc(100% - 4px);
715
+ background:var(--ds-surface); border:1px solid var(--ds-line); border-radius:10px;
716
+ box-shadow:0 12px 32px rgba(0,0,0,0.18); max-height:260px; display:flex; flex-direction:column;
717
+ overflow:hidden; z-index:60; color:inherit;
718
+ }
719
+ .ds-mention-search { border:0; border-bottom:1px solid var(--ds-line); background:transparent; color:inherit; padding:8px 12px; font:inherit; font-size:12.5px; outline:none; }
720
+ .ds-mention-list { overflow:auto; padding:4px 0; }
721
+ .ds-mention-group { font-size:11px; color:color-mix(in srgb, currentColor 55%, transparent); padding:6px 12px 2px; }
722
+ .ds-mention-item {
723
+ display:flex; align-items:center; gap:8px; width:100%; text-align:left; background:none; border:0;
724
+ padding:6px 12px; font:inherit; font-size:12.5px; color:inherit; cursor:pointer;
725
+ }
726
+ .ds-mention-item:hover, .ds-mention-item[data-active='1'] { background:color-mix(in srgb, var(--ds-accent) 12%, transparent); }
727
+ .ds-mention-item .ds-hint { margin-left:auto; }
728
+ .ds-mention-empty { padding:10px 12px; font-size:12px; color:color-mix(in srgb, currentColor 55%, transparent); }
729
+ .ds-colrow { border-bottom:1px solid var(--ds-line); padding:10px 0; display:flex; flex-direction:column; gap:8px; }
730
+ .ds-colrow:last-of-type { border-bottom:0; }
731
+ .ds-colrow-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
732
+ .ds-colrow-name { font-weight:600; font-size:12.5px; min-width:64px; }
733
+ .ds-chip-owner { display:inline-flex; align-items:center; gap:4px; }
734
+ .ds-chip-x { border:0; background:none; color:inherit; cursor:pointer; opacity:0.55; font-size:13px; line-height:1; padding:0 2px; }
735
+ .ds-chip-x:hover { opacity:1; color:#e5484d; }
736
+ .ds-skillbox { display:flex; flex-direction:column; gap:2px; max-height:190px; overflow-y:auto; border:1px solid var(--ds-line); border-radius:8px; padding:6px 8px; background:var(--ds-soft); }
737
+ .ds-skillopt { display:flex; align-items:center; gap:8px; padding:4px 2px; border-radius:6px; cursor:pointer; }
738
+ .ds-skillopt:hover { background:var(--ds-soft2); }
739
+ .ds-skillopt[data-on="1"] { background:color-mix(in srgb, var(--ds-accent) 10%, transparent); }
740
+ .ds-skillname { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px; }
741
+ .ds-skilldesc { flex:1 1 auto; min-width:0; color:var(--ds-muted, #6b7280); font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
742
+ .ds-skillusers { flex:0 0 auto; font-size:10.5px; opacity:.6; }
743
+ .ds-chip[data-missing="1"] { border-color:color-mix(in srgb, #e5484d 45%, var(--ds-line)); }
744
+ .ds-colpick { border:1px solid var(--ds-line); border-radius:10px; max-height:220px; display:flex; flex-direction:column; overflow:hidden; background:var(--ds-surface); }
745
+ .ds-colpick-list { overflow:auto; padding:4px 0; }
746
+ .ds-sec { display:flex; flex-direction:column; gap:8px; margin-bottom:16px; }
747
+ .ds-sec:last-child { margin-bottom:0; }
748
+ .ds-sec-head { display:flex; align-items:center; gap:8px; font-size:12px; font-weight:600; }
749
+ .ds-sec-head .ds-hint { font-weight:400; }
750
+ .ds-sec-body { display:flex; flex-direction:column; gap:8px; }
751
+ /* 详情里不要套内层滚动条:外层 .ds-dw-body 已经能滚,再套一层会把内容「截一半」,让人以为丢了数据。 */
752
+ .ds-sec-body[data-scroll="1"] { padding-right:4px; }
753
+ .ds-comments { padding-right:4px; }
754
+ .ds-kv { display:grid; grid-template-columns:92px 1fr; gap:6px 12px; font-size:12px; margin:0; padding:10px 12px;
755
+ border:1px solid var(--ds-line); border-radius:10px; background:var(--ds-soft); }
756
+ .ds-kv dt { opacity:.55; }
757
+ .ds-kv dd { margin:0; word-break:break-all; }
758
+ /* Comment thread: the human's words sit flush left on an accent surface,
759
+ the agent's answer steps in behind a role-coloured rail. */
760
+ .ds-comments { display:flex; flex-direction:column; gap:2px; }
761
+ .ds-cm { display:grid; grid-template-columns:30px minmax(0,1fr); gap:10px; align-items:start;
762
+ padding:9px 0; border-top:1px solid var(--ds-line); }
763
+ .ds-cm:first-child { border-top:0; }
764
+ .ds-cm-avatar { width:30px; height:30px; border-radius:50%; display:flex; align-items:center; justify-content:center;
765
+ font-size:15px; line-height:1; border:1px solid var(--ds-line); background:var(--ds-card); }
766
+ .ds-cm[data-kind="human"] .ds-cm-avatar { background:var(--ds-accent); border-color:transparent; }
767
+ .ds-cm-initial { font-size:12px; font-weight:700; color:#fff; line-height:1; }
768
+ .ds-cm-head { display:flex; align-items:center; gap:7px; margin-bottom:5px; flex-wrap:wrap; }
769
+ .ds-cm-name { font-size:12px; font-weight:650; }
770
+ .ds-cm-kind { font-size:9.5px; letter-spacing:.06em; text-transform:uppercase; padding:0 5px; line-height:15px;
771
+ border-radius:4px; border:1px solid currentColor; opacity:.75; }
772
+ .ds-cm[data-kind="human"] .ds-cm-kind { color:var(--ds-accent); }
773
+ .ds-cm[data-kind="agent"] .ds-cm-kind { opacity:.45; }
774
+ .ds-cm-meta { font-size:10.5px; opacity:.45; margin-left:auto; }
775
+ .ds-cm-bubble { font-size:12.5px; line-height:1.62; white-space:pre-wrap; border-radius:9px; padding:9px 12px;
776
+ border:1px solid var(--ds-line); background:var(--ds-card); }
777
+ .ds-cm[data-kind="human"] .ds-cm-bubble { background:color-mix(in srgb, var(--ds-accent) 8%, transparent);
778
+ border-color:color-mix(in srgb, var(--ds-accent) 38%, transparent); }
779
+ .ds-cm[data-kind="agent"] { margin-left:22px; }
780
+ .ds-cm[data-kind="agent"] .ds-cm-bubble { border-left-width:3px; border-left-color:var(--ds-line); background:transparent; }
781
+ .ds-cm[data-role="product"] .ds-cm-bubble { border-left-color:#2f6feb; }
782
+ .ds-cm[data-role="architect"] .ds-cm-bubble { border-left-color:#8b5cf6; }
783
+ .ds-cm[data-role="dev"] .ds-cm-bubble { border-left-color:#0f9d58; }
784
+ .ds-cm[data-role="qa"] .ds-cm-bubble { border-left-color:#e08600; }
785
+ .ds-cm-bubble > :first-child { margin-top:0; }
786
+ .ds-cm-bubble > :last-child { margin-bottom:0; }
787
+ .ds-md { white-space:normal; line-height:1.6; word-break:break-word; }
788
+ .ds-md > *:first-child { margin-top:0; }
789
+ .ds-md > *:last-child { margin-bottom:0; }
790
+ .ds-md p { margin:0 0 8px; }
791
+ .ds-md h3, .ds-md h4, .ds-md h5, .ds-md h6 { margin:12px 0 6px; line-height:1.35; }
792
+ .ds-md h3 { font-size:14px; } .ds-md h4 { font-size:13px; } .ds-md h5, .ds-md h6 { font-size:12.5px; }
793
+ .ds-md ul, .ds-md ol { margin:0 0 8px; padding-left:20px; }
794
+ .ds-md li { margin:2px 0; }
795
+ .ds-md code { font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:11.5px;
796
+ background:var(--ds-soft2); border:1px solid var(--ds-line); border-radius:4px; padding:0 4px; }
797
+ .ds-md pre { margin:0 0 10px; padding:10px 12px; overflow-x:auto; background:var(--ds-soft2); border:1px solid var(--ds-line); border-radius:8px; }
798
+ .ds-md pre code { background:none; border:0; padding:0; font-size:11.5px; line-height:1.55; }
799
+ .ds-md blockquote { margin:0 0 8px; padding:2px 0 2px 10px; border-left:3px solid var(--ds-line); opacity:.85; }
800
+ .ds-md a { color:var(--ds-accent); text-decoration:none; }
801
+ .ds-md a:hover { text-decoration:underline; }
802
+ .ds-md hr { border:0; border-top:1px solid var(--ds-line); margin:10px 0; }
803
+ .ds-md table { border-collapse:collapse; margin:0 0 10px; font-size:11.5px; width:100%; table-layout:fixed; }
804
+ .ds-md th, .ds-md td { border:1px solid var(--ds-line); padding:4px 8px; text-align:left; vertical-align:top; word-break:break-word; }
805
+ .ds-md th { background:var(--ds-soft2); font-weight:600; }
806
+ .ds-err { color:#e5484d; font-size:12px; }
807
+ .ds-ok { color:#0f9d58; font-size:12px; }
808
+ .ds-loading { padding:24px; opacity:.5; font-size:12.5px; }
809
+ /* ---- 需求页 ---- */
810
+ .ds-req-grid { display:grid; grid-template-columns:320px minmax(0,1fr); gap:16px; align-items:start; }
811
+ @media (max-width:980px) { .ds-req-grid { grid-template-columns:1fr; } }
812
+ .ds-req-list { display:flex; flex-direction:column; gap:6px; }
813
+ .ds-req-list-head { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:11.5px; opacity:.75; padding:0 2px 4px; }
814
+ .ds-req-item { border:1px solid var(--ds-line); border-radius:9px; padding:9px 11px; cursor:pointer; display:flex; flex-direction:column; gap:5px; }
815
+ .ds-req-item:hover { border-color:var(--ds-accent); }
816
+ .ds-req-item[data-on="1"] { border-color:var(--ds-accent); background:color-mix(in srgb, var(--ds-accent) 7%, transparent); }
817
+ .ds-req-item-title { font-size:12.5px; font-weight:600; line-height:1.35; }
818
+ .ds-req-item-meta { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
819
+ .ds-req-detail { border:1px solid var(--ds-line); border-radius:10px; padding:16px 18px; min-height:220px; }
820
+ .ds-req-headline { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
821
+ .ds-req-code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px; opacity:.6; letter-spacing:.02em; }
822
+ .ds-req-name { font-size:16px; font-weight:650; margin-top:2px; line-height:1.35; }
823
+ .ds-req-actions { display:flex; align-items:center; gap:8px; flex:0 0 auto; }
824
+ .ds-req-actions .ds-select { width:auto; min-width:96px; height:28px; }
825
+ .ds-req-links { display:flex; gap:14px; flex-wrap:wrap; margin-top:10px; }
826
+ .ds-link { color:var(--ds-accent); font-size:12px; text-decoration:none; }
827
+ .ds-link:hover { text-decoration:underline; }
828
+ .ds-req-desc { margin-top:10px; font-size:12px; line-height:1.65; opacity:.92; }
829
+ .ds-req-stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(96px,1fr)); gap:8px; margin-top:14px; }
830
+ .ds-stat-box { border:1px solid var(--ds-line); border-radius:9px; padding:8px 10px; display:flex; flex-direction:column; gap:2px; }
831
+ .ds-stat-box b { font-size:15px; font-weight:650; }
832
+ .ds-matrix { display:flex; flex-direction:column; gap:8px; margin-top:8px; }
833
+ .ds-matrix-sum { font-size:11.5px; opacity:.72; padding:6px 0 2px; }
834
+ .ds-pt-row { display:flex; align-items:flex-start; gap:14px; padding:10px 12px; border:1px solid var(--ds-line); border-left:3px solid color-mix(in srgb, currentColor 20%, transparent); border-radius:9px; }
835
+ .ds-pt-row[data-covered="1"] { border-left-color:#0f9d58; }
836
+ .ds-pt-main { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; gap:3px; }
837
+ .ds-pt-title { display:flex; align-items:center; gap:8px; flex-wrap:wrap; font-size:12.5px; }
838
+ .ds-pt-meta { font-size:11px; opacity:.7; }
839
+ .ds-pt-loose { margin-top:10px; padding:10px 12px; border:1px dashed var(--ds-line); border-radius:9px; display:flex; flex-direction:column; gap:6px; }
840
+ .ds-pt-loose-row { display:flex; align-items:center; gap:8px; font-size:11.5px; }
841
+ .ds-pt-loose-title { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
842
+ .ds-chip[data-pt="covered"] { color:#0f9d58; border-color:color-mix(in srgb, #0f9d58 40%, transparent); }
843
+ .ds-chip[data-pt="open"] { color:#d97706; border-color:color-mix(in srgb, #d97706 40%, transparent); }
844
+ .ds-gate { border:1px solid var(--ds-line); border-radius:9px; padding:10px 12px; display:flex; flex-direction:column; gap:5px; }
845
+ .ds-gate[data-ready="1"] { border-color:color-mix(in srgb, #0f9d58 45%, transparent); }
846
+ .ds-gate-head { display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:12.5px; }
847
+ .ds-gate-item { font-size:11.5px; line-height:1.55; }
848
+ .ds-gate-item[data-kind="block"] { color:#c0392b; }
849
+ .ds-gate-item[data-kind="warn"] { color:#b26a00; }
850
+ .ds-gate-sign { display:flex; align-items:center; gap:10px; flex-wrap:wrap; font-size:11.5px; padding:8px 10px; border-radius:9px; background:var(--ds-soft); }
851
+ .ds-stat-box span { font-size:11px; opacity:.6; }
852
+ .ds-stat-box[data-warn="1"] { border-color:#e5484d; }
853
+ .ds-stat-box[data-warn="1"] b { color:#e5484d; }
854
+ .ds-req-tasks { display:flex; flex-direction:column; }
855
+ .ds-req-task { cursor:pointer; }
856
+ .ds-req-task:hover { background:var(--ds-soft); }
857
+ .ds-req-task-title { display:flex; align-items:center; gap:7px; font-size:12.5px; font-weight:600; }
858
+ .ds-chip-code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:10px; letter-spacing:.02em; border:1px solid var(--ds-line); border-radius:5px; padding:0 5px; opacity:.85; }
859
+ .ds-chip[data-req="active"] { color:#0f9d58; border-color:#0f9d58; }
860
+ .ds-chip[data-req="delivered"] { color:var(--ds-accent); border-color:var(--ds-accent); }
861
+ .ds-chip[data-req="closed"] { opacity:.55; }
862
+ /* ---- 需求文档与基线 ---- */
863
+ .ds-base-box { display:flex; align-items:center; justify-content:space-between; gap:12px; border:1px solid var(--ds-line); border-radius:9px; padding:9px 12px; margin-top:8px; background:color-mix(in srgb, #0f9d58 6%, transparent); }
864
+ .ds-base-box[data-empty="1"] { background:var(--ds-soft); color:inherit; }
865
+ .ds-base-main { display:flex; flex-direction:column; gap:3px; min-width:0; }
866
+ .ds-base-main b { font-size:12.5px; font-weight:650; }
867
+ .ds-base-main span { font-size:11.5px; opacity:.72; line-height:1.45; }
868
+ .ds-doc-list { display:flex; flex-direction:column; margin-top:6px; }
869
+ .ds-doc-row { display:flex; align-items:flex-start; gap:10px; padding:9px 0; border-bottom:1px solid var(--ds-line); }
870
+ .ds-doc-row:last-child { border-bottom:0; }
871
+ .ds-doc-main { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; gap:3px; }
872
+ .ds-doc-title { display:flex; align-items:center; gap:7px; flex-wrap:wrap; font-size:12.5px; font-weight:600; }
873
+ .ds-doc-meta { font-size:11px; opacity:.6; }
874
+ .ds-chip[data-doc="prd"] { color:#2f6feb; border-color:#2f6feb; }
875
+ .ds-chip[data-doc="proto"] { color:#8b5cf6; border-color:#8b5cf6; }
876
+ .ds-chip[data-doc="acceptance"] { color:#0f9d58; border-color:#0f9d58; }
877
+ .ds-chip[data-docstatus="baseline"] { color:#0f9d58; border-color:#0f9d58; background:color-mix(in srgb, #0f9d58 12%, transparent); }
878
+ .ds-chip[data-docstatus="review"] { color:#d97706; border-color:#d97706; }
879
+ /* ---- 滚动条:跟着插件主题走,不跟系统 ---- */
880
+ [data-dsh-studio] { scrollbar-width:thin; scrollbar-color:color-mix(in srgb, currentColor 24%, transparent) transparent; }
881
+ [data-dsh-studio] ::-webkit-scrollbar { width:10px; height:10px; }
882
+ [data-dsh-studio] ::-webkit-scrollbar-track { background:transparent; }
883
+ [data-dsh-studio] ::-webkit-scrollbar-thumb { background:color-mix(in srgb, currentColor 22%, transparent); border-radius:8px; }
884
+ [data-dsh-studio] ::-webkit-scrollbar-thumb:hover { background:color-mix(in srgb, currentColor 38%, transparent); }
885
+ [data-dsh-studio] ::-webkit-scrollbar-corner { background:transparent; }
886
+
887
+ /* ---- 窄屏(平板竖屏 / 手机):单列、卡片占满、按钮换行 ---- */
888
+ @media (max-width:760px) {
889
+ .ds-head { padding:10px 12px 0; }
890
+ .ds-head-top { flex-wrap:wrap; gap:8px; }
891
+ .ds-head-actions { flex-wrap:wrap; gap:6px; margin-left:0; }
892
+ .ds-head-tabs { gap:8px; }
893
+ .ds-head-tabs .ds-grow { display:none; }
894
+ .ds-stat { display:none; }
895
+ .ds-tabs { flex:1 1 auto; min-width:0; flex-wrap:wrap; overflow:visible; }
896
+ .ds-tab { padding:4px 10px; }
897
+ .ds-board { gap:10px; padding:10px 12px 12px; }
898
+ .ds-col { flex:0 0 min(86vw,300px); }
899
+ .ds-panel-body { padding:12px; }
900
+ .ds-kv { grid-template-columns:1fr; gap:2px 0; }
901
+ .ds-kv dt { margin-top:6px; }
902
+ .ds-an-row { grid-template-columns:76px minmax(0,1fr) 64px; }
903
+ .ds-gate-head { flex-wrap:wrap; }
904
+ .ds-audit-row { grid-template-columns:56px 1fr 56px; }
905
+ }
906
+ @media (max-width:520px) {
907
+ .ds-col { flex:0 0 92vw; }
908
+ .ds-tabs { overflow-x:auto; max-width:100%; }
909
+ .ds-head-actions .ds-btn { padding:5px 9px; }
910
+ }
911
+
912
+ /* ---- 强制颜色(Windows 高对比度 / forced-colors):选中态从底色改成边框 ---- */
913
+ @media (forced-colors: active) {
914
+ .ds-panel, .ds-card, .ds-side, .ds-drawer, .ds-gate, .ds-stat-box, .ds-col, .ds-doc-row, .ds-tabs, .ds-seg { border:1px solid CanvasText; }
915
+ .ds-btn, .ds-chip, .ds-seg-btn { border:1px solid ButtonText; }
916
+ .ds-tab[data-on="1"], .ds-seg-btn[data-on="1"], .ds-chip[data-on="1"] { border:2px solid Highlight; }
917
+ .ds-btn-primary { border:2px solid ButtonText; background:ButtonFace; color:ButtonText; }
918
+ .ds-kpi[data-warn="1"], .ds-stat-box[data-warn="1"] { border:2px solid CanvasText; }
919
+ .ds-chip[data-pt="covered"], .ds-chip[data-pt="open"], .ds-chip[data-req="active"], .ds-chip[data-req="delivered"] { border-color:Highlight; }
920
+ [data-dsh-studio] :focus-visible { outline:2px solid Highlight; outline-offset:1px; }
921
+ }
922
+ /* ---- 登录页(统一登录要求登录、又还没有会话时盖的一层) ---- */
923
+ .ds-login-wrap { flex:1 1 auto; display:flex; align-items:center; justify-content:center; padding:24px; }
924
+ /* 已进入工作区后点「登录」时是浮层:固定盖住整页,别把下面的布局挤歪 */
925
+ .ds-login-wrap[data-overlay="1"] { position:fixed; inset:0; z-index:90; flex:none;
926
+ background:color-mix(in srgb, var(--ds-surface) 76%, transparent); backdrop-filter:blur(2px); }
927
+ .ds-login { width:min(380px,100%); display:flex; flex-direction:column; gap:10px; padding:20px;
928
+ border:1px solid var(--ds-line); border-radius:12px; background:var(--ds-surface);
929
+ box-shadow:0 12px 32px color-mix(in srgb, currentColor 12%, transparent); }
930
+ .ds-login-wrap[data-overlay="1"] .ds-login { box-shadow:0 22px 60px color-mix(in srgb, currentColor 26%, transparent); }
931
+ .ds-login-brand { font-size:16px; font-weight:600; }
932
+ .ds-login-hint { font-size:12px; opacity:.75; line-height:1.6; }
933
+ .ds-login-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:2px; }
934
+ .ds-login-note { font-size:11px; opacity:.6; word-break:break-all; }
935
+
936
+ /* ======================================================================
937
+ shadcn/ui 风格层(2026-11-09)
938
+ 对齐 https://ui.shadcn.com 的默认主题:一套 token + 统一圆角/描边/
939
+ 焦点环/间距,组件按 Button / Input / Card / Badge / Tabs / Dialog /
940
+ Table 的默认外观重画。仍然零依赖、无构建,只追加规则覆盖旧样式。
941
+ ====================================================================== */
942
+ [data-dsh-studio] {
943
+ --radius:10px;
944
+ --background:#fafafa; --foreground:#09090b;
945
+ --card:#ffffff; --card-foreground:#09090b;
946
+ --popover:#ffffff; --popover-foreground:#09090b;
947
+ --primary:#18181b; --primary-foreground:#fafafa;
948
+ --secondary:#f4f4f5; --secondary-foreground:#18181b;
949
+ --muted:#f4f4f5; --muted-foreground:#71717a;
950
+ --accent:#f4f4f5; --accent-foreground:#18181b;
951
+ --destructive:#dc2626; --destructive-foreground:#fafafa;
952
+ --border:#e4e4e7; --input:#e4e4e7; --ring:#a1a1aa;
953
+ --ds-bg:var(--background); --ds-surface:var(--card); --ds-surface-2:var(--muted);
954
+ --ds-card:var(--card); --ds-line:var(--border);
955
+ --ds-line-soft:color-mix(in srgb, var(--border) 55%, transparent);
956
+ --ds-soft:var(--muted); --ds-soft2:var(--secondary);
957
+ --ds-accent:var(--primary); --ds-shadow:0 1px 2px rgb(9 9 11 / .05);
958
+ background:var(--background); color:var(--foreground);
959
+ }
960
+ [data-dsh-studio][data-theme="light"] {
961
+ color-scheme:light; color:var(--foreground); background:var(--background);
962
+ --background:#fafafa; --foreground:#09090b;
963
+ --card:#ffffff; --card-foreground:#09090b;
964
+ --popover:#ffffff; --popover-foreground:#09090b;
965
+ --primary:#18181b; --primary-foreground:#fafafa;
966
+ --secondary:#f4f4f5; --secondary-foreground:#18181b;
967
+ --muted:#f4f4f5; --muted-foreground:#71717a;
968
+ --accent:#f4f4f5; --accent-foreground:#18181b;
969
+ --destructive:#dc2626; --border:#e4e4e7; --input:#e4e4e7; --ring:#a1a1aa;
970
+ --ds-bg:var(--background); --ds-surface:var(--card); --ds-surface-2:var(--muted);
971
+ --ds-card:var(--card); --ds-line:var(--border);
972
+ --ds-line-soft:color-mix(in srgb, var(--border) 55%, transparent);
973
+ --ds-soft:var(--muted); --ds-soft2:var(--secondary);
974
+ --ds-accent:var(--primary); --ds-shadow:0 1px 2px rgb(9 9 11 / .05);
975
+ }
976
+ [data-dsh-studio][data-theme="dark"] {
977
+ color-scheme:dark; color:var(--foreground); background:var(--background);
978
+ --background:#09090b; --foreground:#fafafa;
979
+ --card:#111113; --card-foreground:#fafafa;
980
+ --popover:#111113; --popover-foreground:#fafafa;
981
+ --primary:#fafafa; --primary-foreground:#18181b;
982
+ --secondary:#1b1b1f; --secondary-foreground:#fafafa;
983
+ --muted:#1b1b1f; --muted-foreground:#a1a1aa;
984
+ --accent:#1b1b1f; --accent-foreground:#fafafa;
985
+ --destructive:#ef4444; --border:#27272a; --input:#27272a; --ring:#52525b;
986
+ --ds-bg:var(--background); --ds-surface:var(--card); --ds-surface-2:var(--muted);
987
+ --ds-card:var(--card); --ds-line:var(--border);
988
+ --ds-line-soft:color-mix(in srgb, var(--border) 65%, transparent);
989
+ --ds-soft:var(--muted); --ds-soft2:var(--secondary);
990
+ --ds-accent:var(--primary); --ds-shadow:0 1px 2px rgb(0 0 0 / .45);
991
+ }
992
+ /* 焦点环:shadcn 的 ring-2 ring-ring ring-offset-2 */
993
+ [data-dsh-studio] :focus-visible { outline:2px solid var(--ring); outline-offset:2px; border-radius:calc(var(--radius) - 4px); }
994
+
995
+ /* 页头 / 页面底 */
996
+ [data-dsh-studio] .ds-head { background:var(--background); border-bottom:1px solid var(--border); padding:14px 20px 0; }
997
+ [data-dsh-studio] .ds-brand, [data-dsh-studio] .ds-title { letter-spacing:-.01em; }
998
+
999
+ /* 按钮:outline(默认)、primary、ghost 三态 */
1000
+ [data-dsh-studio] .ds-btn { display:inline-flex; align-items:center; justify-content:center; gap:6px;
1001
+ height:32px; padding:0 12px; border:1px solid var(--input); border-radius:var(--radius);
1002
+ background:var(--card); color:var(--foreground); font-size:12.5px; font-weight:500; line-height:1;
1003
+ box-shadow:var(--ds-shadow); cursor:pointer; transition:background .12s ease, border-color .12s ease, box-shadow .12s ease; }
1004
+ [data-dsh-studio] .ds-btn:hover { background:var(--muted); }
1005
+ [data-dsh-studio] .ds-btn:active { transform:translateY(.5px); }
1006
+ [data-dsh-studio] .ds-btn[disabled] { opacity:.5; cursor:not-allowed; box-shadow:none; }
1007
+ [data-dsh-studio] .ds-btn-primary { background:var(--primary); border-color:var(--primary); color:var(--primary-foreground); box-shadow:none; }
1008
+ [data-dsh-studio] .ds-btn-primary:hover { background:color-mix(in srgb, var(--primary) 88%, transparent); filter:none; }
1009
+ [data-dsh-studio] .ds-btn-ghost { background:transparent; border-color:transparent; box-shadow:none; opacity:.7; height:26px; padding:0 8px; }
1010
+ [data-dsh-studio] .ds-btn-ghost:hover { background:var(--muted); opacity:1; }
1011
+
1012
+ /* 输入类:Input / Select / Textarea */
1013
+ [data-dsh-studio] .ds-input, [data-dsh-studio] .ds-select, [data-dsh-studio] .ds-textarea {
1014
+ width:100%; height:32px; padding:0 10px; font:inherit; font-size:12.5px; color:var(--foreground);
1015
+ background:var(--card); border:1px solid var(--input); border-radius:var(--radius);
1016
+ box-shadow:var(--ds-shadow); transition:border-color .12s ease, box-shadow .12s ease; }
1017
+ [data-dsh-studio] .ds-textarea { height:auto; min-height:72px; padding:8px 10px; line-height:1.6; resize:vertical; }
1018
+ [data-dsh-studio] .ds-select { padding-right:8px; }
1019
+ [data-dsh-studio] .ds-input:hover, [data-dsh-studio] .ds-select:hover, [data-dsh-studio] .ds-textarea:hover { border-color:var(--ring); }
1020
+ [data-dsh-studio] .ds-input:focus, [data-dsh-studio] .ds-select:focus, [data-dsh-studio] .ds-textarea:focus {
1021
+ border-color:var(--ring); outline:none; box-shadow:0 0 0 3px color-mix(in srgb, var(--ring) 35%, transparent); }
1022
+ [data-dsh-studio] .ds-input::placeholder, [data-dsh-studio] .ds-textarea::placeholder { color:var(--muted-foreground); }
1023
+ [data-dsh-studio] .ds-label { font-size:12px; font-weight:500; color:var(--foreground); }
1024
+ [data-dsh-studio] .ds-hint, [data-dsh-studio] .ds-sub, [data-dsh-studio] .ds-pane-desc, [data-dsh-studio] .ds-empty { color:var(--muted-foreground); }
1025
+
1026
+ /* 卡片 / 面板 */
1027
+ [data-dsh-studio] .ds-panel { background:var(--card); border:1px solid var(--border); border-radius:calc(var(--radius) + 2px);
1028
+ box-shadow:var(--ds-shadow); margin-bottom:16px; overflow:hidden; }
1029
+ [data-dsh-studio] .ds-panel-head { background:transparent; border-bottom:1px solid var(--border); padding:12px 16px; font-size:13px; }
1030
+ [data-dsh-studio] .ds-panel-body { padding:16px; gap:14px; }
1031
+ [data-dsh-studio] .ds-card { background:var(--card); border:1px solid var(--border); border-radius:var(--radius);
1032
+ box-shadow:var(--ds-shadow); padding:10px 11px; transition:border-color .12s ease, box-shadow .12s ease, transform .12s ease; }
1033
+ [data-dsh-studio] .ds-card:hover { border-color:var(--ring); transform:none; box-shadow:0 2px 8px rgb(9 9 11 / .08); }
1034
+ [data-dsh-studio] .ds-kpi { background:var(--card); border:1px solid var(--border); border-radius:calc(var(--radius) + 2px);
1035
+ box-shadow:var(--ds-shadow), inset 0 0 0 0 transparent; padding:14px 16px; }
1036
+ [data-dsh-studio] .ds-kpi-value { font-size:24px; font-weight:600; font-variant-numeric:tabular-nums; }
1037
+ [data-dsh-studio] .ds-kpi-label { font-size:12px; font-weight:500; color:var(--muted-foreground); }
1038
+ [data-dsh-studio] .ds-kpi-hint { font-size:11.5px; color:var(--muted-foreground); }
1039
+ [data-dsh-studio] .ds-stat-box, [data-dsh-studio] .ds-proj-card, [data-dsh-studio] .ds-market-card,
1040
+ [data-dsh-studio] .ds-req-item, [data-dsh-studio] .ds-gate, [data-dsh-studio] .ds-base-box {
1041
+ background:var(--card); border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--ds-shadow); }
1042
+
1043
+ /* Badge(chip) */
1044
+ [data-dsh-studio] .ds-chip { display:inline-flex; align-items:center; gap:4px; height:20px; padding:0 8px;
1045
+ border:1px solid var(--border); border-radius:999px; background:var(--muted); color:var(--secondary-foreground);
1046
+ font-size:11px; font-weight:500; line-height:1; white-space:nowrap; }
1047
+ [data-dsh-studio] .ds-chip[data-on="1"] { background:var(--primary); border-color:var(--primary); color:var(--primary-foreground); }
1048
+ [data-dsh-studio] .ds-chip-bug, [data-dsh-studio] .ds-chip[data-sla="overdue"] { background:color-mix(in srgb, var(--destructive) 12%, var(--card)); border-color:color-mix(in srgb, var(--destructive) 45%, var(--border)); color:var(--destructive); font-weight:600; }
1049
+ [data-dsh-studio] .ds-chip[data-sla="due_soon"] { background:color-mix(in srgb, #d97706 12%, var(--card)); border-color:color-mix(in srgb, #d97706 45%, var(--border)); color:#b45309; }
1050
+ [data-dsh-studio][data-theme="dark"] .ds-chip[data-sla="due_soon"] { color:#fbbf24; }
1051
+
1052
+ /* Tabs(shadcn: 灰底胶囊 + 选中项浮起) */
1053
+ [data-dsh-studio] .ds-tabs { display:inline-flex; gap:4px; padding:4px; border-radius:calc(var(--radius) + 1px);
1054
+ background:var(--muted); border:1px solid var(--border); }
1055
+ [data-dsh-studio] .ds-tab { border:0; background:transparent; color:var(--muted-foreground); font:inherit; font-size:12.5px;
1056
+ font-weight:500; padding:5px 12px; border-radius:calc(var(--radius) - 2px); white-space:nowrap; flex:0 0 auto; transition:background .12s ease, color .12s ease; }
1057
+ [data-dsh-studio] .ds-tab:hover { background:color-mix(in srgb, var(--card) 60%, transparent); color:var(--foreground); opacity:1; }
1058
+ [data-dsh-studio] .ds-tab[data-on="1"] { background:var(--card); color:var(--foreground); font-weight:600; box-shadow:var(--ds-shadow); }
1059
+ [data-dsh-studio] .ds-seg { display:inline-flex; gap:3px; padding:3px; border-radius:calc(var(--radius) + 1px); background:var(--muted); border:1px solid var(--border); }
1060
+ [data-dsh-studio] .ds-seg-btn { border:0; background:transparent; color:var(--muted-foreground); font:inherit; font-size:11.5px;
1061
+ padding:4px 10px; border-radius:calc(var(--radius) - 3px); white-space:nowrap; }
1062
+ [data-dsh-studio] .ds-seg-btn[data-on="1"] { background:var(--card); color:var(--foreground); font-weight:600; box-shadow:var(--ds-shadow); opacity:1; }
1063
+ [data-dsh-studio] .ds-dw-tabs { border-bottom:1px solid var(--border); gap:2px; }
1064
+ [data-dsh-studio] .ds-dw-tab { border-radius:calc(var(--radius) - 4px) calc(var(--radius) - 4px) 0 0; }
1065
+ [data-dsh-studio] .ds-dw-tab[data-on="1"] { background:var(--muted); border-bottom:2px solid var(--foreground); }
1066
+
1067
+ /* 看板列 */
1068
+ [data-dsh-studio] .ds-col { background:color-mix(in srgb, var(--muted) 55%, transparent); border:1px solid var(--border);
1069
+ border-radius:calc(var(--radius) + 2px); }
1070
+ [data-dsh-studio] .ds-col-head { background:transparent; border-bottom:1px solid var(--border); padding:10px 12px; font-size:12px; }
1071
+
1072
+ /* 抽屉 / 弹窗(Dialog) */
1073
+ [data-dsh-studio] .ds-side, [data-dsh-studio] .ds-drawer { background:var(--card); border-left:1px solid var(--border);
1074
+ box-shadow:-24px 0 60px rgb(9 9 11 / .18); }
1075
+ [data-dsh-studio] .ds-side-head, [data-dsh-studio] .ds-dw-head { border-bottom:1px solid var(--border); }
1076
+ [data-dsh-studio] .ds-side-foot, [data-dsh-studio] .ds-dw-foot { background:var(--card); border-top:1px solid var(--border); }
1077
+ [data-dsh-studio] .ds-modal { background:var(--card); border:1px solid var(--border); border-radius:calc(var(--radius) + 4px);
1078
+ box-shadow:0 24px 64px rgb(9 9 11 / .24); padding:20px; }
1079
+ [data-dsh-studio] .ds-modal-wrap, [data-dsh-studio] .ds-drawer-wrap, [data-dsh-studio] .ds-side-mask { background:rgb(9 9 11 / .5); }
1080
+
1081
+ /* 表格化行 */
1082
+ [data-dsh-studio] .ds-listrow, [data-dsh-studio] .ds-doc-row, [data-dsh-studio] .ds-audit-row,
1083
+ [data-dsh-studio] .ds-art-row, [data-dsh-studio] .ds-pt-row { border-bottom:1px solid var(--border); }
1084
+ [data-dsh-studio] .ds-listrow:hover, [data-dsh-studio] .ds-doc-row:hover, [data-dsh-studio] .ds-art-row:hover { background:var(--muted); }
1085
+
1086
+ /* 滚动条 */
1087
+ [data-dsh-studio] { scrollbar-color:color-mix(in srgb, var(--foreground) 22%, transparent) transparent; }
1088
+ [data-dsh-studio] ::-webkit-scrollbar-thumb { background:color-mix(in srgb, var(--foreground) 20%, transparent); }
1089
+ [data-dsh-studio] ::-webkit-scrollbar-thumb:hover { background:color-mix(in srgb, var(--foreground) 32%, transparent); }
1090
+
1091
+ /* 登录卡(Card + 表单) */
1092
+ [data-dsh-studio] .ds-login { border:1px solid var(--border); border-radius:calc(var(--radius) + 4px); background:var(--card); box-shadow:0 20px 48px rgb(9 9 11 / .18); }
1093
+
1094
+ /* ---- 第二遍:密度与层级微调(看板 / 卡片 / 工具栏) ---- */
1095
+ [data-dsh-studio] .ds-board { gap:14px; padding:14px 20px 18px; }
1096
+ [data-dsh-studio] .ds-board-bar { gap:8px; padding:12px 20px; flex-wrap:wrap; }
1097
+ [data-dsh-studio] .ds-col { background:var(--muted); border:1px solid var(--border); }
1098
+ [data-dsh-studio] .ds-col-head { padding:11px 12px; font-size:12.5px; font-weight:600; letter-spacing:-.005em; }
1099
+ [data-dsh-studio] .ds-count { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px;
1100
+ border-radius:999px; background:var(--card); border:1px solid var(--border); color:var(--muted-foreground); font-size:11px; font-weight:600; }
1101
+ [data-dsh-studio] .ds-col-owner { color:var(--muted-foreground); opacity:1; font-size:11px; }
1102
+ [data-dsh-studio] .ds-col-body { padding:10px; gap:10px; }
1103
+ [data-dsh-studio] .ds-card { padding:11px 12px; border-radius:var(--radius); }
1104
+ [data-dsh-studio] .ds-card-title { font-size:13px; font-weight:600; line-height:1.4; }
1105
+ [data-dsh-studio] .ds-card-foot { color:var(--muted-foreground); opacity:1; font-size:11px; }
1106
+ [data-dsh-studio] .ds-card-body, [data-dsh-studio] .ds-meta { color:var(--muted-foreground); }
1107
+ [data-dsh-studio] .ds-chip { height:21px; font-size:11px; padding:0 8px; background:var(--secondary); border-color:var(--border); }
1108
+ [data-dsh-studio] .ds-chip-code { background:var(--muted); border-color:var(--border); color:var(--muted-foreground); }
1109
+ [data-dsh-studio] .ds-chip[data-proj="1"] { background:color-mix(in srgb, var(--primary) 8%, var(--card)); border-color:color-mix(in srgb, var(--primary) 22%, var(--border)); color:var(--foreground); }
1110
+ [data-dsh-studio] .ds-sec-head, [data-dsh-studio] .ds-an-sec { font-size:12px; font-weight:600; color:var(--muted-foreground); text-transform:none; letter-spacing:.01em; }
1111
+ [data-dsh-studio] .ds-panel-head .ds-hint, [data-dsh-studio] .ds-head .ds-sub { font-size:11.5px; }
1112
+ [data-dsh-studio] .ds-filters, [data-dsh-studio] .ds-check-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
1113
+ `
1114
+
1115
+ // ----------------------------------------------------------------- state
1116
+
1117
+ function useStudio() {
1118
+ const [state, setState] = React.useState(null)
1119
+ const [error, setError] = React.useState(null)
1120
+ /** 上一次渲染用的数据指纹:轮询拿回来的东西没变就不重渲染(每次 /state 600KB 上下,重渲染一次够卡好几帧)。 */
1121
+ const signature = React.useRef('')
1122
+ const reload = React.useCallback(async (force = false) => {
1123
+ if (typeof document !== 'undefined' && document.hidden === true) return
1124
+ try {
1125
+ const payload = await api('/state')
1126
+ // 服务端在「必须登录」又没带会话时只回 { locked: true, state: 精简版 },
1127
+ // locked 得留着,否则登录框不会出现。
1128
+ const next = { ...(payload.state ?? {}), locked: payload.locked === true }
1129
+ const sig = [
1130
+ next.revision ?? '',
1131
+ (next.tasks ?? []).length,
1132
+ (next.requirements ?? []).length,
1133
+ (next.agents ?? []).length,
1134
+ next.locked === true ? 1 : 0,
1135
+ ].join('|')
1136
+ if (!force && sig === signature.current) { setError(null); return }
1137
+ signature.current = sig
1138
+ setState(next)
1139
+ setError(null)
1140
+ } catch (cause) {
1141
+ setError(cause instanceof Error ? cause.message : String(cause))
1142
+ }
1143
+ }, [])
1144
+ React.useEffect(() => {
1145
+ let alive = true
1146
+ const tick = async () => { if (alive) await reload() }
1147
+ void tick()
1148
+ // 5s → 15s:状态没变时本来就不重渲染,但每次仍要传 600KB JSON;挂后台时直接停。
1149
+ const timer = setInterval(() => { void tick() }, 15000)
1150
+ const onVisible = () => { if (document.hidden !== true) void tick() }
1151
+ document.addEventListener('visibilitychange', onVisible)
1152
+ return () => { alive = false; clearInterval(timer); document.removeEventListener('visibilitychange', onVisible) }
1153
+ }, [reload])
1154
+ return { state, error, reload }
1155
+ }
1156
+
1157
+
1158
+ /** epoch 毫秒 ↔ <input type="date"> 的 yyyy-mm-dd。 */
1159
+
1160
+
1161
+ // ------------------------------------------------------------------ card
1162
+
1163
+ /** 这张卡现在实际归谁:正在跑的那个 > 卡上指定的 > 本列负责人 > 没人。 */
1164
+
1165
+
1166
+
1167
+
1168
+
1169
+ // ---------------------------------------------------------------- drawer
1170
+
1171
+ // ------------------------------------------------------------ markdown
1172
+
1173
+ const renderMarkdown = createMarkdown(h)
1174
+
1175
+
1176
+ /** @ 选择器:一列可搜的名单,代替原来平铺的一堆按钮。 */
1177
+ const MentionPicker = createMentionPicker({ h, TextField })
1178
+ const Drawer = createDrawer({ h, React, api, kit, currentUser, renderMarkdown, tipOf, tipLabel, confirmDialog, promptDialog, TextField, TextAreaField, ChipField, CheckField, SelectField, MdField, MentionPicker })
1179
+ const Palette = createPalette({ h, React })
1180
+ provideClient({ h, React, Editor, api, kit, toast, sessionToken, currentUser, confirmDialog, promptDialog, tipOf, tipLabel, renderMarkdown, SideDrawer, MentionPicker, Card, TextField, TextAreaField, ChipField, CheckField, SelectField, MdField })
1181
+
1182
+
1183
+ // -------------------------------------------------------------- market
1184
+
1185
+ function Market({ state, onChanged }) {
1186
+ const [data, setData] = React.useState(null)
1187
+ const [busy, setBusy] = React.useState(null)
1188
+ const [error, setError] = React.useState(null)
1189
+ const [notice, setNotice] = React.useState(null)
1190
+ const [form, setForm] = React.useState({ name: '', spec: '', description: '' })
1191
+ const [adding, setAdding] = React.useState(false)
1192
+ const [saving, setSaving] = React.useState(false)
1193
+
1194
+ const load = React.useCallback(async () => {
1195
+ try { setData(await api('/market')) } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) }
1196
+ }, [])
1197
+ React.useEffect(() => { void load() }, [load])
1198
+
1199
+ const installed = new Set(data?.installed ?? [])
1200
+ const packageOf = spec => spec.replace(/^link:/, '').split('/').pop().replace(/^dsh-/, 'dsh-')
1201
+
1202
+ const act = async (item, action) => {
1203
+ setBusy(item.id); setError(null); setNotice(null)
1204
+ try {
1205
+ const result = await api(`/market/${action}`, { id: item.id })
1206
+ if (result.ok === false) throw new Error((result.result?.output ?? '').slice(-300) || '安装失败')
1207
+ setNotice(action === 'install' ? '已安装,重启工作空间后生效' : '已移除,重启工作空间后生效')
1208
+ await load()
1209
+ } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } finally { setBusy(null) }
1210
+ }
1211
+
1212
+ const addItem = async () => {
1213
+ if (form.name.trim() === '' || form.spec.trim() === '') return
1214
+ setSaving(true); setError(null)
1215
+ try {
1216
+ const items = [...(state.config.market.items ?? []), {
1217
+ id: `m-${Math.random().toString(16).slice(2, 10)}`,
1218
+ name: form.name.trim(), spec: form.spec.trim(), description: form.description.trim(),
1219
+ }]
1220
+ await api('/config', { market: { items } })
1221
+ setForm({ name: '', spec: '', description: '' })
1222
+ setAdding(false)
1223
+ await onChanged(); await load()
1224
+ } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } finally { setSaving(false) }
1225
+ }
1226
+
1227
+ const removeItem = async (item) => {
1228
+ if (!(await confirmDialog(`从市场列表移除「${item.name}」?不会卸载已安装的插件。`, { title: '移除插件', danger: true, okText: '移除' }))) return
1229
+ const items = (state.config.market.items ?? []).filter(entry => entry.id !== item.id)
1230
+ await api('/config', { market: { items } }).catch(() => undefined)
1231
+ await onChanged(); await load()
1232
+ }
1233
+
1234
+ const list = data?.items ?? state.config.market.items ?? []
1235
+
1236
+ return h('div', null,
1237
+ h('div', { className: 'ds-row', style: { marginBottom: 12 } },
1238
+ h('div', { style: { fontSize: 13.5, fontWeight: 650 } }, data?.title ?? state.config.market.title),
1239
+ h('span', { className: 'ds-stat' }, `${list.length} 个条目 · profile ${state.profile ?? '—'}`),
1240
+ h('div', { className: 'ds-grow' }),
1241
+ h('button', { className: 'ds-btn', onClick: () => setAdding(!adding) }, adding ? '收起' : '+ 添加条目'),
1242
+ ),
1243
+ error !== null ? h('div', { className: 'ds-err', style: { padding: '0 0 8px' } }, error) : null,
1244
+ notice !== null ? h('div', { className: 'ds-ok', style: { padding: '0 0 8px' } }, notice) : null,
1245
+ list.length === 0
1246
+ ? h('div', { className: 'ds-empty' }, '还没有条目。把公司内部的插件包加进来,同事就能一键安装。')
1247
+ : h('div', { className: 'ds-market-grid' },
1248
+ list.map(item => {
1249
+ const name = packageOf(item.spec)
1250
+ const isInstalled = installed.has(name) || installed.has(item.name)
1251
+ return h('div', { key: item.id, className: 'ds-market-card' },
1252
+ h('div', { className: 'ds-mc-head' },
1253
+ h('span', { className: 'ds-mc-name ds-grow' }, item.name),
1254
+ h(ChipField, { className: 'ds-chip', 'data-on': isInstalled ? '1' : '0' }, isInstalled ? '已安装' : '未安装'),
1255
+ ),
1256
+ item.description !== '' ? h('div', { className: 'ds-mc-desc' }, item.description) : null,
1257
+ h('code', null, item.spec),
1258
+ h('div', { className: 'ds-row', style: { marginTop: 'auto' } },
1259
+ h('button', {
1260
+ className: isInstalled ? 'ds-btn' : 'ds-btn ds-btn-primary',
1261
+ disabled: busy === item.id,
1262
+ onClick: async () => {
1263
+ const ok = await confirmDialog(isInstalled ? `从 profile 移除「${name}」?` : `安装「${name}」到工作空间?`, { title: isInstalled ? '移除插件' : '安装插件', danger: isInstalled, okText: isInstalled ? '移除' : '安装' })
1264
+ if (ok) void act(item, isInstalled ? 'uninstall' : 'install')
1265
+ },
1266
+ }, busy === item.id ? '处理中…' : isInstalled ? '移除' : '安装'),
1267
+ h('button', { className: 'ds-btn', style: { marginLeft: 'auto', opacity: .7 }, onClick: () => void removeItem(item) }, '删条目'),
1268
+ ),
1269
+ )
1270
+ }),
1271
+ ),
1272
+ adding
1273
+ ? h('div', { className: 'ds-panel', style: { marginTop: 14 } },
1274
+ h('div', { className: 'ds-panel-head' }, '添加条目'),
1275
+ h('div', { className: 'ds-panel-body' },
1276
+ h('div', { className: 'ds-field' },
1277
+ h('div', { className: 'ds-label' }, '显示名'),
1278
+ h(TextField, { className: 'ds-input', value: form.name, placeholder: '代码评审助手', onChange: e => setForm({ ...form, name: e.target.value }) }),
1279
+ ),
1280
+ h('div', { className: 'ds-field' },
1281
+ h('div', { className: 'ds-label' }, '包名或路径'),
1282
+ h(TextField, { className: 'ds-input', value: form.spec, placeholder: '@company/dsh-reviewer 或 link:/home/lyf/pkgs/reviewer', onChange: e => setForm({ ...form, spec: e.target.value }) }),
1283
+ ),
1284
+ h('div', { className: 'ds-field' },
1285
+ h('div', { className: 'ds-label' }, '一句话说明'),
1286
+ h(TextField, { className: 'ds-input', value: form.description, placeholder: '自动跑 GitLab MR 的评审清单', onChange: e => setForm({ ...form, description: e.target.value }) }),
1287
+ ),
1288
+ ),
1289
+ h('div', { style: { padding: '0 14px 14px' } },
1290
+ h('button', { className: 'ds-btn ds-btn-primary', disabled: saving || form.name.trim() === '' || form.spec.trim() === '', onClick: () => void addItem() }, saving ? '添加中…' : '加入市场'),
1291
+ ),
1292
+ )
1293
+ : null,
1294
+ )
1295
+ }
1296
+
1297
+ // -------------------------------------------------------------- settings
1298
+
1299
+ // --------------------------------------------------- enterprise panels
1300
+
1301
+ /** Shared little editor frame: a list of rows plus one open draft. */
1302
+
1303
+ /** Shared right-side editor drawer: fixed head, scrollable form, fixed action bar. */
1304
+
1305
+
1306
+ /**
1307
+ * 统一登录(LDAP):配置连接 + 测试连接 + 开不开「必须登录」。
1308
+ * 服务账号密码只往服务端写一次(存进 0600 的密钥文件),这里永远不回显。
1309
+ */
1310
+
1311
+
1312
+ const STRATEGIES = [
1313
+ { id: 'round_robin', label: '轮询:谁手上活少给谁' },
1314
+ { id: 'best_match', label: '按专长匹配:领域 / 角色位 / 技能' },
1315
+ { id: 'lead', label: '交给专家团团长分派' },
1316
+ ]
1317
+
1318
+ const ACCESS_ROLE_OPTIONS = [
1319
+ { id: 'admin', label: '管理员', hint: '能改配置、删数据、发布版本' },
1320
+ { id: 'member', label: '成员', hint: '能建卡、推卡、评论、跑用例' },
1321
+ { id: 'viewer', label: '只读', hint: '只能看,不能动' },
1322
+ ]
1323
+
1324
+ const ROUTE_LABEL = {
1325
+ '/config': '改配置',
1326
+ '/task/create': '新建卡',
1327
+ '/task/update': '改卡',
1328
+ '/task/delete': '删卡',
1329
+ '/task/block': '标记阻塞',
1330
+ '/task/unblock': '解除阻塞',
1331
+ '/task/comment': '评论',
1332
+ '/task/artifact': '登记交付物',
1333
+ '/task/artifact/sync': '归档交付物',
1334
+ '/task/checklist': '勾验收清单',
1335
+ '/task/case': '登记用例',
1336
+ '/task/case/run': '跑接口用例',
1337
+ '/task/coding': '登记 AI Coding',
1338
+ '/task/worktree': 'worktree 操作',
1339
+ '/mention': '点名派活',
1340
+ '/agents/run': '手动派单',
1341
+ '/agents/stop': '停止派单',
1342
+ '/experts/save': '存专家',
1343
+ '/experts/delete': '删专家',
1344
+ '/teams/save': '存专家团',
1345
+ '/teams/delete': '删专家团',
1346
+ '/skills/save': '存技能',
1347
+ '/skills/delete': '删技能',
1348
+ '/projects/save': '存项目',
1349
+ '/projects/delete': '删项目',
1350
+ '/release/save': '存版本',
1351
+ '/release/publish': '发布版本',
1352
+ '/release/rollback': '回滚版本',
1353
+ '/release/approve': '审批版本',
1354
+ '/requirement/save': '存需求',
1355
+ '/requirement/delete': '删需求',
1356
+ '/access/save': '改成员权限',
1357
+ '/access/delete': '删成员',
1358
+ '/audit/clear': '清空审计日志',
1359
+ '/market/install': '装插件',
1360
+ '/market/uninstall': '卸插件',
1361
+ }
1362
+
1363
+
1364
+ function Settings({ state, onChanged, initialSection, onSection }) {
1365
+ const config = state.config
1366
+ const [form, setForm] = React.useState({
1367
+ boardTitle: config.board.title,
1368
+ gitlabUrl: config.gitlab.url,
1369
+ gitlabGroup: config.gitlab.group,
1370
+ gitlabProject: config.gitlab.project,
1371
+ gitlabToken: '',
1372
+ feishuAppId: config.feishu.appId,
1373
+ feishuAppSecret: '',
1374
+ feishuChatId: config.feishu.chatId,
1375
+ feishuBaseToken: config.feishu.baseToken,
1376
+ feishuTableId: config.feishu.tableId,
1377
+ feishuWebhook: '',
1378
+ feishuNotify: config.feishu.notifyOnChange !== false,
1379
+ gitlabSyncEnabled: config.gitlab.syncEnabled === true,
1380
+ gitlabSyncLabel: config.gitlab.syncLabel ?? '',
1381
+ roles: JSON.parse(JSON.stringify(config.roles)),
1382
+ agentsEnabled: config.agents?.enabled === true,
1383
+ agentsMaxConcurrent: String(config.agents?.maxConcurrent ?? 2),
1384
+ agentsPreset: config.agents?.agentPreset ?? 'standard',
1385
+ agentsWorkspace: config.agents?.workspaceDir ?? '',
1386
+ agentsSandbox: config.agents?.sandbox ?? 'workspace-write',
1387
+ agentsIsolation: config.agents?.isolation ?? 'none',
1388
+ agentsWorktreeRoot: config.agents?.worktreeRoot ?? '',
1389
+ agentsCleanup: config.agents?.cleanupWorktree ?? 'manual',
1390
+ agentsAuto: Array.isArray(config.agents?.autoStatuses) ? config.agents.autoStatuses : ['design', 'dev_ready', 'test'],
1391
+ })
1392
+ const [status, setStatus] = React.useState(null)
1393
+ const [error, setError] = React.useState(null)
1394
+ const [busy, setBusy] = React.useState(false)
1395
+
1396
+ const save = async () => {
1397
+ setBusy(true); setError(null); setStatus(null)
1398
+ try {
1399
+ await api('/config', {
1400
+ board: { title: form.boardTitle },
1401
+ roles: form.roles,
1402
+ agents: {
1403
+ enabled: form.agentsEnabled,
1404
+ maxConcurrent: Number(form.agentsMaxConcurrent) || 2,
1405
+ agentPreset: form.agentsPreset,
1406
+ workspaceDir: form.agentsWorkspace,
1407
+ sandbox: form.agentsSandbox,
1408
+ isolation: form.agentsIsolation,
1409
+ worktreeRoot: form.agentsWorktreeRoot,
1410
+ cleanupWorktree: form.agentsCleanup,
1411
+ autoStatuses: form.agentsAuto,
1412
+ },
1413
+ })
1414
+ setStatus('已保存')
1415
+ await onChanged()
1416
+ } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } finally { setBusy(false) }
1417
+ }
1418
+
1419
+ const field = (key, label, hint, type) => h('div', { className: 'ds-field', key },
1420
+ h('div', { className: 'ds-label' }, label),
1421
+ h(TextField, {
1422
+ className: 'ds-input',
1423
+ type: type ?? 'text',
1424
+ value: form[key],
1425
+ placeholder: hint ?? '',
1426
+ onChange: e => setForm({ ...form, [key]: e.target.value }),
1427
+ }),
1428
+ )
1429
+
1430
+ const roleKeys = Object.keys(form.roles)
1431
+ const [section, setSection] = React.useState(initialSection ?? 'experts')
1432
+
1433
+ React.useEffect(() => {
1434
+ if (typeof initialSection === 'string' && initialSection !== '') setSection(initialSection)
1435
+ }, [initialSection])
1436
+
1437
+ const pickSection = next => {
1438
+ setSection(next)
1439
+ onSection?.(next)
1440
+ }
1441
+
1442
+ const PANE_META = {
1443
+ projects: ['项目与迭代', '项目是看板的第一层分组:卡片挂项目、项目下面划迭代,之后按项目/迭代筛选和考核。'],
1444
+ experts: ['专家', '每个专家是一个能独立接活的工作单元。角色位决定它接哪一列,业务领域决定它懂哪块业务,角色提示词和技能决定它怎么做。'],
1445
+ teams: ['专家团', '团长负责拆活和汇总,成员各自干活;评论里 @团名 就交给团长。'],
1446
+ skills: ['技能库', '技能是一段可复用的做法。写进技能库后会落成 SKILL.md,专家在需要时自己加载,也可以自己新增。'],
1447
+ columns: ['列的负责人', '看板每一列默认派给谁。选「默认」用系统自带的那一位。'],
1448
+ gitlab: ['代码仓库', '一个仓库一条记录:地址、路径、令牌各自独立,同步时互不干扰。'],
1449
+ feishu: ['飞书机器人', '卡片状态变化时往群里发消息。可以按团队或项目建多个机器人。'],
1450
+ agents: ['自动派单', '卡片进入勾选的列时,自动把活派给该列的负责人;这里是全局开关与并发上限。'],
1451
+ models: ['模型', '每个角色位用哪个模型。单个专家还能在「专家」里单独覆盖。'],
1452
+ testing: ['测试执行', '接口用例跑在哪、能访问哪些域名、超时多少。执行器就跑在这台机器上(.218),不依赖外部 CI。'],
1453
+ costs: ['成本与预算', '一个月允许派多少次活、跑多少小时,超了在效能页亮黄亮红。'],
1454
+ environments: ['部署环境', '版本可以登记「上过哪个环境」,只留痕:平台不接 CD,不会触发任何部署。'],
1455
+ board: ['看板外观', '看板顶部显示的标题。'],
1456
+ access: ['权限与审计', '启用成员名单后,每个写操作都要认人:只读不能改,成员能推进卡,管理员才能改配置、删数据、发布版本。所有写操作都会进下面的审计日志。'],
1457
+ ldap: ['统一登录(LDAP)', '用公司账号登录平台:登录成功后服务端记住这个人,写操作和审计都挂在他的名字下。密码只存服务端密钥文件,不回显。'],
1458
+ storage: ['数据与备份', '台账存在哪、密钥存在哪,以及每次写操作留下的快照。改坏了可以回滚到上一版。'],
1459
+ health: ['集成健康', '飞书通知、GitLab、飞书云盘、代码隔离四条外部通道最近一次是成功还是失败。以前这些失败只写进日志,现在会在这里亮出来,坏了能看见、能补一次。'],
1460
+ }
1461
+
1462
+ const saveBar = h('div', { className: 'ds-row', style: { gap: 10, marginTop: 2 } },
1463
+ h('button', { className: 'ds-btn ds-btn-primary', disabled: busy, onClick: () => void save() }, busy ? '保存中…' : '保存'),
1464
+ status !== null ? h('span', { className: 'ds-ok' }, status) : null,
1465
+ error !== null ? h('span', { className: 'ds-err' }, error) : null,
1466
+ )
1467
+
1468
+ const nav = [
1469
+ { group: '团队' },
1470
+ { id: 'projects', label: '项目与迭代', count: (config.projects ?? []).length },
1471
+ { id: 'experts', label: '专家', count: (config.experts ?? []).length },
1472
+ { id: 'teams', label: '专家团', count: (config.teams ?? []).length },
1473
+ { id: 'skills', label: '技能库' },
1474
+ { id: 'columns', label: '列的负责人' },
1475
+ { group: '接入' },
1476
+ { id: 'gitlab', label: '代码仓库', count: (config.gitlab.projects ?? []).length },
1477
+ { id: 'feishu', label: '飞书机器人', count: (config.feishu.bots ?? []).length },
1478
+ { id: 'health', label: '集成健康', count: state.integrations?.broken ?? 0 },
1479
+ { group: '运行' },
1480
+ { id: 'agents', label: '自动派单' },
1481
+ { id: 'testing', label: '测试执行', count: (config.testing?.envs ?? []).length },
1482
+ { id: 'costs', label: '成本与预算' },
1483
+ { id: 'environments', label: '部署环境', count: (config.environments ?? []).length },
1484
+ { id: 'board', label: '看板外观' },
1485
+ { group: '安全' },
1486
+ { id: 'ldap', label: '统一登录(LDAP)', count: config.ldap?.enabled === true ? (config.ldap?.requireLogin === true ? '必须' : '开') : 0 },
1487
+ { id: 'access', label: '权限与审计', count: (config.access?.members ?? []).length },
1488
+ { id: 'storage', label: '数据与备份', count: state.storage?.snapshots ?? 0 },
1489
+ ]
1490
+
1491
+ const paneFor = id => {
1492
+ if (id === 'projects') return h(BusinessPanel, { config, onChanged })
1493
+ if (id === 'experts') return h(ExpertsPanel, { config, onChanged })
1494
+ if (id === 'teams') return h(TeamsPanel, { config, onChanged })
1495
+ if (id === 'skills') return h(SkillsPanel, { config, onChanged })
1496
+ if (id === 'columns') return h(ColumnsPanel, { state, onChanged })
1497
+ if (id === 'testing') return h(TestingPanel, { config, onChanged })
1498
+ if (id === 'costs') return h(CostsPanel, { config, onChanged })
1499
+ if (id === 'environments') return h(EnvironmentsPanel, { config, onChanged })
1500
+ if (id === 'access') return h(AccessPanel, { state, config, onChanged })
1501
+ if (id === 'ldap') return h(LdapPanel, { config, onChanged })
1502
+ if (id === 'storage') return h(StoragePanel, { state, onChanged })
1503
+ if (id === 'gitlab') return h(ReposPanel, { config, onChanged })
1504
+ if (id === 'feishu') return h('div', null, h(BotsPanel, { config, onChanged }), h(EscalatePanel, { config, onChanged }))
1505
+ if (id === 'health') return h(HealthPanel, { state, config, onChanged })
1506
+ if (id === 'board') {
1507
+ return h('div', null,
1508
+ h('div', { className: 'ds-panel ds-form-panel' },
1509
+ h('div', { className: 'ds-panel-head' }, '看板'),
1510
+ h('div', { className: 'ds-panel-body' }, field('boardTitle', '看板标题', '需求&任务工作空间')),
1511
+ ),
1512
+ saveBar,
1513
+ )
1514
+ }
1515
+ if (id === 'models') {
1516
+ return h('div', null,
1517
+ h('div', { className: 'ds-panel ds-form-panel' },
1518
+ h('div', { className: 'ds-panel-head' }, '角色与模型'),
1519
+ h('div', { className: 'ds-panel-body ds-grid2' },
1520
+ roleKeys.map(role => h('div', { className: 'ds-field', key: role },
1521
+ h('div', { className: 'ds-label' }, `${ROLE_LABEL[role] ?? role}`),
1522
+ h(TextField, {
1523
+ className: 'ds-input',
1524
+ value: form.roles[role].model,
1525
+ placeholder: 'deepseek-v4.1-flash',
1526
+ onChange: e => setForm({ ...form, roles: { ...form.roles, [role]: { ...form.roles[role], model: e.target.value } } }),
1527
+ }),
1528
+ )),
1529
+ ),
1530
+ ),
1531
+ h('div', { className: 'ds-hint', style: { margin: '-6px 0 12px' } }, '单个专家可以在「专家」里单独指定模型,优先级:专家模型 → 这里的角色模型 → 宿主默认。'),
1532
+ saveBar,
1533
+ )
1534
+ }
1535
+ // agents
1536
+ return h('div', null,
1537
+ h('div', { className: 'ds-panel ds-form-panel' },
1538
+ h('div', { className: 'ds-panel-head' }, '自动派单(专家 agent)'),
1539
+ h('div', { className: 'ds-panel-body' },
1540
+ h('div', { className: 'ds-field', style: { gridColumn: '1 / -1' } },
1541
+ h('label', { className: 'ds-row', style: { alignItems: 'center', gap: 8, cursor: 'pointer' } },
1542
+ h(CheckField, { checked: form.agentsEnabled,
1543
+ onChange: e => setForm({ ...form, agentsEnabled: e.target.checked }),
1544
+ }),
1545
+ h('span', {}, '卡片进入勾选的列时,自动派给该列的负责人'),
1546
+ ),
1547
+ h('div', { className: 'ds-hint' }, '关闭后仍可在卡片抽屉里点「马上处理」手动派单;派单结果会写回评论并推进卡片。'),
1548
+ ),
1549
+ h('div', { className: 'ds-field', style: { gridColumn: '1 / -1' } },
1550
+ h('div', { className: 'ds-label' }, '哪些列自动派单'),
1551
+ h('div', { className: 'ds-row', style: { flexWrap: 'wrap', gap: 14 } },
1552
+ [['design', '待方案'], ['dev_ready', '待开发'], ['dev', '开发中'], ['test', '待测试']].map(([id, label]) =>
1553
+ h('label', { className: 'ds-row', key: id, style: { alignItems: 'center', gap: 6, cursor: 'pointer' } },
1554
+ h(CheckField, { checked: form.agentsAuto.includes(id),
1555
+ onChange: e => setForm({
1556
+ ...form,
1557
+ agentsAuto: e.target.checked
1558
+ ? [...form.agentsAuto, id]
1559
+ : form.agentsAuto.filter(entry => entry !== id),
1560
+ }),
1561
+ }),
1562
+ h('span', {}, label),
1563
+ )),
1564
+ ),
1565
+ h('div', { className: 'ds-hint' }, '勾选的列在卡片进入时自动派给该列对应的负责人;其余列只能手动「马上处理」。'),
1566
+ ),
1567
+ field('agentsMaxConcurrent', '同时最多几个 agent', '2'),
1568
+ field('agentsPreset', 'Agent 预设', 'standard'),
1569
+ field('agentsWorkspace', '工作区目录', '/home/lyf/dsh-workspace'),
1570
+ h('div', { className: 'ds-hint', style: { gridColumn: '1 / -1' } }, '预设决定 agent 手里有哪些工具:standard 带 shell 与文件读写,minimal 只有基础工具。'),
1571
+ h('div', { className: 'ds-field', key: 'agentsSandbox' },
1572
+ h('div', { className: 'ds-label' }, '沙箱档位(这个会话能写哪、能跑什么)'),
1573
+ h(SelectField, {
1574
+ className: 'ds-input',
1575
+ value: form.agentsSandbox,
1576
+ onChange: e => setForm({ ...form, agentsSandbox: e.target.value }),
1577
+ },
1578
+ [['read-only', '只读:能看不能改'],
1579
+ ['workspace-write', '只写工作区(推荐):能改自己的目录,改不到系统别处'],
1580
+ ['danger-full-access', '无沙箱:可以写任何地方(危险)']].map(([id, label]) =>
1581
+ h('option', { key: id, value: id }, label)),
1582
+ ),
1583
+ h('div', { className: 'ds-hint' }, '由 DSH 的 permission preset 执行:read-only / workspace-write 走 bwrap 或 Landlock 隔离。单个专家可以在「专家」里覆盖。'),
1584
+ ),
1585
+ h('div', { className: 'ds-field', key: 'agentsIsolation' },
1586
+ h('div', { className: 'ds-label' }, '代码隔离'),
1587
+ h(SelectField, {
1588
+ className: 'ds-input',
1589
+ value: form.agentsIsolation,
1590
+ onChange: e => setForm({ ...form, agentsIsolation: e.target.value }),
1591
+ },
1592
+ [['none', '不隔离:所有卡共用一个工作区'],
1593
+ ['worktree', '一卡一个 git worktree(推荐给多卡并行)']].map(([id, label]) =>
1594
+ h('option', { key: id, value: id }, label)),
1595
+ ),
1596
+ h('div', { className: 'ds-hint' }, '开启后,卡要挂在配了「本机代码目录」的项目上,派单时会为它切一条 task/<编号> 分支。'),
1597
+ ),
1598
+ field('agentsWorktreeRoot', 'worktree 根目录', '留空 = 工作区同级的 dsh-worktrees'),
1599
+ h('div', { className: 'ds-field', key: 'agentsCleanup' },
1600
+ h('div', { className: 'ds-label' }, '卡完成时'),
1601
+ h(SelectField, {
1602
+ className: 'ds-input',
1603
+ value: form.agentsCleanup,
1604
+ onChange: e => setForm({ ...form, agentsCleanup: e.target.value }),
1605
+ },
1606
+ [['manual', '保留 worktree 和分支,等人看 diff'],
1607
+ ['on_done', '自动删掉 worktree 和分支']].map(([id, label]) =>
1608
+ h('option', { key: id, value: id }, label)),
1609
+ ),
1610
+ ),
1611
+ ),
1612
+ ),
1613
+ saveBar,
1614
+ )
1615
+ }
1616
+
1617
+ return h('div', { className: 'ds-settings' },
1618
+ h('div', { className: 'ds-settings-nav' },
1619
+ nav.map((item, index) => item.group !== undefined
1620
+ ? h('div', { className: 'ds-settings-nav-group', key: `group-${index}` }, item.group)
1621
+ : tipOf(
1622
+ (PANE_META[item.id] ?? [item.label, ''])[1] || item.label,
1623
+ h('button', {
1624
+ key: item.id,
1625
+ className: 'ds-nav-item',
1626
+ 'data-active': section === item.id ? '1' : '0',
1627
+ onClick: () => pickSection(item.id),
1628
+ },
1629
+ h('span', {}, item.label),
1630
+ item.count === undefined ? null : h('span', { className: 'ds-nav-count' }, String(item.count))))),
1631
+ ),
1632
+ h('div', { className: 'ds-settings-pane' },
1633
+ h('div', { className: 'ds-pane-head' },
1634
+ h('div', null,
1635
+ h('div', { className: 'ds-pane-title' }, (PANE_META[section] ?? ['设置'])[0]),
1636
+ h('div', { className: 'ds-pane-desc' }, (PANE_META[section] ?? ['', ''])[1]),
1637
+ ),
1638
+ ),
1639
+ paneFor(section)),
1640
+ )
1641
+ }
1642
+
1643
+ // ------------------------------------------------------------------ page
1644
+
1645
+ function NewTaskDialog({ state, onClose, onCreated }) {
1646
+ const projects = state?.config?.projects ?? []
1647
+ const [title, setTitle] = React.useState('')
1648
+ const [requirement, setRequirement] = React.useState('')
1649
+ const [type, setType] = React.useState('requirement')
1650
+ const [priority, setPriority] = React.useState('normal')
1651
+ const [severity, setSeverity] = React.useState('p2')
1652
+ const [domain, setDomain] = React.useState('')
1653
+ const requirements = state?.requirements ?? []
1654
+ const [requirementId, setRequirementId] = React.useState('')
1655
+ const [project, setProject] = React.useState(projects.length === 1 ? projects[0].id : '')
1656
+ const [iteration, setIteration] = React.useState('')
1657
+ const [owner, setOwner] = React.useState('')
1658
+ const [due, setDue] = React.useState('')
1659
+ const [error, setError] = React.useState(null)
1660
+ const [busy, setBusy] = React.useState(false)
1661
+ const isBug = type === 'bug'
1662
+ const projectIterations = (projects.find(item => item.id === project)?.iterations ?? [])
1663
+ const submit = async () => {
1664
+ setBusy(true); setError(null)
1665
+ try {
1666
+ await api('/task/create', {
1667
+ title, requirement, type, priority, domain,
1668
+ severity: isBug ? severity : '',
1669
+ role: isBug ? 'dev' : 'product',
1670
+ status: isBug ? 'dev_ready' : 'clarify',
1671
+ project, iteration: project === '' ? '' : iteration,
1672
+ owner: owner.trim(), dueAt: fromDateInput(due),
1673
+ requirementId,
1674
+ })
1675
+ await onCreated()
1676
+ onClose()
1677
+ } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } finally { setBusy(false) }
1678
+ }
1679
+ return h('div', { className: 'ds-modal-wrap', onClick: e => { if (e.target === e.currentTarget) onClose() } },
1680
+ h('div', { className: 'ds-modal' },
1681
+ h('h3', {}, isBug ? '登记缺陷' : '新建任务'),
1682
+ h('div', { className: 'ds-field' },
1683
+ h('div', { className: 'ds-label' }, '标题'),
1684
+ h(TextField, {
1685
+ className: 'ds-input', autoFocus: true,
1686
+ placeholder: isBug ? '一句话说清什么坏了,例如「优惠券叠加后金额算错」' : '一句话说清要做什么',
1687
+ value: title,
1688
+ onChange: e => setTitle(e.target.value),
1689
+ onKeyDown: e => { if (e.key === 'Enter' && !e.isComposing) void submit() },
1690
+ }),
1691
+ ),
1692
+ h('div', { className: 'ds-field' },
1693
+ h('div', { className: 'ds-label' }, isBug ? '复现步骤 / 现象' : '需求 / 背景'),
1694
+ h(MdField, {
1695
+ className: 'ds-textarea', rows: 5,
1696
+ placeholder: isBug
1697
+ ? '复现步骤:1… 2…\n实际结果:\n期望结果:\n影响范围 / 环境(版本、账号、数据条件):'
1698
+ : '背景、期望结果、验收口径。专家会照这段去开工。',
1699
+ value: requirement, onChange: e => setRequirement(e.target.value),
1700
+ }),
1701
+ ),
1702
+ h('div', { className: 'ds-modal-grid' },
1703
+ h('div', { className: 'ds-field' },
1704
+ h('div', { className: 'ds-label' }, '类型'),
1705
+ h(SelectField, { className: 'ds-select', value: type, onChange: e => setType(e.target.value) },
1706
+ Object.keys(TYPE_LABEL).map(v => h('option', { key: v, value: v }, TYPE_LABEL[v]))),
1707
+ ),
1708
+ isBug
1709
+ ? h('div', { className: 'ds-field' },
1710
+ h('div', { className: 'ds-label' }, '严重级别'),
1711
+ h(SelectField, { className: 'ds-select', value: severity, onChange: e => setSeverity(e.target.value) },
1712
+ Object.keys(SEVERITY_LABEL).map(v => h('option', { key: v, value: v }, SEVERITY_LABEL[v]))),
1713
+ )
1714
+ : h('div', { className: 'ds-field' },
1715
+ h('div', { className: 'ds-label' }, '优先级'),
1716
+ h(SelectField, { className: 'ds-select', value: priority, onChange: e => setPriority(e.target.value) },
1717
+ Object.keys(PRIORITY_LABEL).map(v => h('option', { key: v, value: v }, PRIORITY_LABEL[v]))),
1718
+ ),
1719
+ isBug
1720
+ ? h('div', { className: 'ds-field' },
1721
+ h('div', { className: 'ds-label' }, '优先级'),
1722
+ h(SelectField, { className: 'ds-select', value: priority, onChange: e => setPriority(e.target.value) },
1723
+ Object.keys(PRIORITY_LABEL).map(v => h('option', { key: v, value: v }, PRIORITY_LABEL[v]))),
1724
+ )
1725
+ : null,
1726
+ h('div', { className: 'ds-field', style: { gridColumn: '1 / -1' } },
1727
+ h('div', { className: 'ds-label' }, '挂在哪个需求下'),
1728
+ requirements.length === 0
1729
+ ? h('div', { className: 'ds-hint', style: { fontSize: 11.5 } }, '还没有需求。到「需求」页先建一条,这张卡就能拿到 DEV-001 这样的编号。')
1730
+ : h(SelectField, { className: 'ds-select', value: requirementId, onChange: e => setRequirementId(e.target.value) },
1731
+ h('option', { value: '' }, '暂不挂需求(编号留空)'),
1732
+ requirements.map(item => h('option', { key: item.id, value: item.id }, `${item.code || item.id} · ${item.title}`)),
1733
+ ),
1734
+ ),
1735
+ h('div', { className: 'ds-field', style: { gridColumn: '1 / -1' } },
1736
+ h('div', { className: 'ds-label' }, '业务领域(选填)'),
1737
+ h(TextField, {
1738
+ className: 'ds-input', placeholder: '例如 支付 / 会员 / 履约,填了会自动派给同领域的专家',
1739
+ value: domain, onChange: e => setDomain(e.target.value),
1740
+ }),
1741
+ ),
1742
+ projects.length === 0
1743
+ ? null
1744
+ : h('div', { className: 'ds-field' },
1745
+ h('div', { className: 'ds-label' }, '业务项目'),
1746
+ h(SelectField, {
1747
+ className: 'ds-select', value: project,
1748
+ onChange: e => { setProject(e.target.value); setIteration('') },
1749
+ },
1750
+ h('option', { value: '' }, '不属于任何项目'),
1751
+ projects.map(item => h('option', { key: item.id, value: item.id }, `${item.key ? `${item.key} · ` : ''}${item.name}`)),
1752
+ ),
1753
+ ),
1754
+ projects.length === 0 || projectIterations.length === 0
1755
+ ? null
1756
+ : h('div', { className: 'ds-field' },
1757
+ h('div', { className: 'ds-label' }, '迭代'),
1758
+ h(SelectField, { className: 'ds-select', value: iteration, onChange: e => setIteration(e.target.value) },
1759
+ h('option', { value: '' }, '不指定迭代'),
1760
+ projectIterations.map(item => h('option', { key: item.id, value: item.id }, item.name))),
1761
+ ),
1762
+ h('div', { className: 'ds-field' },
1763
+ h('div', { className: 'ds-label' }, '业务负责人(选填)'),
1764
+ h(TextField, {
1765
+ className: 'ds-input', placeholder: '提需求的人,例如 张伟',
1766
+ value: owner, onChange: e => setOwner(e.target.value),
1767
+ }),
1768
+ ),
1769
+ h('div', { className: 'ds-field' },
1770
+ h('div', { className: 'ds-label' }, '期望完成(选填)'),
1771
+ h(TextField, { className: 'ds-input', type: 'date', value: due, onChange: e => setDue(e.target.value) }),
1772
+ ),
1773
+ ),
1774
+ isBug
1775
+ ? h('div', { className: 'ds-hint', style: { fontSize: 11.5 } },
1776
+ '缺陷卡会直接落到「待开发」,由研发先复现、定位根因再修,修完自动交给测试验证。')
1777
+ : null,
1778
+ h('div', { className: 'ds-modal-foot' },
1779
+ error !== null ? h('span', { className: 'ds-err' }, error) : null,
1780
+ h('div', { className: 'ds-grow' }),
1781
+ h('button', { className: 'ds-btn', onClick: onClose }, '取消'),
1782
+ h('button', { className: 'ds-btn ds-btn-primary', disabled: busy || title.trim() === '', onClick: () => void submit() },
1783
+ busy ? '创建中…' : (isBug ? '登记缺陷' : '创建')),
1784
+ ),
1785
+ ),
1786
+ )
1787
+ }
1788
+
1789
+ function ProjectsOverview({ state, onOpenBoard }) {
1790
+ const [metrics, setMetrics] = React.useState(null)
1791
+ const [error, setError] = React.useState(null)
1792
+ React.useEffect(() => {
1793
+ let alive = true
1794
+ api('/metrics')
1795
+ .then(out => { if (alive) setMetrics(out.metrics) })
1796
+ .catch(cause => { if (alive) setError(cause instanceof Error ? cause.message : String(cause)) })
1797
+ return () => { alive = false }
1798
+ }, [state.revision])
1799
+
1800
+ if (error !== null) return h('div', { className: 'ds-body' }, h('div', { className: 'ds-err' }, error))
1801
+ if (metrics === null) return h('div', { className: 'ds-loading' }, '正在统计…')
1802
+
1803
+ const card = (title, subtitle, data, key) => h('div', { className: 'ds-proj-card', key },
1804
+ h('div', { className: 'ds-proj-head' },
1805
+ h('div', null,
1806
+ h('div', { className: 'ds-proj-name' }, title),
1807
+ h('div', { className: 'ds-hint' }, subtitle),
1808
+ ),
1809
+ h('button', { className: 'ds-btn', onClick: () => onOpenBoard(key) }, '看这些卡'),
1810
+ ),
1811
+ h('div', { className: 'ds-proj-stats' },
1812
+ h('div', null, h('b', {}, data.inProgress), h('span', {}, '进行中')),
1813
+ h('div', null, h('b', { 'data-warn': data.blocked > 0 ? '1' : '0' }, data.blocked), h('span', {}, '阻塞')),
1814
+ h('div', null, h('b', { 'data-warn': data.overdue > 0 ? '1' : '0' }, data.overdue), h('span', {}, '逾期')),
1815
+ h('div', null, h('b', {}, data.done), h('span', {}, '已完成')),
1816
+ h('div', null, h('b', {}, `${data.checklistRate}%`), h('span', {}, '清单完成')),
1817
+ h('div', null, h('b', {}, data.artifacts), h('span', {}, '交付物')),
1818
+ ),
1819
+ h('div', { className: 'ds-proj-bar' },
1820
+ h('div', { className: 'ds-check-bar', 'data-full': data.checklistRate === 100 ? '1' : '0' },
1821
+ h('span', { style: { width: `${data.checklistRate}%` } }),
1822
+ ),
1823
+ ),
1824
+ data.cycleDays > 0 ? h('div', { className: 'ds-hint' }, `平均周期 ${data.cycleDays} 天 · 近 7 天完成 ${data.throughput7d} 张`) : h('div', { className: 'ds-hint' }, `近 7 天完成 ${data.throughput7d} 张`),
1825
+ )
1826
+
1827
+ return h('div', { className: 'ds-body' },
1828
+ h('div', { className: 'ds-proj-grid' },
1829
+ (metrics.projects ?? []).map(project => card(
1830
+ `${project.key ? `[${project.key}] ` : ''}${project.name}`,
1831
+ `${(project.iterations ?? []).length} 个迭代`,
1832
+ project.metrics,
1833
+ project.id,
1834
+ )),
1835
+ metrics.unassigned !== null && metrics.unassigned.total > 0
1836
+ ? card('未归属项目', '还没挂到项目上的卡', metrics.unassigned, '')
1837
+ : null,
1838
+ ),
1839
+ (metrics.projects ?? []).length === 0 && (metrics.unassigned === null || metrics.unassigned.total === 0)
1840
+ ? h('div', { className: 'ds-empty' }, '还没有卡片。先去「设置 → 项目与迭代」建一个项目,再新建任务。')
1841
+ : null,
1842
+ )
1843
+ }
1844
+
1845
+
1846
+ /** 效能页:一次通过率、返工率、周期分布、每列停留、AI 采纳情况。 */
1847
+ /* 燃尽用折线 + 面积:柱状图在「剩余 0–2」时全被压成 2px 小横线,看不出趋势。 */
1848
+
1849
+
1850
+ /** 发布页:版本 = 一批卡 + 门禁 + 发布说明。 */
1851
+
1852
+
1853
+ /** 需求页:左侧需求清单,右侧一条需求的完整链路(卡 / 清单 / 用例 / 产物 / MR)。 */
1854
+
1855
+
1856
+ // ------------------------------------------------------------------ 工作台
1857
+
1858
+ /** 工作台:先看风险,再看流程,最后是能点进去的清单。 */
1859
+
1860
+ // ------------------------------------------------------------------ 主页面
1861
+
1862
+ /* 键盘可达性 --------------------------------------------------------------
1863
+ 这个界面里大量「按钮」其实是带 onClick 的 div/span(卡片、页签、chip、行内小按钮),
1864
+ Tab 聚焦不到、读屏也读不出来。与其改几百个调用点,不如在 DOM 层补一层:
1865
+ 凡是计算样式 cursor:pointer 的元素都补上 tabindex / role=button / Enter·Space 激活,
1866
+ 并把 data-on 同步成 aria-pressed。只处理新增节点,开销可控。 */
1867
+ const KB_TAG_SKIP = { INPUT: 1, TEXTAREA: 1, SELECT: 1, BUTTON: 1, A: 1, OPTION: 1 }
1868
+ // 只有「长得像可点东西」的节点才值得量一次 cursor:全量 getComputedStyle 会强制样式重算,
1869
+ // 275 个节点就要 600ms 以上(需求页切换时实测 685ms 长任务)。
1870
+ const KB_CANDIDATE = '[data-on],[data-role],[role="button"],[tabindex],[class*="ds-"]'
1871
+ // 读:只量 cursor,绝不改 DOM。批量调用时不会引发「读→写→读」的强制同步布局。
1872
+ function kbClickable(el) {
1873
+ if (el === null || el.nodeType !== 1) return false
1874
+ if (KB_TAG_SKIP[el.tagName] === 1) return false
1875
+ if (el.dataset.kb === '1') return false
1876
+ if (typeof el.className === 'string' && el.className.indexOf('mask') >= 0) return false
1877
+ if (el.getAttribute('contenteditable') === 'true') return false
1878
+ let cursor = ''
1879
+ try { cursor = window.getComputedStyle(el).cursor } catch (error) { cursor = '' }
1880
+ return cursor === 'pointer'
1881
+ }
1882
+ // 写:补键盘可达性。与读数分开调用,写完不再回读样式。
1883
+ function applyKeyboardSupport(el) {
1884
+ el.dataset.kb = '1'
1885
+ if (el.getAttribute('tabindex') === null) el.setAttribute('tabindex', '0')
1886
+ if (el.getAttribute('role') === null) el.setAttribute('role', 'button')
1887
+ const on = el.getAttribute('data-on')
1888
+ if (on !== null) el.setAttribute('aria-pressed', on === '1' ? 'true' : 'false')
1889
+ if (el.getAttribute('aria-label') === null) {
1890
+ const label = String(el.getAttribute('title') ?? el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 80)
1891
+ if (label.length > 0) el.setAttribute('aria-label', label)
1892
+ }
1893
+ el.addEventListener('keydown', event => {
1894
+ if (event.key !== 'Enter' && event.key !== ' ') return
1895
+ if (el.getAttribute('aria-disabled') === 'true') return
1896
+ event.preventDefault()
1897
+ el.click()
1898
+ })
1899
+ }
1900
+ // 看板横向滚动:滚轮默认滚页面,这里改成滚列(shift+滚轮保留给浏览器)。
1901
+ // 必须用原生非 passive 监听,React 的 onWheel 在 root 上是 passive,preventDefault 会被忽略。
1902
+ function installBoardWheel() {
1903
+ if (typeof document === 'undefined') return () => {}
1904
+ const handler = event => {
1905
+ const target = event.target
1906
+ const board = target instanceof Element ? target.closest('.ds-board') : null
1907
+ if (board === null) return
1908
+ if (board.scrollWidth <= board.clientWidth + 1) return
1909
+ if (event.shiftKey) return
1910
+ if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return
1911
+ board.scrollLeft += event.deltaY
1912
+ event.preventDefault()
1913
+ }
1914
+ document.addEventListener('wheel', handler, { passive: false })
1915
+ return () => document.removeEventListener('wheel', handler)
1916
+ }
1917
+
1918
+ function installKeyboardActivation() {
1919
+ if (typeof document === 'undefined') return () => undefined
1920
+ const patched = new WeakSet()
1921
+ const queue = []
1922
+ let scheduled = false
1923
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now())
1924
+ const retried = new WeakSet()
1925
+ const retry = []
1926
+ let retryTimer = null
1927
+ const pump = () => {
1928
+ scheduled = false
1929
+ const deadline = now() + 6
1930
+ const batch = []
1931
+ while (queue.length > 0 && batch.length < 60 && now() < deadline) batch.push(queue.pop())
1932
+ // 阶段一:只读(不伴随写操作,不会逐节点强制重算样式)
1933
+ const hits = []
1934
+ const misses = []
1935
+ for (const el of batch) {
1936
+ if (el.isConnected === false) continue
1937
+ if (kbClickable(el)) hits.push(el)
1938
+ else misses.push(el)
1939
+ }
1940
+ // 阶段二:只写
1941
+ for (const el of hits) { patched.add(el); applyKeyboardSupport(el) }
1942
+ // 挂载瞬间样式可能还没生效(卡片先渲染、主题类后挂),400ms 后再看一次
1943
+ let fresh = false
1944
+ for (const el of misses) {
1945
+ if (retried.has(el)) continue
1946
+ retried.add(el)
1947
+ if (el.isConnected !== false) { retry.push(el); fresh = true }
1948
+ }
1949
+ if (fresh && retryTimer === null) {
1950
+ retryTimer = window.setTimeout(() => {
1951
+ retryTimer = null
1952
+ queue.push(...retry.splice(0))
1953
+ schedule()
1954
+ }, 400)
1955
+ }
1956
+ if (queue.length > 0) schedule()
1957
+ }
1958
+ const schedule = () => {
1959
+ if (scheduled === true) return
1960
+ scheduled = true
1961
+ if (typeof window.requestIdleCallback === 'function') window.requestIdleCallback(pump, { timeout: 300 })
1962
+ else window.setTimeout(pump, 16)
1963
+ }
1964
+ const scan = () => {
1965
+ const root = document.querySelector('[data-dsh-studio]')
1966
+ if (root === null) return
1967
+ // 只收候选节点,且把处理摊到空闲帧里,单帧最多占 6ms。
1968
+ for (const el of root.querySelectorAll(KB_CANDIDATE)) {
1969
+ if (patched.has(el) || retried.has(el) || el.dataset.kb === '1') continue
1970
+ queue.push(el)
1971
+ }
1972
+ if (queue.length > 0) schedule()
1973
+ }
1974
+ scan()
1975
+ let timer = null
1976
+ const observer = new MutationObserver(() => {
1977
+ if (timer !== null) return
1978
+ timer = window.setTimeout(() => { timer = null; scan() }, 200)
1979
+ })
1980
+ observer.observe(document.body, { childList: true, subtree: true })
1981
+ return () => observer.disconnect()
1982
+ }
1983
+
1984
+ /**
1985
+ * 统一登录:服务端开了 requireLogin 又没带会话时,/state 会返回 locked,
1986
+ * 这里就盖一层登录框。用户名 = LDAP 账号(sAMAccountName / uid / mail 都试)。
1987
+ */
1988
+ function LoginGate({ config, theme, message, onDone, onClose, overlay }) {
1989
+ const localName = String(config?.localAuth?.username ?? 'admin')
1990
+ const localReady = config?.localAuth?.enabled !== false
1991
+ const ldapReady = config?.ldap?.enabled === true
1992
+ const [mode, setMode] = React.useState(ldapReady ? 'ldap' : 'local')
1993
+ const [username, setUsername] = React.useState(ldapReady ? '' : localName)
1994
+ const [password, setPassword] = React.useState('')
1995
+ const [busy, setBusy] = React.useState(false)
1996
+ const [error, setError] = React.useState(message ?? '')
1997
+
1998
+ const switchMode = next => {
1999
+ if (next === mode) return
2000
+ setMode(next)
2001
+ setError('')
2002
+ setPassword('')
2003
+ setUsername(next === 'local' ? localName : '')
2004
+ }
2005
+
2006
+ const submit = async () => {
2007
+ if (busy) return
2008
+ if (username.trim() === '' || password === '') {
2009
+ setError(mode === 'local' ? '本地账号和密码都要填' : '域账号和密码都要填')
2010
+ return
2011
+ }
2012
+ setBusy(true)
2013
+ setError('')
2014
+ try {
2015
+ const payload = await api('/auth/login', { username: username.trim(), password, mode })
2016
+ setSessionToken(payload.token)
2017
+ setAuthUser(payload.user ?? null)
2018
+ setUserName(String(payload.user?.name ?? payload.user?.id ?? username.trim()))
2019
+ setPassword('')
2020
+ onDone()
2021
+ } catch (cause) {
2022
+ setError(cause instanceof Error ? cause.message : String(cause))
2023
+ } finally {
2024
+ setBusy(false)
2025
+ }
2026
+ }
2027
+
2028
+ const tabs = h('div', { className: 'ds-seg', style: { alignSelf: 'flex-start' } },
2029
+ h('button', {
2030
+ className: 'ds-seg-btn',
2031
+ type: 'button',
2032
+ 'data-on': mode === 'ldap' ? '1' : '0',
2033
+ disabled: !ldapReady,
2034
+ title: ldapReady ? '用公司目录(LDAP)的账号登录' : 'LDAP 还没启用,先在设置里配好再用',
2035
+ onClick: () => switchMode('ldap'),
2036
+ }, 'LDAP 账号'),
2037
+ h('button', {
2038
+ className: 'ds-seg-btn',
2039
+ type: 'button',
2040
+ 'data-on': mode === 'local' ? '1' : '0',
2041
+ disabled: !localReady,
2042
+ title: '用平台自带的超管账号登录',
2043
+ onClick: () => switchMode('local'),
2044
+ }, '本地超管'),
2045
+ )
2046
+
2047
+ const hint = mode === 'ldap'
2048
+ ? h('div', { className: 'ds-login-hint' },
2049
+ '用公司账号登录(LDAP)。登录后会记下你的名字,写操作和审计都挂在这个名字下。')
2050
+ : h('div', { className: 'ds-login-hint' },
2051
+ `用平台自带的超管账号登录。默认账号 ${localName},默认口令 admin(改过之后就用新口令)。`)
2052
+
2053
+ const card = h('div', { className: 'ds-login' },
2054
+ h('div', { className: 'ds-login-brand' }, '◈ ', config?.board?.title ?? '工作空间'),
2055
+ tabs,
2056
+ hint,
2057
+ h('label', { className: 'ds-field' },
2058
+ h('span', null, mode === 'local' ? '超管账号' : '域账号'),
2059
+ h('input', {
2060
+ value: username,
2061
+ autoFocus: mode === 'ldap',
2062
+ name: 'studio-account',
2063
+ autoComplete: 'off',
2064
+ spellCheck: false,
2065
+ placeholder: mode === 'local' ? localName : '域账号',
2066
+ onChange: event => setUsername(event.target.value),
2067
+ onKeyDown: event => { if (event.key === 'Enter') void submit() },
2068
+ }),
2069
+ ),
2070
+ h('label', { className: 'ds-field' },
2071
+ h('span', null, '密码'),
2072
+ h('input', {
2073
+ type: 'password',
2074
+ value: password,
2075
+ autoFocus: mode === 'local',
2076
+ name: 'studio-pass',
2077
+ // 用 new-password 而不是 current-password:浏览器就不会把这里当成「可保存的登录表单」,
2078
+ // 也就不会在每次登录后弹保存密码/微软账户同步窗口(那是浏览器自己的行为,不是平台的)
2079
+ autoComplete: 'new-password',
2080
+ placeholder: mode === 'local' ? '默认口令 admin' : '域密码',
2081
+ onChange: event => setPassword(event.target.value),
2082
+ onKeyDown: event => { if (event.key === 'Enter') void submit() },
2083
+ }),
2084
+ ),
2085
+ error === '' ? null : h('div', { className: 'ds-error' }, error),
2086
+ error !== '' && /LDAP|域|目录|连不上/.test(error)
2087
+ ? h('div', { className: 'ds-login-note' }, '连不上目录服务时,切到「本地超管」用平台账号进来;等网络打通再用域账号。')
2088
+ : null,
2089
+ h('div', { className: 'ds-login-actions' },
2090
+ h('button', {
2091
+ className: 'ds-btn ds-btn-primary',
2092
+ disabled: busy,
2093
+ onClick: () => void submit(),
2094
+ }, busy ? '登录中…' : (mode === 'local' ? '登录(本地超管)' : '登录(LDAP)')),
2095
+ typeof onClose === 'function'
2096
+ ? h('button', { className: 'ds-btn ds-btn-ghost', onClick: () => onClose() }, '先不登录')
2097
+ : null,
2098
+ ),
2099
+ h('div', { className: 'ds-login-note' },
2100
+ mode === 'ldap'
2101
+ ? `${config?.ldap?.url ?? '—'} · BASE:${config?.ldap?.baseDN ?? '—'}`
2102
+ : `本地账号存在服务端密钥里(scrypt 哈希),改密码在「设置 → 统一登录(LDAP)」`),
2103
+ )
2104
+ const wrap = h('div', { className: 'ds-login-wrap', 'data-overlay': overlay === true ? '1' : '0' }, card)
2105
+ if (overlay === true) return wrap
2106
+ return h('div', { 'data-dsh-studio': '', 'data-theme': theme }, h('style', null, CSS), wrap)
2107
+ }
2108
+
2109
+ function StudioPage(props) {
2110
+ // The bundled build swaps in the shadcn login card; the standalone artifact
2111
+ // (no build step) keeps the hand-written one below.
2112
+ const LoginGateView = props?.loginGate ?? LoginGate
2113
+ const { state, error, reload } = useStudio()
2114
+ const [tab, setTab] = React.useState('dashboard')
2115
+ const [settingsSection, setSettingsSection] = React.useState('experts')
2116
+ const [openId, setOpenId] = React.useState(null)
2117
+ const [creating, setCreating] = React.useState(false)
2118
+ /** 命令面板里「先选动作、再选卡」的动作真正落地(逻辑在 palette.js,这里只执行副作用)。 */
2119
+ async function applyPaletteAction(item) {
2120
+ const effect = await runPaletteAction(item, { api, state, confirm: confirmDialog, prompt: promptDialog, notify: toast })
2121
+ if (effect.refresh === true) await reload(true)
2122
+ if (effect.open !== undefined) setOpenId(effect.open)
2123
+ }
2124
+ // 统一的「新建」入口:一个按钮弹出「需求 / 任务」两项,两个页面不再各放一个。
2125
+ const [newMenu, setNewMenu] = React.useState(false)
2126
+ const [newReq, setNewReq] = React.useState(0)
2127
+ React.useEffect(() => {
2128
+ if (!newMenu) return undefined
2129
+ const off = () => setNewMenu(false)
2130
+ document.addEventListener('click', off)
2131
+ return () => document.removeEventListener('click', off)
2132
+ }, [newMenu])
2133
+ const [theme, setTheme] = React.useState(currentTheme)
2134
+ const [hostTheme, setHostTheme] = React.useState(() => hostScheme() ?? (systemDarkQuery()?.matches ? 'dark' : 'light'))
2135
+ const [showLogin, setShowLogin] = React.useState(false)
2136
+ // 全局搜索:⌘K / Ctrl+K 打开,选到哪条就跳到哪条。
2137
+ const [paletteOpen, setPaletteOpen] = React.useState(false)
2138
+ const [paletteAction, setPaletteAction] = React.useState(null)
2139
+ const [paletteSeq, setPaletteSeq] = React.useState(0)
2140
+ const [focusReq, setFocusReq] = React.useState(null)
2141
+ React.useEffect(() => {
2142
+ const onKey = event => {
2143
+ const word = String(event.key).toLowerCase()
2144
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && word === 'k') {
2145
+ event.preventDefault()
2146
+ setPaletteAction(null)
2147
+ setPaletteOpen(value => !value)
2148
+ return
2149
+ }
2150
+ const action = paletteShortcut(event)
2151
+ if (action === null) return
2152
+ event.preventDefault()
2153
+ setPaletteAction(action)
2154
+ setPaletteSeq(value => value + 1)
2155
+ setPaletteOpen(true)
2156
+ }
2157
+ document.addEventListener('keydown', onKey)
2158
+ return () => document.removeEventListener('keydown', onKey)
2159
+ }, [])
2160
+
2161
+ // 「跟随系统」实际跟宿主界面:系统深浅色变化、宿主自己换主题都要跟上(1.5s 轮询足够,成本可忽略)
2162
+ React.useEffect(() => {
2163
+ const read = () => {
2164
+ const next = hostScheme() ?? (systemDarkQuery()?.matches ? 'dark' : 'light')
2165
+ setHostTheme(previous => (previous === next ? previous : next))
2166
+ }
2167
+ read()
2168
+ const query = systemDarkQuery()
2169
+ const onChange = () => read()
2170
+ if (query !== null) query.addEventListener('change', onChange)
2171
+ const timer = window.setInterval(read, 1500)
2172
+ return () => {
2173
+ if (query !== null) query.removeEventListener('change', onChange)
2174
+ window.clearInterval(timer)
2175
+ }
2176
+ }, [])
2177
+
2178
+ const resolvedTheme = theme === 'system' ? hostTheme : theme
2179
+
2180
+ // 主题覆盖整个页面(含宿主侧栏/顶栏),不只是工作区子树。
2181
+ React.useEffect(() => {
2182
+ const root = document.documentElement
2183
+ root.setAttribute('data-dsh-theme', resolvedTheme)
2184
+ root.style.colorScheme = resolvedTheme
2185
+ let node = document.getElementById('dsh-studio-global-theme')
2186
+ if (node === null) {
2187
+ node = document.createElement('style')
2188
+ node.id = 'dsh-studio-global-theme'
2189
+ node.textContent = GLOBAL_THEME_CSS
2190
+ document.head.appendChild(node)
2191
+ }
2192
+ return () => {
2193
+ root.removeAttribute('data-dsh-theme')
2194
+ root.style.colorScheme = ''
2195
+ }
2196
+ }, [resolvedTheme])
2197
+
2198
+
2199
+ // 键盘可达性:挂载后给「可点的 div」补 tabindex/role,卸载时断开观察
2200
+ React.useEffect(() => installKeyboardActivation(), [])
2201
+ React.useEffect(() => installBoardWheel(), [])
2202
+
2203
+ if (state === null) {
2204
+ return h('div', { 'data-dsh-studio': '', 'data-theme': resolvedTheme },
2205
+ h('style', null, CSS),
2206
+ h('div', { className: 'ds-loading' }, error === null ? '正在载入工作空间…' : `载入失败:${error}`),
2207
+ )
2208
+ }
2209
+ if (state.locked === true) {
2210
+ return h(LoginGateView, { config: state.config, theme: resolvedTheme, message: '', onDone: () => void reload() })
2211
+ }
2212
+ const openTask = openId === null ? null : state.tasks.find(t => t.id === openId) ?? null
2213
+ const counts = state.tasks.reduce((acc, task) => { acc[task.status] = (acc[task.status] ?? 0) + 1; return acc }, {})
2214
+
2215
+ return h('div', { 'data-dsh-studio': '', 'data-theme': resolvedTheme },
2216
+ h('style', null, CSS),
2217
+ h(DialogHost, null),
2218
+ h(ToastHost, null),
2219
+ h('div', { className: 'ds-head' },
2220
+ h('div', { className: 'ds-head-top' },
2221
+ h('div', { className: 'ds-brand' },
2222
+ h('span', { className: 'ds-brand-mark' }, '◈'),
2223
+ h('div', null,
2224
+ h('div', { className: 'ds-title' }, state.config.board.title),
2225
+ h('div', { className: 'ds-sub' }, `${state.tasks.length} 个任务 · rev ${state.revision} · 更新于 ${fmtTime(state.updatedAt)}`),
2226
+ ),
2227
+ ),
2228
+ h('div', { className: 'ds-head-actions' },
2229
+ authUser() === null
2230
+ ? h(ChipField, {
2231
+ className: 'ds-chip',
2232
+ 'data-on': '1',
2233
+ title: '没配 LDAP 时平台默认以超管身份使用,不需要登录;写操作与审计都记在它名下',
2234
+ }, `👤 超管 ${state.config.localAuth?.username ?? 'admin'}`)
2235
+ : h(ChipField, { className: 'ds-chip', title: `已登录:${authUser()?.id ?? ''}` }, `👤 ${authUser()?.name ?? authUser()?.id}`),
2236
+ authUser() === null
2237
+ ? h('button', { className: 'ds-btn ds-btn-ghost', title: '用域账号或本地超管账号登录', onClick: () => setShowLogin(true) }, '登录')
2238
+ : tipOf('退出当前账号(退出后回到超管身份)', h('button', {
2239
+ className: 'ds-btn ds-btn-ghost',
2240
+ onClick: async () => {
2241
+ try { await api('/auth/logout', {}) } catch { /* 会话可能已过期 */ }
2242
+ setSessionToken('')
2243
+ setAuthUser(null)
2244
+ setShowLogin(false)
2245
+ void reload()
2246
+ },
2247
+ }, '退出登录')),
2248
+ (() => {
2249
+ const Seg = kit('SegGroup')
2250
+ const options = THEMES.map(([id, label]) => ({ id, label }))
2251
+ if (Seg !== null) {
2252
+ return h(Seg, { value: theme, onChange: next => setTheme(setThemeName(next)), options })
2253
+ }
2254
+ return h('div', { className: 'ds-seg', title: '显示模式;「跟随系统」会先跟宿主界面的深浅色,宿主没给出信号时再跟操作系统' },
2255
+ THEMES.map(([id, label]) => h('button', {
2256
+ key: id,
2257
+ className: 'ds-seg-btn',
2258
+ 'data-on': theme === id ? '1' : '0',
2259
+ onClick: () => setTheme(setThemeName(id)),
2260
+ }, label)),
2261
+ )
2262
+ })(),
2263
+ h('button', { className: 'ds-btn', onClick: () => void reload() }, '刷新'),
2264
+ h('a', {
2265
+ className: 'ds-btn ds-btn-ghost',
2266
+ style: { textDecoration: 'none' },
2267
+ href: '/studio-api/export/tasks.csv',
2268
+ title: '把看板上的卡导出成 CSV(Excel 可直接打开)',
2269
+ }, '导出卡'),
2270
+ h('a', {
2271
+ className: 'ds-btn ds-btn-ghost',
2272
+ style: { textDecoration: 'none' },
2273
+ href: '/studio-api/export/requirements.csv',
2274
+ title: '把需求与准出情况导出成 CSV',
2275
+ }, '导出需求'),
2276
+ (state.integrations?.broken ?? 0) > 0
2277
+ ? h('button', {
2278
+ className: 'ds-btn ds-btn-ghost',
2279
+ style: { color: '#e5484d', borderColor: 'color-mix(in srgb, #e5484d 40%, var(--ds-line))' },
2280
+ title: (state.integrations.channels ?? []).filter(item => item.state === 'error').map(item => `${item.label}:${item.lastError}`).join('\n'),
2281
+ onClick: () => { setTab('settings'); setSettingsSection('health') },
2282
+ }, `⚠ 集成异常 ${state.integrations.broken}`)
2283
+ : null,
2284
+ h('div', { className: 'ds-newmenu', style: { position: 'relative' } },
2285
+ h('button', {
2286
+ className: 'ds-btn ds-btn-primary',
2287
+ 'aria-haspopup': 'menu',
2288
+ 'aria-expanded': newMenu ? 'true' : 'false',
2289
+ onClick: event => { event.stopPropagation(); setNewMenu(value => !value) },
2290
+ }, '+ 新建'),
2291
+ newMenu
2292
+ ? h('div', {
2293
+ role: 'menu',
2294
+ className: 'ds-menu',
2295
+ style: { position: 'absolute', right: 0, top: 'calc(100% + 6px)', zIndex: 80, minWidth: 232, padding: 6, background: 'var(--ds-surface)', border: '1px solid var(--ds-line)', borderRadius: 10, boxShadow: '0 12px 32px rgba(0,0,0,.22)' },
2296
+ onClick: event => event.stopPropagation(),
2297
+ },
2298
+ h('button', { role: 'menuitem', className: 'ds-btn ds-btn-ghost', style: { display: 'block', width: '100%', textAlign: 'left', height: 'auto', padding: '9px 10px' }, title: '先建需求,再按功能点拆卡', onClick: () => { setNewMenu(false); setTab('requirements'); setNewReq(count => count + 1) } },
2299
+ h('div', { style: { fontWeight: 600 } }, '新建需求'),
2300
+ h('div', { className: 'ds-hint', style: { marginTop: 2 } }, '需求 → 功能点 → 拆成卡'),
2301
+ ),
2302
+ h('button', { role: 'menuitem', className: 'ds-btn ds-btn-ghost', style: { display: 'block', width: '100%', textAlign: 'left', height: 'auto', padding: '9px 10px', marginTop: 2 }, title: '直接在看板上建一张卡', onClick: () => { setNewMenu(false); setTab('board'); setCreating(true) } },
2303
+ h('div', { style: { fontWeight: 600 } }, '新建任务'),
2304
+ h('div', { className: 'ds-hint', style: { marginTop: 2 } }, '直接在看板上建一张卡'),
2305
+ ),
2306
+ )
2307
+ : null,
2308
+ ),
2309
+ ),
2310
+ ),
2311
+ h('div', { className: 'ds-head-tabs' },
2312
+ (() => {
2313
+ const Tabs = kit('TabsBar')
2314
+ const TRoot = kit('Tabs')
2315
+ const TList = kit('TabsList')
2316
+ const TItem = kit('TabsTrigger')
2317
+ const items = [
2318
+ { id: 'dashboard', label: '工作台', hint: '今天该看什么:在办、逾期、阻塞、缺陷超时、准出被拦' },
2319
+ { id: 'board', label: '看板', hint: '按列推进卡片;点开卡片能看计划、交付物、测试、讨论' },
2320
+ { id: 'requirements', label: '需求', hint: '需求、功能点、可追溯矩阵与需求级准出' },
2321
+ { id: 'cases', label: '用例', hint: '跨卡用例总览:谁在跑、最近结果、挂在哪条需求' },
2322
+ { id: 'releases', label: '版本', hint: '版本清单、发布门禁、灰度与部署记录' },
2323
+ { id: 'projects', label: '项目', hint: '业务项目、迭代与每个服务对应的代码目录' },
2324
+ { id: 'analytics', label: '效能', hint: '周期分布、每列停留、团队负载、返工与 AI 成本' },
2325
+ { id: 'settings', label: '设置', hint: '列、模型、自动派单、集成、权限、备份' },
2326
+ ]
2327
+ if (TRoot !== null && TList !== null && TItem !== null) {
2328
+ return h(TRoot, { value: tab, onValueChange: next => setTab(next) },
2329
+ h(TList, { className: '' },
2330
+ items.map(item => h(TItem, {
2331
+ key: item.id,
2332
+ value: item.id,
2333
+ title: item.hint,
2334
+ className: '',
2335
+ }, item.label))))
2336
+ }
2337
+ if (Tabs !== null) {
2338
+ return h(Tabs, { items, value: tab, onChange: next => setTab(next) })
2339
+ }
2340
+ return h('div', { className: 'ds-tabs' },
2341
+ items.map(item => h('button', {
2342
+ key: item.id,
2343
+ className: 'ds-tab',
2344
+ 'data-on': tab === item.id ? '1' : '0',
2345
+ onClick: () => setTab(item.id),
2346
+ }, item.label)),
2347
+ )
2348
+ })(),
2349
+ h('div', { className: 'ds-grow' }),
2350
+ h('span', { className: 'ds-stat' },
2351
+ tab === 'dashboard'
2352
+ ? '今天该看什么:风险 · 流程 · 待办'
2353
+ : tab === 'board'
2354
+ ? `${(state.config.experts ?? []).filter(expert => expert.enabled !== false).length} 位专家 · ${(state.config.teams ?? []).length} 个专家团`
2355
+ : tab === 'requirements'
2356
+ ? `${(state.requirements ?? []).length} 条需求 · ${state.tasks.filter(task => (task.requirementId ?? '') === '').length} 张卡未挂需求`
2357
+ : tab === 'releases'
2358
+ ? `${(state.releases ?? []).length} 个版本 · ${state.tasks.filter(task => (task.releaseId ?? '') !== '').length} 张卡已挂版本`
2359
+ : tab === 'projects'
2360
+ ? `${(state.config.projects ?? []).length} 个项目`
2361
+ : tab === 'cases'
2362
+ ? `${(state.tasks ?? []).reduce((sum, task) => sum + (task.caseSummary?.total ?? 0), 0)} 条用例 · 点一行跳回卡片`
2363
+ : tab === 'analytics'
2364
+ ? '过程指标:通过率 · 返工 · 周期'
2365
+ : tab === 'settings'
2366
+ ? '改动即时生效,技能与专家保存在 ~/.dsh'
2367
+ : '内部插件市场'),
2368
+ ),
2369
+ ),
2370
+ error !== null
2371
+ ? h('div', { className: 'ds-err', style: { padding: '6px 18px' } }, error)
2372
+ : null,
2373
+ tab === 'board'
2374
+ ? h(Board, { state, onWip: async (status, current) => { const raw = await promptDialog(`这一列的 WIP 上限(0 = 不限,最多 50)`, { title: 'WIP 上限', defaultValue: String(current) }); if (raw === null) return; const value = Math.min(50, Math.max(0, Math.trunc(Number(String(raw).trim()) || 0))); try { await api('/columns/wip', { status, wip: value }); toast(value === 0 ? '已取消 WIP 上限' : `WIP 上限设为 ${value}`); await reload() } catch (cause) { toast(cause instanceof Error ? cause.message : String(cause), 'error') } }, onMove: async (id, status) => { await api('/task/update', { id, status }); await reload() }, onOpen: setOpenId })
2375
+ : tab === 'dashboard'
2376
+ ? h(DashboardPage, { state, onOpenTask: setOpenId, onOpenBoard: () => setTab('board'), onOpenCases: () => setTab('cases') })
2377
+ : tab === 'requirements'
2378
+ ? h(RequirementsPage, { state, onOpenTask: setOpenId, onChanged: reload, createSignal: newReq, focusReq })
2379
+ : tab === 'cases'
2380
+ ? h(CasesPage, { state, onOpenTask: setOpenId })
2381
+ : tab === 'releases'
2382
+ ? h(ReleasesPage, { state, onOpenTask: setOpenId })
2383
+ : tab === 'projects'
2384
+ ? h(ProjectsOverview, { state, onOpenBoard: () => setTab('board') })
2385
+ : tab === 'analytics'
2386
+ ? h(AnalyticsPage, { state, onOpenTask: setOpenId })
2387
+ : tab === 'market'
2388
+ ? h('div', { className: 'ds-body' }, h(Market, { state, onChanged: reload }))
2389
+ : h('div', { className: 'ds-body' }, h(Settings, { state, onChanged: reload, initialSection: settingsSection, onSection: setSettingsSection })),
2390
+ paletteOpen
2391
+ ? h(Palette, {
2392
+ state,
2393
+ initialAction: paletteAction,
2394
+ initialSeq: paletteSeq,
2395
+ onClose: () => { setPaletteOpen(false); setPaletteAction(null) },
2396
+ onPick: item => {
2397
+ if (item.kind === 'run') { void applyPaletteAction(item); return }
2398
+ if (item.kind === 'task') { setOpenId(item.id); return }
2399
+ if (item.kind === 'requirement') { setTab('requirements'); setFocusReq({ id: item.id, at: Date.now() }); return }
2400
+ if (item.kind === 'release') { setTab('releases'); return }
2401
+ if (item.kind === 'expert') { setTab('settings'); setSettingsSection('experts'); return }
2402
+ if (item.kind === 'team') { setTab('settings'); setSettingsSection('teams'); return }
2403
+ if (item.kind === 'project') { setTab('projects'); return }
2404
+ if (item.kind === 'skill') { setTab('settings'); setSettingsSection('skills'); return }
2405
+ if (item.kind !== 'action') return
2406
+ if (item.id === 'new-requirement') { setTab('requirements'); setNewReq(n => n + 1); return }
2407
+ if (item.id === 'new-task') { setTab('board'); setCreating(true); return }
2408
+ if (item.id.startsWith('tab:')) { setTab(item.id.slice(4)); return }
2409
+ if (item.id === 'export:tasks') { window.open('/studio-api/export/tasks.csv', '_blank'); return }
2410
+ if (item.id === 'export:requirements') { window.open('/studio-api/export/requirements.csv', '_blank'); return }
2411
+ if (item.id.startsWith('theme:')) { setTheme(setThemeName(item.id.slice(6))); return }
2412
+ if (item.id === 'refresh') { void reload(); return }
2413
+ if (item.id.startsWith('settings:')) { setTab('settings'); setSettingsSection(item.id.slice(9)) }
2414
+ },
2415
+ })
2416
+ : null,
2417
+ openTask !== null ? h(Drawer, { task: openTask, state, onClose: () => setOpenId(null), onChanged: reload }) : null,
2418
+ creating ? h(NewTaskDialog, { state, onClose: () => setCreating(false), onCreated: reload }) : null,
2419
+ showLogin && state.locked !== true
2420
+ ? h(LoginGateView, {
2421
+ config: state.config,
2422
+ theme: resolvedTheme,
2423
+ message: '',
2424
+ overlay: true,
2425
+ onDone: () => { setShowLogin(false); void reload() },
2426
+ onClose: () => setShowLogin(false),
2427
+ })
2428
+ : null,
2429
+ )
2430
+ }
2431
+
2432
+ // ------------------------------------------------------------------ icon
2433
+
2434
+ function StudioIcon(props) {
2435
+ const size = typeof props.size === 'number' ? props.size : 16
2436
+ return h('svg', {
2437
+ width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
2438
+ stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round',
2439
+ 'aria-hidden': true,
2440
+ },
2441
+ h('rect', { x: 1.5, y: 2.5, width: 4, height: 11, rx: 1 }),
2442
+ h('rect', { x: 6, y: 2.5, width: 4, height: 7, rx: 1 }),
2443
+ h('rect', { x: 10.5, y: 2.5, width: 4, height: 9, rx: 1 }),
2444
+ )
2445
+ }
2446
+
2447
+ // ----------------------------------------------------------------- mount
2448
+
2449
+ return {
2450
+ inject: ['slots'],
2451
+ apply(ctx) {
2452
+ const disposers = []
2453
+ const page = StudioPage
2454
+ disposers.push(ctx.slots.inject('main', () => ctx.slots.register(
2455
+ { name: 'main', key: PANEL_ID }, page,
2456
+ )))
2457
+ disposers.push(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
2458
+ { name: 'sidebar.panellist', id: PANEL_ID, order: 12, label: () => '需求&任务工作空间' }, StudioIcon,
2459
+ )))
2460
+ const cleanup = () => { for (const dispose of disposers.splice(0).reverse()) dispose() }
2461
+ if (typeof ctx.effect === 'function') ctx.effect(() => cleanup)
2462
+ return cleanup
2463
+ },
2464
+ }
2465
+ },
2466
+ })