@cnbmtech/dsh-zentao-session 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,1792 @@
1
+ /**
2
+ * dsh-zentao-session — Web 客户端部分(DSH 原生面板风格)。
3
+ *
4
+ * 侧边栏「插件」分组下的「禅道」条目(槽位 sidebar.panellist)+ 主面板(槽位 main)。
5
+ * 面板:双栏(BUG/任务列表 + 完整详情)、可折叠分区、与禅道一致的确认/解决等操作弹窗、
6
+ * 首次安装的登录卡片(凭据写入 DSH 凭据库,之后免登录)。
7
+ *
8
+ * 面板:BUG / 任务 / 需求 三个页签,各自的浏览范围(指派给我、我评审…)与禅道「我的地盘」一致;
9
+ * 条目可拖拽/复制为 Markdown 引用,也可按角色预设提示词一键发到对话。
10
+ *
11
+ * 宿主路由:
12
+ * GET /api/dsh-zentao-session/{auth,bugs,tasks,stories,bugfull,taskfull,storyfull,bugops,bugform}
13
+ * (列表类可带 ?type=assignedTo|openedBy|resolvedBy|finishedBy|reviewedBy|closedBy|canceledBy)
14
+ * POST /api/dsh-zentao-session/{login,logout,bugaction,comment} 需请求头 x-dsh-zentao-session: 1
15
+ * GET /api/dsh-zentao-session-file?path=/file-read-… 截图代理
16
+ */
17
+ window.__ModuleLoader__.load({
18
+ id: 'dsh-zentao-session',
19
+ factory: (require) => {
20
+ const React = require('react')
21
+ const h = React.createElement
22
+
23
+ /** 详情请求序号:用于丢弃过期响应,避免快速切换条目时旧响应覆盖新数据。 */
24
+ let detailSeq = 0
25
+
26
+ const API_BASE = '/api/dsh-zentao-session'
27
+ const FILE_BASE = '/api/dsh-zentao-session-file'
28
+ const RPC_CHANNEL = '/dsh-zentao-session'
29
+ const PANEL_ID = 'zentao-session'
30
+ const ROLE_KEY = 'dsh-zentao-role'
31
+ const CSS_MARK = 'dsh-zentao-session/panel.css'
32
+ const REMARK_TEMPLATE = '问题原因:\n\n解决方案:\n\n解决时间:'
33
+
34
+ let hostConnection = null
35
+ /**
36
+ * 面板与宿主服务的桥。handlePrompt 要等服务注入完成才可用,所以用**稳定对象**传递:
37
+ * 若把它作为 prop 值直接注册组件,服务就绪后组件标识变化会导致整个面板重挂载、丢掉已加载的列表。
38
+ */
39
+ const panelRuntime = { handlePrompt: null, build: null }
40
+
41
+ /**
42
+ * 建立「按角色处理」到会话服务的桥。三道兜底,任何一道成功即可用:
43
+ * ① apply 时立刻解析(服务已就绪);
44
+ * ② ctx.inject 回调(服务晚于 apply 就绪,例如对话插件后加载);
45
+ * ③ 面板点击时懒重试(panelRuntime.build())。
46
+ * 三次都拿不到就只保留「复制 / 拖拽」入口,绝不静默失败。
47
+ */
48
+ function wireConversationBridge(ctx) {
49
+ const owner = ctx || {}
50
+ const pickFrom = (source, key) => {
51
+ if (!source) return undefined
52
+ try {
53
+ if (typeof source.get === 'function') {
54
+ const hit = source.get(key)
55
+ if (hit) return hit
56
+ }
57
+ } catch { /* 未 inject 的服务在 ctx.get 上只是取不到 */ }
58
+ try { return source[key] } catch { return undefined }
59
+ }
60
+ const build = (source) => {
61
+ const sessions = pickFrom(source, 'sessions')
62
+ const workspaces = pickFrom(source, 'workspaces')
63
+ const uiWorkspace = pickFrom(source, 'uiWorkspace')
64
+ if (!sessions || !workspaces || !uiWorkspace) return null
65
+ return async (text) => {
66
+ const list = sessions.list.getSnapshot()
67
+ const ws = workspaces.list.getSnapshot()
68
+ const items = (ws && ws.items) || []
69
+ // 0.2.x 没有 current/recentWorkspaceId:屏幕上那个会话就是 mainView 保留的那个,
70
+ // 它所属的项目才是「当前项目」。
71
+ const current = Object.values((list && list.byId) || {}).find((row) => {
72
+ const kept = row && row.retainedBy && row.retainedBy.mainView
73
+ return (kept || 0) > 0
74
+ })
75
+ const ownerRow = current ? items.find((item) => (item.sessionIds || []).includes(current.id)) : undefined
76
+ const target = (ownerRow && ownerRow.workspaceId) || (items[0] && items[0].workspaceId)
77
+ if (!target) throw new Error('未找到当前项目(workspace),请先打开一个项目')
78
+ // connectWorkspace 会复用空白会话或新建一个;openSession 把它显示出来
79
+ const sessionId = await uiWorkspace.connectWorkspace(target)
80
+ uiWorkspace.openSession(sessionId)
81
+ // 发送要求会话作用域存活:保留到发送完成再释放
82
+ await sessions.using(sessionId, { source: 'zentao' }, async () => {
83
+ const scoped = sessions.scope(sessionId)
84
+ const conversation = scoped && typeof scoped.get === 'function' ? scoped.get('conversation') : undefined
85
+ if (!conversation || typeof conversation.send !== 'function') {
86
+ throw new Error('conversation 服务不可用(Web 对话插件未加载?)')
87
+ }
88
+ await conversation.send(text)
89
+ })
90
+ }
91
+ }
92
+ panelRuntime.build = () => build(owner)
93
+ if (typeof panelRuntime.handlePrompt !== 'function') panelRuntime.handlePrompt = build(owner)
94
+ if (typeof owner.inject === 'function') {
95
+ try {
96
+ owner.inject(['sessions', 'workspaces', 'uiWorkspace'], (svcCtx) => {
97
+ if (typeof panelRuntime.handlePrompt !== 'function') {
98
+ panelRuntime.handlePrompt = build(svcCtx) || build(owner)
99
+ }
100
+ })
101
+ } catch { /* 注入不可用:留给点击时懒解析 */ }
102
+ }
103
+ if (typeof panelRuntime.handlePrompt === 'function') {
104
+ try { owner.logger?.info?.('[zentao-session] 已启用「按角色处理」:可在当前项目新建对话并发送') } catch { /* 忽略 */ }
105
+ }
106
+ }
107
+
108
+
109
+
110
+ // ── 禅道字典 ────────────────────────────────────────────────────────────
111
+ const BUG_TYPE = {
112
+ codeerror: '代码错误', config: '配置相关', install: '安装部署', security: '安全相关',
113
+ performance: '性能问题', standard: '标准规范', automation: '测试脚本', designdefect: '设计缺陷', others: '其他',
114
+ zsj: '数据问题', kfsj: '开发设计缺陷',
115
+ }
116
+ const BUG_STATUS = { active: '激活', resolved: '已解决', closed: '已关闭' }
117
+ const BUG_RESOLUTION = {
118
+ fixed: '已修复', duplicate: '重复bug', bydesign: '设计如此', notrepro: '无法重现',
119
+ willnotfix: '不予解决', toll: '延期处理', external: '外部原因', postponed: '延期处理', technology: '技术不支持',
120
+ // 该禅道实例的自定义解决方案
121
+ solve: '已解决', updatedoc: '修改文档', deployment: '环境问题', design_revision: '设计变更',
122
+ version: '版本问题', outside: '非测试范围', by_pm_design: '产品设计如此', by_dev_design: '开发设计如此',
123
+ pre_cond_missing: '前置条件缺失', testscope_changing: '提测范围变更',
124
+ }
125
+ const SEVERITY = { 1: '1 致命', 2: '2 严重', 3: '3 一般', 4: '4 轻微' }
126
+ const PRIORITY = { 1: '1 最高', 2: '2 高', 3: '3 中', 4: '4 低' }
127
+ const ACTION = {
128
+ opened: '创建', edited: '编辑', commented: '添加备注', confirmed: '确认', assigned: '指派',
129
+ activated: '激活', resolved: '解决', closed: '关闭', deleted: '删除', started: '开始',
130
+ finished: '完成', paused: '暂停', canceled: '取消', restarted: '重新开始', reviewed: '评审',
131
+ }
132
+ const TASK_TYPE = { devel: '开发', design: '设计', test: '测试', study: '研究', discuss: '讨论', ui: '界面', affair: '事务', misc: '其他' }
133
+ const TASK_STATUS = { wait: '未开始', doing: '进行中', done: '已完成', pause: '已暂停', cancel: '已取消', closed: '已关闭' }
134
+ const STORY_STATUS = { draft: '草稿', reviewing: '评审中', active: '激活', changing: '变更中', closed: '已关闭' }
135
+ const STORY_STAGE = {
136
+ wait: '未开始', planned: '已计划', projected: '已立项', developing: '研发中', developed: '研发完毕',
137
+ testing: '测试中', tested: '测试完毕', verified: '已验收', released: '已发布', closed: '已关闭',
138
+ }
139
+ const STATUS_TONE = {
140
+ active: 'amber', resolved: 'green', closed: 'grey',
141
+ wait: 'grey', doing: 'blue', done: 'green', pause: 'grey', cancel: 'grey',
142
+ draft: 'grey', reviewing: 'blue', changing: 'amber',
143
+ }
144
+ /** 页签名(同时是宿主 action 与 tab 的键)。 */
145
+ const TABS = [['bugs', '我的 BUG'], ['tasks', '我的任务'], ['stories', '我的需求']]
146
+ const KIND_LABEL = { bugs: 'BUG', tasks: '任务', stories: '需求' }
147
+ const STATUS_MAP = { bugs: BUG_STATUS, tasks: TASK_STATUS, stories: STORY_STATUS }
148
+ /**
149
+ * 浏览范围(与禅道「我的地盘」的 tab 逐一对应,含顺序)。
150
+ * 值必须逐字一致:写错时禅道不报错,而是静默退化成未过滤的全量列表。
151
+ */
152
+ const SCOPES = {
153
+ bugs: [['assignedTo', '指派给我'], ['openedBy', '由我创建'], ['resolvedBy', '由我解决'], ['closedBy', '由我关闭']],
154
+ tasks: [['assignedTo', '指派给我'], ['openedBy', '由我创建'], ['finishedBy', '由我完成'], ['closedBy', '由我关闭'], ['canceledBy', '由我取消']],
155
+ stories: [['assignedTo', '指派给我'], ['openedBy', '我创建'], ['reviewedBy', '我评审'], ['closedBy', '我关闭']],
156
+ }
157
+ /** 角色预设:一键把条目按职位送进对话(提示词可自行修改)。 */
158
+ const ROLES = [['dev', '开发'], ['test', '测试'], ['product', '产品'], ['manage', '管理']]
159
+ /** 自动刷新可选间隔(秒)。 */
160
+ const AUTO_OPTIONS = [[0, '关'], [60, '1 分'], [300, '5 分'], [900, '15 分'], [1800, '30 分']]
161
+ const ROLE_INTRO = {
162
+ dev: '你是一名开发工程师。请针对下面这条禅道条目做开发侧处理:给出技术方案、改动点、影响面、风险与自测计划。',
163
+ test: '你是一名测试工程师。请针对下面这条禅道条目做测试侧处理:给出测试点、用例设计(含边界与异常)、回归范围与潜在风险。',
164
+ product: '你是一名产品经理。请针对下面这条禅道条目做产品侧处理:梳理背景、目标与验收标准,并给出拆分、优先级与关联方建议。',
165
+ manage: '你是一名研发管理者。请针对下面这条禅道条目做管理侧处理:给出优先级、排期、资源与风险判断,以及是否需要升级或协调。',
166
+ }
167
+ /** 禅道 BUG 操作 → 中文按钮名(与禅道操作栏一致)。 */
168
+ const OP_LABEL = {
169
+ confirmBug: '确认', resolve: '解决', close: '关闭', activate: '激活',
170
+ assignTo: '指派', edit: '编辑', finish: '完成', create: '创建',
171
+ }
172
+ /** 操作弹窗标题(禅道表单页里那一行小字)。 */
173
+ const OP_TITLE = {
174
+ confirmBug: '确认 BUG', resolve: '解决 BUG', close: '关闭 BUG', activate: '激活 BUG',
175
+ assignTo: '指派 BUG', edit: '编辑 BUG',
176
+ }
177
+
178
+ const CSS = `
179
+ .zts-root{
180
+ --line:#e9eaec; --line2:#f1f2f4; --line-strong:#cfd4da; --card:#ffffff; --page:#fbfbfc;
181
+ --ink:#1f2328; --ink2:#5b6470; --dim:#8b939c; --dim2:#a8afb7;
182
+ /* 禅道主题蓝:从禅道界面截图采样得到 #0c64eb(按钮底色) */
183
+ --blue:#0c64eb; --blue-hover:#0a53c4; --blue-bg:#eaf1fe; --blue-line:#cfe0fd;
184
+ --btn-primary:#0c64eb; --btn-primary-hover:#0a53c4;
185
+ --green:#16a34a; --green-bg:#f0fdf4; --green-line:#cdeed7;
186
+ --amber:#b45309; --amber-bg:#fff8ec; --amber-line:#f6e3c5;
187
+ --grey-bg:#f4f5f7; --shadow:0 18px 48px -24px rgba(16,24,40,.35);
188
+ --font:'Segoe UI Variable Text','Segoe UI','PingFang SC','Microsoft YaHei',system-ui,sans-serif;
189
+ --mono:'Cascadia Mono',Consolas,monospace;
190
+ display:flex; flex-direction:column; height:100%; min-height:0; padding:18px 22px 0;
191
+ background:var(--page); color:var(--ink); font-family:var(--font); font-size:13px; line-height:1.55;
192
+ -webkit-font-smoothing:antialiased;
193
+ }
194
+ .zts-dark{
195
+ --line:#2b2e33; --line2:#232629; --line-strong:#3a3f46; --card:#1c1e22; --page:#141619;
196
+ --ink:#e9ebee; --ink2:#a8adb6; --dim:#7f858e; --dim2:#6b7178;
197
+ --blue:#6f9bf3; --blue-hover:#8bb0f6; --blue-bg:rgba(111,155,243,.14); --blue-line:rgba(111,155,243,.32);
198
+ /* 深色下按钮用深一档的蓝,保证白字对比度 */
199
+ --btn-primary:#2f6fed; --btn-primary-hover:#3b7ef5;
200
+ --green:#57d18a; --green-bg:rgba(87,209,138,.12); --green-line:rgba(87,209,138,.3);
201
+ --amber:#e5a94a; --amber-bg:rgba(229,169,74,.12); --amber-line:rgba(229,169,74,.3);
202
+ --grey-bg:rgba(255,255,255,.06); --shadow:0 18px 48px -24px rgba(0,0,0,.75);
203
+ }
204
+ .zts-top{display:flex; align-items:center; gap:12px; padding-bottom:14px}
205
+ .zts-brand{display:flex; align-items:center; gap:9px; font-size:16px; font-weight:600}
206
+ .zts-mark{width:22px; height:22px; border-radius:6px; background:linear-gradient(150deg,#2f6fed,#5b8def);
207
+ display:grid; place-items:center; color:#fff; font-size:12px; flex:none}
208
+ .zts-crumb{color:var(--dim); font-size:12.5px}
209
+ .zts-sp{flex:1}
210
+ .zts-conn{display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--ink2)}
211
+ .zts-conn i{width:7px; height:7px; border-radius:50%; background:var(--green)}
212
+ .zts-conn.syncing i{background:var(--amber)} .zts-conn.err i{background:#e05252}
213
+ .zts-iconbtn{width:30px; height:30px; border:1px solid var(--line); border-radius:8px; background:var(--card);
214
+ color:var(--ink2); display:grid; place-items:center; cursor:pointer; font-size:13px; text-decoration:none}
215
+ .zts-iconbtn:hover{background:var(--grey-bg)}
216
+ .zts-rot{animation:zts-spin .9s linear infinite; display:inline-block}
217
+ @keyframes zts-spin{to{transform:rotate(360deg)}}
218
+ .zts-tabs{display:flex; gap:22px; border-bottom:1px solid var(--line); margin-bottom:16px}
219
+ .zts-tab{font:inherit; font-size:13.5px; background:none; border:0; color:var(--dim); cursor:pointer; padding:0 0 10px; position:relative}
220
+ .zts-tab:hover{color:var(--ink)}
221
+ .zts-tab.on{color:var(--ink); font-weight:500}
222
+ .zts-tab.on::after{content:''; position:absolute; left:0; right:0; bottom:-1px; height:2px; background:var(--blue); border-radius:2px}
223
+ .zts-tab b{color:var(--dim2); font-weight:400; margin-left:5px}
224
+ .zts-stats{display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; margin-bottom:16px}
225
+ .zts-stat{border:1px solid var(--line); border-radius:12px; padding:13px 15px 11px; background:var(--card);
226
+ width:100%; font:inherit; color:inherit; text-align:left; cursor:pointer; transition:border-color .16s, background .16s, box-shadow .16s}
227
+ .zts-stat:hover{border-color:var(--line-strong, #cfd4da); background:var(--grey-bg)}
228
+ .zts-stat.is-active{border-color:var(--blue); background:var(--blue-bg); box-shadow:0 0 0 3px var(--blue-bg)}
229
+ .zts-stat .k{font-size:12px; color:var(--ink2); margin-bottom:6px}
230
+ .zts-stat .v{font-size:26px; font-weight:600; letter-spacing:-.02em; line-height:1.1}
231
+ .zts-stat .v.amber{color:var(--amber)} .zts-stat .v.green{color:var(--green)} .zts-stat .v.blue{color:var(--blue)}
232
+ .zts-stat .s{font-size:11px; color:var(--dim); margin-top:5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
233
+ .zts-bar{display:flex; align-items:center; gap:10px; margin-bottom:14px; flex-wrap:wrap}
234
+ .zts-search{flex:1; max-width:420px; min-width:200px; display:flex; align-items:center; gap:8px;
235
+ border:1px solid var(--line); border-radius:10px; padding:6px 12px; background:var(--card); color:var(--dim)}
236
+ .zts-search input{border:0; outline:0; font:inherit; font-size:12.5px; color:var(--ink); flex:1; background:transparent}
237
+ .zts-seg{display:inline-flex; border:1px solid var(--line); border-radius:10px; overflow:hidden; background:var(--card)}
238
+ .zts-seg button{font:inherit; font-size:12.5px; padding:7px 13px; border:0; background:transparent; color:var(--ink2); cursor:pointer}
239
+ .zts-seg button+button{border-left:1px solid var(--line)}
240
+ .zts-seg button.on{background:var(--grey-bg); color:var(--ink); font-weight:500}
241
+ .zts-cols{flex:1; min-height:0; display:grid; grid-template-columns:minmax(300px,38fr) minmax(340px,62fr); gap:16px; padding-bottom:16px}
242
+ .zts-cols.is-full{grid-template-columns:minmax(0,1fr)}
243
+ .zts-card{border:1px solid var(--line); border-radius:12px; background:var(--card); display:flex; flex-direction:column; min-height:0; overflow:hidden}
244
+ .zts-cardhd{display:flex; align-items:center; gap:8px; padding:11px 14px; border-bottom:1px solid var(--line2); font-size:13px; font-weight:500; flex:none}
245
+ .zts-cardhd .n{font-weight:400; color:var(--dim); font-size:12px}
246
+ .zts-cnt{margin-left:auto; font-size:12px; color:var(--dim)}
247
+ .zts-scroll{flex:1; min-height:0; overflow-y:auto}
248
+ .zts-scroll::-webkit-scrollbar{width:9px} .zts-scroll::-webkit-scrollbar-thumb{background:var(--line); border-radius:9px}
249
+ .zts-item{padding:11px 14px; border-bottom:1px solid var(--line2); cursor:pointer}
250
+ .zts-item[draggable="true"]{cursor:grab}
251
+ .zts-item[draggable="true"]:active{cursor:grabbing}
252
+ .zts-item:last-child{border-bottom:0}
253
+ .zts-item:hover{background:var(--grey-bg)}
254
+ .zts-item.on{background:var(--blue-bg); box-shadow:inset 3px 0 0 var(--blue)}
255
+ .zts-item-t{font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
256
+ .zts-item-m{margin-top:5px; display:flex; gap:8px; flex-wrap:wrap; align-items:center; font-size:11.5px; color:var(--dim)}
257
+ .zts-mono{font-family:var(--mono); font-size:11px; color:var(--dim)}
258
+ .zts-chip{display:inline-flex; align-items:center; gap:5px; font-size:11px; line-height:18px; padding:0 7px;
259
+ border-radius:6px; background:var(--grey-bg); border:1px solid var(--line); color:var(--ink2); white-space:nowrap}
260
+ .zts-chip.amber{color:var(--amber); background:var(--amber-bg); border-color:var(--amber-line)}
261
+ .zts-chip.green{color:var(--green); background:var(--green-bg); border-color:var(--green-line)}
262
+ .zts-chip.blue{color:var(--blue); background:var(--blue-bg); border-color:var(--blue-line)}
263
+ .zts-chip.grey{color:var(--ink2); background:var(--grey-bg); border-color:var(--line)}
264
+ .zts-detail{padding:15px 17px 20px}
265
+ .zts-dh{display:flex; align-items:flex-start; gap:12px}
266
+ .zts-dh h2{margin:0; font-size:15.5px; font-weight:600; line-height:1.5; flex:1; overflow-wrap:anywhere}
267
+ .zts-acts{display:flex; gap:8px; margin:13px 0 6px; flex-wrap:wrap; align-items:center}
268
+ .zts-btn{font:inherit; font-size:12.5px; padding:7px 14px; border-radius:9px; cursor:pointer;
269
+ border:1px solid var(--line); background:var(--card); color:var(--ink)}
270
+ .zts-btn:hover:not(:disabled){background:var(--grey-bg)}
271
+ .zts-btn:disabled{opacity:.55; cursor:progress}
272
+ .zts-btn.pri{background:var(--btn-primary); border-color:var(--btn-primary); color:#fff}
273
+ .zts-btn.pri:hover:not(:disabled){background:var(--btn-primary-hover)}
274
+ .zts-dark .zts-btn.pri{background:#e9ebee; border-color:#e9ebee; color:#1b1d21}
275
+ .zts-dark .zts-btn.pri:hover:not(:disabled){background:#fff}
276
+ .zts-select.mini{width:auto; max-width:none; padding:5px 8px; font-size:12.5px}
277
+ .zts-iconbtn.mini{width:22px; height:22px; font-size:12px; margin-left:4px; flex:none}
278
+ .zts-topsel{display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--ink2); white-space:nowrap}
279
+ .zts-topsel .lbl{color:var(--dim)}
280
+ .zts-btn.link{border-color:transparent; background:transparent; color:var(--dim); padding:7px 6px}
281
+ .zts-btn.link:hover{color:var(--blue); background:transparent}
282
+ .zts-sect{display:flex; align-items:center; gap:7px; width:100%; background:none; border:0; cursor:pointer;
283
+ font:inherit; font-size:12px; font-weight:500; color:var(--ink2); padding:9px 0; text-align:left}
284
+ .zts-sect:hover{color:var(--ink)}
285
+ .zts-sect .chev{display:inline-block; transition:transform .18s; color:var(--dim); font-size:10px}
286
+ .zts-sect.open .chev{transform:rotate(90deg)}
287
+ .zts-sect .n{font-weight:400; color:var(--dim); font-size:11.5px}
288
+ .zts-sect-body{padding:0 0 14px}
289
+ .zts-grid{display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); border:1px solid var(--line); border-radius:10px; overflow:hidden}
290
+ .zts-cell{padding:9px 12px; border-right:1px solid var(--line2); border-bottom:1px solid var(--line2); min-width:0}
291
+ .zts-grid .zts-cell:nth-child(3n){border-right:0}
292
+ .zts-grid .zts-cell:last-child{border-bottom:0}
293
+ .zts-cell .k{font-size:11px; color:var(--dim); margin-bottom:3px}
294
+ .zts-cell .v{font-size:12.5px; color:var(--ink); overflow-wrap:anywhere}
295
+ .zts-cell .v.mono{font-family:var(--mono); font-size:11.5px; color:var(--ink)}
296
+ .zts-steps{border:1px solid var(--line); border-radius:10px; padding:12px 14px; background:var(--page);
297
+ font-size:12.5px; line-height:1.8; color:var(--ink2); overflow-wrap:anywhere}
298
+ .zts-steps img{max-width:100%; height:auto; border:1px solid var(--line); border-radius:8px; margin:6px 0; display:block; cursor:zoom-in; transition:box-shadow .18s}
299
+ .zts-steps img:hover{box-shadow:0 8px 24px -12px rgba(16,24,40,.5)}
300
+ /* 备注/操作历史里的贴图同样可点击预览 */
301
+ .zts-tl-comment img{max-width:100%; height:auto; border:1px solid var(--line); border-radius:8px; margin:6px 0; display:block; cursor:zoom-in}
302
+ .zts-tl-comment img:hover{box-shadow:0 8px 24px -12px rgba(16,24,40,.5)}
303
+ /* ── 图片画廊(多图左右切换;与宿主图片弹窗同款观感,经 body portal 全屏渲染) ── */
304
+ .zts-gallery{position:fixed; inset:0; z-index:90; display:grid; place-items:center; padding:34px;
305
+ background:rgba(8,12,20,.88); backdrop-filter:blur(3px); cursor:zoom-out}
306
+ .zts-gallery-img{max-width:94vw; max-height:88vh; border-radius:10px; cursor:zoom-out;
307
+ box-shadow:0 30px 80px -30px #000; background:#fff}
308
+ .zts-gallery-close{position:absolute; top:18px; right:22px; width:34px; height:34px; border-radius:9px; cursor:pointer;
309
+ font:inherit; font-size:15px; color:#e5e7eb; background:rgba(255,255,255,.12); border:1px solid rgba(255,255,255,.22)}
310
+ .zts-gallery-close:hover{background:rgba(255,255,255,.22)}
311
+ .zts-gallery-nav{position:absolute; top:50%; transform:translateY(-50%); width:46px; height:76px; cursor:pointer;
312
+ font:inherit; font-size:30px; line-height:1; color:#e5e7eb; background:rgba(255,255,255,.1);
313
+ border:1px solid rgba(255,255,255,.18); border-radius:12px}
314
+ .zts-gallery-nav:hover{background:rgba(255,255,255,.24)}
315
+ .zts-gallery-nav.prev{left:22px}
316
+ .zts-gallery-nav.next{right:22px}
317
+ .zts-gallery-meta{position:absolute; bottom:18px; left:0; right:0; text-align:center; color:#cbd5e1; font-size:12px}
318
+ .zts-steps p{margin:0 0 6px} .zts-steps a{color:var(--blue)} .zts-steps table{max-width:100%}
319
+ .zts-tl{border:1px solid var(--line); border-radius:10px; overflow:hidden}
320
+ .zts-tl-row{display:flex; gap:10px; padding:10px 13px; border-bottom:1px solid var(--line2); font-size:12.5px; flex-wrap:wrap}
321
+ .zts-tl-row:last-child{border-bottom:0}
322
+ .zts-tl-who{min-width:96px; color:var(--ink); flex:none}
323
+ .zts-tl-act{color:var(--ink2); flex:none}
324
+ .zts-tl-date{color:var(--dim); font-family:var(--mono); font-size:11px; margin-left:auto; flex:none}
325
+ .zts-tl-comment{margin:0; flex-basis:100%; padding:8px 10px; border-left:2px solid var(--line); color:var(--ink2);
326
+ white-space:pre-wrap; font-size:12.5px; overflow-wrap:anywhere}
327
+ .zts-editor{border:1px solid var(--line); border-radius:10px; padding:12px; background:var(--card)}
328
+ .zts-editor .eh{font-size:11.5px; color:var(--dim); margin-bottom:8px}
329
+ .zts-textarea,.zts-input,.zts-select{width:100%; border:1px solid var(--line); border-radius:8px; padding:8px 10px;
330
+ font:inherit; font-size:12.5px; color:var(--ink); background:var(--card); outline:0}
331
+ .zts-textarea{min-height:92px; resize:vertical}
332
+ .zts-input:focus,.zts-select:focus,.zts-textarea:focus{border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-bg)}
333
+ .zts-editor .ef{display:flex; gap:8px; align-items:center; margin-top:9px; flex-wrap:wrap}
334
+ .zts-notice{margin:0 0 12px; padding:8px 12px; border-radius:9px; font-size:12.5px;
335
+ border:1px solid var(--green-line); color:var(--green); background:var(--green-bg)}
336
+ .zts-notice.fail{border-color:#f0c2c2; color:#c0392b; background:#fdf2f2}
337
+ .zts-dark .zts-notice.fail{border-color:rgba(240,113,113,.35); color:#f28b8b; background:rgba(240,113,113,.12)}
338
+ .zts-empty{padding:26px 14px; text-align:center; color:var(--dim); font-size:12.5px}
339
+ .zts-skeleton{height:58px; margin:10px; border-radius:9px; background:var(--grey-bg); position:relative; overflow:hidden}
340
+ .zts-skeleton::after{content:''; position:absolute; inset:0; transform:translateX(-100%);
341
+ background:linear-gradient(90deg,transparent,rgba(127,127,127,.14),transparent); animation:zts-sweep 1.3s ease-in-out infinite}
342
+ @keyframes zts-sweep{100%{transform:translateX(100%)}}
343
+ /* ── 登录卡片 ── */
344
+ .zts-login{max-width:420px; margin:6vh auto 0; border:1px solid var(--line); border-radius:14px; background:var(--card);
345
+ padding:22px 22px 20px; box-shadow:var(--shadow)}
346
+ .zts-login h3{margin:0 0 4px; font-size:17px; font-weight:600}
347
+ .zts-login p{margin:0 0 16px; font-size:12.5px; color:var(--dim)}
348
+ .zts-login label{display:block; font-size:12px; color:var(--ink2); margin-bottom:5px}
349
+ .zts-login .row{margin-bottom:12px}
350
+ .zts-login .foot{display:flex; align-items:center; gap:10px; margin-top:16px}
351
+ /* ── 操作弹窗(与禅道表单同构:右对齐标签 + 控件列 + 宽行 + 必填星号) ── */
352
+ .zts-mask{position:fixed; inset:0; z-index:60; background:rgba(16,24,40,.45); display:grid; place-items:center; padding:24px}
353
+ .zts-dark .zts-mask{background:rgba(0,0,0,.6)}
354
+ .zts-modal{width:min(720px,100%); max-height:88vh; display:flex; flex-direction:column; background:var(--card);
355
+ border:1px solid var(--line); border-radius:14px; box-shadow:var(--shadow); overflow:hidden}
356
+ .zts-modal-hd{display:flex; align-items:center; gap:10px; padding:14px 18px; border-bottom:1px solid var(--line2); font-size:14px}
357
+ .zts-modal-hd .t{font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
358
+ .zts-badge{font-family:var(--mono); font-size:12px; color:var(--dim); border:1px solid var(--line); border-radius:6px; padding:1px 7px; flex:none}
359
+ .zts-modal-bd{padding:16px 18px; overflow-y:auto}
360
+ .zts-modal-ft{display:flex; align-items:center; gap:10px; padding:12px 18px; border-top:1px solid var(--line2); background:var(--page)}
361
+ .zts-form{border:1px solid var(--line); border-radius:10px; padding:14px 16px 4px; background:var(--card)}
362
+ .zts-frow{display:flex; align-items:flex-start; gap:12px; margin-bottom:13px}
363
+ .zts-flabel{width:88px; flex:none; text-align:right; font-size:12.5px; color:var(--ink2); padding-top:8px; line-height:1.4}
364
+ .zts-req{color:#e05252; margin-left:3px}
365
+ .zts-fctrl{flex:1; min-width:0; display:flex; align-items:center; gap:10px; flex-wrap:wrap}
366
+ .zts-fctrl .zts-select,.zts-fctrl .zts-input{max-width:340px}
367
+ .zts-fctrl .wide{max-width:100%}
368
+ .zts-select[multiple]{min-height:118px; max-width:100%}
369
+ .zts-textarea.wide{min-height:150px}
370
+ .zts-cbox{display:inline-flex; align-items:center; gap:5px; font-size:12.5px; color:var(--ink2); white-space:nowrap}
371
+ .zts-extra{display:inline-flex; align-items:center; gap:6px}
372
+ .zts-extra .zts-input,.zts-extra .zts-picker{max-width:220px; width:220px}
373
+ .zts-addon{font-size:12px; color:var(--dim)}
374
+ .zts-fhint{font-size:11px; color:var(--dim)}
375
+ .zts-btn.blue{background:var(--btn-primary); border-color:var(--btn-primary); color:#fff}
376
+ .zts-btn.blue:hover:not(:disabled){background:var(--btn-primary-hover)}
377
+ .zts-form-actions{display:flex; align-items:center; justify-content:center; gap:12px; padding:6px 0 14px}
378
+ .zts-picker{position:relative; width:340px; max-width:100%}
379
+ .zts-picker.wide{width:100%}
380
+ .zts-picker-input{display:flex; align-items:center; gap:8px; width:100%; text-align:left; font:inherit; font-size:12.5px;
381
+ padding:7px 10px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--ink); cursor:pointer}
382
+ .zts-picker-input:hover{border-color:var(--line-strong, #cfd4da)}
383
+ .zts-picker-input .ph{color:var(--dim)}
384
+ .zts-picker-input > span:first-child{flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
385
+ .zts-picker-caret{color:var(--dim); font-size:10px; flex:none}
386
+ .zts-picker-menu{margin-top:6px; border:1px solid var(--line); border-radius:8px; background:var(--card); padding:8px}
387
+ .zts-picker-list{max-height:190px; overflow-y:auto; margin-top:6px}
388
+ .zts-picker-item{display:flex; align-items:center; gap:7px; padding:4px 6px; border-radius:6px; font-size:12.5px; cursor:pointer}
389
+ .zts-picker-item:hover{background:var(--grey-bg)}
390
+ .zts-picker-item.on{background:var(--blue-bg); color:var(--blue)}
391
+ .zts-modal-hist{margin-top:8px; border-top:1px solid var(--line2); padding-top:10px}
392
+ .zts-histlist{margin:0; padding-left:22px; font-size:12.5px; color:var(--ink2); line-height:1.9}
393
+ .zts-histlist li{margin-bottom:2px}
394
+ @media (prefers-reduced-motion:reduce){ .zts-root *{animation:none !important; transition:none !important} }
395
+ @media (max-width:900px){
396
+ .zts-cols{grid-template-columns:minmax(0,1fr)}
397
+ .zts-stats{grid-template-columns:repeat(2,minmax(0,1fr))}
398
+ .zts-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
399
+ .zts-grid .zts-cell:nth-child(3n){border-right:1px solid var(--line2)}
400
+ .zts-grid .zts-cell:nth-child(2n){border-right:0}
401
+ }
402
+ `
403
+
404
+ function ensureStyles() {
405
+ try {
406
+ if (typeof document === 'undefined' || !document.head) return
407
+ if (document.querySelector(`style[data-plugin-css="${CSS_MARK}"]`)) return
408
+ const style = document.createElement('style')
409
+ // data-plugin 让运行时的样式认领/回收归属到本插件(claimStyles 只认这个属性)
410
+ style.setAttribute('data-plugin', PANEL_ID)
411
+ style.setAttribute('data-plugin-css', CSS_MARK)
412
+ style.textContent = CSS
413
+ document.head.appendChild(style)
414
+ } catch { /* 样式失败不影响功能 */ }
415
+ }
416
+
417
+ function detectTheme() {
418
+ try {
419
+ const raw = getComputedStyle(document.body).backgroundColor || ''
420
+ const match = /rgba?\(([^)]+)\)/.exec(raw)
421
+ if (!match) return 'light'
422
+ const parts = match[1].split(',').map((value) => parseFloat(value))
423
+ if (!(parts.length > 3 ? parts[3] : 1)) return 'light'
424
+ const luminance = (0.2126 * parts[0] + 0.7152 * parts[1] + 0.0722 * parts[2]) / 255
425
+ return luminance < 0.5 ? 'dark' : 'light'
426
+ } catch { return 'light' }
427
+ }
428
+
429
+ function useTheme() {
430
+ const [theme, setTheme] = React.useState(detectTheme)
431
+ React.useEffect(() => {
432
+ const sync = () => setTheme(detectTheme())
433
+ try {
434
+ const observer = new MutationObserver(sync)
435
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] })
436
+ observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'style'] })
437
+ const media = window.matchMedia?.('(prefers-color-scheme: dark)')
438
+ media?.addEventListener?.('change', sync)
439
+ return () => { observer.disconnect(); media?.removeEventListener?.('change', sync) }
440
+ } catch { return undefined }
441
+ }, [])
442
+ return theme
443
+ }
444
+
445
+ function resolveConnection(props) {
446
+ return (props && props.connection) || hostConnection || null
447
+ }
448
+
449
+ async function callHost(connection, action, payload) {
450
+ const input = payload || {}
451
+ const isWrite = action === 'confirm' || action === 'comment' || action === 'login' || action === 'logout' || action === 'bugaction'
452
+ const params = new URLSearchParams()
453
+ if (!isWrite) {
454
+ if (input.id) params.set('id', input.id)
455
+ if (input.op) params.set('op', input.op)
456
+ if (input.type) params.set('type', input.type)
457
+ }
458
+ const query = params.toString()
459
+ const suffix = query ? `?${query}` : ''
460
+ // 读操作也要带自定义头:宿主路由对所有 action 都要求它(这是该接口唯一的访问控制,
461
+ // 既能挡住跨站页面,也避免裸 GET 触发真实登录)
462
+ const headers = { accept: 'application/json', 'x-dsh-zentao-session': '1' }
463
+ try {
464
+ const response = await fetch(`${API_BASE}/${action}${suffix}`, isWrite
465
+ ? { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(input) }
466
+ : { headers })
467
+ const parsed = JSON.parse(await response.text())
468
+ if (parsed && typeof parsed === 'object') return parsed
469
+ } catch { /* 退到 RPC */ }
470
+ if (connection && connection.rpc && typeof connection.rpc.call === 'function') {
471
+ return connection.rpc.call(RPC_CHANNEL, action, input)
472
+ }
473
+ return { ok: false, error: '数据通道不可用:同源路由未命中,且没有 connection 服务' }
474
+ }
475
+
476
+ const proxyFile = (src) => (/^\/file-(read|download)-/.test(src) ? `${FILE_BASE}?path=${encodeURIComponent(src)}` : src)
477
+
478
+ function sanitizeSteps(html) {
479
+ let out = String(html || '')
480
+ // script/style 连同内容一起删(内容本就该丢)
481
+ .replace(/<(script|style)\b[^>]*>([\s\S]*?<\/\1\s*>)?/gi, '')
482
+ // 其余危险标签「只删标签、不删中间内容」:若写成成对删除,
483
+ // 遇到不闭合的 <iframe> + 远处的 </iframe> 会把正文一起吃掉。
484
+ .replace(/<\/?(iframe|object|embed|form|base|meta|link|frame|frameset)\b[^>]*>/gi, '')
485
+ .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
486
+ // 会绕过图片代理或触发导航的属性
487
+ .replace(/\s(srcset|poster|formaction|xlink:href|data-src)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
488
+ .replace(/javascript:/gi, '')
489
+ // 图片走本机代理(引号可省略:禅道编辑器偶有无引号写法)
490
+ out = out.replace(/(<img[^>]*?\ssrc=)(["']?)([^"'\s>]+)\2/gi,
491
+ (_m, pre, quote, src) => `${pre}${quote}${proxyFile(src)}${quote}`)
492
+ // 外链补 rel,避免 window.opener 风险
493
+ out = out.replace(/<a\s([^>]*?)>/gi, (_m, attrs) => `<a ${attrs} rel="noreferrer noopener">`)
494
+ return out
495
+ }
496
+
497
+ /** HTML → 纯文本:Markdown 引用里不能出现标签。 */
498
+ function htmlToText(value) {
499
+ return String(value || '')
500
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '')
501
+ .replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n')
502
+ .replace(/<br\s*\/?>/gi, '\n')
503
+ .replace(/<[^>]+>/g, '')
504
+ .replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
505
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
506
+ .replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
507
+ }
508
+
509
+ /**
510
+ * 一条禅道条目 → 可编辑的 Markdown 引用。
511
+ * 拖拽到输入框、复制、按角色发到对话三个入口共用同一份文本,避免三处内容不一致。
512
+ */
513
+ function itemMarkdown(kind, record, options) {
514
+ const opts = options || {}
515
+ const row = record || {}
516
+ const users = opts.users || {}
517
+ const statusMap = STATUS_MAP[kind] || {}
518
+ const who = (account) => (account && users[account]) ? users[account] : (account || '')
519
+ const id = row.id === undefined || row.id === null || row.id === '' ? '-' : row.id
520
+ const lines = [`【禅道${KIND_LABEL[kind] || ''} #${id}】${row.title || row.name || '(无标题)'}`, '']
521
+ const push = (label, value, unit) => {
522
+ if (value === undefined || value === null) return
523
+ const text = String(value).trim()
524
+ // 禅道的空日期是 "0000-00-00",空数值是 "0":都不该出现在引用里
525
+ if (!text || text === '0' || /^0+[-0: ]*$/.test(text)) return
526
+ lines.push(`- ${label}:${text}${unit ? ` ${unit}` : ''}`)
527
+ }
528
+ push('状态', statusMap[row.status] || row.status)
529
+ push('优先级', row.pri ? `P${row.pri}` : '')
530
+ if (kind === 'bugs') {
531
+ push('严重程度', row.severity ? `S${row.severity}` : '')
532
+ push('类型', BUG_TYPE[row.type] || row.type)
533
+ push('解决方案', BUG_RESOLUTION[row.resolution] || row.resolution)
534
+ push('所属产品', opts.productName || row.productTitle)
535
+ push('所属模块', opts.modulePath)
536
+ push('指派给', who(row.assignedTo))
537
+ push('由谁创建', who(row.openedBy), row.openedDate ? `(${row.openedDate})` : '')
538
+ } else if (kind === 'tasks') {
539
+ push('所属项目', opts.projectName || row.projectName)
540
+ push('所属执行', row.executionName)
541
+ push('指派给', who(row.assignedTo))
542
+ push('截止日期', row.deadline)
543
+ push('工时(预计/已耗/剩余)', [row.estimate, row.consumed, row.left].filter((v) => v !== undefined && v !== null && v !== '').join(' / '), 'h')
544
+ } else {
545
+ push('所处阶段', STORY_STAGE[row.stage] || row.stage)
546
+ push('所属产品', opts.productName || row.productTitle)
547
+ push('所属模块', opts.modulePath)
548
+ push('指派给', who(row.assignedTo))
549
+ push('由谁创建', who(row.openedBy), row.openedDate ? `(${row.openedDate})` : '')
550
+ }
551
+ const rich = kind === 'bugs' ? row.steps : kind === 'tasks' ? (row.desc || row.storySpec) : (row.spec || row.desc)
552
+ const body = htmlToText(rich)
553
+ if (body) lines.push('', kind === 'bugs' ? '重现步骤:' : (kind === 'tasks' ? '任务描述:' : '需求描述:'), body)
554
+ if (opts.url) lines.push('', `禅道原地址:${opts.url}`)
555
+ return lines.join('\n')
556
+ }
557
+
558
+ /** 复制到剪贴板:优先 Clipboard API,失败退回临时 textarea(旧内核/无权限时)。 */
559
+ async function copyText(text) {
560
+ try {
561
+ await navigator.clipboard.writeText(text)
562
+ return true
563
+ } catch {
564
+ try {
565
+ const area = document.createElement('textarea')
566
+ area.value = text
567
+ area.style.position = 'fixed'
568
+ area.style.top = '0'
569
+ area.style.left = '0'
570
+ area.style.opacity = '0'
571
+ document.body.appendChild(area)
572
+ area.focus()
573
+ area.select()
574
+ const ok = document.execCommand('copy')
575
+ document.body.removeChild(area)
576
+ return ok
577
+ } catch { return false }
578
+ }
579
+ }
580
+
581
+ function Chip(text, tone, key) {
582
+ return h('span', { className: `zts-chip${tone ? ` ${tone}` : ''}`, key: key || `c-${text}` }, text)
583
+ }
584
+
585
+ function Field(label, value, mono, key) {
586
+ return h('div', { className: 'zts-cell', key: key || label }, [
587
+ h('div', { key: 'k', className: 'k' }, label),
588
+ h('div', { key: 'v', className: `v${mono ? ' mono' : ''}` }, value === undefined || value === null || value === '' ? '—' : String(value)),
589
+ ])
590
+ }
591
+
592
+ /** 可折叠分区。 */
593
+ function Section(key, title, open, onToggle, children, count) {
594
+ return h('div', { key }, [
595
+ h('button', { key: 'h', type: 'button', className: `zts-sect${open ? ' open' : ''}`, onClick: onToggle }, [
596
+ h('span', { key: 'chev', className: 'chev' }, '▶'),
597
+ h('span', { key: 't' }, title),
598
+ count ? h('span', { key: 'n', className: 'n' }, `(${count})`) : null,
599
+ ]),
600
+ open ? h('div', { key: 'b', className: 'zts-sect-body' }, children) : null,
601
+ ])
602
+ }
603
+
604
+ function ZentaoIcon(props) {
605
+ const size = typeof props?.size === 'number' ? props.size : 16
606
+ return h('svg', {
607
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
608
+ stroke: 'currentColor', 'aria-hidden': 'true',
609
+ },
610
+ h('rect', { x: 2.7, y: 2.7, width: 18.6, height: 18.6, rx: 5, strokeWidth: 1.5 }),
611
+ h('path', { d: 'M7 8.5h6.4M7 12h4.2', strokeWidth: 1.5, strokeLinecap: 'round' }),
612
+ h('path', { d: 'M6.8 15.6h3.4l2 2 4-4.4', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }))
613
+ }
614
+
615
+ /** 单选/多选的选择器(对齐禅道的 picker-select:单行框 + 可搜索下拉)。 */
616
+ function Picker(props) {
617
+ const { field, value, onChange } = props
618
+ const [open, setOpen] = React.useState(false)
619
+ const [search, setSearch] = React.useState('')
620
+ const options = (field.options || []).filter((option) => option.value !== '')
621
+ const selected = field.multiple ? (Array.isArray(value) ? value.map(String) : []) : (value ? [String(value)] : [])
622
+ const labelOf = (raw) => (options.find((option) => String(option.value) === raw) || {}).label || raw
623
+ const shown = search
624
+ ? options.filter((option) => `${option.label} ${option.value}`.toLowerCase().includes(search.toLowerCase()))
625
+ : options
626
+ return h('div', { className: `zts-picker${field.wide ? ' wide' : ''}` }, [
627
+ h('button', {
628
+ key: 'i', type: 'button', className: 'zts-picker-input',
629
+ onClick: () => setOpen((prev) => !prev),
630
+ }, [
631
+ h('span', { key: 't', className: selected.length ? '' : 'ph' }, selected.length ? selected.map(labelOf).join('、') : '(未选择)'),
632
+ h('span', { key: 'c', className: 'zts-picker-caret' }, '▾'),
633
+ ]),
634
+ open ? h('div', { key: 'm', className: 'zts-picker-menu' }, [
635
+ h('input', {
636
+ key: 's', className: 'zts-input', placeholder: '搜索…', value: search,
637
+ onChange: (event) => setSearch(event.target.value),
638
+ }),
639
+ h('div', { key: 'l', className: 'zts-picker-list' }, shown.slice(0, 300).map((option) => {
640
+ const raw = String(option.value)
641
+ const on = selected.includes(raw)
642
+ return h('label', { key: `${field.name}-${raw}`, className: `zts-picker-item${on ? ' on' : ''}` }, [
643
+ h('input', {
644
+ key: 'i', type: field.multiple ? 'checkbox' : 'radio', checked: on,
645
+ onChange: () => {
646
+ if (field.multiple) {
647
+ onChange(field.name, on ? selected.filter((item) => item !== raw) : [...selected, raw])
648
+ } else {
649
+ onChange(field.name, option.value)
650
+ setOpen(false)
651
+ }
652
+ },
653
+ }),
654
+ h('span', { key: 't' }, option.label || raw),
655
+ ])
656
+ })),
657
+ ]) : null,
658
+ ])
659
+ }
660
+
661
+ /**
662
+ * 图片预览画廊:点任意一张图进入,← →(或两侧按钮)切换当前页面的全部图片。
663
+ * 宿主自带的 ImageLightbox 只支持单图(签名里没有列表/翻页),所以画廊这层自己实现,
664
+ * 但沿用它的做法:body portal 渲染(绕开面板容器的 transform/fixed 裁剪)、ESC 与遮罩关闭。
665
+ */
666
+ function ImageGallery(props) {
667
+ const { list, index, onIndex, onClose } = props
668
+ const total = list.length
669
+ const safeIndex = Math.max(0, Math.min(index, total - 1))
670
+ const current = list[safeIndex] || ''
671
+ React.useEffect(() => {
672
+ const onKey = (event) => {
673
+ if (event.key === 'Escape') { event.stopPropagation(); onClose() }
674
+ else if (event.key === 'ArrowLeft') { event.preventDefault(); onIndex(safeIndex - 1) }
675
+ else if (event.key === 'ArrowRight') { event.preventDefault(); onIndex(safeIndex + 1) }
676
+ }
677
+ try { window.addEventListener('keydown', onKey, true) } catch { /* 忽略 */ }
678
+ return () => { try { window.removeEventListener('keydown', onKey, true) } catch { /* 忽略 */ } }
679
+ }, [safeIndex, onClose, onIndex])
680
+
681
+ const stop = (event) => event.stopPropagation()
682
+ const overlay = h('div', {
683
+ className: 'zts-gallery', role: 'dialog', 'aria-modal': 'true', 'aria-label': '图片预览',
684
+ onClick: onClose,
685
+ }, [
686
+ h('img', { key: 'img', className: 'zts-gallery-img', src: current, alt: '图片预览', onClick: stop }),
687
+ total > 1 ? h('button', {
688
+ key: 'prev', type: 'button', className: 'zts-gallery-nav prev', title: '上一张(←)',
689
+ onClick: (event) => { stop(event); onIndex(safeIndex - 1) },
690
+ }, '‹') : null,
691
+ total > 1 ? h('button', {
692
+ key: 'next', type: 'button', className: 'zts-gallery-nav next', title: '下一张(→)',
693
+ onClick: (event) => { stop(event); onIndex(safeIndex + 1) },
694
+ }, '›') : null,
695
+ h('button', {
696
+ key: 'close', type: 'button', className: 'zts-gallery-close', title: '关闭(ESC)',
697
+ onClick: (event) => { stop(event); onClose() },
698
+ }, '✕'),
699
+ h('div', { key: 'meta', className: 'zts-gallery-meta' },
700
+ `${safeIndex + 1} / ${total}${total > 1 ? ' · ← → 切换 · ESC 关闭' : ' · ESC 关闭'}`),
701
+ ])
702
+ try {
703
+ const reactDom = require('react-dom')
704
+ if (reactDom && typeof reactDom.createPortal === 'function' && typeof document !== 'undefined') {
705
+ return reactDom.createPortal(overlay, document.body)
706
+ }
707
+ } catch { /* 落到内联渲染 */ }
708
+ return overlay
709
+ }
710
+
711
+ /** 与禅道一致的操作弹窗:字段、条件显隐、必填标记、历史记录都来自禅道。 */
712
+ function ActionModal(props) {
713
+ const { modal, onChange, onSubmit, onClose } = props
714
+
715
+ const visible = (field) => {
716
+ if (field.hidden) return false
717
+ // 多个显示条件之间是「与」关系(如既是 resolution≠duplicate,又需勾选「创建」)
718
+ if (field.showWhen) {
719
+ const current = String(modal.values[field.showWhen.field] ?? '')
720
+ if (Array.isArray(field.showWhen.in) && !field.showWhen.in.includes(current)) return false
721
+ if (Array.isArray(field.showWhen.notIn) && field.showWhen.notIn.includes(current)) return false
722
+ }
723
+ if (field.showWhenChecked && !modal.values[field.showWhenChecked]) return false
724
+ return true
725
+ }
726
+ const shown = (modal.fields || [])
727
+ .filter((field) => field.type !== 'hidden' && field.type !== 'note' && field.type !== 'file')
728
+ .filter(visible)
729
+
730
+ // 按禅道的「行」分组:主控件 + 同行的附加控件 + 勾选框
731
+ const groups = new Map()
732
+ for (const field of shown) {
733
+ const key = field.rowKey || field.name
734
+ if (!groups.has(key)) groups.set(key, [])
735
+ groups.get(key).push(field)
736
+ }
737
+ const rows = [...groups.values()].map((group) => ({
738
+ main: group.find((field) => field.primary) || group.find((field) => field.type !== 'checkbox') || null,
739
+ extras: group.filter((field) => field.type !== 'checkbox' && !field.primary),
740
+ boxes: group.filter((field) => field.type === 'checkbox'),
741
+ })).filter((row) => row.main || row.boxes.length)
742
+
743
+ const control = (field) => {
744
+ const value = modal.values[field.name] ?? field.value ?? ''
745
+ const wide = field.wide || field.type === 'textarea'
746
+ if (field.type === 'select') {
747
+ return h(Picker, { key: field.name, field, value, onChange })
748
+ }
749
+ if (field.type === 'textarea') {
750
+ return h('textarea', {
751
+ key: field.name,
752
+ className: 'zts-textarea wide',
753
+ rows: field.rows || 6,
754
+ value: String(value),
755
+ onChange: (event) => onChange(field.name, event.target.value),
756
+ })
757
+ }
758
+ return h('input', {
759
+ key: field.name,
760
+ className: `zts-input${wide ? ' wide' : ''}`,
761
+ value: String(value),
762
+ placeholder: field.placeholder || undefined,
763
+ onChange: (event) => onChange(field.name, event.target.value),
764
+ })
765
+ }
766
+
767
+ const checkbox = (field) => h('label', { key: field.name, className: 'zts-cbox' }, [
768
+ h('input', {
769
+ key: 'i', type: 'checkbox',
770
+ checked: Boolean(modal.values[field.name]),
771
+ onChange: (event) => onChange(field.name, event.target.checked ? (field.value || '1') : ''),
772
+ }),
773
+ h('span', { key: 'l' }, field.label || field.name),
774
+ ])
775
+
776
+ // 禅道的历史记录是正序(最早的在前)且带序号
777
+ const actions = (modal.actions || []).slice().reverse()
778
+
779
+ return h('div', { className: 'zts-mask', onClick: (event) => { if (event.target === event.currentTarget) onClose() } }, [
780
+ h('div', { key: 'm', className: 'zts-modal' }, [
781
+ h('div', { key: 'hd', className: 'zts-modal-hd' }, [
782
+ modal.bugId ? h('span', { key: 'id', className: 'zts-badge' }, modal.bugId) : null,
783
+ h('span', { key: 't', className: 't' }, modal.bugTitle || modal.title || '操作'),
784
+ h('span', { key: 'sp', className: 'zts-sp' }),
785
+ h('button', { key: 'x', type: 'button', className: 'zts-btn link', onClick: onClose }, '✕'),
786
+ ]),
787
+ h('div', { key: 'bd', className: 'zts-modal-bd' }, [
788
+ modal.error ? h('div', { key: 'err', className: 'zts-notice fail' }, modal.error) : null,
789
+ h('div', { key: 'form', className: 'zts-form' }, [
790
+ ...rows.map((row, index) => h('div', { key: row.main ? row.main.name : `boxes-${index}`, className: 'zts-frow' }, [
791
+ row.main
792
+ ? h('label', { key: 'l', className: 'zts-flabel' }, row.main.label || row.main.name)
793
+ : h('span', { key: 'l', className: 'zts-flabel' }),
794
+ h('div', { key: 'c', className: 'zts-fctrl' }, [
795
+ row.main ? control(row.main) : null,
796
+ // 必填标记与禅道一致:跟在控件后面
797
+ row.main && row.main.required ? h('span', { key: 'r', className: 'zts-req' }, '*') : null,
798
+ // 同行的附加控件(如勾选「创建」后的所属项目、新版本名称)
799
+ ...row.extras.map((extra) => h('span', { key: extra.name, className: 'zts-extra' }, [
800
+ extra.addon ? h('span', { key: 'a', className: 'zts-addon' }, extra.addon) : null,
801
+ control(extra),
802
+ ])),
803
+ ...row.boxes.map(checkbox),
804
+ ]),
805
+ ])),
806
+ // 与禅道一致:保存/返回居中
807
+ h('div', { key: 'actions', className: 'zts-form-actions' }, [
808
+ h('button', {
809
+ key: 'save', type: 'button', className: 'zts-btn blue', disabled: modal.busy, onClick: onSubmit,
810
+ }, modal.busy ? '保存中…' : '保存'),
811
+ h('button', { key: 'back', type: 'button', className: 'zts-btn', onClick: onClose, disabled: modal.busy }, '返回'),
812
+ ]),
813
+ ]),
814
+ actions.length ? h('div', { key: 'hist', className: 'zts-modal-hist' }, [
815
+ h('div', { key: 't', className: 'zts-sect open', style: { cursor: 'default' } }, '历史记录'),
816
+ h('ol', { key: 'l', className: 'zts-histlist' }, actions.map((item, index) => h('li', {
817
+ key: item.id || `${item.date}-${item.action}`, value: index + 1,
818
+ }, [
819
+ `${item.date},由 `,
820
+ h('strong', { key: 'w' }, item.actorName || item.actor),
821
+ ` ${ACTION[item.action] || item.action}。`,
822
+ item.comment ? h('div', {
823
+ key: 'c', className: 'zts-tl-comment',
824
+ onClick: props.onPreview,
825
+ dangerouslySetInnerHTML: { __html: sanitizeSteps(item.comment) },
826
+ }) : null,
827
+ ]))),
828
+ ]) : null,
829
+ ]),
830
+ ]),
831
+ ])
832
+ }
833
+
834
+ function ZentaoPanel(props) {
835
+ const connection = resolveConnection(props)
836
+ const theme = useTheme()
837
+ const [auth, setAuth] = React.useState({ status: 'loading' })
838
+ const [loginForm, setLoginForm] = React.useState({ baseUrl: '', account: '', password: '' })
839
+ const [loginBusy, setLoginBusy] = React.useState(false)
840
+ const [loginError, setLoginError] = React.useState('')
841
+ const [tab, setTab] = React.useState('bugs')
842
+ const [state, setState] = React.useState({ status: 'idle' })
843
+ const [selectedId, setSelectedId] = React.useState('')
844
+ const [detail, setDetail] = React.useState({ status: 'idle' })
845
+ const [ops, setOps] = React.useState([])
846
+ const [opsState, setOpsState] = React.useState('idle') // idle | loading | ready | error
847
+ const [modal, setModal] = React.useState(null)
848
+ const [open, setOpen] = React.useState({ steps: true, info: false, handle: false, relations: false, timeline: false, remark: false })
849
+ const [query, setQuery] = React.useState('')
850
+ const [filter, setFilter] = React.useState('all')
851
+ const [draft, setDraft] = React.useState('')
852
+ const [busy, setBusy] = React.useState('')
853
+ const [zenTaoSid, setZenTaoSid] = React.useState('')
854
+ const [gallery, setGallery] = React.useState(null)
855
+ const [detailFull, setDetailFull] = React.useState(false)
856
+ const [notice, setNotice] = React.useState(null)
857
+ // 每个页签各自的浏览范围(默认"指派给我",与禅道 tab 一致)
858
+ const [scopes, setScopes] = React.useState({ bugs: 'assignedTo', tasks: 'assignedTo', stories: 'assignedTo' })
859
+ const [autoRefresh, setAutoRefresh] = React.useState(0) // 秒;0 = 关闭
860
+ const [role, setRole] = React.useState(() => {
861
+ try { return window.localStorage.getItem(ROLE_KEY) || 'dev' } catch { return 'dev' }
862
+ })
863
+ const [busyHint, setBusyHint] = React.useState('')
864
+
865
+ const checkAuth = React.useCallback(async () => {
866
+ setAuth({ status: 'loading' })
867
+ try {
868
+ const result = await callHost(connection, 'auth', {})
869
+ setAuth({ status: 'ready', ...result })
870
+ return result
871
+ } catch (error) {
872
+ setAuth({ status: 'ready', authenticated: false, error: String((error && error.message) || error) })
873
+ return null
874
+ }
875
+ }, [connection])
876
+
877
+ /** 三类列表按各自的浏览范围并行取;任一失败只报自己那一类,不牵连其它。 */
878
+ const loadLists = React.useCallback(async (active) => {
879
+ const wanted = active || { bugs: 'assignedTo', tasks: 'assignedTo', stories: 'assignedTo' }
880
+ setState((prev) => ({ ...prev, status: 'loading' }))
881
+ try {
882
+ const [bugs, tasks, stories] = await Promise.all([
883
+ callHost(connection, 'bugs', { type: wanted.bugs }),
884
+ callHost(connection, 'tasks', { type: wanted.tasks }),
885
+ callHost(connection, 'stories', { type: wanted.stories }),
886
+ ])
887
+ for (const [label, result] of [['BUG', bugs], ['任务', tasks], ['需求', stories]]) {
888
+ if (!result || result.ok !== true) throw new Error(`${label}列表获取失败:${(result && result.error) || '未知错误'}`)
889
+ }
890
+ setState({
891
+ status: 'ready',
892
+ bugs: bugs.bugs || [], bugTotal: bugs.recTotal || (bugs.bugs || []).length,
893
+ tasks: tasks.tasks || [], taskTotal: tasks.recTotal || (tasks.tasks || []).length,
894
+ stories: stories.stories || [], storyTotal: stories.recTotal || (stories.stories || []).length,
895
+ baseUrl: bugs.baseUrl || tasks.baseUrl || stories.baseUrl,
896
+ })
897
+ } catch (error) {
898
+ // 刷新失败时保留上一次的数据,不要把列表清空(否则界面变成 0/0 条 与错误提示并存)
899
+ setState((prev) => ({ ...prev, status: 'ready', error: String((error && error.message) || error) }))
900
+ }
901
+ }, [connection])
902
+
903
+ React.useEffect(() => { checkAuth() }, [checkAuth])
904
+ React.useEffect(() => {
905
+ if (auth.status === 'ready' && auth.authenticated) loadLists(scopes)
906
+ }, [auth.status, auth.authenticated, loadLists, scopes])
907
+
908
+ // 自动刷新:间隔可选;页面不可见时跳过这一轮(后台标签页不该一直打禅道)
909
+ React.useEffect(() => {
910
+ if (!autoRefresh || autoRefresh <= 0) return undefined
911
+ if (!(auth.status === 'ready' && auth.authenticated)) return undefined
912
+ const timer = window.setInterval(() => {
913
+ try { if (document.visibilityState === 'hidden') return } catch { /* 忽略 */ }
914
+ loadLists(scopes)
915
+ }, autoRefresh * 1000)
916
+ return () => window.clearInterval(timer)
917
+ }, [autoRefresh, auth.status, auth.authenticated, loadLists, scopes])
918
+ React.useEffect(() => {
919
+ if (auth.sid) setZenTaoSid(auth.sid)
920
+ }, [auth.sid])
921
+ // 真全屏状态同步:ESC 退出全屏时,面板也要跟着恢复双栏
922
+ React.useEffect(() => {
923
+ const onChange = () => {
924
+ try { setDetailFull(Boolean(document.fullscreenElement)) } catch { /* 忽略 */ }
925
+ }
926
+ try { document.addEventListener('fullscreenchange', onChange) } catch { /* 忽略 */ }
927
+ return () => { try { document.removeEventListener('fullscreenchange', onChange) } catch { /* 忽略 */ } }
928
+ }, [])
929
+
930
+ /** 禅道页面链接:附带 ?zentaosid= 以在浏览器里免登录打开。 */
931
+ const zenTaoUrl = (path) => {
932
+ if (!baseUrl) return ''
933
+ const url = `${baseUrl}${path || ''}`
934
+ if (!zenTaoSid) return url
935
+ return `${url}${url.includes('?') ? '&' : '?'}zentaosid=${encodeURIComponent(zenTaoSid)}`
936
+ }
937
+
938
+ const bugs = state.bugs || []
939
+ const tasks = state.tasks || []
940
+ const stories = state.stories || []
941
+ const items = state[tab] || []
942
+ const baseUrl = state.baseUrl || auth.baseUrl || ''
943
+
944
+ /** 禅道详情页路径:三类条目只有前缀不同(bug-view / task-view / story-view)。 */
945
+ const KIND_PATH = { bugs: 'bug', tasks: 'task', stories: 'story' }
946
+ const KIND_KEY = { bugs: 'bug', tasks: 'task', stories: 'story' }
947
+ const itemUrl = (kind, id) => (id === undefined || id === null || id === '' ? '' : zenTaoUrl(`/${KIND_PATH[kind] || 'bug'}-view-${id}.html`))
948
+ /** 当前页签浏览范围的中文名(禅道 tab 上的写法)。 */
949
+ const scopeLabel = (kind) => ((SCOPES[kind] || []).find(([value]) => value === scopes[kind]) || ['', ''])[1] || ''
950
+ /** 卡片标题:沿用面板既有写法(BUG 后带空格,其余不带),避免与既有文案规范打架。 */
951
+ const cardTitle = (suffix) => (tab === 'bugs' ? `BUG ${suffix}` : `${KIND_LABEL[tab]}${suffix}`)
952
+ /** 列表条数文案:一页只有 20 条,必须说清"本页多少 / 服务端共多少"。 */
953
+ const listCountText = () => {
954
+ const total = Number(state[`${KIND_KEY[tab]}Total`]) || items.length
955
+ const base = `${filtered.length} / ${items.length} 条`
956
+ return total > items.length ? `${base}(服务端共 ${total})` : base
957
+ }
958
+
959
+ /** 收集当前面板里出现过的全部图片(重现步骤 + 备注/操作历史贴图,按文档顺序)。 */
960
+ const collectImages = (clickedSrc) => {
961
+ const list = []
962
+ try {
963
+ for (const node of document.querySelectorAll('.zts-root img')) {
964
+ if (node.closest && node.closest('.zts-gallery')) continue
965
+ const src = node.getAttribute('src') || node.src
966
+ if (src && !list.includes(src)) list.push(src)
967
+ }
968
+ } catch { /* 忽略 */ }
969
+ if (list.length === 0 && detail.status === 'ready') {
970
+ // 没有真实 DOM(测试环境)时从数据里兜底:按渲染顺序取 steps 与各条备注里的图
971
+ const record = detail.data.bug || detail.data.task || detail.data.story || {}
972
+ const html = [record.steps, record.desc, record.storySpec]
973
+ .concat((Array.isArray(detail.data.actions) ? detail.data.actions : []).map((item) => item.comment))
974
+ .filter(Boolean).join(' ')
975
+ for (const match of String(html).matchAll(/<img[^>]*?\ssrc=(["']?)([^"'\s>]+)\1/gi)) {
976
+ const src = proxyFile(match[2])
977
+ if (!list.includes(src)) list.push(src)
978
+ }
979
+ }
980
+ const src = clickedSrc || list[0] || ''
981
+ if (src && !list.includes(src)) list.unshift(src)
982
+ return { list, index: Math.max(0, list.indexOf(src)) }
983
+ }
984
+
985
+ /** 点击图片 → 进入画廊预览(可左右切换当前页面的所有图片)。 */
986
+ const onImageClick = (event) => {
987
+ const target = event && event.target
988
+ if (target && target.tagName === 'IMG') {
989
+ const src = target.getAttribute ? (target.getAttribute('src') || target.src) : target.src
990
+ if (src) setGallery(collectImages(src))
991
+ }
992
+ }
993
+
994
+ /** 全屏:优先浏览器真全屏(Fullscreen API);不支持时退回面板内铺满。 */
995
+ /**
996
+ * 全屏:作用域限定在插件面板本身(.zts-root),不动宿主其它区域。
997
+ * 面板元素不可用时退回「面板内铺满」,**不会**去全屏 documentElement。
998
+ */
999
+ const toggleFullscreen = () => {
1000
+ try {
1001
+ if (typeof document !== 'undefined' && document.fullscreenElement) {
1002
+ document.exitFullscreen?.()
1003
+ setDetailFull(false)
1004
+ return
1005
+ }
1006
+ const el = (typeof document !== 'undefined' && document.querySelector('.zts-root')) || null
1007
+ if (el && typeof el.requestFullscreen === 'function') {
1008
+ const entered = el.requestFullscreen({ navigationUI: 'hide' })
1009
+ setDetailFull(true) // 立即铺满,不等 fullscreenchange(ESC 退出时由事件同步回来)
1010
+ if (entered && typeof entered.catch === 'function') entered.catch(() => setDetailFull((prev) => !prev))
1011
+ return
1012
+ }
1013
+ } catch { /* 落到下面兜底 */ }
1014
+ setDetailFull((prev) => !prev)
1015
+ }
1016
+
1017
+ // 面板卸载(切走插件页/关闭)时退出全屏,避免把全屏状态带走
1018
+ React.useEffect(() => () => {
1019
+ try { if (document.fullscreenElement?.classList?.contains('zts-root')) document.exitFullscreen?.() } catch { /* 忽略 */ }
1020
+ }, [])
1021
+
1022
+ const openDetail = React.useCallback(async (kind, id) => {
1023
+ const seq = ++detailSeq
1024
+ setSelectedId(id)
1025
+ setDetail({ status: 'loading' })
1026
+ setOps([])
1027
+ setOpsState(kind === 'bugs' ? 'loading' : 'idle')
1028
+ setDraft('')
1029
+ setNotice(null)
1030
+ // 操作弹窗与图片预览都绑定在具体条目上,改选条目必须关掉,避免误提交到别的 BUG
1031
+ setModal(null)
1032
+ setGallery(null)
1033
+ setOpen({ steps: true, info: false, handle: false, relations: false, timeline: false, remark: false })
1034
+ try {
1035
+ const action = kind === 'bugs' ? 'bugfull' : kind === 'tasks' ? 'taskfull' : 'storyfull'
1036
+ const result = await callHost(connection, action, { id })
1037
+ if (seq !== detailSeq) return // 已有更新的请求,丢弃这次结果(防竞态)
1038
+ if (result && result.sid) setZenTaoSid(result.sid)
1039
+ if (result && result.ok === true) setDetail({ status: 'ready', kind, data: result })
1040
+ else setDetail({ status: 'error', error: (result && result.error) || '详情获取失败' })
1041
+ if (kind === 'bugs') {
1042
+ const opsResult = await callHost(connection, 'bugops', { id })
1043
+ if (seq !== detailSeq) return
1044
+ const okOps = opsResult && opsResult.ok !== false && Array.isArray(opsResult.ops)
1045
+ setOps(okOps ? opsResult.ops : [])
1046
+ setOpsState(okOps ? 'ready' : 'error')
1047
+ }
1048
+ } catch (error) {
1049
+ if (seq !== detailSeq) return
1050
+ setDetail({ status: 'error', error: String((error && error.message) || error) })
1051
+ setOpsState(kind === 'bugs' ? 'error' : 'idle')
1052
+ }
1053
+ }, [connection])
1054
+
1055
+ // 列表就绪后默认选中当前页签的第一条(BUG 页签看 BUG,任务页签看任务)
1056
+ React.useEffect(() => {
1057
+ if (state.status !== 'ready' || selectedId) return
1058
+ const source = state[tab] || []
1059
+ const first = source[0]
1060
+ if (first) openDetail(tab, String(first.id))
1061
+ }, [state, selectedId, tab, openDetail])
1062
+
1063
+ /** 切换页签:详情必须整体重置,否则会残留上一个页签的内容。 */
1064
+ const switchTab = (next) => {
1065
+ if (next === tab) return
1066
+ setTab(next)
1067
+ setSelectedId('')
1068
+ setFilter('all')
1069
+ setDetail({ status: 'idle' })
1070
+ setOps([])
1071
+ setOpsState('idle')
1072
+ setDraft('')
1073
+ setNotice(null)
1074
+ setModal(null)
1075
+ setGallery(null)
1076
+ // 全屏态若不退出,列表不渲染、只剩一个小按钮能救回来
1077
+ setDetailFull(false)
1078
+ setOpen({ steps: true, info: false, handle: false, relations: false, timeline: false, remark: false })
1079
+ }
1080
+
1081
+ const refreshAll = async (text) => {
1082
+ await loadLists(scopes)
1083
+ if (selectedId) await openDetail(tab, selectedId)
1084
+ if (text) setNotice({ text })
1085
+ }
1086
+
1087
+ const runComment = async () => {
1088
+ const value = draft.trim()
1089
+ if (!value) { setNotice({ fail: true, text: '备注内容不能为空' }); return }
1090
+ setBusy('comment')
1091
+ try {
1092
+ const result = await callHost(connection, 'comment', { id: selectedId, comment: value })
1093
+ if (result && result.ok === false) setNotice({ fail: true, text: `失败:${result.error}` })
1094
+ else await refreshAll(`已给 #${selectedId} 添加备注`)
1095
+ } catch (error) {
1096
+ setNotice({ fail: true, text: `失败:${String((error && error.message) || error)}` })
1097
+ } finally { setBusy('') }
1098
+ }
1099
+
1100
+ // ── 与对话联动:复制 / 拖拽 / 按角色发到对话 ────────────────────────────
1101
+ const roleLabel = (ROLES.find(([key]) => key === role) || ['dev', '开发'])[1]
1102
+ const changeRole = (value) => {
1103
+ setRole(value)
1104
+ try { window.localStorage.setItem(ROLE_KEY, value) } catch { /* 忽略 */ }
1105
+ }
1106
+ const changeScope = (kind, value) => setScopes((prev) => (prev[kind] === value ? prev : { ...prev, [kind]: value }))
1107
+
1108
+ /** 当前列表里对应的那一行:详情缺字段时(如需求的产品名)用它兜底。 */
1109
+ const listRowOf = (kind, id) => (state[kind] || []).find((item) => String(item.id) === String(id)) || {}
1110
+
1111
+ /** 列表行 → Markdown 引用(拖拽与复制入口用)。 */
1112
+ const rowMarkdown = (kind, row) => itemMarkdown(kind, row, { url: itemUrl(kind, row.id) })
1113
+
1114
+ /** 当前详情 → Markdown 引用(含相关人真名、模块路径、完整描述)。 */
1115
+ const detailMarkdown = () => {
1116
+ if (detail.status !== 'ready') return ''
1117
+ const kind = detail.kind
1118
+ const data = detail.data || {}
1119
+ const record = kind === 'bugs' ? data.bug : kind === 'tasks' ? data.task : data.story
1120
+ if (!record) return ''
1121
+ return itemMarkdown(kind, record, {
1122
+ url: itemUrl(kind, record.id),
1123
+ users: data.users || {},
1124
+ // 详情接口对需求只回产品 id(product:"55"),产品名只有列表行有 → 回落到列表行
1125
+ productName: data.productName || record.productTitle || listRowOf(kind, record.id).productTitle,
1126
+ projectName: data.projectName,
1127
+ modulePath: data.modulePath,
1128
+ })
1129
+ }
1130
+
1131
+ const onRowDragStart = (event, kind, row) => {
1132
+ try {
1133
+ event.dataTransfer.effectAllowed = 'copy'
1134
+ event.dataTransfer.setData('text/plain', rowMarkdown(kind, row))
1135
+ } catch { /* 忽略 */ }
1136
+ }
1137
+
1138
+ const doCopy = async (text, label) => {
1139
+ if (!text) { setNotice({ fail: true, text: '暂时没有可复制的内容' }); return }
1140
+ const ok = await copyText(text)
1141
+ setNotice(ok
1142
+ ? { text: `${label}已复制为 Markdown,粘贴到聊天框即可提问` }
1143
+ : { fail: true, text: '复制失败,请手动选中文本复制' })
1144
+ }
1145
+
1146
+ /** 按角色把条目送进对话:新建/复用当前项目的会话并发送提示词。 */
1147
+ const doHandle = async (text) => {
1148
+ if (!text) return
1149
+ let handler = props.runtime && props.runtime.handlePrompt
1150
+ // 懒重试:apply 时服务可能还没就绪(例如对话插件后加载),点击时再解析一次
1151
+ if (typeof handler !== 'function' && props.runtime && typeof props.runtime.build === 'function') {
1152
+ try {
1153
+ props.runtime.handlePrompt = props.runtime.build()
1154
+ handler = props.runtime.handlePrompt
1155
+ } catch { /* 落到下面的复制兜底 */ }
1156
+ }
1157
+ if (typeof handler !== 'function') {
1158
+ await doCopy(text, `「${roleLabel}」提示词`)
1159
+ setNotice({ text: `当前宿主不支持自动新建对话,「${roleLabel}」提示词已复制,粘贴后发送即可` })
1160
+ return
1161
+ }
1162
+ setBusyHint('处理中…')
1163
+ try {
1164
+ await handler(text)
1165
+ setNotice({ text: `已按【${roleLabel}】新建对话并发送,切到会话查看` })
1166
+ } catch (error) {
1167
+ const message = String((error && error.message) || error)
1168
+ const ok = await copyText(text)
1169
+ setNotice({ fail: true, text: `自动发送失败(${message})${ok ? ';提示词已复制,粘贴后发送即可' : ''}` })
1170
+ } finally { setBusyHint('') }
1171
+ }
1172
+
1173
+ /** 角色提示词 + 条目内容:与复制/拖拽用的是同一份 Markdown。 */
1174
+ const handlePromptText = (markdown) => `${ROLE_INTRO[role] || ROLE_INTRO.dev}\n\n${markdown}`
1175
+
1176
+ const openAction = async (op) => {
1177
+ const id = selectedId
1178
+ const record = (detail.status === 'ready' ? (detail.data.bug || detail.data.task || detail.data.story) : {}) || {}
1179
+ const meta = {
1180
+ op,
1181
+ title: OP_TITLE[op] || (OP_LABEL[op] || op),
1182
+ // forId 是提交目标,必须与 bugId 展示串分开:弹窗打开后用户可能改选列表项,
1183
+ // 提交必须落在弹窗自己的 BUG 上,而不是「当前选中项」。
1184
+ forId: id,
1185
+ bugId: id ? `#${id}` : '',
1186
+ bugTitle: record.title || record.name || '',
1187
+ actions: Array.isArray(detail.data && detail.data.actions) ? detail.data.actions : [],
1188
+ }
1189
+ setModal({ ...meta, fields: [], values: {}, busy: true })
1190
+ try {
1191
+ const form = await callHost(connection, 'bugform', { id, op })
1192
+ if (!form || form.ok === false) throw new Error((form && form.error) || '表单获取失败')
1193
+ const values = {}
1194
+ for (const field of form.fields || []) {
1195
+ if (field.type === 'checkbox') {
1196
+ // 勾选框默认不选中(除非禅道表单里本来就 checked)
1197
+ values[field.name] = field.defaultChecked ? (field.value || '1') : ''
1198
+ } else if (field.name === 'comment') {
1199
+ // 三段式只用于「解决」(团队惯例);确认等操作保持空白,避免把模板文字误提交上去
1200
+ values[field.name] = field.value || (op === 'resolve' ? REMARK_TEMPLATE : '')
1201
+ } else {
1202
+ values[field.name] = field.value ?? ''
1203
+ }
1204
+ }
1205
+ setModal({ ...meta, fields: form.fields || [], values, busy: false, error: '' })
1206
+ } catch (error) {
1207
+ setModal({ ...meta, fields: [], values: {}, busy: false, error: String((error && error.message) || error) })
1208
+ }
1209
+ }
1210
+
1211
+ const submitAction = async () => {
1212
+ if (!modal) return
1213
+ if (modal.op === 'resolve' && !modal.values.resolution) { setModal({ ...modal, error: '请选择解决方案' }); return }
1214
+ setModal({ ...modal, busy: true, error: '' })
1215
+ try {
1216
+ const targetId = modal.forId || selectedId
1217
+ const result = await callHost(connection, 'bugaction', { id: targetId, op: modal.op, values: modal.values })
1218
+ if (result && result.ok === false) { setModal({ ...modal, busy: false, error: result.error || '提交失败' }); return }
1219
+ const inner = result && result.result
1220
+ // 禅道拒绝提交时会在返回页里给 alert(如缺必填项)。alert 非空 = 被拒,绝不能当成功。
1221
+ if (inner && inner.alert) {
1222
+ setModal({ ...modal, busy: false, error: `禅道拒绝了这次操作:${inner.alert}` })
1223
+ return
1224
+ }
1225
+ // 回读证实真的生效(禅道没报错 ≠ 一定写进去了)
1226
+ let verifyFail = ''
1227
+ try {
1228
+ const after = await callHost(connection, 'bugfull', { id: targetId })
1229
+ const record = (after && after.ok === true) ? (after.bug || {}) : {}
1230
+ if (modal.op === 'resolve' && String(record.status) !== 'resolved') {
1231
+ verifyFail = '禅道未报错,但回读发现状态未变成「已解决」,操作可能未生效'
1232
+ }
1233
+ } catch { /* 回读失败不阻塞成功提示 */ }
1234
+ setModal(null)
1235
+ await refreshAll(`${OP_LABEL[modal.op] || modal.op}成功(#${targetId})${verifyFail ? ` ⚠ ${verifyFail}` : ''}`)
1236
+ } catch (error) {
1237
+ setModal({ ...modal, busy: false, error: String((error && error.message) || error) })
1238
+ }
1239
+ }
1240
+
1241
+ const doLogin = async () => {
1242
+ setLoginBusy(true)
1243
+ setLoginError('')
1244
+ try {
1245
+ const result = await callHost(connection, 'login', {
1246
+ baseUrl: loginForm.baseUrl.trim(),
1247
+ account: loginForm.account.trim(),
1248
+ password: loginForm.password,
1249
+ })
1250
+ if (result && result.ok === false) { setLoginError(result.error || '登录失败'); return }
1251
+ setLoginForm((prev) => ({ ...prev, password: '' }))
1252
+ await checkAuth()
1253
+ } catch (error) {
1254
+ setLoginError(String((error && error.message) || error))
1255
+ } finally { setLoginBusy(false) }
1256
+ }
1257
+
1258
+ const doLogout = async () => {
1259
+ if (!window.confirm('退出登录将清除已保存的禅道密码,确定吗?')) return
1260
+ const result = await callHost(connection, 'logout', {})
1261
+ if (result && result.ok === false) {
1262
+ setNotice({ fail: true, text: `退出失败:${result.error}` })
1263
+ return
1264
+ }
1265
+ setState({ status: 'idle' })
1266
+ setSelectedId('')
1267
+ setDetail({ status: 'idle' })
1268
+ setOps([])
1269
+ setOpsState('idle')
1270
+ setModal(null)
1271
+ setGallery(null)
1272
+ setDetailFull(false)
1273
+ setDraft('')
1274
+ setZenTaoSid('')
1275
+ setLoginForm((prev) => ({ ...prev, password: '' }))
1276
+ await checkAuth()
1277
+ }
1278
+
1279
+ // ── 登录卡片 ────────────────────────────────────────────────────────────
1280
+ if (auth.status === 'loading') {
1281
+ return h('div', { className: `zts-root ${theme === 'dark' ? 'zts-dark' : ''}` },
1282
+ h('div', { className: 'zts-empty' }, '正在检查禅道登录状态…'))
1283
+ }
1284
+ if (!auth.authenticated) {
1285
+ return h('div', { className: `zts-root ${theme === 'dark' ? 'zts-dark' : ''}` }, [
1286
+ h('div', { key: 'top', className: 'zts-top' }, [
1287
+ h('div', { key: 'brand', className: 'zts-brand' }, [h('span', { key: 'm', className: 'zts-mark' }, '禅'), h('span', { key: 'n' }, '禅道 · 登录')]),
1288
+ h('span', { key: 'sp', className: 'zts-sp' }),
1289
+ h('span', { key: 'c', className: 'zts-conn err' }, [h('i', { key: 'd' }), '未登录']),
1290
+ ]),
1291
+ h('div', { key: 'card', className: 'zts-login' }, [
1292
+ h('h3', { key: 't' }, '连接你的禅道'),
1293
+ h('p', { key: 'p' }, '首次使用需要登录一次。凭据会保存到 DSH 凭据库(~/.dsh/.credentials.yaml),之后免登录。'),
1294
+ loginError ? h('div', { key: 'e', className: 'zts-notice fail' }, loginError) : null,
1295
+ auth.error && !loginError ? h('div', { key: 'ae', className: 'zts-notice fail' }, `当前状态:${auth.error}`) : null,
1296
+ h('div', { key: 'f1', className: 'row' }, [
1297
+ h('label', { key: 'l' }, '禅道地址'),
1298
+ h('input', {
1299
+ key: 'i', className: 'zts-input', value: loginForm.baseUrl,
1300
+ placeholder: 'https://your-zentao.example.com',
1301
+ onChange: (event) => setLoginForm((prev) => ({ ...prev, baseUrl: event.target.value })),
1302
+ }),
1303
+ ]),
1304
+ h('div', { key: 'f2', className: 'row' }, [
1305
+ h('label', { key: 'l' }, '账号'),
1306
+ h('input', {
1307
+ key: 'i', className: 'zts-input', value: loginForm.account,
1308
+ onChange: (event) => setLoginForm((prev) => ({ ...prev, account: event.target.value })),
1309
+ }),
1310
+ ]),
1311
+ h('div', { key: 'f3', className: 'row' }, [
1312
+ h('label', { key: 'l' }, '密码'),
1313
+ h('input', {
1314
+ key: 'i', className: 'zts-input', type: 'password', value: loginForm.password,
1315
+ onChange: (event) => setLoginForm((prev) => ({ ...prev, password: event.target.value })),
1316
+ onKeyDown: (event) => { if (event.key === 'Enter') doLogin() },
1317
+ }),
1318
+ ]),
1319
+ h('div', { key: 'foot', className: 'foot' }, [
1320
+ h('button', { key: 'b', type: 'button', className: 'zts-btn pri', disabled: loginBusy, onClick: doLogin }, loginBusy ? '登录中…' : '登录并保存'),
1321
+ h('span', { key: 'h', style: { fontSize: 11, color: 'var(--dim)' } }, '账号需有该禅道的访问权限'),
1322
+ ]),
1323
+ ]),
1324
+ ])
1325
+ }
1326
+
1327
+ // ── 主面板 ──────────────────────────────────────────────────────────────
1328
+ const filtered = items.filter((item) => {
1329
+ if (query) {
1330
+ const text = `${item.id} ${item.title || item.name || ''}`.toLowerCase()
1331
+ if (!text.includes(query.toLowerCase())) return false
1332
+ }
1333
+ if (filter === 'all') return true
1334
+ if (filter === 'confirmed') return String(item.confirmed) === '1'
1335
+ if (filter === 'unconfirmed') return String(item.confirmed) !== '1'
1336
+ return item.status === filter
1337
+ })
1338
+
1339
+ // 统计卡:每张对应一个筛选条件,点击即筛选(再点一次取消)
1340
+ const countBy = (rows, key, value) => rows.filter((row) => row.status === value).length
1341
+ const scopeText = `范围:${scopeLabel(tab)}`
1342
+ const stats = tab === 'bugs'
1343
+ ? [
1344
+ { key: 'all', k: '我的 BUG', v: bugs.length, s: scopeText },
1345
+ { key: 'unconfirmed', k: '未确认', v: bugs.filter((b) => String(b.confirmed) !== '1').length, s: '需要你确认', tone: 'amber' },
1346
+ { key: 'confirmed', k: '已确认', v: bugs.filter((b) => String(b.confirmed) === '1').length, s: '已进入处理流程', tone: 'green' },
1347
+ { key: 'resolved', k: '已解决', v: countBy(bugs, 'status', 'resolved'), s: '等待验证或关闭' },
1348
+ ]
1349
+ : tab === 'tasks'
1350
+ ? [
1351
+ { key: 'all', k: '我的任务', v: tasks.length, s: scopeText },
1352
+ { key: 'doing', k: '进行中', v: countBy(tasks, 'status', 'doing'), s: '正在处理', tone: 'blue' },
1353
+ { key: 'wait', k: '未开始', v: countBy(tasks, 'status', 'wait'), s: '尚未启动' },
1354
+ { key: 'done', k: '已完成', v: countBy(tasks, 'status', 'done'), s: '已交付', tone: 'green' },
1355
+ ]
1356
+ : [
1357
+ { key: 'all', k: '我的需求', v: stories.length, s: scopeText },
1358
+ { key: 'active', k: '激活', v: countBy(stories, 'status', 'active'), s: '正在推进', tone: 'amber' },
1359
+ { key: 'reviewing', k: '评审中', v: countBy(stories, 'status', 'reviewing'), s: '等待评审', tone: 'blue' },
1360
+ { key: 'closed', k: '已关闭', v: countBy(stories, 'status', 'closed'), s: '已结束' },
1361
+ ]
1362
+
1363
+ const filters = tab === 'bugs'
1364
+ ? [['all', '全部'], ['unconfirmed', '未确认'], ['confirmed', '已确认'], ['resolved', '已解决']]
1365
+ : tab === 'tasks'
1366
+ ? [['all', '全部'], ['doing', '进行中'], ['wait', '未开始'], ['done', '已完成']]
1367
+ : [['all', '全部'], ['active', '激活'], ['reviewing', '评审中'], ['closed', '已关闭']]
1368
+ const activeFilterLabel = (filters.find(([value]) => value === filter) || ['all', '全部'])[1]
1369
+ const applyFilter = (key) => setFilter((prev) => (prev === key ? 'all' : key))
1370
+
1371
+ const emptyText = () => {
1372
+ if (items.length) return '没有匹配的条目。'
1373
+ return `没有「${scopeLabel(tab)}」的${KIND_LABEL[tab]}。`
1374
+ }
1375
+ const list = filtered.length === 0
1376
+ ? h('div', { className: 'zts-empty' }, state.status === 'error' ? `连接失败:${state.error}` : emptyText())
1377
+ : filtered.map((item) => {
1378
+ const id = String(item.id)
1379
+ const isBug = tab === 'bugs'
1380
+ const status = (STATUS_MAP[tab] || {})[item.status] || item.status
1381
+ return h('div', {
1382
+ key: `${tab}-${id}`,
1383
+ className: `zts-item${selectedId === id ? ' on' : ''}`,
1384
+ draggable: true,
1385
+ title: '点击查看详情;按住拖到聊天输入框可插入 Markdown 引用',
1386
+ onDragStart: (event) => onRowDragStart(event, tab, item),
1387
+ onClick: () => openDetail(tab, id),
1388
+ }, [
1389
+ h('div', { key: 't', className: 'zts-item-t' }, item.title || item.name || '(无标题)'),
1390
+ h('div', { key: 'm', className: 'zts-item-m' }, [
1391
+ h('span', { key: 'id', className: 'zts-mono' }, `#${id}`),
1392
+ Chip(status, STATUS_TONE[item.status], 'st'),
1393
+ item.pri ? Chip(`P${item.pri}`, '', 'pri') : null,
1394
+ isBug && String(item.confirmed) === '1' ? Chip('已确认', 'blue', 'cf') : null,
1395
+ tab === 'stories' && item.stage ? Chip(STORY_STAGE[item.stage] || item.stage, '', 'stage') : null,
1396
+ h('span', { key: 'who' }, item.assignedTo || ''),
1397
+ h('span', { key: 'd' }, String(item.openedDate || '').slice(5, 16)),
1398
+ h('button', {
1399
+ key: 'cp', type: 'button', className: 'zts-iconbtn mini', title: '复制为 Markdown 引用',
1400
+ onClick: (event) => { event.stopPropagation(); doCopy(rowMarkdown(tab, item), `#${id} `) },
1401
+ }, '⧉'),
1402
+ ]),
1403
+ ])
1404
+ })
1405
+
1406
+ // ── 详情 ────────────────────────────────────────────────────────────────
1407
+ let detailBody
1408
+ if (!selectedId) {
1409
+ detailBody = h('div', { className: 'zts-empty' }, '从左侧选择一条 BUG、任务或需求查看完整详情。')
1410
+ } else if (detail.status === 'loading') {
1411
+ detailBody = [0, 1, 2, 3].map((n) => h('div', { key: n, className: 'zts-skeleton' }))
1412
+ } else if (detail.status === 'error') {
1413
+ detailBody = h('div', { className: 'zts-empty' }, `详情加载失败:${detail.error}`)
1414
+ } else if (detail.status === 'ready') {
1415
+ const data = detail.data
1416
+ const isBug = detail.kind === 'bugs'
1417
+ const isStory = detail.kind === 'stories'
1418
+ const record = isBug ? data.bug : isStory ? data.story : data.task
1419
+ const users = data.users || {}
1420
+ const name = (account) => (account ? (users[account] || account) : '')
1421
+ const title = record.title || record.name
1422
+ const confirmed = String(record.confirmed) === '1'
1423
+ const listRow = listRowOf(detail.kind, record.id)
1424
+ const node = []
1425
+ const actionButtons = []
1426
+
1427
+ if (isBug) {
1428
+ // 操作按钮只在成功取到禅道操作列表后渲染:取不到就不猜,避免在已解决 BUG 上给出「确认」
1429
+ const available = ops.filter((op) => op !== 'edit')
1430
+ const primaryOp = confirmed ? 'resolve' : 'confirmBug'
1431
+ // 已确认的 BUG 不再提供「确认」,与禅道操作栏一致
1432
+ const rest = available.filter((op) => op !== primaryOp && OP_LABEL[op] && !(op === 'confirmBug' && confirmed))
1433
+ if (opsState === 'ready' && available.includes(primaryOp)) {
1434
+ actionButtons.push(h('button', {
1435
+ key: 'primary', type: 'button', className: 'zts-btn pri', onClick: () => openAction(primaryOp),
1436
+ }, OP_LABEL[primaryOp]))
1437
+ }
1438
+ for (const op of rest) {
1439
+ actionButtons.push(h('button', {
1440
+ key: op, type: 'button', className: 'zts-btn', onClick: () => openAction(op),
1441
+ }, OP_LABEL[op]))
1442
+ }
1443
+ if (opsState === 'error') {
1444
+ actionButtons.push(h('span', { key: 'opserr', className: 'zts-fhint' }, '未取到可用操作(可在禅道网页中操作)'))
1445
+ }
1446
+ }
1447
+ // 对话联动:与列表拖拽/复制共用同一份 Markdown(itemMarkdown)
1448
+ const markdown = detailMarkdown()
1449
+ actionButtons.push(h('button', {
1450
+ key: 'drag', type: 'button', className: 'zts-btn', draggable: true,
1451
+ title: '按住拖到聊天输入框,释放即插入可编辑的 Markdown 引用',
1452
+ onDragStart: (event) => {
1453
+ try {
1454
+ event.dataTransfer.effectAllowed = 'copy'
1455
+ event.dataTransfer.setData('text/plain', markdown)
1456
+ } catch { /* 忽略 */ }
1457
+ },
1458
+ }, '⠿ 拖拽引用'))
1459
+ actionButtons.push(h('button', {
1460
+ key: 'copy', type: 'button', className: 'zts-btn',
1461
+ onClick: () => doCopy(markdown, `${KIND_LABEL[detail.kind]} #${record.id}`),
1462
+ }, '复制'))
1463
+ actionButtons.push(h('button', {
1464
+ key: 'handle', type: 'button', className: 'zts-btn pri', disabled: busyHint !== '',
1465
+ title: `按【${roleLabel}】角色新建对话并发送这条${KIND_LABEL[detail.kind]}`,
1466
+ onClick: () => doHandle(handlePromptText(markdown)),
1467
+ }, busyHint || `按【${roleLabel}】处理`))
1468
+ actionButtons.push(h('a', {
1469
+ key: 'open', className: 'zts-btn link',
1470
+ href: itemUrl(detail.kind, record.id), target: '_blank', rel: 'noreferrer',
1471
+ title: zenTaoSid ? '已附带会话参数,浏览器里免登录打开' : '打开禅道页面',
1472
+ }, `在禅道中打开 ↗${zenTaoSid ? '(免登录)' : ''}`))
1473
+
1474
+ node.push(h('div', { key: 'hd', className: 'zts-dh' }, [
1475
+ h('h2', { key: 't' }, title || '(无标题)'),
1476
+ Chip((STATUS_MAP[detail.kind] || {})[record.status] || record.status, STATUS_TONE[record.status], 'st'),
1477
+ isStory && record.stage ? Chip(STORY_STAGE[record.stage] || record.stage, '', 'stage') : null,
1478
+ isBug && confirmed ? Chip('已确认', 'blue', 'cf') : null,
1479
+ isBug && record.resolution ? Chip(BUG_RESOLUTION[record.resolution] || record.resolution, 'green', 'res') : null,
1480
+ ]))
1481
+ node.push(h('div', { key: 'acts', className: 'zts-acts' }, actionButtons))
1482
+
1483
+ // 重现步骤 / 描述:默认展开,可点击折叠
1484
+ const rich = isBug ? record.steps : isStory ? (record.spec || record.desc) : (record.desc || record.storySpec)
1485
+ if (rich) {
1486
+ node.push(Section('steps', isBug ? '重现步骤' : isStory ? '需求描述' : '任务描述', open.steps !== false,
1487
+ () => setOpen((prev) => ({ ...prev, steps: !(prev.steps ?? true) })),
1488
+ [h('div', {
1489
+ key: 'steps', className: 'zts-steps',
1490
+ onClick: onImageClick,
1491
+ dangerouslySetInnerHTML: { __html: sanitizeSteps(rich) },
1492
+ })]))
1493
+ }
1494
+
1495
+ if (isBug) {
1496
+ node.push(Section('info', '基本信息', open.info, () => setOpen((prev) => ({ ...prev, info: !prev.info })), [
1497
+ h('div', { key: 'g', className: 'zts-grid' }, [
1498
+ Field('产品', data.productName), Field('项目', data.projectName), Field('模块', data.modulePath),
1499
+ Field('类型', BUG_TYPE[record.type] || record.type), Field('严重度', SEVERITY[record.severity] || record.severity),
1500
+ Field('优先级', PRIORITY[record.pri] || record.pri),
1501
+ Field('关键词', record.keywords), Field('操作系统', record.os), Field('浏览器', record.browser),
1502
+ Field('硬件平台', record.hardware), Field('来源', record.found), Field('创建构建', record.openedBuild),
1503
+ ]),
1504
+ ]))
1505
+ node.push(Section('handle', '处理信息', open.handle, () => setOpen((prev) => ({ ...prev, handle: !prev.handle })), [
1506
+ h('div', { key: 'g', className: 'zts-grid' }, [
1507
+ Field('创建者', name(record.openedBy)), Field('创建时间', record.openedDate, true), Field('指派给', name(record.assignedTo)),
1508
+ Field('指派时间', record.assignedDate, true), Field('截止日期', /^0+[-0]*$/.test(String(record.deadline || '')) ? '' : record.deadline, true),
1509
+ Field('激活次数', record.activatedCount),
1510
+ Field('解决者', name(record.resolvedBy)), Field('解决时间', record.resolvedDate, true),
1511
+ Field('解决方案', BUG_RESOLUTION[record.resolution] || record.resolution),
1512
+ Field('关闭者', name(record.closedBy)), Field('关闭时间', record.closedDate, true), Field('最后修改', record.lastEditedDate, true),
1513
+ ]),
1514
+ ]))
1515
+ const relations = [
1516
+ ['需求', record.storyTitle || record.story], ['任务', record.taskName || record.task],
1517
+ ['计划', record.planName || record.plan], ['用例', record.case],
1518
+ ['重复BUG', record.duplicateBug], ['相关BUG', record.linkBug],
1519
+ ['反馈', record.feedback], ['仓库提交', record.entry || record.repo],
1520
+ ].filter(([, value]) => value && String(value) !== '0')
1521
+ if (relations.length) {
1522
+ node.push(Section('relations', '关联对象', open.relations, () => setOpen((prev) => ({ ...prev, relations: !prev.relations })), [
1523
+ h('div', { key: 'g', className: 'zts-grid' }, relations.map(([k, v]) => Field(k, v, false, `rel-${k}`))),
1524
+ ], relations.length))
1525
+ }
1526
+ } else if (isStory) {
1527
+ node.push(Section('info', '基本信息', open.info, () => setOpen((prev) => ({ ...prev, info: !prev.info })), [
1528
+ h('div', { key: 'g', className: 'zts-grid' }, [
1529
+ Field('产品', data.productName || record.productTitle || listRow.productTitle), Field('模块', data.modulePath),
1530
+ Field('优先级', PRIORITY[record.pri] || record.pri),
1531
+ Field('状态', STORY_STATUS[record.status] || record.status),
1532
+ Field('所处阶段', STORY_STAGE[record.stage] || record.stage),
1533
+ Field('来源', record.source), Field('来源备注', record.sourceNote),
1534
+ Field('计划', record.planTitle || record.plan), Field('版本', record.version),
1535
+ ]),
1536
+ ]))
1537
+ node.push(Section('handle', '相关人与时间', open.handle, () => setOpen((prev) => ({ ...prev, handle: !prev.handle })), [
1538
+ h('div', { key: 'g', className: 'zts-grid' }, [
1539
+ Field('创建者', name(record.openedBy)), Field('创建时间', record.openedDate, true),
1540
+ Field('指派给', name(record.assignedTo)), Field('指派时间', record.assignedDate, true),
1541
+ Field('评审人', String(record.reviewedBy || '').split(',').filter(Boolean).map(name).join('、')),
1542
+ Field('评审时间', record.reviewedDate, true),
1543
+ Field('关闭者', name(record.closedBy)), Field('关闭时间', record.closedDate, true),
1544
+ Field('最后修改', record.lastEditedDate, true),
1545
+ ]),
1546
+ ]))
1547
+ const storyRelations = [
1548
+ ['父需求', record.parentName], ['子需求', record.childStories], ['关联需求', record.linkStories],
1549
+ ['转BUG', record.toBug], ['来自BUG', record.fromBug], ['反馈', record.feedback],
1550
+ ].filter(([, value]) => value && String(value) !== '0')
1551
+ if (storyRelations.length) {
1552
+ node.push(Section('relations', '关联对象', open.relations, () => setOpen((prev) => ({ ...prev, relations: !prev.relations })), [
1553
+ h('div', { key: 'g', className: 'zts-grid' }, storyRelations.map(([k, v]) => Field(k, v, false, `rel-${k}`))),
1554
+ ], storyRelations.length))
1555
+ }
1556
+ } else {
1557
+ node.push(Section('info', '基本信息', open.info, () => setOpen((prev) => ({ ...prev, info: !prev.info })), [
1558
+ h('div', { key: 'g', className: 'zts-grid' }, [
1559
+ Field('项目', data.projectName), Field('模块', data.modulePath), Field('类型', TASK_TYPE[record.type] || record.type),
1560
+ Field('优先级', PRIORITY[record.pri] || record.pri), Field('状态', TASK_STATUS[record.status] || record.status),
1561
+ Field('所属需求', record.storyTitle || record.story), Field('父任务', record.parentName),
1562
+ ]),
1563
+ ]))
1564
+ node.push(Section('handle', '工时与时间', open.handle, () => setOpen((prev) => ({ ...prev, handle: !prev.handle })), [
1565
+ h('div', { key: 'g', className: 'zts-grid' }, [
1566
+ Field('预计工时', record.estimate), Field('已耗工时', record.consumed), Field('剩余工时', record.left),
1567
+ Field('截止日期', /^0+[-0]*$/.test(String(record.deadline || '')) ? '' : record.deadline, true),
1568
+ Field('预计开始', /^0+[-0]*$/.test(String(record.estStarted || '')) ? '' : record.estStarted, true),
1569
+ Field('实际开始', record.realStarted, true),
1570
+ Field('创建者', name(record.openedBy)), Field('创建时间', record.openedDate, true), Field('指派时间', record.assignedDate, true),
1571
+ Field('完成者', name(record.finishedBy)), Field('完成时间', record.finishedDate, true), Field('最后修改', record.lastEditedDate, true),
1572
+ ]),
1573
+ ]))
1574
+ }
1575
+
1576
+ if (Array.isArray(data.actions) && data.actions.length) {
1577
+ node.push(Section('timeline', '操作历史', open.timeline, () => setOpen((prev) => ({ ...prev, timeline: !prev.timeline })), [
1578
+ h('div', { key: 'tl', className: 'zts-tl' }, data.actions.map((item) => h('div', {
1579
+ key: item.id || `${item.date}-${item.action}`, className: 'zts-tl-row',
1580
+ }, [
1581
+ h('span', { key: 'w', className: 'zts-tl-who' }, item.actorName || item.actor),
1582
+ h('span', { key: 'a', className: 'zts-tl-act' }, ACTION[item.action] || item.action),
1583
+ h('span', { key: 'd', className: 'zts-tl-date' }, item.date),
1584
+ item.comment ? h('div', {
1585
+ key: 'c', className: 'zts-tl-comment',
1586
+ // 备注里可直接贴图(禅道编辑器支持),按 HTML 渲染并接上图片预览
1587
+ onClick: onImageClick,
1588
+ dangerouslySetInnerHTML: { __html: sanitizeSteps(item.comment) },
1589
+ }) : null,
1590
+ ]))),
1591
+ ], data.actions.length))
1592
+ }
1593
+
1594
+ if (isBug && String(record.status) !== 'closed') {
1595
+ node.push(Section('remark', '添加备注', open.remark, () => setOpen((prev) => ({ ...prev, remark: !prev.remark })), [
1596
+ h('div', { key: 'ed', className: 'zts-editor' }, [
1597
+ h('div', { key: 'h', className: 'eh' }, '提交后公示于禅道操作历史;该版本禅道不支持删除备注。'),
1598
+ h('textarea', {
1599
+ key: 'ta', className: 'zts-textarea', value: draft, placeholder: '写下你的备注…',
1600
+ onChange: (event) => setDraft(event.target.value),
1601
+ }),
1602
+ h('div', { key: 'f', className: 'ef' }, [
1603
+ h('button', { key: 'clr', type: 'button', className: 'zts-btn', onClick: () => setDraft('') }, '清空'),
1604
+ h('span', { key: 'sp', className: 'zts-sp' }),
1605
+ h('span', { key: 'n', style: { fontSize: 11, color: 'var(--dim)' } }, `${draft.length} 字`),
1606
+ h('button', { key: 'send', type: 'button', className: 'zts-btn pri', disabled: busy === 'comment', onClick: runComment },
1607
+ busy === 'comment' ? '提交中…' : '提交备注'),
1608
+ ]),
1609
+ ]),
1610
+ ]))
1611
+ }
1612
+
1613
+ detailBody = node
1614
+ }
1615
+
1616
+ const loading = state.status === 'loading'
1617
+
1618
+ return h('div', { className: `zts-root ${theme === 'dark' ? 'zts-dark' : ''}` }, [
1619
+ h('div', { key: 'top', className: 'zts-top' }, [
1620
+ h('div', { key: 'brand', className: 'zts-brand' }, [
1621
+ h('span', { key: 'mark', className: 'zts-mark' }, '禅'),
1622
+ h('span', { key: 'name' }, '禅道 · 我的工作'),
1623
+ ]),
1624
+ baseUrl ? h('span', { key: 'crumb', className: 'zts-crumb' }, String(baseUrl).replace(/^https?:\/\//, '')) : null,
1625
+ auth.account ? h('span', { key: 'acct', className: 'zts-crumb' }, `· ${auth.account}`) : null,
1626
+ h('span', { key: 'sp', className: 'zts-sp' }),
1627
+ h('span', { key: 'conn', className: `zts-conn${loading ? ' syncing' : (state.status === 'error' ? ' err' : '')}` },
1628
+ [h('i', { key: 'd' }), state.status === 'error' ? '未连接' : (loading ? '同步中' : '已连接')]),
1629
+ h('button', { key: 'r', type: 'button', className: 'zts-iconbtn', title: '刷新', onClick: () => loadLists(scopes), disabled: loading },
1630
+ h('span', { className: loading ? 'zts-rot' : '', style: { display: 'inline-block' } }, '⟳')),
1631
+ baseUrl ? h('a', {
1632
+ key: 'o', className: 'zts-iconbtn', href: zenTaoUrl(''), target: '_blank', rel: 'noreferrer',
1633
+ title: zenTaoSid ? '打开禅道(已附带会话参数,免登录)' : '打开禅道',
1634
+ }, '↗') : null,
1635
+ h('button', { key: 'lo', type: 'button', className: 'zts-iconbtn', title: '退出登录', onClick: doLogout }, '⏻'),
1636
+ ]),
1637
+
1638
+ h('div', { key: 'tabs', className: 'zts-tabs' }, TABS.map(([key, label]) => h('button', {
1639
+ key, type: 'button', className: `zts-tab${tab === key ? ' on' : ''}`, onClick: () => switchTab(key),
1640
+ }, [label, h('b', { key: 'n' }, String((state[key] || []).length))]))),
1641
+
1642
+ h('div', { key: 'stats', className: 'zts-stats' }, stats.map((item) => h('button', {
1643
+ key: item.key,
1644
+ type: 'button',
1645
+ className: `zts-stat${filter === item.key ? ' is-active' : ''}`,
1646
+ title: filter === item.key ? `取消筛选:${item.k}` : `点击筛选:${item.k}`,
1647
+ onClick: () => applyFilter(item.key),
1648
+ }, [
1649
+ h('div', { key: 'k', className: 'k' }, item.k),
1650
+ h('div', { key: 'v', className: `v${item.tone ? ` ${item.tone}` : ''}` }, String(item.v)),
1651
+ h('div', { key: 's', className: 's' }, item.s),
1652
+ ]))),
1653
+
1654
+ notice ? h('div', { key: 'notice', className: `zts-notice${notice.fail ? ' fail' : ''}` }, notice.text) : null,
1655
+
1656
+ h('div', { key: 'bar', className: 'zts-bar' }, [
1657
+ h('label', { key: 'search', className: 'zts-search' }, ['🔍', h('input', {
1658
+ key: 'i', value: query, placeholder: `搜索${KIND_LABEL[tab]}标题 / 编号…`,
1659
+ onChange: (event) => setQuery(event.target.value),
1660
+ })]),
1661
+ h('span', { key: 'sp', className: 'zts-sp' }),
1662
+ h('label', { key: 'role', className: 'zts-topsel', title: '「按角色处理」使用的预设提示词(保存在本机)' }, [
1663
+ h('span', { key: 'l', className: 'lbl' }, '角色'),
1664
+ h('select', {
1665
+ key: 's', className: 'zts-select mini', value: role,
1666
+ onChange: (event) => changeRole(event.target.value),
1667
+ }, ROLES.map(([value, label]) => h('option', { key: value, value }, label))),
1668
+ ]),
1669
+ h('label', { key: 'auto', className: 'zts-topsel', title: '自动刷新间隔;页面不可见时跳过这一轮' }, [
1670
+ h('span', { key: 'l', className: 'lbl' }, '自动'),
1671
+ h('select', {
1672
+ key: 's', className: 'zts-select mini', value: String(autoRefresh),
1673
+ onChange: (event) => setAutoRefresh(Number(event.target.value) || 0),
1674
+ }, AUTO_OPTIONS.map(([value, label]) => h('option', { key: String(value), value: String(value) }, label))),
1675
+ ]),
1676
+ h('div', { key: 'seg', className: 'zts-seg' }, filters.map(([value, label]) => h('button', {
1677
+ key: value, type: 'button', className: filter === value ? 'on' : '', onClick: () => setFilter(value),
1678
+ }, label))),
1679
+ ]),
1680
+
1681
+ h('div', { key: 'cols', className: `zts-cols${detailFull ? ' is-full' : ''}` }, [
1682
+ detailFull ? null : h('div', { key: 'list', className: 'zts-card' }, [
1683
+ h('div', { key: 'hd', className: 'zts-cardhd' }, [
1684
+ cardTitle('列表'),
1685
+ // 范围选择器:值直接决定 /my-{kind}-{scope}.json 的路径段
1686
+ h('select', {
1687
+ key: 'scope', className: 'zts-select mini', value: scopes[tab],
1688
+ title: '浏览范围(与禅道「我的地盘」的 tab 一致)',
1689
+ onChange: (event) => changeScope(tab, event.target.value),
1690
+ }, (SCOPES[tab] || []).map(([value, label]) => h('option', { key: value, value }, label))),
1691
+ h('span', { key: 'n', className: 'n' }, filter === 'all' ? '' : `筛选:${activeFilterLabel}`),
1692
+ h('span', { key: 'c', className: 'zts-cnt' }, listCountText()),
1693
+ ]),
1694
+ h('div', { key: 'sc', className: 'zts-scroll' }, loading && items.length === 0
1695
+ ? [0, 1, 2, 3, 4].map((n) => h('div', { key: n, className: 'zts-skeleton' }))
1696
+ : list),
1697
+ ]),
1698
+ h('div', { key: 'detail', className: 'zts-card' }, [
1699
+ h('div', { key: 'hd', className: 'zts-cardhd' }, [
1700
+ cardTitle('详情'),
1701
+ h('span', { key: 'n', className: 'n' }, selectedId ? `#${selectedId}` : ''),
1702
+ h('span', { key: 'c', className: 'zts-cnt' }, detail.status === 'ready'
1703
+ ? `更新于 ${String(((detail.data.bug || detail.data.task || detail.data.story) || {}).lastEditedDate || ((detail.data.bug || detail.data.task || detail.data.story) || {}).openedDate || '').slice(5, 16) || '—'}`
1704
+ : ''),
1705
+ h('button', {
1706
+ key: 'fs', type: 'button', className: 'zts-iconbtn', style: { width: 26, height: 26, marginLeft: 6 },
1707
+ title: detailFull ? '退出全屏(ESC)' : '全屏(浏览器真全屏,ESC 退出)',
1708
+ onClick: toggleFullscreen,
1709
+ }, detailFull ? '⤡' : '⛶'),
1710
+ ]),
1711
+ h('div', { key: 'sc', className: 'zts-scroll' },
1712
+ h('div', { key: 'body', className: 'zts-detail' }, detailBody)),
1713
+ ]),
1714
+ ]),
1715
+
1716
+ modal ? h(ActionModal, {
1717
+ key: 'modal', modal,
1718
+ onChange: (field, value) => setModal((prev) => ({ ...prev, values: { ...prev.values, [field]: value } })),
1719
+ onSubmit: submitAction,
1720
+ onPreview: onImageClick,
1721
+ onClose: () => setModal(null),
1722
+ }) : null,
1723
+
1724
+ // 图片画廊:点任意一张图进入,可左右切换当前页面的全部图片
1725
+ gallery && gallery.list && gallery.list.length
1726
+ ? h(ImageGallery, {
1727
+ key: 'gallery', list: gallery.list, index: gallery.index,
1728
+ onIndex: (next) => setGallery((prev) => {
1729
+ if (!prev || !prev.list.length) return prev
1730
+ // 循环切换:首尾相接
1731
+ const total = prev.list.length
1732
+ const wrapped = ((next % total) + total) % total
1733
+ return { ...prev, index: wrapped }
1734
+ }),
1735
+ onClose: () => setGallery(null),
1736
+ })
1737
+ : null,
1738
+ ])
1739
+ }
1740
+
1741
+ const module = { exports: {} }
1742
+
1743
+ module.exports = {
1744
+ name: 'dsh-zentao-session',
1745
+ inject: ['slots'],
1746
+ apply(ctx) {
1747
+ try {
1748
+ ensureStyles()
1749
+ try {
1750
+ if (ctx && typeof ctx.inject === 'function') {
1751
+ ctx.inject(['connection'], (connCtx) => {
1752
+ hostConnection = (connCtx && connCtx.connection)
1753
+ || (connCtx && typeof connCtx.get === 'function' ? connCtx.get('connection') : null)
1754
+ })
1755
+ }
1756
+ } catch { /* 退回 props.connection 或同源 HTTP */ }
1757
+ // 「按角色处理」需要 sessions / workspaces / uiWorkspace 三个服务;都是可选依赖,
1758
+ // 缺任何一个就只保留「复制 / 拖拽」入口(按钮会退回复制提示词)。
1759
+ try {
1760
+ wireConversationBridge(ctx)
1761
+ } catch (error) {
1762
+ try { ctx.logger?.warn?.('[zentao-session] 对话联动桥接失败:' + String((error && error.message) || error)) } catch { /* 忽略 */ }
1763
+ }
1764
+ if (!ctx || !ctx.slots || typeof ctx.slots.inject !== 'function') return
1765
+ const safe = (slot, register) => {
1766
+ try {
1767
+ ctx.slots.inject(slot, register)
1768
+ } catch (error) {
1769
+ try { ctx.logger?.warn?.(`[zentao-session] 槽位 ${slot} 注册失败:${(error && error.message) || error}`) } catch {}
1770
+ }
1771
+ }
1772
+ safe('sidebar.panellist', () => ctx.slots.register({
1773
+ name: 'sidebar.panellist',
1774
+ id: PANEL_ID,
1775
+ order: 40,
1776
+ label: () => '禅道',
1777
+ }, (props) => h(ZentaoIcon, { size: typeof props?.size === 'number' ? props.size : undefined })))
1778
+ safe('main', () => ctx.slots.register({
1779
+ name: 'main',
1780
+ key: PANEL_ID,
1781
+ }, (props) => h(ZentaoPanel, Object.assign({}, props, { runtime: panelRuntime }))))
1782
+ } catch (error) {
1783
+ try {
1784
+ ctx.logger?.warn?.(`[zentao-session] 客户端注册失败:${(error && error.message) || error}`)
1785
+ } catch {}
1786
+ }
1787
+ },
1788
+ }
1789
+
1790
+ return module.exports
1791
+ },
1792
+ })