dsh-redteam-report 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/WORKSPACE-REPORTS.md +48 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +747 -0
- package/lib/host.js +119 -0
- package/lib/parts/client.head.js +15 -0
- package/lib/parts/client.shim.js +45 -0
- package/lib/parts/client.tail.js +5 -0
- package/lib/parts/host.head.js +80 -0
- package/lib/parts/host.tail.js +37 -0
- package/package.json +68 -0
- package/src/client.js +602 -0
- package/src/docx.js +776 -0
- package/src/host.js +1176 -0
- package/src/workspace-client.js +108 -0
- package/src/workspace-evidence.js +310 -0
- package/src/workspace-install.js +60 -0
- package/src/workspace-runtime.js +409 -0
- package/tools/build-lib.mjs +120 -0
- package/tools/prepare-gh-packages.mjs +66 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
// 常驻(静态)客户端半边 —— 浏览器 bundle 形态(由 tools/build-lib.mjs 生成)。
|
|
2
|
+
//
|
|
3
|
+
// client-modules 是 CJS 懒执行模型:bundle 只【注册】工厂,副作用留在闭包内,
|
|
4
|
+
// 首次 require 时物化。因此这里用 window.__ModuleLoader__.load({id, factory}) 注册。
|
|
5
|
+
//
|
|
6
|
+
// 包装刻意保持极薄(只做作用域与导出),全部改造集中在主体自己的 applyClient 里,
|
|
7
|
+
// 见 lib/parts/client.shim.js。
|
|
8
|
+
window.__ModuleLoader__.load({
|
|
9
|
+
id: 'dsh-redteam-report',
|
|
10
|
+
factory: (require) => {
|
|
11
|
+
let React = require('react');
|
|
12
|
+
var module = { exports: {} };
|
|
13
|
+
var exports = module.exports;
|
|
14
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
15
|
+
|
|
16
|
+
// 红队报告 · Client 半边
|
|
17
|
+
//
|
|
18
|
+
// lib/client.js 由 `npm run build:lib` 从本文件生成,不要手改 lib/。
|
|
19
|
+
// 生成器以函数名 `applyClient` 为入口锚点,并在其中注入 host.call / styles.insert 垫片
|
|
20
|
+
// ——静态 bundle 里没有这两个闭包符号,原因见 ../../docs/DEVELOPMENT.md。
|
|
21
|
+
//
|
|
22
|
+
// 注意:本文件必须以 `return { name, inject, apply }` 块【结尾】,生成器据此剥离动态包装。
|
|
23
|
+
//
|
|
24
|
+
// ── 界面结构 ──────────────────────────────────────────────────────────────────
|
|
25
|
+
// ┌ 顶栏:报告份数 · 当前字数(未保存)· 证据规模 · 生成进度 + 刷新
|
|
26
|
+
// ├ 标签页:报告 / 证据 / 设置 / 日志
|
|
27
|
+
// ├ 报告:报告列表(切换)· 标题 · 操作行(生成 / 保存 | 导出 MD·HTML·Word | 导入记忆 / 删除)
|
|
28
|
+
// │ 左编辑(Markdown,带字数)右预览(宿主渲染的完整 HTML,所见即导出)
|
|
29
|
+
// ├ 证据:试算证据 —— 会话采集表 + **真正喂给模型的那份 digest**
|
|
30
|
+
// ├ 设置:撰写模型 · 证据预算 · 路径 · 额外要求
|
|
31
|
+
// └ 日志:最近操作与错误
|
|
32
|
+
//
|
|
33
|
+
// ── 两条设计纪律 ─────────────────────────────────────────────────────────────
|
|
34
|
+
// 1. **一屏只留一个主操作**。原来九个按钮挤在一行里(生成 / 新建×2 / 保存 / 导出×3 /
|
|
35
|
+
// 导入 / 删除),主次不分;现在生成与保存是主,导出归成一组,删除这类破坏性操作
|
|
36
|
+
// 弱化到右侧并带二次语义(红色文字而不是红色实心块)。
|
|
37
|
+
// 2. **颜色只用真实存在的主题 token**。之前写的 --dsw-alias-bg-l1/l2/l3 并不存在,
|
|
38
|
+
// 于是背景一直掉回硬编码 rgba —— 深浅色主题下观感不一致,看着「脏」。
|
|
39
|
+
|
|
40
|
+
function applyClient(ctx) {
|
|
41
|
+
// ── 静态形态垫片(动态半边的闭包符号在静态包里不存在)──
|
|
42
|
+
//
|
|
43
|
+
// 1) host.call:转到宿主 HTTP 路由(见 lib/host.js 的 /dsh-redteam-report/rpc)。
|
|
44
|
+
// 选 HTTP 而非 ctx.remote:Remote 需 typert 代码生成(zod schema + 生成绑定),
|
|
45
|
+
// 而本插件有 20 个无类型 JSON 句柄,为此引入整套生成链不划算;
|
|
46
|
+
// 且 fetch 只被【动态】半边屏蔽,静态模块可直接用。
|
|
47
|
+
// 2) styles.insert:动态 runner 把它作为闭包参数注入,静态 bundle 里没有,
|
|
48
|
+
// 故自行插入 <style> 元素(浏览器全局可用),并登记到 fiber 便于卸载清理。
|
|
49
|
+
// 路径必须由生成器按包名填充(/dsh-redteam-report)。这里曾经写死成
|
|
50
|
+
// `/dsh-redteam-asset-graph/rpc` —— 那是从资产图谱早期版本复制骨架时带过来的缺陷:
|
|
51
|
+
// 本插件的面板会去打资产图谱的路由,请求全 404,两者同时安装还会读到对方的数据
|
|
52
|
+
// (资产图谱 README 记的那次事故是同一个根因)。测试里钉住了实际请求的 url。
|
|
53
|
+
const RPC_PATH = '/dsh-redteam-report/rpc'
|
|
54
|
+
const host = {
|
|
55
|
+
call(method, args) {
|
|
56
|
+
return fetch(RPC_PATH, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'content-type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ method: method, args: args === undefined ? null : args }),
|
|
60
|
+
}).then(function (res) {
|
|
61
|
+
return res.json().catch(function () { return null }).then(function (payload) {
|
|
62
|
+
if (!res.ok || !payload || payload.ok !== true) {
|
|
63
|
+
const detail = (payload && payload.error) || ('HTTP ' + res.status)
|
|
64
|
+
throw new Error('redteam-report rpc ' + method + ' 失败:' + detail)
|
|
65
|
+
}
|
|
66
|
+
return payload.result
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const STYLE_ID = 'redteam-report-styles'
|
|
73
|
+
const styles = {
|
|
74
|
+
insert(css) {
|
|
75
|
+
if (typeof document === 'undefined') return function () {}
|
|
76
|
+
let el = document.getElementById(STYLE_ID)
|
|
77
|
+
if (!el) { el = document.createElement('style'); el.id = STYLE_ID; document.head.appendChild(el) }
|
|
78
|
+
el.textContent += String(css) + '\n'
|
|
79
|
+
const dispose = function () { if (el && el.parentNode) el.parentNode.removeChild(el) }
|
|
80
|
+
try { ctx.effect(function () { return dispose }, 'redteam-report: styles') } catch (e) { return dispose }
|
|
81
|
+
return dispose
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const settingsHub = ctx.get('redteamSettingsUI');
|
|
86
|
+
if (!settingsHub) throw new Error('请先安装并启用 dsh-redteam-memory >= 0.4.0');
|
|
87
|
+
const slots = ctx.slots;
|
|
88
|
+
const reportHost = host;
|
|
89
|
+
let selection = { id:'', path:'', title:'', sessionId:'' };
|
|
90
|
+
let activationError = '';
|
|
91
|
+
let switchSequence = 0;
|
|
92
|
+
const subscribers = new Set();
|
|
93
|
+
const viewId = 'report-view-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
|
94
|
+
function publish(next) {
|
|
95
|
+
if (selection.id === next.id && selection.path === next.path && selection.title === next.title && selection.sessionId === next.sessionId) return;
|
|
96
|
+
selection = next;
|
|
97
|
+
activationError = '';
|
|
98
|
+
for (const notify of subscribers) notify();
|
|
99
|
+
}
|
|
100
|
+
function useWorkspace() {
|
|
101
|
+
const [value, setValue] = React.useState(() => selection);
|
|
102
|
+
React.useEffect(() => {
|
|
103
|
+
const notify = () => setValue(selection);
|
|
104
|
+
subscribers.add(notify); notify();
|
|
105
|
+
return () => subscribers.delete(notify);
|
|
106
|
+
}, []);
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
function WorkspaceObserver(props) {
|
|
110
|
+
const sessionId = props.useSessions(s => s.current || '');
|
|
111
|
+
const workspaceId = props.useWorkspaces(s => {
|
|
112
|
+
const w = s.items.find(item => item.sessionIds.includes(sessionId));
|
|
113
|
+
return w ? w.workspaceId : '';
|
|
114
|
+
});
|
|
115
|
+
const path = props.useWorkspaces(s => {
|
|
116
|
+
const w = s.items.find(item => item.workspaceId === workspaceId);
|
|
117
|
+
return w ? w.path : '';
|
|
118
|
+
});
|
|
119
|
+
const title = props.useWorkspaces(s => {
|
|
120
|
+
const w = s.items.find(item => item.workspaceId === workspaceId);
|
|
121
|
+
return w ? w.title : '';
|
|
122
|
+
});
|
|
123
|
+
React.useEffect(() => { publish({id:workspaceId,path:path,title:title,sessionId:sessionId}); }, [workspaceId,path,title,sessionId]);
|
|
124
|
+
React.useEffect(() => {
|
|
125
|
+
let live = true;
|
|
126
|
+
const sequence = ++switchSequence;
|
|
127
|
+
reportHost.call('activate', {workspaceId:'', viewId:viewId, sequence:sequence}).catch(() => {});
|
|
128
|
+
const cancel = ctx.timeout(() => {
|
|
129
|
+
reportHost.call('activate', {workspaceId:workspaceId, viewId:viewId, sequence:sequence}).then(result => {
|
|
130
|
+
if (live && result && result.ok === false) activationError = result.error || '自动报告启动失败';
|
|
131
|
+
}).catch(error => { if (live) activationError = String(error.message || error); });
|
|
132
|
+
}, 1200);
|
|
133
|
+
return () => { live = false; cancel(); };
|
|
134
|
+
}, [workspaceId]);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
function ScopedPanel(props) {
|
|
138
|
+
const current = useWorkspace();
|
|
139
|
+
if (!current.id) return el('div', {className:'rtr-root'}, props && props.settingsOnly ? '报告设置:请先选择工作区' : '请先选择工作区;报告不会回退到其他工作区。');
|
|
140
|
+
return el(Panel, {key:current.id, workspaceId:current.id, workspacePath:current.path, workspaceTitle:current.title, settingsOnly:!!(props && props.settingsOnly)});
|
|
141
|
+
}
|
|
142
|
+
ctx.effect(() => slots.inject('shell.overlay', () => slots.register({name:'shell.overlay',id:'redteam-report-workspace-observer',order:0}, WorkspaceObserver)));
|
|
143
|
+
ctx.effect(() => settingsHub.register(function ReportSettings() { return el(ScopedPanel, {settingsOnly:true}); }));
|
|
144
|
+
ctx.effect(() => () => { subscribers.clear(); reportHost.call('activate', {workspaceId:'',viewId:viewId,sequence:++switchSequence}).catch(() => {}); });
|
|
145
|
+
|
|
146
|
+
const PANEL_KEY = 'redteam-report'
|
|
147
|
+
const TABS = [['doc', '报告'], ['evi', '证据'], ['log', '日志']]
|
|
148
|
+
const PREVIEW_DEBOUNCE_MS = 400
|
|
149
|
+
|
|
150
|
+
// ── 通用小工具 ──────────────────────────────────────────────────────────────
|
|
151
|
+
function el(tag, props) {
|
|
152
|
+
const children = Array.prototype.slice.call(arguments, 2)
|
|
153
|
+
return React.createElement.apply(React, [tag, props || {}].concat(children))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 过桥的参数必须是 JSON:丢掉 undefined / null,否则 bridge 会直接拒绝
|
|
157
|
+
function jsonArgs(o) {
|
|
158
|
+
const out = {}
|
|
159
|
+
for (const k of Object.keys(o || {})) {
|
|
160
|
+
const v = o[k]
|
|
161
|
+
if (v === undefined || v === null) continue
|
|
162
|
+
out[k] = v
|
|
163
|
+
}
|
|
164
|
+
return out
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function fmtTime(ms) {
|
|
168
|
+
if (!ms) return '—'
|
|
169
|
+
const n = Number(ms)
|
|
170
|
+
if (!isFinite(n) || n <= 0) return String(ms)
|
|
171
|
+
const d = new Date(n)
|
|
172
|
+
if (isNaN(d.getTime())) return String(ms)
|
|
173
|
+
const p = function (x) { return x < 10 ? '0' + x : String(x) }
|
|
174
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes())
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function reportError(e) { return String((e && e.message) || e) }
|
|
178
|
+
function fmtNum(n) { const v = Number(n) || 0; return v >= 10000 ? (v / 1000).toFixed(1) + 'k' : String(v) }
|
|
179
|
+
|
|
180
|
+
// UI 基元:按钮 / 卡片 / 徽标 / 按钮组。两个层级(主/次)就够,
|
|
181
|
+
// 全部平铺才是「丑」的根源 —— 人看不出该点哪个。
|
|
182
|
+
function btn(label, opts, onClick) {
|
|
183
|
+
const o = opts || {}
|
|
184
|
+
const cls = 'rtr-btn' + (o.primary ? ' rtr-btn-primary' : '') + (o.danger ? ' rtr-btn-danger' : '') + (o.mini ? ' rtr-btn-mini' : '')
|
|
185
|
+
return el('button', { key: o.key || label, className: cls, disabled: o.disabled === true, title: o.title || '', onClick: onClick }, label)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function card(title, sub, children, extra) {
|
|
189
|
+
return el('section', { className: 'rtr-card' },
|
|
190
|
+
el('div', { className: 'rtr-card-h' },
|
|
191
|
+
el('span', { className: 'rtr-card-t' }, title),
|
|
192
|
+
sub ? el('span', { className: 'rtr-card-s' }, sub) : null,
|
|
193
|
+
el('span', { className: 'rtr-sp' }),
|
|
194
|
+
extra || null),
|
|
195
|
+
el('div', { className: 'rtr-card-b' }, children))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function badge(text, kind) {
|
|
199
|
+
return el('span', { className: 'rtr-badge' + (kind ? ' rtr-badge-' + kind : '') }, text)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function group(label, children) {
|
|
203
|
+
return el('div', { className: 'rtr-group' }, el('span', { className: 'rtr-group-l' }, label), children)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function hint(text) { return el('span', { className: 'rtr-hint' }, text) }
|
|
207
|
+
|
|
208
|
+
function Panel(props) {
|
|
209
|
+
const settingsOnly = !!(props && props.settingsOnly);
|
|
210
|
+
const workspaceId = props.workspaceId;
|
|
211
|
+
const host = { call(method, args) { return reportHost.call(method, Object.assign({}, args || {}, {workspaceId:workspaceId})); } };
|
|
212
|
+
const [snap, setSnap] = React.useState(null)
|
|
213
|
+
const [tab, setTab] = React.useState('doc')
|
|
214
|
+
const [busy, setBusy] = React.useState('')
|
|
215
|
+
const [error, setError] = React.useState(null)
|
|
216
|
+
const [toast, setToast] = React.useState('')
|
|
217
|
+
const [draft, setDraft] = React.useState(null)
|
|
218
|
+
const [title, setTitle] = React.useState('')
|
|
219
|
+
const [markdown, setMarkdown] = React.useState('')
|
|
220
|
+
const [dirty, setDirty] = React.useState(false)
|
|
221
|
+
const [html, setHtml] = React.useState('')
|
|
222
|
+
const [instruction, setInstruction] = React.useState('')
|
|
223
|
+
const [evidence, setEvidence] = React.useState(null)
|
|
224
|
+
const [exportInfo, setExportInfo] = React.useState(null)
|
|
225
|
+
|
|
226
|
+
function applySnap(r) {
|
|
227
|
+
if (!r || r.ok !== true) { setError((r && r.error) || '宿主没有返回数据'); return }
|
|
228
|
+
setError(null)
|
|
229
|
+
setSnap(r.snapshot)
|
|
230
|
+
setDraft(function (d) { return d || JSON.parse(JSON.stringify(r.snapshot.settings)) })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function refresh() {
|
|
234
|
+
setBusy('load')
|
|
235
|
+
return host.call('snapshot', {}).then(applySnap)
|
|
236
|
+
.catch(function (e) { setError(reportError(e)) })
|
|
237
|
+
.then(function () { setBusy('') })
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function call(method, args, label) {
|
|
241
|
+
setBusy(method)
|
|
242
|
+
setToast('')
|
|
243
|
+
return host.call(method, jsonArgs(args || {}))
|
|
244
|
+
.then(function (r) {
|
|
245
|
+
if (r && r.snapshot) applySnap({ ok: true, snapshot: r.snapshot })
|
|
246
|
+
if (r && r.ok === false) { setError(r.error || (label + '失败')); return r }
|
|
247
|
+
setError(null)
|
|
248
|
+
if (label) setToast(label + '完成')
|
|
249
|
+
return r
|
|
250
|
+
})
|
|
251
|
+
.catch(function (e) { setError(reportError(e)); return null })
|
|
252
|
+
.then(function (r) { setBusy(''); return r })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
React.useEffect(function () { refresh() }, [])
|
|
256
|
+
|
|
257
|
+
// 生成是后台任务:轮询 snapshot 才能看到进度与正在长出来的正文。
|
|
258
|
+
const liveRef = React.useRef({ generating: false, dirty: false })
|
|
259
|
+
liveRef.current = { generating: !!(snap && snap.status && snap.status.generating), dirty: dirty }
|
|
260
|
+
React.useEffect(function () {
|
|
261
|
+
if (settingsOnly || typeof ctx.interval !== 'function') return undefined
|
|
262
|
+
return ctx.interval(function () {
|
|
263
|
+
// Always poll this scoped panel: automatic jobs can start while it is open.
|
|
264
|
+
host.call('snapshot', {}).then(function (r) {
|
|
265
|
+
if (!r || r.ok !== true) return
|
|
266
|
+
setSnap(r.snapshot)
|
|
267
|
+
// 用户改过就不覆盖他的字;没改过就把流出来的正文同步进来。
|
|
268
|
+
if (!liveRef.current.dirty && r.snapshot.current) {
|
|
269
|
+
setMarkdown(r.snapshot.current.markdown || '')
|
|
270
|
+
setTitle(r.snapshot.current.title || '')
|
|
271
|
+
}
|
|
272
|
+
}).catch(function () {})
|
|
273
|
+
}, 2500)
|
|
274
|
+
}, [])
|
|
275
|
+
|
|
276
|
+
// 切换当前报告时同步编辑框(用户有未保存的改动就不动)
|
|
277
|
+
const curId = snap && snap.currentId ? snap.currentId : ''
|
|
278
|
+
React.useEffect(function () {
|
|
279
|
+
if (!snap || !snap.current || dirty) return
|
|
280
|
+
setTitle(snap.current.title || '')
|
|
281
|
+
setMarkdown(snap.current.markdown || '')
|
|
282
|
+
}, [curId])
|
|
283
|
+
|
|
284
|
+
// 预览:防抖后让宿主渲染,拿到完整 HTML 文档塞进 iframe。
|
|
285
|
+
// 定时器走 ctx.get('timer'):动态客户端沙箱里没有 setTimeout。
|
|
286
|
+
React.useEffect(function () {
|
|
287
|
+
if (settingsOnly) return undefined;
|
|
288
|
+
const timer = ctx.get('timer')
|
|
289
|
+
const run = function () {
|
|
290
|
+
host.call('preview', jsonArgs({ id: curId, title: title, markdown: markdown }))
|
|
291
|
+
.then(function (r) { if (r && r.ok === true) setHtml(r.html) })
|
|
292
|
+
.catch(function () {})
|
|
293
|
+
}
|
|
294
|
+
if (timer && typeof timer.timeout === 'function') {
|
|
295
|
+
const dispose = timer.timeout(run, PREVIEW_DEBOUNCE_MS)
|
|
296
|
+
return function () { try { dispose() } catch (e) {} }
|
|
297
|
+
}
|
|
298
|
+
run()
|
|
299
|
+
return undefined
|
|
300
|
+
}, [markdown, title, curId])
|
|
301
|
+
|
|
302
|
+
function doGenerate(createNew) {
|
|
303
|
+
setToast('')
|
|
304
|
+
setExportInfo(null)
|
|
305
|
+
call('generate', { reportId: createNew ? undefined : curId, createNew: createNew === true, instruction: instruction.trim() || undefined }, '已开始生成')
|
|
306
|
+
.then(function (r) {
|
|
307
|
+
if (r && r.ok === true) { setDirty(false); setMarkdown(''); setTitle(''); setTab('doc') }
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function doSave() {
|
|
312
|
+
call('saveDraft', { id: curId, title: title, markdown: markdown }, '保存').then(function (r) {
|
|
313
|
+
if (r && r.ok === true) setDirty(false)
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function doExport(format) {
|
|
318
|
+
setExportInfo(null)
|
|
319
|
+
call('export', { id: curId, format: format }).then(function (r) {
|
|
320
|
+
if (r && r.ok === true) setExportInfo(r)
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function doImport() {
|
|
325
|
+
call('importToMemory', { id: curId }, '导入记忆').then(function (r) {
|
|
326
|
+
if (r && r.ok === true) setToast('已导入记忆:新增 ' + r.added + ' 条 / 覆盖 ' + r.updated + ' 条' + (r.indexError ? '(索引未同步,本地已存)' : ''))
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function doCollect() {
|
|
331
|
+
setEvidence(null)
|
|
332
|
+
call('collect', { preview: 12000 }, '试算证据').then(function (r) {
|
|
333
|
+
if (r && r.ok === true) setEvidence(r.evidence)
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function doNew() {
|
|
338
|
+
call('create', { title: '' }, '新建').then(function (r) {
|
|
339
|
+
if (r && r.ok === true) { setDirty(false); setMarkdown(''); setTitle(''); setTab('doc') }
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function doRemove() {
|
|
344
|
+
if (!curId) { setError('先选中一份报告'); return }
|
|
345
|
+
call('remove', { ids: [curId] }, '删除报告').then(function (r) {
|
|
346
|
+
if (r && r.ok === true) { setDirty(false); setMarkdown(''); setTitle(''); setEvidence(null) }
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function setField(k, v) {
|
|
351
|
+
setDraft(function (d) {
|
|
352
|
+
const n = JSON.parse(JSON.stringify(d || {}))
|
|
353
|
+
n[k] = v
|
|
354
|
+
return n
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function setModelField(k, v) {
|
|
359
|
+
setDraft(function (d) {
|
|
360
|
+
const n = JSON.parse(JSON.stringify(d || {}))
|
|
361
|
+
if (!n.model) n.model = { provider: '', model: '' }
|
|
362
|
+
n.model[k] = v
|
|
363
|
+
return n
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const st = snap ? snap.status : null
|
|
368
|
+
const reports = (snap && snap.reports) || []
|
|
369
|
+
const cur = snap && snap.current ? snap.current : null
|
|
370
|
+
const generating = !!(st && st.generating)
|
|
371
|
+
const evMeta = (cur && cur.meta && cur.meta.evidence) || (st && st.evidence) || null
|
|
372
|
+
const hasBody = !!String(markdown || '').trim()
|
|
373
|
+
|
|
374
|
+
function head() {
|
|
375
|
+
const bits = ['报告 ' + reports.length + ' 份']
|
|
376
|
+
if (cur) bits.push(fmtNum(markdown.length) + ' 字' + (dirty ? '(未保存)' : ''))
|
|
377
|
+
if (evMeta) bits.push('证据:会话 ' + (evMeta.sessions || 0) + ' · 矩阵 ' + (evMeta.matrixConfirmed || 0) + '/' + (evMeta.matrixSuspected || 0) + ' · 记忆 ' + (evMeta.memoryHits || 0))
|
|
378
|
+
return el('div', { className: 'rtr-head' },
|
|
379
|
+
el('span', { className: 'rtr-brand' }, '红队报告'),
|
|
380
|
+
el('span', { className: 'rtr-stat' }, bits.join(' · ')),
|
|
381
|
+
st && st.progress ? el('span', { className: 'rtr-stat rtr-brand-c' }, st.progress.text) : null,
|
|
382
|
+
el('span', { className: 'rtr-sp' }),
|
|
383
|
+
btn('刷新', { disabled: !!busy }, refresh))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── 报告 tab ──────────────────────────────────────────────────────────────
|
|
387
|
+
function docTab() {
|
|
388
|
+
if (!reports.length) {
|
|
389
|
+
return el('div', { className: 'rtr-body' },
|
|
390
|
+
card('还没有报告', '一句话说清它读什么', [
|
|
391
|
+
el('div', { className: 'rtr-hint' },
|
|
392
|
+
'点下面的按钮,AI 会自动收集三样东西再动笔:**当前工作区的对话**(用户要求 / 关键操作 / 结果与结论)、'
|
|
393
|
+
+ '**攻击矩阵的命中**(已确认与疑似分开,带技术点名字与判据)、**记忆库里相关的知识条目**。'
|
|
394
|
+
+ '写完之后可以在「证据」页看到它到底读到了什么。'),
|
|
395
|
+
el('div', { className: 'rtr-row' },
|
|
396
|
+
btn('生成报告', { primary: true, disabled: !!busy || generating }, function () { doGenerate(false) }),
|
|
397
|
+
btn('新建空白报告', { disabled: !!busy }, doNew)),
|
|
398
|
+
]))
|
|
399
|
+
}
|
|
400
|
+
return el('div', { className: 'rtr-body' },
|
|
401
|
+
// 报告列表:切换用,带字数与时间,比一行纯标题好认
|
|
402
|
+
card('报告', reports.length + ' 份', [
|
|
403
|
+
el('div', { className: 'rtr-chips' }, reports.map(function (r) {
|
|
404
|
+
return el('button', {
|
|
405
|
+
key: r.id, className: 'rtr-chip' + (r.id === curId ? ' rtr-chip-on' : ''), disabled: !!busy,
|
|
406
|
+
title: (r.provider ? r.provider + '/' + r.model + ' · ' : '') + '更新于 ' + fmtTime(r.updatedAt),
|
|
407
|
+
onClick: function () { if (r.id === curId) return; setDirty(false); call('select', { id: r.id }, '') },
|
|
408
|
+
},
|
|
409
|
+
el('span', { className: 'rtr-chip-t' }, r.title || '未命名'),
|
|
410
|
+
el('span', { className: 'rtr-chip-m' }, fmtNum(r.chars) + ' 字 · ' + fmtTime(r.updatedAt)))
|
|
411
|
+
})),
|
|
412
|
+
], btn('新建空白', { mini: true, disabled: !!busy }, doNew)),
|
|
413
|
+
|
|
414
|
+
curId ? el('div', { className: 'rtr-edit' },
|
|
415
|
+
el('input', {
|
|
416
|
+
className: 'rtr-in rtr-title', value: title, placeholder: '报告标题',
|
|
417
|
+
onChange: function (e) { setTitle(e.target.value); setDirty(true) },
|
|
418
|
+
}),
|
|
419
|
+
|
|
420
|
+
// 一条操作行:主操作在左,导出成组在中间,破坏性操作弱化在右
|
|
421
|
+
el('div', { className: 'rtr-tools' },
|
|
422
|
+
btn(generating ? '生成中…' : (hasBody ? '重新生成' : '生成报告'), { primary: true, disabled: !!busy || generating }, function () { doGenerate(false) }),
|
|
423
|
+
btn(dirty ? '保存 *' : '保存', { disabled: !!busy || generating || !dirty }, doSave),
|
|
424
|
+
el('span', { className: 'rtr-sep' }),
|
|
425
|
+
group('导出', el('span', { className: 'rtr-seg' },
|
|
426
|
+
btn('Markdown', { mini: true, disabled: !!busy || !hasBody }, function () { doExport('md') }),
|
|
427
|
+
btn('HTML', { mini: true, disabled: !!busy || !hasBody }, function () { doExport('html') }),
|
|
428
|
+
btn('Word', { mini: true, primary: true, disabled: !!busy || !hasBody, title: '导出 .docx(落盘路径显示在下面)' }, function () { doExport('docx') }))),
|
|
429
|
+
btn('导入记忆', { disabled: !!busy || !hasBody, title: '把报告按章节切段写进红队记忆库' }, doImport),
|
|
430
|
+
el('span', { className: 'rtr-sp' }),
|
|
431
|
+
btn('删除', { danger: true, mini: true, disabled: !!busy, title: '删除这份报告(不可恢复)' }, doRemove)),
|
|
432
|
+
|
|
433
|
+
el('div', { className: 'rtr-panes' },
|
|
434
|
+
el('div', { className: 'rtr-pane' },
|
|
435
|
+
el('div', { className: 'rtr-pane-h' },
|
|
436
|
+
el('span', null, 'Markdown'),
|
|
437
|
+
el('span', { className: 'rtr-sp' }),
|
|
438
|
+
hint(fmtNum(markdown.length) + ' 字') + ''),
|
|
439
|
+
el('textarea', {
|
|
440
|
+
className: 'rtr-ta', value: markdown, placeholder: '点「生成报告」让 AI 写,或者直接在这里手写…',
|
|
441
|
+
onChange: function (e) { setMarkdown(e.target.value); setDirty(true) },
|
|
442
|
+
})),
|
|
443
|
+
el('div', { className: 'rtr-pane' },
|
|
444
|
+
el('div', { className: 'rtr-pane-h' }, el('span', null, '预览'), el('span', { className: 'rtr-sp' }), hint('与导出的 HTML / Word 同一套渲染')),
|
|
445
|
+
html
|
|
446
|
+
? el('iframe', { className: 'rtr-frame', srcDoc: html, sandbox: 'allow-same-origin' })
|
|
447
|
+
: el('div', { className: 'rtr-frame rtr-frame-empty' }, '预览生成中…'))),
|
|
448
|
+
|
|
449
|
+
exportInfo ? el('div', { className: exportInfo.writeError ? 'rtr-warn' : 'rtr-ok' },
|
|
450
|
+
'已导出 ' + exportInfo.name + '(' + exportInfo.bytes + ' 字节)'
|
|
451
|
+
+ (exportInfo.path ? ':' + exportInfo.path : '(未落盘)')
|
|
452
|
+
+ (exportInfo.writeError ? '|落盘失败:' + exportInfo.writeError : '')) : null,
|
|
453
|
+
|
|
454
|
+
cur && cur.meta && cur.meta.generatedAt
|
|
455
|
+
? el('div', { className: 'rtr-hint' }, '生成于 ' + fmtTime(cur.meta.generatedAt) + ' · 模型 ' + (cur.meta.provider || '?') + '/' + (cur.meta.model || '?')
|
|
456
|
+
+ (cur.meta.evidence ? ' · 证据 digest ' + fmtNum(cur.meta.evidence.digestChars) + ' 字' : ''))
|
|
457
|
+
: null) : null
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── 证据 tab ──────────────────────────────────────────────────────────────
|
|
462
|
+
function eviTab() {
|
|
463
|
+
return el('div', { className: 'rtr-body' },
|
|
464
|
+
el('div', { className: 'rtr-tools' },
|
|
465
|
+
btn(busy === 'collect' ? '试算中…' : '试算证据', { primary: true, disabled: !!busy }, doCollect),
|
|
466
|
+
hint('把真正喂给模型的那份材料打出来 —— 报告写得不对,九成是证据没采到或提示词没说清')),
|
|
467
|
+
|
|
468
|
+
!evidence ? card('还没试算', null, [
|
|
469
|
+
el('div', { className: 'rtr-hint' }, '点「试算证据」:它会真的跑一遍采集(读工作区会话、攻击矩阵、记忆库),并把裁剪后的 digest 原样显示在这里。不写任何文件、不调模型。'),
|
|
470
|
+
]) : el('div', { className: 'rtr-body' },
|
|
471
|
+
el('div', { className: 'rtr-row' },
|
|
472
|
+
badge('会话 ' + evidence.sessions.length, evidence.sessions.length ? 'brand' : 'warn'),
|
|
473
|
+
badge('已确认 ' + evidence.matrix.confirmed, evidence.matrix.confirmed ? 'ok' : 'warn'),
|
|
474
|
+
badge('疑似 ' + evidence.matrix.suspected, evidence.matrix.suspected ? 'warn' : ''),
|
|
475
|
+
badge('记忆 ' + evidence.memory.count, evidence.memory.count ? 'brand' : 'warn'),
|
|
476
|
+
badge('digest ' + fmtNum(evidence.digestChars) + ' 字', 'brand')),
|
|
477
|
+
hint('工作区 ' + ((evidence.workspace && evidence.workspace.path) || '(无)')
|
|
478
|
+
+ ' · 矩阵来源 ' + evidence.matrix.from
|
|
479
|
+
+ (evidence.memory.available ? '' : ' · 记忆插件未在运行')),
|
|
480
|
+
evidence.matrix.error ? el('div', { className: 'rtr-warn' }, '矩阵读取问题:' + evidence.matrix.error) : null,
|
|
481
|
+
evidence.queries && evidence.queries.length ? hint('记忆检索词:' + evidence.queries.join(' / ')) : null,
|
|
482
|
+
|
|
483
|
+
card('会话采集', evidence.sessions.length + ' 个',
|
|
484
|
+
evidence.sessions.length
|
|
485
|
+
? el('table', { className: 'rtr-tbl' },
|
|
486
|
+
el('thead', null, el('tr', null,
|
|
487
|
+
el('th', null, '会话'), el('th', null, '用户要求'), el('th', null, '操作'), el('th', null, '结果'), el('th', null, '时间'))),
|
|
488
|
+
el('tbody', null, evidence.sessions.map(function (s) {
|
|
489
|
+
return el('tr', { key: s.id },
|
|
490
|
+
el('td', null, s.title || String(s.id).slice(0, 14)),
|
|
491
|
+
el('td', null, String(s.users)),
|
|
492
|
+
el('td', null, String(s.ops)),
|
|
493
|
+
el('td', null, String(s.results)),
|
|
494
|
+
el('td', { className: 'rtr-hint' }, fmtTime(s.firstAt) + ' → ' + fmtTime(s.lastAt)))
|
|
495
|
+
})))
|
|
496
|
+
: el('div', { className: 'rtr-dim' }, '没有采到会话(工作区里还没有对话,或者 sessions 服务不可用)')),
|
|
497
|
+
|
|
498
|
+
evidence.files ? card('工作区文件采集', '清单与限制', [
|
|
499
|
+
hint(JSON.stringify(evidence.files.stats)),
|
|
500
|
+
el('div',{className:'rtr-hint'},evidence.files.notes.join(';')),
|
|
501
|
+
el('pre',{className:'rtr-pre'},evidence.files.inventory.map(f=>f.path+' ['+f.status+']'+(f.reason?' '+f.reason:'')).join('\n'))
|
|
502
|
+
]) : null,
|
|
503
|
+
card('digest', '前 ' + fmtNum(evidence.digest.length) + ' 字' + (evidence.truncated ? ',实际 ' + fmtNum(evidence.digestChars) + ' 字' : ''),
|
|
504
|
+
el('pre', { className: 'rtr-pre' }, evidence.digest)))
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── 设置 tab ──────────────────────────────────────────────────────────────
|
|
509
|
+
function setTab_() {
|
|
510
|
+
if (!draft) return el('div', { className: 'rtr-body rtr-dim' }, '加载中…')
|
|
511
|
+
return el('div', { className: 'rtr-body' },
|
|
512
|
+
el('div', { className: 'rtr-tools' },
|
|
513
|
+
btn('保存报告设置', { primary: true, disabled: !!busy }, function () { call('saveSettings', draft, '保存设置') }),
|
|
514
|
+
btn('用当前会话默认模型', { disabled: !!busy }, function () { setModelField('provider', ''); setModelField('model', '') }),
|
|
515
|
+
hint('留空 provider / model = 用你正在对话的那个模型(' + ((snap.model && snap.model.provider) || '?') + '/' + ((snap.model && snap.model.model) || '?') + ',来源 ' + ((snap.model && snap.model.from) || '?') + ')')),
|
|
516
|
+
|
|
517
|
+
el('div', { className: 'rtr-grid' },
|
|
518
|
+
card('工作区自动报告', '切换触发', [
|
|
519
|
+
el('label',{className:'rtr-f'},
|
|
520
|
+
el('span',null,'启用此工作区的自动报告'),
|
|
521
|
+
el('input',{type:'checkbox',checked:draft.autoGenerate !== false,onChange:e=>setField('autoGenerate',e.target.checked)})),
|
|
522
|
+
hint('先保存配置。快速切换只保留最后一个待处理工作区;已开始的报告完成后只写回原工作区。'),
|
|
523
|
+
hint('文件枚举有数量、深度与摘录预算;跳过凭据、依赖、二进制和符号链接。采集边界写入报告。')
|
|
524
|
+
]),
|
|
525
|
+
card('撰写模型', '报告由它写', [
|
|
526
|
+
el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, 'provider'),
|
|
527
|
+
el('input', { className: 'rtr-in', value: (draft.model && draft.model.provider) || '', placeholder: '留空 = 当前会话默认(例如 deepseek-official)', onChange: function (e) { setModelField('provider', e.target.value) } })),
|
|
528
|
+
el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, 'model'),
|
|
529
|
+
el('input', { className: 'rtr-in', value: (draft.model && draft.model.model) || '', placeholder: '留空 = 当前会话默认(例如 deepseek-flash)', onChange: function (e) { setModelField('model', e.target.value) } })),
|
|
530
|
+
el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, 'maxTokens'),
|
|
531
|
+
el('input', { className: 'rtr-in rtr-in-xs', type: 'number', value: draft.maxTokens, onChange: function (e) { setField('maxTokens', Number(e.target.value)) } })),
|
|
532
|
+
]),
|
|
533
|
+
|
|
534
|
+
card('证据预算', '喂给模型多少材料', [
|
|
535
|
+
el('div', { className: 'rtr-grid2' },
|
|
536
|
+
el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '最多读几个会话'),
|
|
537
|
+
el('input', { className: 'rtr-in', type: 'number', value: draft.sessionLimit, onChange: function (e) { setField('sessionLimit', Number(e.target.value)) } })),
|
|
538
|
+
el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '每个会话最多取多少字'),
|
|
539
|
+
el('input', { className: 'rtr-in', type: 'number', value: draft.sessionChars, onChange: function (e) { setField('sessionChars', Number(e.target.value)) } })),
|
|
540
|
+
el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '已确认命中上限'),
|
|
541
|
+
el('input', { className: 'rtr-in', type: 'number', value: draft.maxConfirmed, onChange: function (e) { setField('maxConfirmed', Number(e.target.value)) } })),
|
|
542
|
+
el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '疑似命中上限'),
|
|
543
|
+
el('input', { className: 'rtr-in', type: 'number', value: draft.maxSuspected, onChange: function (e) { setField('maxSuspected', Number(e.target.value)) } })),
|
|
544
|
+
el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, 'digest 总上限(字)'),
|
|
545
|
+
el('input', { className: 'rtr-in', type: 'number', value: draft.digestMax, onChange: function (e) { setField('digestMax', Number(e.target.value)) } })),
|
|
546
|
+
el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '记忆:条数 / 查询数'),
|
|
547
|
+
el('div', { className: 'rtr-row' },
|
|
548
|
+
el('input', { className: 'rtr-in rtr-in-xs', type: 'number', value: draft.memoryTopK, onChange: function (e) { setField('memoryTopK', Number(e.target.value)) } }),
|
|
549
|
+
el('input', { className: 'rtr-in rtr-in-xs', type: 'number', value: draft.memoryQueries, onChange: function (e) { setField('memoryQueries', Number(e.target.value)) } })))),
|
|
550
|
+
]),
|
|
551
|
+
|
|
552
|
+
card('路径', '留空就用默认', [
|
|
553
|
+
hint('相对名由宿主按插件自己的工作区解析(实际路径见下方);写不进去时改成能写的绝对路径。'),
|
|
554
|
+
el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '攻击矩阵存储'),
|
|
555
|
+
el('input', { className: 'rtr-in', value: draft.matrixStore || '', placeholder: (st && st.matrixPath) || '', onChange: function (e) { setField('matrixStore', e.target.value) } })),
|
|
556
|
+
el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '报告库文件'),
|
|
557
|
+
el('input', { className: 'rtr-in', value: (st && st.storePath) || '', readOnly: true })),
|
|
558
|
+
el('label', { className: 'rtr-f' }, el('span', { className: 'rtr-f-l' }, '导出目录'),
|
|
559
|
+
el('input', { className: 'rtr-in', value: draft.exportDir || '', placeholder: '留空 = 与报告库同目录', onChange: function (e) { setField('exportDir', e.target.value) } })),
|
|
560
|
+
hint('当前落盘:' + ((st && st.storePath) || '(未解析)')),
|
|
561
|
+
]),
|
|
562
|
+
|
|
563
|
+
card('额外要求', '每次生成都会带上', [
|
|
564
|
+
el('textarea', {
|
|
565
|
+
className: 'rtr-ta rtr-ta-sm', value: draft.instruction || '',
|
|
566
|
+
placeholder: '例如:重点写未授权访问链路,每条发现给出修复优先级;不要写攻击载荷细节。',
|
|
567
|
+
onChange: function (e) { setField('instruction', e.target.value) },
|
|
568
|
+
}),
|
|
569
|
+
]))
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function logTab() {
|
|
574
|
+
return el('div', { className: 'rtr-body' },
|
|
575
|
+
el('div', { className: 'rtr-tools' },
|
|
576
|
+
btn('刷新', { disabled: !!busy }, refresh),
|
|
577
|
+
btn('清空日志', { disabled: !!busy }, function () { call('logClear', {}, '清空日志') })),
|
|
578
|
+
el('div', { className: 'rtr-logs' }, (snap.log || []).slice().reverse().map(function (l) {
|
|
579
|
+
return el('div', { key: l.seq, className: 'rtr-ln rtr-ln-' + (l.level || 'info') },
|
|
580
|
+
el('span', { className: 'rtr-lt' }, fmtTime(l.at)),
|
|
581
|
+
el('span', { className: 'rtr-ll' }, l.level || ''),
|
|
582
|
+
el('span', { className: 'rtr-lx' }, l.text))
|
|
583
|
+
})))
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (settingsOnly) return el('section', {className:'rtr-root', style:{borderTop:'1px solid var(--dsw-alias-border-l1)'}},
|
|
587
|
+
el('h2',{className:'rtr-brand'},'报告设置 · ' + (props.workspaceTitle || props.workspacePath)),
|
|
588
|
+
hint('以下配置仅用于当前工作区;报告库按工作区隔离。'),
|
|
589
|
+
error ? el('div',{className:'rtr-errbar'},error) : null,
|
|
590
|
+
toast ? el('div',{className:'rtr-ok'},toast) : null,
|
|
591
|
+
snap && draft ? setTab_() : el('div',{className:'rtr-dim'},'加载报告设置…'));
|
|
592
|
+
return el('div', { className: 'rtr-root' },
|
|
593
|
+
head(),
|
|
594
|
+
hint('当前工作区:' + (props.workspaceTitle || '') + ' · ' + props.workspacePath),
|
|
595
|
+
hint('切换工作区自动采集并生成;相同证据复用已有报告,五分钟内不重复自动生成。配置入口:设置 → 红队设置。'),
|
|
596
|
+
activationError ? el('div',{className:'rtr-warn'},activationError) : null,
|
|
597
|
+
st && st.legacyUnassignedReports ? hint('旧版未归属工作区报告 ' + st.legacyUnassignedReports + ' 份仍保存在旧报告库,未自动迁移。') : null,
|
|
598
|
+
st && st.queued ? hint('等待自动报告任务…') : null,
|
|
599
|
+
st && st.lastError ? el('div',{className:'rtr-warn'},st.lastError) : null,
|
|
600
|
+
error ? el('div', { className: 'rtr-errbar' }, el('span', null, error), el('button', { className: 'rtr-x', onClick: function () { setError(null) } }, '×')) : null,
|
|
601
|
+
toast ? el('div', { className: 'rtr-ok' }, toast) : null,
|
|
602
|
+
st && st.persistence === 'memory' ? el('div', { className: 'rtr-warn' }, 'fs 服务不可用:报告只存在内存里,重启会丢') : null,
|
|
603
|
+
st && st.lastError && st.persistence === 'error' ? el('div', { className: 'rtr-warn' }, st.lastError) : null,
|
|
604
|
+
|
|
605
|
+
el('div', { className: 'rtr-tabs' }, TABS.map(function (t) {
|
|
606
|
+
return el('button', { key: t[0], className: 'rtr-tab' + (tab === t[0] ? ' rtr-tab-on' : ''), onClick: function () { setTab(t[0]) } }, t[1])
|
|
607
|
+
})),
|
|
608
|
+
|
|
609
|
+
!snap ? el('div', { className: 'rtr-body rtr-dim' }, busy === 'load' ? '加载中…' : '正在读取报告库…') : null,
|
|
610
|
+
snap && tab === 'doc' ? docTab() : null,
|
|
611
|
+
snap && tab === 'evi' ? eviTab() : null,
|
|
612
|
+
|
|
613
|
+
snap && tab === 'log' ? logTab() : null
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// 侧边栏图标:一份带批注点的文档
|
|
618
|
+
function Glyph(props) {
|
|
619
|
+
const size = props && props.size ? props.size : 16
|
|
620
|
+
const active = props && props.active
|
|
621
|
+
const c = active ? 'var(--dsw-alias-brand-primary)' : 'currentColor'
|
|
622
|
+
return React.createElement('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none' },
|
|
623
|
+
React.createElement('path', { d: 'M6 3.5h8.5L19 8v12.5H6z', stroke: c, strokeWidth: 1.6, strokeLinejoin: 'round' }),
|
|
624
|
+
React.createElement('path', { d: 'M14.2 3.6V8H19', stroke: c, strokeWidth: 1.4, strokeLinejoin: 'round' }),
|
|
625
|
+
React.createElement('path', { d: 'M9 12h6M9 15h6M9 18h3.5', stroke: c, strokeWidth: 1.4, strokeLinecap: 'round' }),
|
|
626
|
+
React.createElement('circle', { cx: 5.2, cy: 12, r: 1.5, fill: c }),
|
|
627
|
+
React.createElement('circle', { cx: 5.2, cy: 16.6, r: 1.5, fill: c })
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ── 样式:全部走主题 token ──────────────────────────────────────────────────
|
|
632
|
+
ctx.effect(function () {
|
|
633
|
+
return styles.insert([
|
|
634
|
+
'.rtr-root{padding:14px 16px;display:flex;flex-direction:column;gap:12px;font-size:13px;color:var(--dsw-alias-label-primary)}',
|
|
635
|
+
'.rtr-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
|
|
636
|
+
'.rtr-brand{font-size:14px;font-weight:600;letter-spacing:.2px}',
|
|
637
|
+
'.rtr-brand-c{color:var(--dsw-alias-brand-primary)}',
|
|
638
|
+
'.rtr-stat{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
|
|
639
|
+
'.rtr-sp{flex:1 1 auto}',
|
|
640
|
+
'.rtr-dim{color:var(--dsw-alias-label-secondary)}',
|
|
641
|
+
'.rtr-hint{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
|
|
642
|
+
'.rtr-err{color:var(--dsw-alias-state-error-primary);font-size:12px}',
|
|
643
|
+
'.rtr-ok{color:var(--dsw-alias-state-success-primary);font-size:12px;word-break:break-all}',
|
|
644
|
+
'.rtr-warn{color:var(--dsw-alias-state-warn-primary);font-size:12px}',
|
|
645
|
+
'.rtr-errbar{display:flex;justify-content:space-between;gap:8px;align-items:flex-start;color:var(--dsw-alias-state-error-primary);font-size:12px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 10px}',
|
|
646
|
+
'.rtr-x{background:transparent;border:none;color:inherit;font-size:16px;line-height:1;cursor:pointer;padding:0 4px}',
|
|
647
|
+
|
|
648
|
+
'.rtr-tabs{display:flex;gap:2px;border-bottom:1px solid var(--dsw-alias-border-l1)}',
|
|
649
|
+
'.rtr-tab{background:transparent;border:none;border-bottom:2px solid transparent;color:var(--dsw-alias-label-secondary);padding:6px 12px;font-size:12.5px;cursor:pointer;font-family:inherit}',
|
|
650
|
+
'.rtr-tab:hover{color:var(--dsw-alias-label-primary)}',
|
|
651
|
+
'.rtr-tab-on{color:var(--dsw-alias-brand-primary);border-bottom-color:var(--dsw-alias-brand-primary);font-weight:600}',
|
|
652
|
+
'.rtr-body{display:flex;flex-direction:column;gap:12px}',
|
|
653
|
+
|
|
654
|
+
'.rtr-tools{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
|
|
655
|
+
'.rtr-sep{width:1px;height:18px;background:var(--dsw-alias-border-l1)}',
|
|
656
|
+
'.rtr-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
|
|
657
|
+
'.rtr-group{display:flex;align-items:center;gap:6px}',
|
|
658
|
+
'.rtr-group-l{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
|
|
659
|
+
'.rtr-seg{display:inline-flex}',
|
|
660
|
+
|
|
661
|
+
'.rtr-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}',
|
|
662
|
+
'.rtr-card-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
663
|
+
'.rtr-card-t{font-size:12.5px;font-weight:600}',
|
|
664
|
+
'.rtr-card-s{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
|
|
665
|
+
'.rtr-card-b{display:flex;flex-direction:column;gap:8px}',
|
|
666
|
+
|
|
667
|
+
'.rtr-btn{height:26px;padding:0 10px;border-radius:7px;font-size:12px;font-family:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}',
|
|
668
|
+
'.rtr-btn:hover:not(:disabled){border-color:var(--dsw-alias-border-l2)}',
|
|
669
|
+
'.rtr-btn:disabled{opacity:.45;cursor:not-allowed}',
|
|
670
|
+
'.rtr-btn-primary{background:var(--dsw-alias-brand-primary);border-color:transparent;color:#fff}',
|
|
671
|
+
'.rtr-btn-primary:hover:not(:disabled){filter:brightness(1.06)}',
|
|
672
|
+
'.rtr-btn-danger{color:var(--dsw-alias-state-error-primary)}',
|
|
673
|
+
'.rtr-btn-mini{height:22px;padding:0 8px;font-size:11.5px}',
|
|
674
|
+
'.rtr-seg .rtr-btn{border-radius:0;margin-left:-1px}',
|
|
675
|
+
'.rtr-seg .rtr-btn:first-child{border-radius:7px 0 0 7px;margin-left:0}',
|
|
676
|
+
'.rtr-seg .rtr-btn:last-child{border-radius:0 7px 7px 0}',
|
|
677
|
+
|
|
678
|
+
'.rtr-in{height:26px;box-sizing:border-box;width:100%;padding:0 8px;border-radius:7px;font-size:12px;font-family:inherit;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1)}',
|
|
679
|
+
'.rtr-in:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}',
|
|
680
|
+
'.rtr-in-xs{width:64px}',
|
|
681
|
+
'.rtr-title{height:32px;font-size:14px;font-weight:600}',
|
|
682
|
+
'.rtr-ta{min-height:120px;box-sizing:border-box;width:100%;padding:8px;border-radius:8px;font-size:12px;font-family:ui-monospace,monospace;line-height:1.6;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);resize:vertical;flex:1 1 auto}',
|
|
683
|
+
'.rtr-ta:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}',
|
|
684
|
+
'.rtr-ta-sm{min-height:74px}',
|
|
685
|
+
|
|
686
|
+
'.rtr-badge{display:inline-flex;align-items:center;height:20px;padding:0 7px;border-radius:5px;font-size:11.5px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);white-space:nowrap}',
|
|
687
|
+
'.rtr-badge-ok{color:var(--dsw-alias-state-success-primary)}',
|
|
688
|
+
'.rtr-badge-warn{color:var(--dsw-alias-state-warn-primary)}',
|
|
689
|
+
'.rtr-badge-brand{color:var(--dsw-alias-brand-primary)}',
|
|
690
|
+
|
|
691
|
+
'.rtr-chips{display:flex;gap:6px;flex-wrap:wrap}',
|
|
692
|
+
'.rtr-chip{display:flex;flex-direction:column;align-items:flex-start;gap:1px;text-align:left;max-width:280px;padding:5px 10px;border-radius:8px;cursor:pointer;font-family:inherit;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary)}',
|
|
693
|
+
'.rtr-chip:hover:not(:disabled){border-color:var(--dsw-alias-border-l2)}',
|
|
694
|
+
'.rtr-chip-on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}',
|
|
695
|
+
'.rtr-chip-t{font-size:12px;font-weight:600;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
696
|
+
'.rtr-chip-m{font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
697
|
+
|
|
698
|
+
'.rtr-edit{display:flex;flex-direction:column;gap:8px}',
|
|
699
|
+
'.rtr-panes{display:grid;grid-template-columns:1fr 1fr;gap:10px;min-height:52vh}',
|
|
700
|
+
'.rtr-pane{display:flex;flex-direction:column;gap:5px;min-width:0}',
|
|
701
|
+
'.rtr-pane-h{display:flex;align-items:baseline;gap:8px;font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}',
|
|
702
|
+
'.rtr-frame{flex:1 1 auto;min-height:46vh;width:100%;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:#fff}',
|
|
703
|
+
'.rtr-frame-empty{padding:10px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
704
|
+
|
|
705
|
+
'.rtr-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;align-items:start}',
|
|
706
|
+
'.rtr-grid2{display:grid;grid-template-columns:1fr 1fr;gap:8px}',
|
|
707
|
+
'.rtr-f{display:flex;flex-direction:column;gap:4px}',
|
|
708
|
+
'.rtr-f-l{font-size:11.5px;color:var(--dsw-alias-label-secondary)}',
|
|
709
|
+
|
|
710
|
+
'.rtr-tbl{width:100%;border-collapse:collapse;font-size:12px}',
|
|
711
|
+
'.rtr-tbl th{text-align:left;font-weight:500;font-size:11.5px;color:var(--dsw-alias-label-secondary);padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l1)}',
|
|
712
|
+
'.rtr-tbl td{padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);vertical-align:top}',
|
|
713
|
+
'.rtr-tbl tbody tr:hover td{background:var(--dsw-alias-bg-layer-2)}',
|
|
714
|
+
'.rtr-tbl tbody tr:last-child td{border-bottom:none}',
|
|
715
|
+
|
|
716
|
+
'.rtr-pre{white-space:pre-wrap;word-break:break-word;font-size:11.5px;line-height:1.6;max-height:52vh;overflow:auto;margin:0;padding:10px;border-radius:8px;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);font-family:ui-monospace,monospace}',
|
|
717
|
+
|
|
718
|
+
'.rtr-logs{display:flex;flex-direction:column;gap:2px;font-size:11.5px;max-height:60vh;overflow:auto}',
|
|
719
|
+
'.rtr-ln{display:flex;gap:8px;align-items:baseline}',
|
|
720
|
+
'.rtr-lt{flex:0 0 auto;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,monospace}',
|
|
721
|
+
'.rtr-ll{flex:0 0 auto;min-width:34px;color:var(--dsw-alias-label-secondary)}',
|
|
722
|
+
'.rtr-lx{white-space:pre-wrap;word-break:break-word}',
|
|
723
|
+
'.rtr-ln-err .rtr-lx{color:var(--dsw-alias-state-error-primary)}',
|
|
724
|
+
'.rtr-ln-warn .rtr-lx{color:var(--dsw-alias-state-warn-primary)}',
|
|
725
|
+
'.rtr-ln-ok .rtr-lx{color:var(--dsw-alias-state-success-primary)}',
|
|
726
|
+
].join('\n'))
|
|
727
|
+
}, 'redteam-report: styles')
|
|
728
|
+
|
|
729
|
+
ctx.effect(function () {
|
|
730
|
+
return slots.inject('sidebar.panellist', function () {
|
|
731
|
+
return slots.register({ name: 'sidebar.panellist', id: PANEL_KEY, order: 60, label: '红队报告' }, Glyph)
|
|
732
|
+
})
|
|
733
|
+
}, 'redteam-report: panel button')
|
|
734
|
+
|
|
735
|
+
ctx.effect(function () {
|
|
736
|
+
return slots.inject('main', function () {
|
|
737
|
+
return slots.register({ name: 'main', key: PANEL_KEY }, ScopedPanel)
|
|
738
|
+
})
|
|
739
|
+
}, 'redteam-report: main panel')
|
|
740
|
+
|
|
741
|
+
console.log('[rtreport] redteam-report client half ready; panel =', PANEL_KEY)
|
|
742
|
+
}
|
|
743
|
+
exports.inject = ['slots', 'timer', 'redteamSettingsUI']
|
|
744
|
+
exports.apply = function (ctx) { return applyClient(ctx) }
|
|
745
|
+
return module.exports
|
|
746
|
+
}
|
|
747
|
+
});
|