token-studio 4.8.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/.nvmrc +1 -0
- package/CHANGELOG.md +89 -0
- package/Dockerfile +17 -0
- package/LICENSE +22 -0
- package/NOTICE.md +21 -0
- package/PRIVACY.md +68 -0
- package/README.en.md +220 -0
- package/README.md +220 -0
- package/config/collectors.json +54 -0
- package/data/.gitkeep +1 -0
- package/docker-compose.yml +17 -0
- package/docs/assets/.gitkeep +1 -0
- package/docs/assets/token-studio-v44-dashboard.png +0 -0
- package/docs/assets/token-studio-v44-live.png +0 -0
- package/docs/assets/token-studio-v44-review-mobile.png +0 -0
- package/docs/assets/token-studio-v44-review.png +0 -0
- package/docs/assets/token-studio-v45-dashboard.png +0 -0
- package/docs/assets/token-studio-v45-live.png +0 -0
- package/docs/assets/token-studio-v45-review-mobile.png +0 -0
- package/docs/assets/token-studio-v45-review.png +0 -0
- package/docs/blog-case-study.md +34 -0
- package/docs/collector-support-matrix.md +65 -0
- package/docs/competitive-notes.md +87 -0
- package/docs/demo-data/README.md +12 -0
- package/docs/demo-data/token-studio-v2-demo.json +146 -0
- package/docs/demo-flow.md +39 -0
- package/docs/first-run.md +95 -0
- package/docs/local-collectors.md +49 -0
- package/docs/public-launch-checklist.md +45 -0
- package/docs/resume-bullets.md +7 -0
- package/docs/statusline.md +52 -0
- package/index.html +16 -0
- package/package.json +36 -0
- package/render.yaml +17 -0
- package/src/auto-attribution.mjs +396 -0
- package/src/ccusage-bridge.mjs +74 -0
- package/src/ccusage-import.mjs +415 -0
- package/src/cli.mjs +643 -0
- package/src/client/dashboard/App.jsx +1734 -0
- package/src/client/dashboard/annotation-presets.js +138 -0
- package/src/client/dashboard/attribution.js +328 -0
- package/src/client/dashboard/components-charts.jsx +622 -0
- package/src/client/dashboard/components-tables.jsx +1531 -0
- package/src/client/dashboard/components-top.jsx +307 -0
- package/src/client/dashboard/import-budget.js +41 -0
- package/src/client/dashboard/model-usage.js +108 -0
- package/src/client/dashboard/onboarding.js +80 -0
- package/src/client/dashboard/styles.css +2606 -0
- package/src/client/live/LiveApp.jsx +226 -0
- package/src/client/live/styles.css +446 -0
- package/src/client/main.jsx +20 -0
- package/src/client/review/ReviewApp.jsx +507 -0
- package/src/client/review/closure-progress.js +165 -0
- package/src/client/review/markdown-report.js +401 -0
- package/src/client/review/model-strategy.js +273 -0
- package/src/client/review/roi-advisor.js +255 -0
- package/src/client/review/roi-evidence.js +78 -0
- package/src/client/review/savings-simulator.js +252 -0
- package/src/client/review/sections-1.jsx +277 -0
- package/src/client/review/sections-2.jsx +927 -0
- package/src/client/review/styles.css +2321 -0
- package/src/client/review/utils.js +345 -0
- package/src/client/shared/utils.js +236 -0
- package/src/closure-check.mjs +537 -0
- package/src/closure-import.mjs +646 -0
- package/src/collect.mjs +247 -0
- package/src/collector-config.mjs +82 -0
- package/src/collector-registry.mjs +333 -0
- package/src/collectors/claude-code.mjs +355 -0
- package/src/collectors/codex.mjs +418 -0
- package/src/collectors/copilot.mjs +19 -0
- package/src/collectors/cursor.mjs +23 -0
- package/src/collectors/gemini.mjs +530 -0
- package/src/collectors/goose.mjs +15 -0
- package/src/collectors/hermes.mjs +206 -0
- package/src/collectors/kimi.mjs +15 -0
- package/src/collectors/openclaw.mjs +400 -0
- package/src/collectors/opencode.mjs +349 -0
- package/src/collectors/qwen.mjs +15 -0
- package/src/collectors/structured-usage.mjs +437 -0
- package/src/collectors/utils.mjs +93 -0
- package/src/db.mjs +1397 -0
- package/src/demo-seed.mjs +39 -0
- package/src/dev.mjs +43 -0
- package/src/live.mjs +428 -0
- package/src/model-policy.mjs +147 -0
- package/src/pricing.mjs +434 -0
- package/src/privacy-check.mjs +126 -0
- package/src/server.mjs +1240 -0
- package/src/source-health.mjs +195 -0
- package/src/statusline.mjs +156 -0
- package/src/terminal-report.mjs +245 -0
- package/src/update-pricing.mjs +8 -0
- package/test/annotation-presets.test.mjs +137 -0
- package/test/api-annotations.test.mjs +202 -0
- package/test/api-auto-attribution.test.mjs +169 -0
- package/test/api-source-health.test.mjs +109 -0
- package/test/api-v2.test.mjs +278 -0
- package/test/api-v43.test.mjs +151 -0
- package/test/api-v44.test.mjs +128 -0
- package/test/attribution-summary.test.mjs +164 -0
- package/test/auto-attribution.test.mjs +116 -0
- package/test/ccusage-bridge.test.mjs +36 -0
- package/test/ccusage-import.test.mjs +93 -0
- package/test/cli-v43.test.mjs +64 -0
- package/test/cli-v45.test.mjs +34 -0
- package/test/cli-v46.test.mjs +129 -0
- package/test/cli-v47.test.mjs +98 -0
- package/test/closure-check.test.mjs +202 -0
- package/test/closure-import.test.mjs +263 -0
- package/test/collector-config.test.mjs +25 -0
- package/test/collector-registry.test.mjs +56 -0
- package/test/csv.test.mjs +19 -0
- package/test/db-annotations.test.mjs +186 -0
- package/test/db-v2.test.mjs +200 -0
- package/test/db-v4.test.mjs +178 -0
- package/test/experimental-collectors.test.mjs +103 -0
- package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
- package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
- package/test/fixtures/collectors/goose/usage.jsonl +2 -0
- package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
- package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
- package/test/import-budget.test.mjs +40 -0
- package/test/live.test.mjs +256 -0
- package/test/markdown-report.test.mjs +193 -0
- package/test/model-policy.test.mjs +34 -0
- package/test/model-strategy.test.mjs +116 -0
- package/test/model-usage.test.mjs +99 -0
- package/test/official-pricing.test.mjs +70 -0
- package/test/onboarding.test.mjs +55 -0
- package/test/privacy-check.test.mjs +33 -0
- package/test/review-closure-progress.test.mjs +99 -0
- package/test/roi-advisor.test.mjs +188 -0
- package/test/roi-evidence.test.mjs +48 -0
- package/test/roi-summary.test.mjs +101 -0
- package/test/savings-simulator.test.mjs +141 -0
- package/test/source-health.test.mjs +62 -0
- package/test/statusline.test.mjs +148 -0
- package/vite.config.js +23 -0
|
@@ -0,0 +1,1531 @@
|
|
|
1
|
+
/* =============================================================
|
|
2
|
+
Tables — sortable, searchable, drill-down rows
|
|
3
|
+
============================================================= */
|
|
4
|
+
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import { U } from '../shared/utils.js';
|
|
7
|
+
import {
|
|
8
|
+
buildReviewAttributionChecklist,
|
|
9
|
+
buildReviewAttributionProgress,
|
|
10
|
+
buildReviewUnattributedSessions,
|
|
11
|
+
buildUnattributedSessions
|
|
12
|
+
} from './attribution.js';
|
|
13
|
+
import {
|
|
14
|
+
applyAnnotationTemplate,
|
|
15
|
+
QUICK_ANNOTATION_TEMPLATES,
|
|
16
|
+
readAnnotationPresets,
|
|
17
|
+
rememberAnnotationPreset,
|
|
18
|
+
writeAnnotationPresets
|
|
19
|
+
} from './annotation-presets.js';
|
|
20
|
+
|
|
21
|
+
// Generic data table
|
|
22
|
+
function DataTable({ rows, columns, initialSort, search, onSearch, onRowClick, selectedKey, getKey, height, emptyText }) {
|
|
23
|
+
const [sortBy, setSortBy] = useState(initialSort || { field: null, dir: 'desc' });
|
|
24
|
+
|
|
25
|
+
const filtered = useMemo(() => {
|
|
26
|
+
if (!search) return rows;
|
|
27
|
+
const q = search.toLowerCase();
|
|
28
|
+
return rows.filter(r =>
|
|
29
|
+
columns.some(c => {
|
|
30
|
+
const v = typeof c.value === 'function' ? c.value(r) : r[c.field];
|
|
31
|
+
return String(v ?? '').toLowerCase().includes(q);
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
}, [rows, columns, search]);
|
|
35
|
+
|
|
36
|
+
const sorted = useMemo(() => {
|
|
37
|
+
if (!sortBy.field) return filtered;
|
|
38
|
+
const arr = [...filtered];
|
|
39
|
+
const col = columns.find(c => c.field === sortBy.field);
|
|
40
|
+
if (!col) return arr;
|
|
41
|
+
arr.sort((a, b) => {
|
|
42
|
+
const va = typeof col.value === 'function' ? col.value(a) : a[col.field];
|
|
43
|
+
const vb = typeof col.value === 'function' ? col.value(b) : b[col.field];
|
|
44
|
+
if (typeof va === 'number' && typeof vb === 'number') return sortBy.dir === 'asc' ? va - vb : vb - va;
|
|
45
|
+
const sa = String(va ?? '').toLowerCase();
|
|
46
|
+
const sb = String(vb ?? '').toLowerCase();
|
|
47
|
+
return sortBy.dir === 'asc' ? sa.localeCompare(sb) : sb.localeCompare(sa);
|
|
48
|
+
});
|
|
49
|
+
return arr;
|
|
50
|
+
}, [filtered, sortBy, columns]);
|
|
51
|
+
|
|
52
|
+
const toggleSort = (field) => {
|
|
53
|
+
setSortBy(prev =>
|
|
54
|
+
prev.field === field
|
|
55
|
+
? { field, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
|
|
56
|
+
: { field, dir: 'desc' }
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="table-wrap" style={{maxHeight: height, overflow: 'auto'}}>
|
|
62
|
+
<table className="dt">
|
|
63
|
+
<thead>
|
|
64
|
+
<tr>
|
|
65
|
+
{columns.map(c => (
|
|
66
|
+
<th key={c.field || c.title}
|
|
67
|
+
onClick={() => c.sortable !== false && toggleSort(c.field)}
|
|
68
|
+
className={sortBy.field === c.field ? 'sorted' : ''}
|
|
69
|
+
style={{
|
|
70
|
+
width: c.width,
|
|
71
|
+
textAlign: c.hozAlign === 'right' ? 'right' : 'left',
|
|
72
|
+
cursor: c.sortable === false ? 'default' : 'pointer'
|
|
73
|
+
}}>
|
|
74
|
+
{c.title}
|
|
75
|
+
{c.sortable !== false && (
|
|
76
|
+
<span className="sort-ind">
|
|
77
|
+
{sortBy.field === c.field ? (sortBy.dir === 'asc' ? '▲' : '▼') : '▾'}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
</th>
|
|
81
|
+
))}
|
|
82
|
+
</tr>
|
|
83
|
+
</thead>
|
|
84
|
+
<tbody>
|
|
85
|
+
{sorted.length === 0 && (
|
|
86
|
+
<tr><td colSpan={columns.length} style={{textAlign:'center', padding:'30px', color:'var(--muted)'}}>{emptyText || '暂无数据'}</td></tr>
|
|
87
|
+
)}
|
|
88
|
+
{sorted.map((r, i) => {
|
|
89
|
+
const k = getKey ? getKey(r) : i;
|
|
90
|
+
return (
|
|
91
|
+
<tr key={k}
|
|
92
|
+
className={selectedKey === k ? 'selected' : ''}
|
|
93
|
+
onClick={() => onRowClick?.(r)}>
|
|
94
|
+
{columns.map(c => (
|
|
95
|
+
<td key={c.field || c.title}
|
|
96
|
+
style={{textAlign: c.hozAlign === 'right' ? 'right' : 'left'}}>
|
|
97
|
+
{c.render ? c.render(r) : (typeof c.value === 'function' ? c.value(r) : r[c.field])}
|
|
98
|
+
</td>
|
|
99
|
+
))}
|
|
100
|
+
</tr>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</tbody>
|
|
104
|
+
</table>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ───────────────────────────────────────────────────────────────
|
|
110
|
+
// Combined tabbed table panel
|
|
111
|
+
// ───────────────────────────────────────────────────────────────
|
|
112
|
+
function TablePanel({
|
|
113
|
+
daily,
|
|
114
|
+
sessions,
|
|
115
|
+
unattributedSessions,
|
|
116
|
+
runs,
|
|
117
|
+
sources,
|
|
118
|
+
totalTokens,
|
|
119
|
+
sessionTotalTokens,
|
|
120
|
+
taskTypes,
|
|
121
|
+
outputStatuses,
|
|
122
|
+
workPurposes,
|
|
123
|
+
workStages,
|
|
124
|
+
valueLevels,
|
|
125
|
+
outputTypes,
|
|
126
|
+
projectAliasRules,
|
|
127
|
+
projectAliasMatchTypes,
|
|
128
|
+
onSaveAnnotation,
|
|
129
|
+
onBatchSaveAnnotations,
|
|
130
|
+
onDeleteAnnotation,
|
|
131
|
+
onSaveOutput,
|
|
132
|
+
onDeleteOutput,
|
|
133
|
+
onSaveProjectAliasRule,
|
|
134
|
+
onDeleteProjectAliasRule,
|
|
135
|
+
onCreateBackup,
|
|
136
|
+
onExportAnnotations,
|
|
137
|
+
onImportAnnotations,
|
|
138
|
+
onDrill
|
|
139
|
+
}) {
|
|
140
|
+
const [tab, setTab] = useState('sources');
|
|
141
|
+
const [search, setSearch] = useState('');
|
|
142
|
+
const [editingSession, setEditingSession] = useState(null);
|
|
143
|
+
const [annotationBusy, setAnnotationBusy] = useState(false);
|
|
144
|
+
const [annotationError, setAnnotationError] = useState(null);
|
|
145
|
+
const [selectedSessions, setSelectedSessions] = useState(() => new Set());
|
|
146
|
+
const [batchOpen, setBatchOpen] = useState(false);
|
|
147
|
+
const [batchBusy, setBatchBusy] = useState(false);
|
|
148
|
+
const [batchError, setBatchError] = useState(null);
|
|
149
|
+
const [editingRule, setEditingRule] = useState(null);
|
|
150
|
+
const [ruleBusy, setRuleBusy] = useState(false);
|
|
151
|
+
const [ruleError, setRuleError] = useState(null);
|
|
152
|
+
const [panelMessage, setPanelMessage] = useState(null);
|
|
153
|
+
const [annotationPresets, setAnnotationPresets] = useState(() => readAnnotationPresets());
|
|
154
|
+
const importRef = useRef(null);
|
|
155
|
+
const formatRunTime = r => U.formatTs(r.collectedAt);
|
|
156
|
+
const queueRows = useMemo(
|
|
157
|
+
() => unattributedSessions || buildUnattributedSessions(sessions),
|
|
158
|
+
[sessions, unattributedSessions]
|
|
159
|
+
);
|
|
160
|
+
const reviewQueueRows = useMemo(
|
|
161
|
+
() => buildReviewUnattributedSessions(sessions),
|
|
162
|
+
[sessions]
|
|
163
|
+
);
|
|
164
|
+
const reviewProgress = useMemo(
|
|
165
|
+
() => buildReviewAttributionProgress(sessions),
|
|
166
|
+
[sessions]
|
|
167
|
+
);
|
|
168
|
+
const attributionTotalTokens = sessionTotalTokens ?? sessions.reduce((sum, s) => sum + (s.totalTokens || 0), 0);
|
|
169
|
+
const selectedSessionRows = useMemo(
|
|
170
|
+
() => sessions.filter(session => selectedSessions.has(sessionKey(session))),
|
|
171
|
+
[sessions, selectedSessions]
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Aggregate by source
|
|
175
|
+
const bySource = useMemo(() => {
|
|
176
|
+
const m = new Map();
|
|
177
|
+
for (const r of daily) {
|
|
178
|
+
const k = `${r.source}::${r.device}`;
|
|
179
|
+
if (!m.has(k)) m.set(k, { source: r.source, device: r.device, totalTokens: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUSD: 0, models: new Set() });
|
|
180
|
+
const x = m.get(k);
|
|
181
|
+
x.totalTokens += r.totalTokens;
|
|
182
|
+
x.inputTokens += r.inputTokens;
|
|
183
|
+
x.outputTokens += r.outputTokens;
|
|
184
|
+
x.cacheReadTokens += r.cacheReadTokens;
|
|
185
|
+
x.costUSD += r.costUSD;
|
|
186
|
+
x.models.add(r.model);
|
|
187
|
+
}
|
|
188
|
+
return Array.from(m.values()).map(x => ({...x, modelCount: x.models.size}));
|
|
189
|
+
}, [daily]);
|
|
190
|
+
|
|
191
|
+
const byModel = useMemo(() => {
|
|
192
|
+
const m = new Map();
|
|
193
|
+
for (const r of daily) {
|
|
194
|
+
const k = `${r.source}::${r.model}`;
|
|
195
|
+
if (!m.has(k)) m.set(k, { source: r.source, model: r.model, totalTokens: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUSD: 0, days: new Set() });
|
|
196
|
+
const x = m.get(k);
|
|
197
|
+
x.totalTokens += r.totalTokens;
|
|
198
|
+
x.inputTokens += r.inputTokens;
|
|
199
|
+
x.outputTokens += r.outputTokens;
|
|
200
|
+
x.cacheReadTokens += r.cacheReadTokens;
|
|
201
|
+
x.costUSD += r.costUSD;
|
|
202
|
+
x.days.add(r.usageDate);
|
|
203
|
+
}
|
|
204
|
+
return Array.from(m.values()).map(x => ({...x, dayCount: x.days.size}));
|
|
205
|
+
}, [daily]);
|
|
206
|
+
|
|
207
|
+
const attributionRows = useMemo(() => {
|
|
208
|
+
const m = new Map();
|
|
209
|
+
for (const s of sessions) {
|
|
210
|
+
const project = sessionProjectLabel(s);
|
|
211
|
+
const taskType = s.taskType || '未分类';
|
|
212
|
+
const outputStatus = s.outputStatus || '未标注';
|
|
213
|
+
const workPurpose = s.workPurpose || '未说明';
|
|
214
|
+
const workStage = s.workStage || '未说明';
|
|
215
|
+
const valueLevel = s.valueLevel || '未评估';
|
|
216
|
+
const k = `${project}::${taskType}::${outputStatus}::${workPurpose}::${workStage}::${valueLevel}`;
|
|
217
|
+
if (!m.has(k)) {
|
|
218
|
+
m.set(k, {
|
|
219
|
+
project,
|
|
220
|
+
taskType,
|
|
221
|
+
outputStatus,
|
|
222
|
+
workPurpose,
|
|
223
|
+
workStage,
|
|
224
|
+
valueLevel,
|
|
225
|
+
sessionCount: 0,
|
|
226
|
+
totalTokens: 0,
|
|
227
|
+
inputTokens: 0,
|
|
228
|
+
outputTokens: 0,
|
|
229
|
+
costUSD: 0,
|
|
230
|
+
sources: new Set(),
|
|
231
|
+
devices: new Set()
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const x = m.get(k);
|
|
235
|
+
x.sessionCount += 1;
|
|
236
|
+
x.totalTokens += s.totalTokens || 0;
|
|
237
|
+
x.inputTokens += s.inputTokens || 0;
|
|
238
|
+
x.outputTokens += s.outputTokens || 0;
|
|
239
|
+
x.costUSD += s.costUSD || 0;
|
|
240
|
+
if (s.source) x.sources.add(s.source);
|
|
241
|
+
if (s.device) x.devices.add(s.device);
|
|
242
|
+
}
|
|
243
|
+
return Array.from(m.values()).map(x => ({
|
|
244
|
+
...x,
|
|
245
|
+
sourceCount: x.sources.size,
|
|
246
|
+
deviceCount: x.devices.size
|
|
247
|
+
}));
|
|
248
|
+
}, [sessions]);
|
|
249
|
+
|
|
250
|
+
const TABS = [
|
|
251
|
+
{ id: 'sources', label: '来源 / 设备', count: bySource.length },
|
|
252
|
+
{ id: 'models', label: '模型', count: byModel.length },
|
|
253
|
+
{ id: 'sessions', label: '项目 / 会话', count: sessions.length },
|
|
254
|
+
{ id: 'unattributed', label: '待确认队列', count: queueRows.length },
|
|
255
|
+
{ id: 'attribution', label: '任务归因', count: attributionRows.length },
|
|
256
|
+
{ id: 'aliasRules', label: '别名规则', count: projectAliasRules.length },
|
|
257
|
+
{ id: 'runs', label: '采集记录', count: runs.length }
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
const isSessionTab = tab === 'sessions' || tab === 'unattributed';
|
|
261
|
+
const visibleSessionRows = tab === 'unattributed' ? queueRows : sessions;
|
|
262
|
+
const allVisibleSelected = visibleSessionRows.length > 0
|
|
263
|
+
&& visibleSessionRows.every(row => selectedSessions.has(sessionKey(row)));
|
|
264
|
+
|
|
265
|
+
const setVisibleSelection = (checked) => {
|
|
266
|
+
setSelectedSessions(prev => {
|
|
267
|
+
const next = new Set(prev);
|
|
268
|
+
for (const row of visibleSessionRows) {
|
|
269
|
+
const key = sessionKey(row);
|
|
270
|
+
if (checked) next.add(key); else next.delete(key);
|
|
271
|
+
}
|
|
272
|
+
return next;
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const toggleSessionSelection = (session) => {
|
|
277
|
+
const key = sessionKey(session);
|
|
278
|
+
setSelectedSessions(prev => {
|
|
279
|
+
const next = new Set(prev);
|
|
280
|
+
if (next.has(key)) next.delete(key); else next.add(key);
|
|
281
|
+
return next;
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
const openAnnotation = (session) => {
|
|
285
|
+
setAnnotationError(null);
|
|
286
|
+
setEditingSession(session);
|
|
287
|
+
};
|
|
288
|
+
const openTopReviewGap = () => {
|
|
289
|
+
const next = reviewQueueRows[0];
|
|
290
|
+
if (next) {
|
|
291
|
+
setTab('unattributed');
|
|
292
|
+
setSearch('');
|
|
293
|
+
openAnnotation(next);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
const copyReviewChecklist = async () => {
|
|
297
|
+
try {
|
|
298
|
+
await copyText(buildReviewAttributionChecklist(sessions, { limit: 10 }));
|
|
299
|
+
setPanelMessage({ type: 'ok', text: '已复制最高成本归因工作清单' });
|
|
300
|
+
} catch {
|
|
301
|
+
setPanelMessage({ type: 'error', text: '复制失败,请检查浏览器剪贴板权限' });
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
const nextReviewSession = (session) => {
|
|
305
|
+
const key = sessionKey(session);
|
|
306
|
+
return reviewQueueRows.find(row => sessionKey(row) !== key) || null;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// Columns per tab
|
|
310
|
+
const sourceColumns = [
|
|
311
|
+
{ field: 'source', title: '来源', render: r => (
|
|
312
|
+
<span className="tag"><span className="tag-dot" style={{background: U.getSourceColor(r.source)}}/>{r.source}</span>
|
|
313
|
+
)},
|
|
314
|
+
{ field: 'device', title: '设备', render: r => <span className="muted" style={{fontSize:11.5}}>{r.device}</span> },
|
|
315
|
+
{ field: 'modelCount', title: '模型', hozAlign: 'right', render: r => r.modelCount, width: 70 },
|
|
316
|
+
{ field: 'totalTokens', title: 'Total', hozAlign: 'right', render: r => (
|
|
317
|
+
<span className="num-strong">{U.fmt.format(r.totalTokens)}</span>
|
|
318
|
+
), width: 130 },
|
|
319
|
+
{ field: 'share', title: '占比', hozAlign: 'left',
|
|
320
|
+
value: r => r.totalTokens / (totalTokens || 1),
|
|
321
|
+
render: r => {
|
|
322
|
+
const p = (r.totalTokens / (totalTokens || 1)) * 100;
|
|
323
|
+
return (
|
|
324
|
+
<span>
|
|
325
|
+
<span className="share-bar"><span style={{width: `${Math.min(100, p)}%`, background: U.getSourceColor(r.source)}}/></span>
|
|
326
|
+
<span className="share-pct">{p.toFixed(1)}%</span>
|
|
327
|
+
</span>
|
|
328
|
+
);
|
|
329
|
+
}, width: 180
|
|
330
|
+
},
|
|
331
|
+
{ field: 'inputTokens', title: 'Input', hozAlign: 'right', render: r => U.compact(r.inputTokens), width: 80 },
|
|
332
|
+
{ field: 'outputTokens', title: 'Output', hozAlign: 'right', render: r => U.compact(r.outputTokens), width: 80 },
|
|
333
|
+
{ field: 'cacheReadTokens', title: 'Cache', hozAlign: 'right', render: r => U.compact(r.cacheReadTokens), width: 80 },
|
|
334
|
+
{ field: 'costUSD', title: '官方价', hozAlign: 'right', render: r => (
|
|
335
|
+
r.costUSD > 0 ? <span style={{color:'var(--c-amber)'}}>{U.fmtUS.format(r.costUSD)}</span> : <span className="muted">—</span>
|
|
336
|
+
), width: 90 }
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
const modelColumns = [
|
|
340
|
+
{ field: 'source', title: '来源', render: r => (
|
|
341
|
+
<span className="tag"><span className="tag-dot" style={{background: U.getSourceColor(r.source)}}/>{r.source}</span>
|
|
342
|
+
)},
|
|
343
|
+
{ field: 'model', title: '模型', render: r => <span className="mono">{r.model}</span> },
|
|
344
|
+
{ field: 'dayCount', title: '活跃天', hozAlign: 'right', render: r => r.dayCount, width: 80 },
|
|
345
|
+
{ field: 'inputTokens', title: 'Input', hozAlign: 'right', render: r => U.compact(r.inputTokens), width: 90 },
|
|
346
|
+
{ field: 'outputTokens', title: 'Output', hozAlign: 'right', render: r => U.compact(r.outputTokens), width: 90 },
|
|
347
|
+
{ field: 'cacheReadTokens', title: 'Cache Read', hozAlign: 'right', render: r => U.compact(r.cacheReadTokens), width: 110 },
|
|
348
|
+
{ field: 'totalTokens', title: 'Total', hozAlign: 'right', render: r => (
|
|
349
|
+
<span className="num-strong">{U.fmt.format(r.totalTokens)}</span>
|
|
350
|
+
), width: 130 },
|
|
351
|
+
{ field: 'costUSD', title: '官方价', hozAlign: 'right', render: r => (
|
|
352
|
+
r.costUSD > 0 ? <span style={{color:'var(--c-amber)'}}>{U.fmtUS4.format(r.costUSD)}</span> : <span className="muted">—</span>
|
|
353
|
+
), width: 100 }
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
const selectionColumn = {
|
|
357
|
+
field: 'selected',
|
|
358
|
+
title: (
|
|
359
|
+
<input
|
|
360
|
+
type="checkbox"
|
|
361
|
+
aria-label="选择当前列表"
|
|
362
|
+
checked={allVisibleSelected}
|
|
363
|
+
onChange={event => setVisibleSelection(event.target.checked)}
|
|
364
|
+
onClick={event => event.stopPropagation()} />
|
|
365
|
+
),
|
|
366
|
+
sortable: false,
|
|
367
|
+
export: false,
|
|
368
|
+
width: 42,
|
|
369
|
+
render: r => (
|
|
370
|
+
<input
|
|
371
|
+
type="checkbox"
|
|
372
|
+
aria-label="选择会话"
|
|
373
|
+
checked={selectedSessions.has(sessionKey(r))}
|
|
374
|
+
onChange={() => toggleSessionSelection(r)}
|
|
375
|
+
onClick={event => event.stopPropagation()} />
|
|
376
|
+
)
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const sessionColumns = [
|
|
380
|
+
{ field: 'source', title: '来源', render: r => (
|
|
381
|
+
<span className="tag"><span className="tag-dot" style={{background: U.getSourceColor(r.source)}}/>{r.source}</span>
|
|
382
|
+
), width: 130 },
|
|
383
|
+
{ field: 'model', title: '模型', render: r => (
|
|
384
|
+
r.model ? <span className="mono">{r.model}</span> : <span className="muted">—</span>
|
|
385
|
+
), width: 150 },
|
|
386
|
+
{ field: 'projectLabel', title: '项目', value: sessionProjectLabel, render: r => {
|
|
387
|
+
const raw = r.projectPath && r.projectPath !== 'Unknown Project'
|
|
388
|
+
? r.projectPath
|
|
389
|
+
: (r.sessionId ? r.sessionId.split('/').slice(-1)[0] || r.sessionId : '—');
|
|
390
|
+
return (
|
|
391
|
+
<span className="session-project" title={r.sessionId || ''}>
|
|
392
|
+
<span className="mono">{sessionProjectLabel(r)}</span>
|
|
393
|
+
{r.projectAlias && <span className="session-project-raw">{raw}</span>}
|
|
394
|
+
{r.ruleProjectAlias && !r.manualProjectAlias && <span className="session-project-rule">规则建议</span>}
|
|
395
|
+
</span>
|
|
396
|
+
);
|
|
397
|
+
}},
|
|
398
|
+
{ field: 'taskType', title: '任务', render: r => <span className="tag tag-soft">{r.taskType || '未分类'}</span>, width: 110 },
|
|
399
|
+
{ field: 'outputStatus', title: '状态', render: r => (
|
|
400
|
+
<span className={`status-badge annotation-status-${statusClass(r.outputStatus)}`}>{r.outputStatus || '未标注'}</span>
|
|
401
|
+
), width: 100 },
|
|
402
|
+
{ field: 'workPurpose', title: '目的', render: r => <span className="tag tag-soft">{r.workPurpose || '未说明'}</span>, width: 110 },
|
|
403
|
+
{ field: 'workStage', title: '阶段', render: r => <span className="tag tag-soft">{r.workStage || '未说明'}</span>, width: 90 },
|
|
404
|
+
{ field: 'valueLevel', title: '价值', render: r => <span className={`status-badge value-level-${valueClass(r.valueLevel)}`}>{r.valueLevel || '未评估'}</span>, width: 90 },
|
|
405
|
+
{ field: 'attributionQuality', title: '归因', value: r => attributionLabel(r), render: r => <AttributionSourceBadge session={r}/>, width: 110 },
|
|
406
|
+
{ field: 'annotationSource', title: '归因来源', value: r => r.annotationSource || (r.autoSuggestion ? 'suggested' : ''), render: r => <span className="muted">{r.annotationSource || (r.autoSuggestion ? 'suggested' : '—')}</span>, width: 90 },
|
|
407
|
+
{ field: 'annotationConfidence', title: '置信度', value: r => r.annotationConfidence ?? r.autoSuggestion?.annotationConfidence ?? '', render: r => {
|
|
408
|
+
const value = r.annotationConfidence ?? r.autoSuggestion?.annotationConfidence;
|
|
409
|
+
return value == null ? <span className="muted">—</span> : <span className="num-strong">{value}%</span>;
|
|
410
|
+
}, width: 80 },
|
|
411
|
+
{ field: 'annotationReason', title: '归因原因', value: r => r.annotationReason || r.autoSuggestion?.annotationReason || '', render: r => (
|
|
412
|
+
r.annotationReason || r.autoSuggestion?.annotationReason
|
|
413
|
+
? <span title={r.annotationReason || r.autoSuggestion?.annotationReason} className="annotation-note">{r.annotationReason || r.autoSuggestion?.annotationReason}</span>
|
|
414
|
+
: <span className="muted">—</span>
|
|
415
|
+
), width: 180 },
|
|
416
|
+
{ field: 'note', title: '备注', render: r => (
|
|
417
|
+
r.note
|
|
418
|
+
? <span title={r.note} className="annotation-note">{r.note}</span>
|
|
419
|
+
: <span className="muted">—</span>
|
|
420
|
+
), width: 150 },
|
|
421
|
+
{ field: 'outputLink', title: '产出', value: r => r.outputUrl ? `${r.outputLabel || ''} ${r.outputUrl}` : '', render: r => (
|
|
422
|
+
r.outputUrl
|
|
423
|
+
? <a className="output-link" href={r.outputUrl} target="_blank" rel="noreferrer" onClick={event => event.stopPropagation()}>
|
|
424
|
+
{r.outputLabel || r.outputType || '产出链接'}
|
|
425
|
+
</a>
|
|
426
|
+
: <span className="muted">—</span>
|
|
427
|
+
), width: 130 },
|
|
428
|
+
{ field: 'outputType', title: '类型', render: r => r.outputUrl ? <span className="tag tag-soft">{r.outputType || '未分类'}</span> : <span className="muted">—</span>, width: 90 },
|
|
429
|
+
{ field: 'lastActivity', title: '最后活动', render: r => (
|
|
430
|
+
<span className="muted" style={{fontSize:11.5}}>{r.lastActivity}</span>
|
|
431
|
+
), width: 130 },
|
|
432
|
+
{ field: 'inputTokens', title: 'Input', hozAlign: 'right', render: r => U.compact(r.inputTokens), width: 90 },
|
|
433
|
+
{ field: 'outputTokens', title: 'Output', hozAlign: 'right', render: r => U.compact(r.outputTokens), width: 90 },
|
|
434
|
+
{ field: 'totalTokens', title: 'Total', hozAlign: 'right', render: r => (
|
|
435
|
+
<span className="num-strong">{U.fmt.format(r.totalTokens)}</span>
|
|
436
|
+
), width: 130 },
|
|
437
|
+
{ field: 'costUSD', title: '官方价', hozAlign: 'right', render: r => (
|
|
438
|
+
r.costUSD > 0 ? <span style={{color:'var(--c-amber)'}}>{U.fmtUS4.format(r.costUSD)}</span> : <span className="muted">—</span>
|
|
439
|
+
), width: 100 },
|
|
440
|
+
{ field: 'annotationAction', title: '操作', sortable: false, export: false, render: r => (
|
|
441
|
+
<button className={`btn btn-mini ${hasSessionDetails(r) ? 'btn-annotated' : ''}`}
|
|
442
|
+
onClick={(event) => {
|
|
443
|
+
event.stopPropagation();
|
|
444
|
+
openAnnotation(r);
|
|
445
|
+
}}>
|
|
446
|
+
{hasSessionDetails(r) ? '编辑' : '标注'}
|
|
447
|
+
</button>
|
|
448
|
+
), width: 80 }
|
|
449
|
+
];
|
|
450
|
+
|
|
451
|
+
const attributionColumns = [
|
|
452
|
+
{ field: 'project', title: '项目', render: r => <span className="mono">{r.project}</span> },
|
|
453
|
+
{ field: 'taskType', title: '任务', render: r => <span className="tag tag-soft">{r.taskType}</span>, width: 120 },
|
|
454
|
+
{ field: 'outputStatus', title: '状态', render: r => (
|
|
455
|
+
<span className={`status-badge annotation-status-${statusClass(r.outputStatus)}`}>{r.outputStatus}</span>
|
|
456
|
+
), width: 110 },
|
|
457
|
+
{ field: 'workPurpose', title: '目的', render: r => <span className="tag tag-soft">{r.workPurpose}</span>, width: 110 },
|
|
458
|
+
{ field: 'workStage', title: '阶段', render: r => <span className="tag tag-soft">{r.workStage}</span>, width: 90 },
|
|
459
|
+
{ field: 'valueLevel', title: '价值', render: r => <span className={`status-badge value-level-${valueClass(r.valueLevel)}`}>{r.valueLevel}</span>, width: 90 },
|
|
460
|
+
{ field: 'sessionCount', title: '会话', hozAlign: 'right', render: r => r.sessionCount, width: 80 },
|
|
461
|
+
{ field: 'sourceCount', title: '来源', hozAlign: 'right', render: r => r.sourceCount, width: 80 },
|
|
462
|
+
{ field: 'deviceCount', title: '设备', hozAlign: 'right', render: r => r.deviceCount, width: 80 },
|
|
463
|
+
{ field: 'inputTokens', title: 'Input', hozAlign: 'right', render: r => U.compact(r.inputTokens), width: 90 },
|
|
464
|
+
{ field: 'outputTokens', title: 'Output', hozAlign: 'right', render: r => U.compact(r.outputTokens), width: 90 },
|
|
465
|
+
{ field: 'totalTokens', title: 'Total', hozAlign: 'right', render: r => (
|
|
466
|
+
<span className="num-strong">{U.fmt.format(r.totalTokens)}</span>
|
|
467
|
+
), width: 130 },
|
|
468
|
+
{ field: 'share', title: '占比', value: r => r.totalTokens / (attributionTotalTokens || 1), render: r => {
|
|
469
|
+
const p = (r.totalTokens / (attributionTotalTokens || 1)) * 100;
|
|
470
|
+
return <span className="share-pct">{p.toFixed(1)}%</span>;
|
|
471
|
+
}, width: 80 },
|
|
472
|
+
{ field: 'costUSD', title: '官方价', hozAlign: 'right', render: r => (
|
|
473
|
+
r.costUSD > 0 ? <span style={{color:'var(--c-amber)'}}>{U.fmtUS4.format(r.costUSD)}</span> : <span className="muted">—</span>
|
|
474
|
+
), width: 100 }
|
|
475
|
+
];
|
|
476
|
+
|
|
477
|
+
const aliasRuleColumns = [
|
|
478
|
+
{ field: 'pattern', title: '路径规则', render: r => (
|
|
479
|
+
<span className="session-project">
|
|
480
|
+
<span className="mono">{r.pattern}</span>
|
|
481
|
+
<span className="session-project-raw">{humanMatchType(r.matchType)}</span>
|
|
482
|
+
</span>
|
|
483
|
+
)},
|
|
484
|
+
{ field: 'projectAlias', title: '项目别名', render: r => <span className="tag tag-soft">{r.projectAlias}</span>, width: 180 },
|
|
485
|
+
{ field: 'enabled', title: '状态', render: r => (
|
|
486
|
+
<span className={`status-badge ${r.enabled ? 'status-ok' : 'status-empty'}`}>{r.enabled ? '启用' : '停用'}</span>
|
|
487
|
+
), width: 100 },
|
|
488
|
+
{ field: 'updatedAt', title: '更新时间', render: r => (
|
|
489
|
+
<span className="muted" style={{fontSize:11.5}}>{U.formatTs(r.updatedAt)}</span>
|
|
490
|
+
), width: 150 },
|
|
491
|
+
{ field: 'ruleAction', title: '操作', sortable: false, export: false, render: r => (
|
|
492
|
+
<button className="btn btn-mini" onClick={(event) => {
|
|
493
|
+
event.stopPropagation();
|
|
494
|
+
setPanelMessage(null);
|
|
495
|
+
setEditingRule(r);
|
|
496
|
+
}}>编辑</button>
|
|
497
|
+
), width: 80 }
|
|
498
|
+
];
|
|
499
|
+
|
|
500
|
+
const runColumns = [
|
|
501
|
+
{ field: 'collectedAt', title: '时间', render: r => (
|
|
502
|
+
<span className="mono" style={{fontSize: 11.5, color: 'var(--text-2)', whiteSpace: 'nowrap'}}>{formatRunTime(r)}</span>
|
|
503
|
+
), value: formatRunTime, width: 160 },
|
|
504
|
+
{ field: 'source', title: '来源', render: r => (
|
|
505
|
+
<span className="tag"><span className="tag-dot" style={{background: U.getSourceColor(r.source)}}/>{r.source}</span>
|
|
506
|
+
), width: 140 },
|
|
507
|
+
{ field: 'device', title: '设备', render: r => <span className="muted">{r.device}</span>, width: 200 },
|
|
508
|
+
{ field: 'status', title: '状态', render: r => (
|
|
509
|
+
<span className={`status-badge status-${r.status}`}>{r.status}</span>
|
|
510
|
+
), width: 90 },
|
|
511
|
+
{ field: 'message', title: '说明', render: r => (
|
|
512
|
+
<span title={r.message} style={{
|
|
513
|
+
color: 'var(--text-2)', fontSize: 12,
|
|
514
|
+
display: 'block', overflow: 'hidden',
|
|
515
|
+
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
516
|
+
maxWidth: 380
|
|
517
|
+
}}>{r.message}</span>
|
|
518
|
+
)}
|
|
519
|
+
];
|
|
520
|
+
|
|
521
|
+
let columns, rows, initialSort, emptyText;
|
|
522
|
+
if (tab === 'sources') { columns = sourceColumns; rows = bySource; initialSort = { field: 'totalTokens', dir: 'desc' }; emptyText = '当前筛选下无来源'; }
|
|
523
|
+
if (tab === 'models') { columns = modelColumns; rows = byModel; initialSort = { field: 'totalTokens', dir: 'desc' }; emptyText = '当前筛选下无模型'; }
|
|
524
|
+
if (tab === 'sessions') { columns = [selectionColumn, ...sessionColumns]; rows = sessions; initialSort = { field: 'totalTokens', dir: 'desc' }; emptyText = '暂无会话数据'; }
|
|
525
|
+
if (tab === 'unattributed') { columns = [selectionColumn, ...sessionColumns]; rows = queueRows; initialSort = { field: 'totalTokens', dir: 'desc' }; emptyText = '暂无待确认会话'; }
|
|
526
|
+
if (tab === 'attribution') { columns = attributionColumns; rows = attributionRows; initialSort = { field: 'totalTokens', dir: 'desc' }; emptyText = '暂无可归因的会话'; }
|
|
527
|
+
if (tab === 'aliasRules') { columns = aliasRuleColumns; rows = projectAliasRules; initialSort = { field: 'updatedAt', dir: 'desc' }; emptyText = '暂无项目别名规则'; }
|
|
528
|
+
if (tab === 'runs') { columns = runColumns; rows = runs; initialSort = { field: 'collectedAt', dir: 'desc' }; emptyText = '暂无采集记录'; }
|
|
529
|
+
|
|
530
|
+
const exportCSV = () => {
|
|
531
|
+
U.downloadCSV(`tokens-${tab}-${U.daysAgo(0)}.csv`, rows, columns.filter(c => c.export !== false));
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const openBatch = () => {
|
|
535
|
+
setBatchError(null);
|
|
536
|
+
setBatchOpen(true);
|
|
537
|
+
};
|
|
538
|
+
const rememberPresets = (values) => {
|
|
539
|
+
const next = rememberAnnotationPreset(annotationPresets, values);
|
|
540
|
+
setAnnotationPresets(next);
|
|
541
|
+
writeAnnotationPresets(next);
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const saveBatch = async (values) => {
|
|
545
|
+
setBatchBusy(true);
|
|
546
|
+
setBatchError(null);
|
|
547
|
+
try {
|
|
548
|
+
const payloadValues = {};
|
|
549
|
+
if (values.projectAlias) payloadValues.projectAlias = values.projectAlias;
|
|
550
|
+
if (values.taskType) payloadValues.taskType = values.taskType;
|
|
551
|
+
if (values.outputStatus) payloadValues.outputStatus = values.outputStatus;
|
|
552
|
+
if (values.workPurpose) payloadValues.workPurpose = values.workPurpose;
|
|
553
|
+
if (values.workStage) payloadValues.workStage = values.workStage;
|
|
554
|
+
if (values.valueLevel) payloadValues.valueLevel = values.valueLevel;
|
|
555
|
+
if (values.note) payloadValues.note = values.note;
|
|
556
|
+
if (Object.keys(payloadValues).length === 0) throw new Error('至少选择一个要批量更新的字段');
|
|
557
|
+
await onBatchSaveAnnotations({
|
|
558
|
+
sessions: selectedSessionRows.map(sessionIdentity),
|
|
559
|
+
values: payloadValues
|
|
560
|
+
});
|
|
561
|
+
rememberPresets(payloadValues);
|
|
562
|
+
setSelectedSessions(new Set());
|
|
563
|
+
setBatchOpen(false);
|
|
564
|
+
setPanelMessage({ type: 'ok', text: `已批量标注 ${selectedSessionRows.length} 个会话` });
|
|
565
|
+
} catch (error) {
|
|
566
|
+
setBatchError(error.message || '批量标注失败');
|
|
567
|
+
} finally {
|
|
568
|
+
setBatchBusy(false);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const saveRule = async (values) => {
|
|
573
|
+
setRuleBusy(true);
|
|
574
|
+
setRuleError(null);
|
|
575
|
+
try {
|
|
576
|
+
await onSaveProjectAliasRule(values);
|
|
577
|
+
setEditingRule(null);
|
|
578
|
+
setPanelMessage({ type: 'ok', text: '项目别名规则已保存' });
|
|
579
|
+
} catch (error) {
|
|
580
|
+
setRuleError(error.message || '保存规则失败');
|
|
581
|
+
} finally {
|
|
582
|
+
setRuleBusy(false);
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const deleteRule = async (rule) => {
|
|
587
|
+
setRuleBusy(true);
|
|
588
|
+
setRuleError(null);
|
|
589
|
+
try {
|
|
590
|
+
await onDeleteProjectAliasRule({ id: rule.id });
|
|
591
|
+
setEditingRule(null);
|
|
592
|
+
setPanelMessage({ type: 'ok', text: '项目别名规则已删除' });
|
|
593
|
+
} catch (error) {
|
|
594
|
+
setRuleError(error.message || '删除规则失败');
|
|
595
|
+
} finally {
|
|
596
|
+
setRuleBusy(false);
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const createBackup = async () => {
|
|
601
|
+
try {
|
|
602
|
+
const backup = await onCreateBackup();
|
|
603
|
+
setPanelMessage({ type: 'ok', text: `已创建备份:${backup.fileName}` });
|
|
604
|
+
} catch (error) {
|
|
605
|
+
setPanelMessage({ type: 'error', text: error.message || '创建备份失败' });
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const exportAnnotations = async () => {
|
|
610
|
+
try {
|
|
611
|
+
await onExportAnnotations();
|
|
612
|
+
setPanelMessage({ type: 'ok', text: '标注 JSON 已导出' });
|
|
613
|
+
} catch (error) {
|
|
614
|
+
setPanelMessage({ type: 'error', text: error.message || '导出失败' });
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const importAnnotations = async (event) => {
|
|
619
|
+
const file = event.target.files?.[0];
|
|
620
|
+
event.target.value = '';
|
|
621
|
+
if (!file) return;
|
|
622
|
+
try {
|
|
623
|
+
const result = await onImportAnnotations(file);
|
|
624
|
+
setPanelMessage({
|
|
625
|
+
type: 'ok',
|
|
626
|
+
text: `已导入:标注 ${result.sessionAnnotations || 0},产出 ${result.sessionOutputs || 0},规则 ${result.projectAliasRules || 0}`
|
|
627
|
+
});
|
|
628
|
+
} catch (error) {
|
|
629
|
+
setPanelMessage({ type: 'error', text: error.message || '导入失败' });
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const saveAnnotation = async (values, options = {}) => {
|
|
634
|
+
setAnnotationBusy(true);
|
|
635
|
+
setAnnotationError(null);
|
|
636
|
+
try {
|
|
637
|
+
await onSaveAnnotation({
|
|
638
|
+
device: editingSession.device,
|
|
639
|
+
source: editingSession.source,
|
|
640
|
+
sessionId: editingSession.sessionId,
|
|
641
|
+
projectAlias: values.projectAlias,
|
|
642
|
+
taskType: values.taskType,
|
|
643
|
+
outputStatus: values.outputStatus,
|
|
644
|
+
workPurpose: values.workPurpose,
|
|
645
|
+
workStage: values.workStage,
|
|
646
|
+
valueLevel: values.valueLevel,
|
|
647
|
+
note: values.note
|
|
648
|
+
});
|
|
649
|
+
if (values.outputUrl) {
|
|
650
|
+
await onSaveOutput({
|
|
651
|
+
device: editingSession.device,
|
|
652
|
+
source: editingSession.source,
|
|
653
|
+
sessionId: editingSession.sessionId,
|
|
654
|
+
outputUrl: values.outputUrl,
|
|
655
|
+
outputLabel: values.outputLabel,
|
|
656
|
+
outputType: values.outputType
|
|
657
|
+
});
|
|
658
|
+
} else if (editingSession.outputUrl) {
|
|
659
|
+
await onDeleteOutput({
|
|
660
|
+
device: editingSession.device,
|
|
661
|
+
source: editingSession.source,
|
|
662
|
+
sessionId: editingSession.sessionId
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
rememberPresets(values);
|
|
666
|
+
if (options.continueNext) {
|
|
667
|
+
const next = nextReviewSession(editingSession);
|
|
668
|
+
if (next) {
|
|
669
|
+
setEditingSession(next);
|
|
670
|
+
setPanelMessage({ type: 'ok', text: '已保存,已打开下一条需要补齐的会话' });
|
|
671
|
+
} else {
|
|
672
|
+
setEditingSession(null);
|
|
673
|
+
setPanelMessage({ type: 'ok', text: '已保存,当前筛选没有下一条需要补齐的会话' });
|
|
674
|
+
}
|
|
675
|
+
} else {
|
|
676
|
+
setEditingSession(null);
|
|
677
|
+
}
|
|
678
|
+
} catch (error) {
|
|
679
|
+
setAnnotationError(error.message || '保存失败');
|
|
680
|
+
} finally {
|
|
681
|
+
setAnnotationBusy(false);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
const clearAnnotation = async () => {
|
|
686
|
+
setAnnotationBusy(true);
|
|
687
|
+
setAnnotationError(null);
|
|
688
|
+
try {
|
|
689
|
+
await onDeleteAnnotation({
|
|
690
|
+
device: editingSession.device,
|
|
691
|
+
source: editingSession.source,
|
|
692
|
+
sessionId: editingSession.sessionId
|
|
693
|
+
});
|
|
694
|
+
setEditingSession(null);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
setAnnotationError(error.message || '清除失败');
|
|
697
|
+
} finally {
|
|
698
|
+
setAnnotationBusy(false);
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
return (
|
|
703
|
+
<div className="panel">
|
|
704
|
+
<div className="panel-header" style={{marginBottom: 14}}>
|
|
705
|
+
<div className="panel-tabs">
|
|
706
|
+
{TABS.map(t => (
|
|
707
|
+
<button key={t.id} className={`tab ${tab === t.id ? 'active' : ''}`} onClick={() => { setTab(t.id); setSearch(''); }}>
|
|
708
|
+
{t.label} <span style={{opacity:0.55, marginLeft:4}}>{t.count}</span>
|
|
709
|
+
</button>
|
|
710
|
+
))}
|
|
711
|
+
</div>
|
|
712
|
+
<div className="panel-actions">
|
|
713
|
+
<input ref={importRef} type="file" accept="application/json" style={{ display: 'none' }} onChange={importAnnotations}/>
|
|
714
|
+
<button className="btn" onClick={createBackup}>备份</button>
|
|
715
|
+
<button className="btn" onClick={exportAnnotations}>导出 JSON</button>
|
|
716
|
+
<button className="btn" onClick={() => importRef.current?.click()}>导入</button>
|
|
717
|
+
{tab === 'aliasRules' && (
|
|
718
|
+
<button className="btn btn-primary" onClick={() => {
|
|
719
|
+
setRuleError(null);
|
|
720
|
+
setEditingRule({ matchType: 'prefix', enabled: true });
|
|
721
|
+
}}>新增规则</button>
|
|
722
|
+
)}
|
|
723
|
+
<input className="search-input" placeholder="搜索..." value={search} onChange={e => setSearch(e.target.value)}/>
|
|
724
|
+
<button className="btn" onClick={exportCSV}>
|
|
725
|
+
<svg className="icon" viewBox="0 0 16 16" fill="none">
|
|
726
|
+
<path d="M8 2v8M5 7l3 3 3-3M3 13h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
727
|
+
</svg>
|
|
728
|
+
CSV
|
|
729
|
+
</button>
|
|
730
|
+
</div>
|
|
731
|
+
</div>
|
|
732
|
+
{panelMessage && (
|
|
733
|
+
<div className={`panel-message panel-message-${panelMessage.type}`}>
|
|
734
|
+
{panelMessage.text}
|
|
735
|
+
</div>
|
|
736
|
+
)}
|
|
737
|
+
{isSessionTab && (
|
|
738
|
+
<ReviewAttributionProgress
|
|
739
|
+
progress={reviewProgress}
|
|
740
|
+
nextSession={reviewQueueRows[0]}
|
|
741
|
+
onOpenNext={openTopReviewGap}
|
|
742
|
+
onCopyChecklist={copyReviewChecklist} />
|
|
743
|
+
)}
|
|
744
|
+
{isSessionTab && (
|
|
745
|
+
<div className="batch-bar">
|
|
746
|
+
<div>
|
|
747
|
+
<strong>{selectedSessionRows.length}</strong>
|
|
748
|
+
<span> 个会话已选</span>
|
|
749
|
+
{selectedSessionRows.length > 0 && <span className="muted"> · 当前筛选可批量设置任务类型、产出状态和备注</span>}
|
|
750
|
+
</div>
|
|
751
|
+
<div className="batch-actions">
|
|
752
|
+
<button className="btn" onClick={() => setVisibleSelection(true)} disabled={visibleSessionRows.length === 0}>选择当前列表</button>
|
|
753
|
+
<button className="btn" onClick={() => setSelectedSessions(new Set())} disabled={selectedSessions.size === 0}>清空</button>
|
|
754
|
+
<button className="btn btn-primary" onClick={openBatch} disabled={selectedSessionRows.length === 0}>批量标注</button>
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
)}
|
|
758
|
+
<DataTable
|
|
759
|
+
key={tab}
|
|
760
|
+
rows={rows}
|
|
761
|
+
columns={columns}
|
|
762
|
+
initialSort={initialSort}
|
|
763
|
+
search={search}
|
|
764
|
+
height={420}
|
|
765
|
+
emptyText={emptyText}
|
|
766
|
+
getKey={r => r.id ? `rule-${r.id}` : r.sessionId ? sessionKey(r) : `${r.source}-${r.model || ''}-${r.device || ''}-${r.collectedAt || ''}`}
|
|
767
|
+
onRowClick={tab === 'attribution' || tab === 'aliasRules' ? undefined : r => onDrill?.({ kind: tab === 'unattributed' ? 'session' : tab.slice(0,-1), row: r })}
|
|
768
|
+
/>
|
|
769
|
+
{batchOpen && (
|
|
770
|
+
<BatchAnnotationModal
|
|
771
|
+
count={selectedSessionRows.length}
|
|
772
|
+
taskTypes={taskTypes}
|
|
773
|
+
outputStatuses={outputStatuses}
|
|
774
|
+
workPurposes={workPurposes}
|
|
775
|
+
workStages={workStages}
|
|
776
|
+
valueLevels={valueLevels}
|
|
777
|
+
annotationPresets={annotationPresets}
|
|
778
|
+
busy={batchBusy}
|
|
779
|
+
error={batchError}
|
|
780
|
+
onSave={saveBatch}
|
|
781
|
+
onClose={() => {
|
|
782
|
+
if (!batchBusy) {
|
|
783
|
+
setBatchOpen(false);
|
|
784
|
+
setBatchError(null);
|
|
785
|
+
}
|
|
786
|
+
}} />
|
|
787
|
+
)}
|
|
788
|
+
{editingRule && (
|
|
789
|
+
<AliasRuleModal
|
|
790
|
+
rule={editingRule}
|
|
791
|
+
matchTypes={projectAliasMatchTypes}
|
|
792
|
+
busy={ruleBusy}
|
|
793
|
+
error={ruleError}
|
|
794
|
+
onSave={saveRule}
|
|
795
|
+
onDelete={editingRule.id ? () => deleteRule(editingRule) : null}
|
|
796
|
+
onClose={() => {
|
|
797
|
+
if (!ruleBusy) {
|
|
798
|
+
setEditingRule(null);
|
|
799
|
+
setRuleError(null);
|
|
800
|
+
}
|
|
801
|
+
}} />
|
|
802
|
+
)}
|
|
803
|
+
{editingSession && (
|
|
804
|
+
<AnnotationModal
|
|
805
|
+
session={editingSession}
|
|
806
|
+
taskTypes={taskTypes}
|
|
807
|
+
outputStatuses={outputStatuses}
|
|
808
|
+
workPurposes={workPurposes}
|
|
809
|
+
workStages={workStages}
|
|
810
|
+
valueLevels={valueLevels}
|
|
811
|
+
outputTypes={outputTypes}
|
|
812
|
+
annotationPresets={annotationPresets}
|
|
813
|
+
busy={annotationBusy}
|
|
814
|
+
error={annotationError}
|
|
815
|
+
onSave={saveAnnotation}
|
|
816
|
+
onSaveAndNext={(form) => saveAnnotation(form, { continueNext: true })}
|
|
817
|
+
onDelete={clearAnnotation}
|
|
818
|
+
hasNext={Boolean(nextReviewSession(editingSession))}
|
|
819
|
+
onClose={() => {
|
|
820
|
+
if (!annotationBusy) {
|
|
821
|
+
setEditingSession(null);
|
|
822
|
+
setAnnotationError(null);
|
|
823
|
+
}
|
|
824
|
+
}}
|
|
825
|
+
/>
|
|
826
|
+
)}
|
|
827
|
+
</div>
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function ReviewAttributionProgress({ progress, nextSession, onOpenNext, onCopyChecklist }) {
|
|
832
|
+
const sessionPct = (progress.completionShare * 100).toFixed(0);
|
|
833
|
+
const tokenPct = (progress.tokenCompletionShare * 100).toFixed(0);
|
|
834
|
+
return (
|
|
835
|
+
<div className="review-progress">
|
|
836
|
+
<div className="review-progress-main">
|
|
837
|
+
<div>
|
|
838
|
+
<strong>复盘归因进度 {sessionPct}%</strong>
|
|
839
|
+
<span>{progress.attributedSessionCount} / {progress.sessionCount} 个 session 已补齐任务、状态、目的、阶段和价值</span>
|
|
840
|
+
</div>
|
|
841
|
+
<div className="review-progress-meter" aria-label="复盘归因进度">
|
|
842
|
+
<span style={{width: `${Math.max(0, Math.min(100, progress.completionShare * 100))}%`}}/>
|
|
843
|
+
</div>
|
|
844
|
+
</div>
|
|
845
|
+
<div className="review-progress-side">
|
|
846
|
+
<span>已归因 token {tokenPct}%</span>
|
|
847
|
+
<span>{U.compact(progress.unattributedTokens)} tokens 待补齐</span>
|
|
848
|
+
<button className="btn btn-mini" onClick={onOpenNext} disabled={!nextSession}>
|
|
849
|
+
打开最高成本待确认
|
|
850
|
+
</button>
|
|
851
|
+
<button className="btn btn-mini" onClick={onCopyChecklist} disabled={!nextSession}>
|
|
852
|
+
复制归因清单
|
|
853
|
+
</button>
|
|
854
|
+
</div>
|
|
855
|
+
</div>
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function AttributionSourceBadge({ session }) {
|
|
860
|
+
const source = session.annotationSource || '';
|
|
861
|
+
const quality = session.attributionQuality || 'missing';
|
|
862
|
+
const confidence = Number(session.annotationConfidence || 0);
|
|
863
|
+
if (quality === 'missing') {
|
|
864
|
+
return session.autoSuggestion
|
|
865
|
+
? <span className="attribution-source-badge auto-low">建议 {session.autoSuggestion.annotationConfidence}%</span>
|
|
866
|
+
: <span className="attribution-source-badge missing">待确认</span>;
|
|
867
|
+
}
|
|
868
|
+
if (source === 'auto') {
|
|
869
|
+
return <span className={`attribution-source-badge ${confidence >= 80 ? 'auto-high' : 'auto-low'}`}>自动 {confidence}%</span>;
|
|
870
|
+
}
|
|
871
|
+
if (source === 'imported') return <span className="attribution-source-badge imported">导入确认</span>;
|
|
872
|
+
return <span className="attribution-source-badge manual">人工确认</span>;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function AutoSuggestionBox({ suggestion, annotationSource, confidence, reason, onApply }) {
|
|
876
|
+
const existingAuto = annotationSource === 'auto';
|
|
877
|
+
if (!suggestion && !existingAuto) return null;
|
|
878
|
+
const rows = suggestion ? [
|
|
879
|
+
['项目别名', suggestion.values.projectAlias],
|
|
880
|
+
['任务类型', suggestion.values.taskType],
|
|
881
|
+
['产出状态', suggestion.values.outputStatus],
|
|
882
|
+
['主要目的', suggestion.values.workPurpose],
|
|
883
|
+
['工作阶段', suggestion.values.workStage],
|
|
884
|
+
['产出价值', suggestion.values.valueLevel]
|
|
885
|
+
].filter(([, value]) => value) : [];
|
|
886
|
+
|
|
887
|
+
return (
|
|
888
|
+
<div className="auto-suggestion-box">
|
|
889
|
+
<div className="auto-suggestion-head">
|
|
890
|
+
<div>
|
|
891
|
+
<strong>{suggestion ? `自动建议 ${suggestion.annotationConfidence}%` : `自动归因 ${confidence || 0}%`}</strong>
|
|
892
|
+
<span>{suggestion ? suggestion.annotationReason : reason}</span>
|
|
893
|
+
</div>
|
|
894
|
+
{suggestion && (
|
|
895
|
+
<button className="btn btn-mini" type="button" onClick={() => onApply(suggestion.values)}>
|
|
896
|
+
套用建议
|
|
897
|
+
</button>
|
|
898
|
+
)}
|
|
899
|
+
</div>
|
|
900
|
+
{rows.length > 0 && (
|
|
901
|
+
<div className="auto-suggestion-grid">
|
|
902
|
+
{rows.map(([label, value]) => (
|
|
903
|
+
<div key={label}>
|
|
904
|
+
<span>{label}</span>
|
|
905
|
+
<b>{value}</b>
|
|
906
|
+
</div>
|
|
907
|
+
))}
|
|
908
|
+
</div>
|
|
909
|
+
)}
|
|
910
|
+
</div>
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function AnnotationModal({ session, taskTypes, outputStatuses, workPurposes, workStages, valueLevels, outputTypes, annotationPresets, busy, error, onSave, onSaveAndNext, onDelete, hasNext, onClose }) {
|
|
915
|
+
const [form, setForm] = useState(() => annotationFormFromSession(session));
|
|
916
|
+
|
|
917
|
+
useEffect(() => {
|
|
918
|
+
setForm(annotationFormFromSession(session));
|
|
919
|
+
}, [session]);
|
|
920
|
+
|
|
921
|
+
const update = (key, value) => setForm(prev => ({ ...prev, [key]: value }));
|
|
922
|
+
const applyLastTemplate = () => setForm(prev => applyAnnotationTemplate(prev, annotationPresets.lastTemplate));
|
|
923
|
+
const applyQuickTemplate = (template) => setForm(prev => applyAnnotationTemplate(prev, template.values, { includeProjectAlias: false }));
|
|
924
|
+
const title = sessionProjectLabel(session);
|
|
925
|
+
const projectListId = `recent-projects-${sessionKey(session).replace(/[^\w-]+/g, '-')}`;
|
|
926
|
+
|
|
927
|
+
return (
|
|
928
|
+
<>
|
|
929
|
+
<div className="modal-backdrop open" onClick={onClose}/>
|
|
930
|
+
<div className="annotation-modal" role="dialog" aria-modal="true" aria-label="标注项目会话">
|
|
931
|
+
<div className="annotation-modal-header">
|
|
932
|
+
<div>
|
|
933
|
+
<div className="eyebrow">项目会话标注</div>
|
|
934
|
+
<h3>{title}</h3>
|
|
935
|
+
</div>
|
|
936
|
+
<button className="drawer-close" onClick={onClose} disabled={busy}>
|
|
937
|
+
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
|
938
|
+
<path d="M3 3l7 7M10 3l-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
|
939
|
+
</svg>
|
|
940
|
+
</button>
|
|
941
|
+
</div>
|
|
942
|
+
|
|
943
|
+
<div className="annotation-modal-body">
|
|
944
|
+
<label className="form-field">
|
|
945
|
+
<span>项目别名</span>
|
|
946
|
+
<input value={form.projectAlias} maxLength={120} list={projectListId}
|
|
947
|
+
placeholder="例如 AI 选题雷达、简历优化页"
|
|
948
|
+
onChange={e => update('projectAlias', e.target.value)} />
|
|
949
|
+
<datalist id={projectListId}>
|
|
950
|
+
{annotationPresets.recentProjects.map(project => <option key={project} value={project}/>)}
|
|
951
|
+
</datalist>
|
|
952
|
+
</label>
|
|
953
|
+
<div className="preset-row">
|
|
954
|
+
<button className="btn btn-mini" onClick={applyLastTemplate} disabled={!annotationPresets.lastTemplate || busy}>
|
|
955
|
+
套用上次标注
|
|
956
|
+
</button>
|
|
957
|
+
<span>只套用项目、任务、状态、目的、阶段和价值;不套用备注或产出链接。</span>
|
|
958
|
+
</div>
|
|
959
|
+
<QuickTemplatePicker
|
|
960
|
+
templates={QUICK_ANNOTATION_TEMPLATES}
|
|
961
|
+
disabled={busy}
|
|
962
|
+
onApply={applyQuickTemplate}
|
|
963
|
+
/>
|
|
964
|
+
<AutoSuggestionBox
|
|
965
|
+
suggestion={session.autoSuggestion}
|
|
966
|
+
annotationSource={session.annotationSource}
|
|
967
|
+
confidence={session.annotationConfidence}
|
|
968
|
+
reason={session.annotationReason}
|
|
969
|
+
onApply={(values) => setForm(prev => ({ ...prev, ...values }))}
|
|
970
|
+
/>
|
|
971
|
+
<div className="form-grid">
|
|
972
|
+
<label className="form-field">
|
|
973
|
+
<span>任务类型</span>
|
|
974
|
+
<select value={form.taskType} onChange={e => update('taskType', e.target.value)}>
|
|
975
|
+
{taskTypes.map(t => <option key={t} value={t}>{t}</option>)}
|
|
976
|
+
</select>
|
|
977
|
+
</label>
|
|
978
|
+
<label className="form-field">
|
|
979
|
+
<span>产出状态</span>
|
|
980
|
+
<select value={form.outputStatus} onChange={e => update('outputStatus', e.target.value)}>
|
|
981
|
+
{outputStatuses.map(s => <option key={s} value={s}>{s}</option>)}
|
|
982
|
+
</select>
|
|
983
|
+
</label>
|
|
984
|
+
</div>
|
|
985
|
+
<div className="form-grid form-grid-3">
|
|
986
|
+
<label className="form-field">
|
|
987
|
+
<span>本次主要目的</span>
|
|
988
|
+
<select value={form.workPurpose} onChange={e => update('workPurpose', e.target.value)}>
|
|
989
|
+
{workPurposes.map(p => <option key={p} value={p}>{p}</option>)}
|
|
990
|
+
</select>
|
|
991
|
+
</label>
|
|
992
|
+
<label className="form-field">
|
|
993
|
+
<span>工作阶段</span>
|
|
994
|
+
<select value={form.workStage} onChange={e => update('workStage', e.target.value)}>
|
|
995
|
+
{workStages.map(s => <option key={s} value={s}>{s}</option>)}
|
|
996
|
+
</select>
|
|
997
|
+
</label>
|
|
998
|
+
<label className="form-field">
|
|
999
|
+
<span>产出价值</span>
|
|
1000
|
+
<select value={form.valueLevel} onChange={e => update('valueLevel', e.target.value)}>
|
|
1001
|
+
{valueLevels.map(v => <option key={v} value={v}>{v}</option>)}
|
|
1002
|
+
</select>
|
|
1003
|
+
</label>
|
|
1004
|
+
</div>
|
|
1005
|
+
<label className="form-field">
|
|
1006
|
+
<span>备注</span>
|
|
1007
|
+
<textarea value={form.note} maxLength={500}
|
|
1008
|
+
placeholder="只写复盘摘要,不放对话正文或敏感信息"
|
|
1009
|
+
onChange={e => update('note', e.target.value)} />
|
|
1010
|
+
</label>
|
|
1011
|
+
<div className="form-grid">
|
|
1012
|
+
<label className="form-field">
|
|
1013
|
+
<span>产出链接</span>
|
|
1014
|
+
<input value={form.outputUrl} maxLength={500}
|
|
1015
|
+
placeholder="PR / commit / 文章 / 部署地址"
|
|
1016
|
+
onChange={e => update('outputUrl', e.target.value)} />
|
|
1017
|
+
</label>
|
|
1018
|
+
<label className="form-field">
|
|
1019
|
+
<span>链接标签</span>
|
|
1020
|
+
<input value={form.outputLabel} maxLength={120}
|
|
1021
|
+
placeholder="例如 PR #42、发布页、复盘文章"
|
|
1022
|
+
onChange={e => update('outputLabel', e.target.value)} />
|
|
1023
|
+
</label>
|
|
1024
|
+
</div>
|
|
1025
|
+
<label className="form-field">
|
|
1026
|
+
<span>产出类型</span>
|
|
1027
|
+
<select value={form.outputType} onChange={e => update('outputType', e.target.value)}>
|
|
1028
|
+
{outputTypes.map(t => <option key={t} value={t}>{t}</option>)}
|
|
1029
|
+
</select>
|
|
1030
|
+
</label>
|
|
1031
|
+
<div className="annotation-meta">
|
|
1032
|
+
<span>{session.source}</span>
|
|
1033
|
+
<span>{U.compact(session.totalTokens)} tokens</span>
|
|
1034
|
+
<span>{session.lastActivity || '无活动日期'}</span>
|
|
1035
|
+
<span>{attributionLabel(session)}</span>
|
|
1036
|
+
{session.ruleProjectAlias && !session.manualProjectAlias && <span>规则建议:{session.ruleProjectAlias}</span>}
|
|
1037
|
+
</div>
|
|
1038
|
+
{error && <div className="form-error">{error}</div>}
|
|
1039
|
+
</div>
|
|
1040
|
+
|
|
1041
|
+
<div className="annotation-modal-actions">
|
|
1042
|
+
<button className="btn" onClick={onDelete} disabled={busy || !hasAnnotation(session)}>清除标注</button>
|
|
1043
|
+
<span className="form-spacer"/>
|
|
1044
|
+
<button className="btn" onClick={onClose} disabled={busy}>取消</button>
|
|
1045
|
+
<button className="btn" onClick={() => onSaveAndNext?.(form)} disabled={busy || !hasNext}>
|
|
1046
|
+
{busy ? '保存中' : '保存并下一条'}
|
|
1047
|
+
</button>
|
|
1048
|
+
<button className="btn btn-primary" onClick={() => onSave(form)} disabled={busy}>
|
|
1049
|
+
{busy ? '保存中' : '保存'}
|
|
1050
|
+
</button>
|
|
1051
|
+
</div>
|
|
1052
|
+
</div>
|
|
1053
|
+
</>
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
export function BatchAnnotationModal({ count, taskTypes, outputStatuses, workPurposes, workStages, valueLevels, annotationPresets, busy, error, onSave, onClose }) {
|
|
1058
|
+
const [form, setForm] = useState({
|
|
1059
|
+
projectAlias: '',
|
|
1060
|
+
taskType: '',
|
|
1061
|
+
outputStatus: '',
|
|
1062
|
+
workPurpose: '',
|
|
1063
|
+
workStage: '',
|
|
1064
|
+
valueLevel: '',
|
|
1065
|
+
note: ''
|
|
1066
|
+
});
|
|
1067
|
+
const update = (key, value) => setForm(prev => ({ ...prev, [key]: value }));
|
|
1068
|
+
const applyLastTemplate = () => setForm(prev => applyAnnotationTemplate(prev, annotationPresets.lastTemplate));
|
|
1069
|
+
const applyQuickTemplate = (template) => setForm(prev => applyAnnotationTemplate(prev, template.values, { includeProjectAlias: false }));
|
|
1070
|
+
|
|
1071
|
+
return (
|
|
1072
|
+
<>
|
|
1073
|
+
<div className="modal-backdrop open" onClick={onClose}/>
|
|
1074
|
+
<div className="annotation-modal" role="dialog" aria-modal="true" aria-label="批量标注会话">
|
|
1075
|
+
<div className="annotation-modal-header">
|
|
1076
|
+
<div>
|
|
1077
|
+
<div className="eyebrow">批量标注</div>
|
|
1078
|
+
<h3>{count} 个会话</h3>
|
|
1079
|
+
</div>
|
|
1080
|
+
<button className="drawer-close" onClick={onClose} disabled={busy}>
|
|
1081
|
+
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
|
1082
|
+
<path d="M3 3l7 7M10 3l-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
|
1083
|
+
</svg>
|
|
1084
|
+
</button>
|
|
1085
|
+
</div>
|
|
1086
|
+
<div className="annotation-modal-body">
|
|
1087
|
+
<label className="form-field">
|
|
1088
|
+
<span>项目别名</span>
|
|
1089
|
+
<input value={form.projectAlias} maxLength={120} list="batch-recent-projects"
|
|
1090
|
+
placeholder="留空则不修改项目别名"
|
|
1091
|
+
onChange={e => update('projectAlias', e.target.value)} />
|
|
1092
|
+
<datalist id="batch-recent-projects">
|
|
1093
|
+
{annotationPresets.recentProjects.map(project => <option key={project} value={project}/>)}
|
|
1094
|
+
</datalist>
|
|
1095
|
+
</label>
|
|
1096
|
+
<div className="preset-row">
|
|
1097
|
+
<button className="btn btn-mini" onClick={applyLastTemplate} disabled={!annotationPresets.lastTemplate || busy}>
|
|
1098
|
+
套用上次标注
|
|
1099
|
+
</button>
|
|
1100
|
+
<span>批量套用前请先确认当前选中的 session 真实属于同一类工作。</span>
|
|
1101
|
+
</div>
|
|
1102
|
+
<QuickTemplatePicker
|
|
1103
|
+
templates={QUICK_ANNOTATION_TEMPLATES}
|
|
1104
|
+
disabled={busy}
|
|
1105
|
+
onApply={applyQuickTemplate}
|
|
1106
|
+
/>
|
|
1107
|
+
<div className="form-grid">
|
|
1108
|
+
<label className="form-field">
|
|
1109
|
+
<span>任务类型</span>
|
|
1110
|
+
<select value={form.taskType} onChange={e => update('taskType', e.target.value)}>
|
|
1111
|
+
<option value="">不修改</option>
|
|
1112
|
+
{taskTypes.map(t => <option key={t} value={t}>{t}</option>)}
|
|
1113
|
+
</select>
|
|
1114
|
+
</label>
|
|
1115
|
+
<label className="form-field">
|
|
1116
|
+
<span>产出状态</span>
|
|
1117
|
+
<select value={form.outputStatus} onChange={e => update('outputStatus', e.target.value)}>
|
|
1118
|
+
<option value="">不修改</option>
|
|
1119
|
+
{outputStatuses.map(s => <option key={s} value={s}>{s}</option>)}
|
|
1120
|
+
</select>
|
|
1121
|
+
</label>
|
|
1122
|
+
</div>
|
|
1123
|
+
<div className="form-grid form-grid-3">
|
|
1124
|
+
<label className="form-field">
|
|
1125
|
+
<span>主要目的</span>
|
|
1126
|
+
<select value={form.workPurpose} onChange={e => update('workPurpose', e.target.value)}>
|
|
1127
|
+
<option value="">不修改</option>
|
|
1128
|
+
{workPurposes.map(p => <option key={p} value={p}>{p}</option>)}
|
|
1129
|
+
</select>
|
|
1130
|
+
</label>
|
|
1131
|
+
<label className="form-field">
|
|
1132
|
+
<span>工作阶段</span>
|
|
1133
|
+
<select value={form.workStage} onChange={e => update('workStage', e.target.value)}>
|
|
1134
|
+
<option value="">不修改</option>
|
|
1135
|
+
{workStages.map(s => <option key={s} value={s}>{s}</option>)}
|
|
1136
|
+
</select>
|
|
1137
|
+
</label>
|
|
1138
|
+
<label className="form-field">
|
|
1139
|
+
<span>产出价值</span>
|
|
1140
|
+
<select value={form.valueLevel} onChange={e => update('valueLevel', e.target.value)}>
|
|
1141
|
+
<option value="">不修改</option>
|
|
1142
|
+
{valueLevels.map(v => <option key={v} value={v}>{v}</option>)}
|
|
1143
|
+
</select>
|
|
1144
|
+
</label>
|
|
1145
|
+
</div>
|
|
1146
|
+
<label className="form-field">
|
|
1147
|
+
<span>备注</span>
|
|
1148
|
+
<textarea value={form.note} maxLength={500}
|
|
1149
|
+
placeholder="留空则不修改备注"
|
|
1150
|
+
onChange={e => update('note', e.target.value)} />
|
|
1151
|
+
</label>
|
|
1152
|
+
{error && <div className="form-error">{error}</div>}
|
|
1153
|
+
</div>
|
|
1154
|
+
<div className="annotation-modal-actions">
|
|
1155
|
+
<button className="btn" onClick={onClose} disabled={busy}>取消</button>
|
|
1156
|
+
<span className="form-spacer"/>
|
|
1157
|
+
<button className="btn btn-primary" onClick={() => onSave(form)} disabled={busy}>
|
|
1158
|
+
{busy ? '保存中' : '保存'}
|
|
1159
|
+
</button>
|
|
1160
|
+
</div>
|
|
1161
|
+
</div>
|
|
1162
|
+
</>
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function QuickTemplatePicker({ templates, disabled, onApply }) {
|
|
1167
|
+
return (
|
|
1168
|
+
<div className="quick-template-picker" aria-label="快捷标注模板">
|
|
1169
|
+
<div className="quick-template-head">
|
|
1170
|
+
<span>快捷模板</span>
|
|
1171
|
+
<p>只填结构化归因字段;保存前请按真实工作内容核对。</p>
|
|
1172
|
+
</div>
|
|
1173
|
+
<div className="quick-template-grid">
|
|
1174
|
+
{templates.map(template => (
|
|
1175
|
+
<button
|
|
1176
|
+
key={template.id}
|
|
1177
|
+
type="button"
|
|
1178
|
+
className="quick-template-button"
|
|
1179
|
+
disabled={disabled}
|
|
1180
|
+
onClick={() => onApply(template)}>
|
|
1181
|
+
<strong>{template.label}</strong>
|
|
1182
|
+
<span>{template.description}</span>
|
|
1183
|
+
</button>
|
|
1184
|
+
))}
|
|
1185
|
+
</div>
|
|
1186
|
+
</div>
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function AliasRuleModal({ rule, matchTypes, busy, error, onSave, onDelete, onClose }) {
|
|
1191
|
+
const [form, setForm] = useState(() => ({
|
|
1192
|
+
id: rule.id,
|
|
1193
|
+
pattern: rule.pattern || '',
|
|
1194
|
+
matchType: rule.matchType || 'prefix',
|
|
1195
|
+
projectAlias: rule.projectAlias || '',
|
|
1196
|
+
enabled: rule.enabled !== false
|
|
1197
|
+
}));
|
|
1198
|
+
const update = (key, value) => setForm(prev => ({ ...prev, [key]: value }));
|
|
1199
|
+
|
|
1200
|
+
useEffect(() => {
|
|
1201
|
+
setForm({
|
|
1202
|
+
id: rule.id,
|
|
1203
|
+
pattern: rule.pattern || '',
|
|
1204
|
+
matchType: rule.matchType || 'prefix',
|
|
1205
|
+
projectAlias: rule.projectAlias || '',
|
|
1206
|
+
enabled: rule.enabled !== false
|
|
1207
|
+
});
|
|
1208
|
+
}, [rule]);
|
|
1209
|
+
|
|
1210
|
+
return (
|
|
1211
|
+
<>
|
|
1212
|
+
<div className="modal-backdrop open" onClick={onClose}/>
|
|
1213
|
+
<div className="annotation-modal" role="dialog" aria-modal="true" aria-label="项目别名规则">
|
|
1214
|
+
<div className="annotation-modal-header">
|
|
1215
|
+
<div>
|
|
1216
|
+
<div className="eyebrow">项目别名规则</div>
|
|
1217
|
+
<h3>{rule.id ? '编辑规则' : '新增规则'}</h3>
|
|
1218
|
+
</div>
|
|
1219
|
+
<button className="drawer-close" onClick={onClose} disabled={busy}>
|
|
1220
|
+
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
|
1221
|
+
<path d="M3 3l7 7M10 3l-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
|
1222
|
+
</svg>
|
|
1223
|
+
</button>
|
|
1224
|
+
</div>
|
|
1225
|
+
<div className="annotation-modal-body">
|
|
1226
|
+
<label className="form-field">
|
|
1227
|
+
<span>路径规则</span>
|
|
1228
|
+
<input value={form.pattern} maxLength={300}
|
|
1229
|
+
placeholder="例如 D:/Projects/token-studio-roi"
|
|
1230
|
+
onChange={e => update('pattern', e.target.value)} />
|
|
1231
|
+
</label>
|
|
1232
|
+
<div className="form-grid">
|
|
1233
|
+
<label className="form-field">
|
|
1234
|
+
<span>匹配方式</span>
|
|
1235
|
+
<select value={form.matchType} onChange={e => update('matchType', e.target.value)}>
|
|
1236
|
+
{matchTypes.map(type => <option key={type} value={type}>{humanMatchType(type)}</option>)}
|
|
1237
|
+
</select>
|
|
1238
|
+
</label>
|
|
1239
|
+
<label className="form-field">
|
|
1240
|
+
<span>项目别名</span>
|
|
1241
|
+
<input value={form.projectAlias} maxLength={120}
|
|
1242
|
+
placeholder="例如 Token Studio"
|
|
1243
|
+
onChange={e => update('projectAlias', e.target.value)} />
|
|
1244
|
+
</label>
|
|
1245
|
+
</div>
|
|
1246
|
+
<label className={`toggle ${form.enabled ? 'on' : ''}`} style={{width: 'fit-content'}}>
|
|
1247
|
+
<span className="toggle-slot"/>
|
|
1248
|
+
<input type="checkbox" checked={form.enabled}
|
|
1249
|
+
onChange={e => update('enabled', e.target.checked)}
|
|
1250
|
+
style={{display:'none'}} />
|
|
1251
|
+
启用规则
|
|
1252
|
+
</label>
|
|
1253
|
+
<div className="annotation-meta">
|
|
1254
|
+
<span>人工项目别名优先于规则建议</span>
|
|
1255
|
+
<span>规则只根据项目路径匹配,不读取对话正文</span>
|
|
1256
|
+
</div>
|
|
1257
|
+
{error && <div className="form-error">{error}</div>}
|
|
1258
|
+
</div>
|
|
1259
|
+
<div className="annotation-modal-actions">
|
|
1260
|
+
{onDelete && <button className="btn" onClick={onDelete} disabled={busy}>删除规则</button>}
|
|
1261
|
+
<span className="form-spacer"/>
|
|
1262
|
+
<button className="btn" onClick={onClose} disabled={busy}>取消</button>
|
|
1263
|
+
<button className="btn btn-primary" onClick={() => onSave(form)} disabled={busy}>
|
|
1264
|
+
{busy ? '保存中' : '保存'}
|
|
1265
|
+
</button>
|
|
1266
|
+
</div>
|
|
1267
|
+
</div>
|
|
1268
|
+
</>
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function annotationFormFromSession(session) {
|
|
1273
|
+
return {
|
|
1274
|
+
projectAlias: session.manualProjectAlias || '',
|
|
1275
|
+
taskType: session.taskType || '未分类',
|
|
1276
|
+
outputStatus: session.outputStatus || '未标注',
|
|
1277
|
+
workPurpose: session.workPurpose || '未说明',
|
|
1278
|
+
workStage: session.workStage || '未说明',
|
|
1279
|
+
valueLevel: session.valueLevel || '未评估',
|
|
1280
|
+
note: session.note || '',
|
|
1281
|
+
outputUrl: session.outputUrl || '',
|
|
1282
|
+
outputLabel: session.outputLabel || '',
|
|
1283
|
+
outputType: session.outputType || '未分类'
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function hasAnnotation(session) {
|
|
1288
|
+
return Boolean(
|
|
1289
|
+
session.manualProjectAlias ||
|
|
1290
|
+
session.note ||
|
|
1291
|
+
(session.taskType && session.taskType !== '未分类') ||
|
|
1292
|
+
(session.outputStatus && session.outputStatus !== '未标注') ||
|
|
1293
|
+
(session.workPurpose && session.workPurpose !== '未说明') ||
|
|
1294
|
+
(session.workStage && session.workStage !== '未说明') ||
|
|
1295
|
+
(session.valueLevel && session.valueLevel !== '未评估')
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function hasSessionDetails(session) {
|
|
1300
|
+
return hasAnnotation(session) || Boolean(session.outputUrl);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function attributionLabel(session) {
|
|
1304
|
+
if (session.annotationSource === 'auto') return `自动 ${Number(session.annotationConfidence || 0)}%`;
|
|
1305
|
+
if (session.annotationSource === 'manual') return '人工确认';
|
|
1306
|
+
if (session.annotationSource === 'imported') return '导入确认';
|
|
1307
|
+
if (session.autoSuggestion) return `建议 ${session.autoSuggestion.annotationConfidence}%`;
|
|
1308
|
+
return '待确认';
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function sessionProjectLabel(session) {
|
|
1312
|
+
if (session.projectAlias) return session.projectAlias;
|
|
1313
|
+
if (session.projectPath && session.projectPath !== 'Unknown Project') return session.projectPath;
|
|
1314
|
+
if (session.sessionId) return session.sessionId.split('/').slice(-1)[0] || session.sessionId;
|
|
1315
|
+
return '未归档项目';
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function sessionKey(session) {
|
|
1319
|
+
return `${session.device}::${session.source}::${session.sessionId}`;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function sessionIdentity(session) {
|
|
1323
|
+
return {
|
|
1324
|
+
device: session.device,
|
|
1325
|
+
source: session.source,
|
|
1326
|
+
sessionId: session.sessionId
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function humanMatchType(type) {
|
|
1331
|
+
if (type === 'prefix') return '路径前缀';
|
|
1332
|
+
if (type === 'contains') return '包含文本';
|
|
1333
|
+
if (type === 'regex') return '正则';
|
|
1334
|
+
return type || '路径前缀';
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function statusClass(value) {
|
|
1338
|
+
return String(value || '未标注').replace(/[^\u4e00-\u9fa5\w-]+/g, '-');
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function valueClass(value) {
|
|
1342
|
+
return String(value || '未评估').replace(/[^\u4e00-\u9fa5\w-]+/g, '-');
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// ───────────────────────────────────────────────────────────────
|
|
1346
|
+
// Drawer — drill-down panel
|
|
1347
|
+
// ───────────────────────────────────────────────────────────────
|
|
1348
|
+
function DrillDrawer({ drill, daily, onClose }) {
|
|
1349
|
+
const open = !!drill;
|
|
1350
|
+
|
|
1351
|
+
useEffect(() => {
|
|
1352
|
+
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
|
1353
|
+
document.addEventListener('keydown', onKey);
|
|
1354
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
1355
|
+
}, [onClose]);
|
|
1356
|
+
|
|
1357
|
+
const detail = useMemo(() => {
|
|
1358
|
+
if (!drill) return null;
|
|
1359
|
+
const { kind, row } = drill;
|
|
1360
|
+
let title = '', sub = '', filterFn = () => true;
|
|
1361
|
+
if (kind === 'source') { title = row.source; sub = row.device; filterFn = r => r.source === row.source && r.device === row.device; }
|
|
1362
|
+
if (kind === 'model') { title = row.model; sub = row.source; filterFn = r => r.source === row.source && r.model === row.model; }
|
|
1363
|
+
if (kind === 'session'){ title = row.projectPath || row.sessionId; sub = `${row.source} · ${row.device}`;
|
|
1364
|
+
filterFn = r => r.source === row.source; /* session doesn't tie to daily directly — show source's daily */ }
|
|
1365
|
+
if (kind === 'run') { title = `采集: ${row.source}`; sub = U.formatTs(row.collectedAt); filterFn = () => false; }
|
|
1366
|
+
|
|
1367
|
+
const matching = daily.filter(filterFn);
|
|
1368
|
+
const totals = U.aggregateTotals(matching);
|
|
1369
|
+
const byDate = U.groupByDate(matching);
|
|
1370
|
+
const dates = Array.from(byDate.keys()).sort();
|
|
1371
|
+
const values = dates.map(d => {
|
|
1372
|
+
let sum = 0;
|
|
1373
|
+
const sources = byDate.get(d);
|
|
1374
|
+
for (const k of Object.keys(sources)) sum += sources[k];
|
|
1375
|
+
return sum;
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
return { kind, row, title, sub, totals, dates, values, count: matching.length };
|
|
1379
|
+
}, [drill, daily]);
|
|
1380
|
+
|
|
1381
|
+
return (
|
|
1382
|
+
<>
|
|
1383
|
+
<div className={`drawer-backdrop ${open ? 'open' : ''}`} onClick={onClose}/>
|
|
1384
|
+
<div className={`drawer ${open ? 'open' : ''}`} role="dialog">
|
|
1385
|
+
{detail && (
|
|
1386
|
+
<>
|
|
1387
|
+
<div className="drawer-header" style={{position: 'relative'}}>
|
|
1388
|
+
<button className="drawer-close" onClick={onClose}>
|
|
1389
|
+
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
|
1390
|
+
<path d="M3 3l7 7M10 3l-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
|
1391
|
+
</svg>
|
|
1392
|
+
</button>
|
|
1393
|
+
<div style={{fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 4}}>
|
|
1394
|
+
{detail.kind === 'source' && '来源详情'}
|
|
1395
|
+
{detail.kind === 'model' && '模型详情'}
|
|
1396
|
+
{detail.kind === 'session' && '项目详情'}
|
|
1397
|
+
{detail.kind === 'run' && '采集详情'}
|
|
1398
|
+
</div>
|
|
1399
|
+
<h3>{detail.title}</h3>
|
|
1400
|
+
<div className="sub">{detail.sub}</div>
|
|
1401
|
+
</div>
|
|
1402
|
+
<div className="drawer-body">
|
|
1403
|
+
{detail.kind !== 'run' ? (
|
|
1404
|
+
<>
|
|
1405
|
+
<div className="drawer-kpi-row">
|
|
1406
|
+
<div className="drawer-kpi">
|
|
1407
|
+
<div className="l">Total</div>
|
|
1408
|
+
<div className="v">{U.compactCN(detail.totals.totalTokens)}</div>
|
|
1409
|
+
</div>
|
|
1410
|
+
<div className="drawer-kpi">
|
|
1411
|
+
<div className="l">官方价</div>
|
|
1412
|
+
<div className="v" style={{color: detail.totals.costUSD > 0 ? 'var(--c-amber)' : 'var(--muted)'}}>
|
|
1413
|
+
{detail.totals.costUSD > 0 ? U.fmtUS.format(detail.totals.costUSD) : '—'}
|
|
1414
|
+
</div>
|
|
1415
|
+
</div>
|
|
1416
|
+
<div className="drawer-kpi">
|
|
1417
|
+
<div className="l">活跃天数</div>
|
|
1418
|
+
<div className="v">{detail.dates.length}</div>
|
|
1419
|
+
</div>
|
|
1420
|
+
</div>
|
|
1421
|
+
|
|
1422
|
+
<div className="detail-section">
|
|
1423
|
+
<h4>趋势</h4>
|
|
1424
|
+
<DrillSpark dates={detail.dates} values={detail.values}/>
|
|
1425
|
+
</div>
|
|
1426
|
+
|
|
1427
|
+
<div className="detail-section">
|
|
1428
|
+
<h4>分布</h4>
|
|
1429
|
+
<div className="detail-row"><span className="k">Input</span><span className="v">{U.fmt.format(detail.totals.inputTokens)}</span></div>
|
|
1430
|
+
<div className="detail-row"><span className="k">Output</span><span className="v">{U.fmt.format(detail.totals.outputTokens)}</span></div>
|
|
1431
|
+
<div className="detail-row"><span className="k">Cache Read</span><span className="v">{U.fmt.format(detail.totals.cacheReadTokens)}</span></div>
|
|
1432
|
+
<div className="detail-row"><span className="k">Cache Creation</span><span className="v">{U.fmt.format(detail.totals.cacheCreationTokens)}</span></div>
|
|
1433
|
+
<div className="detail-row"><span className="k">Reasoning</span><span className="v">{U.fmt.format(detail.totals.reasoningTokens)}</span></div>
|
|
1434
|
+
<div className="detail-row"><span className="k">缓存命中率</span><span className="v" style={{color:'var(--c-indigo)', fontWeight: 600}}>{detail.totals.cacheHitRate.toFixed(1)}%</span></div>
|
|
1435
|
+
</div>
|
|
1436
|
+
|
|
1437
|
+
{detail.kind === 'session' && (
|
|
1438
|
+
<div className="detail-section">
|
|
1439
|
+
<h4>元数据</h4>
|
|
1440
|
+
<div className="detail-row"><span className="k">项目别名</span><span className="v">{detail.row.projectAlias || '—'}</span></div>
|
|
1441
|
+
<div className="detail-row"><span className="k">模型</span><span className="v mono">{detail.row.model || '—'}</span></div>
|
|
1442
|
+
<div className="detail-row"><span className="k">任务类型</span><span className="v">{detail.row.taskType || '未分类'}</span></div>
|
|
1443
|
+
<div className="detail-row"><span className="k">产出状态</span><span className="v">{detail.row.outputStatus || '未标注'}</span></div>
|
|
1444
|
+
<div className="detail-row"><span className="k">主要目的</span><span className="v">{detail.row.workPurpose || '未说明'}</span></div>
|
|
1445
|
+
<div className="detail-row"><span className="k">工作阶段</span><span className="v">{detail.row.workStage || '未说明'}</span></div>
|
|
1446
|
+
<div className="detail-row"><span className="k">产出价值</span><span className="v">{detail.row.valueLevel || '未评估'}</span></div>
|
|
1447
|
+
<div className="detail-row"><span className="k">产出类型</span><span className="v">{detail.row.outputUrl ? (detail.row.outputType || '未分类') : '—'}</span></div>
|
|
1448
|
+
<div className="detail-row"><span className="k">备注</span><span className="v" style={{maxWidth: '60%', textAlign: 'right'}}>{detail.row.note || '—'}</span></div>
|
|
1449
|
+
<div className="detail-row"><span className="k">Session ID</span><span className="v mono" style={{fontSize: 11, maxWidth: '60%', textAlign: 'right'}}>{detail.row.sessionId}</span></div>
|
|
1450
|
+
<div className="detail-row"><span className="k">最后活动</span><span className="v">{detail.row.lastActivity}</span></div>
|
|
1451
|
+
</div>
|
|
1452
|
+
)}
|
|
1453
|
+
|
|
1454
|
+
{detail.kind === 'model' && (
|
|
1455
|
+
<div className="detail-section">
|
|
1456
|
+
<h4>记录</h4>
|
|
1457
|
+
<div className="detail-row"><span className="k">活跃天数</span><span className="v">{detail.row.dayCount}</span></div>
|
|
1458
|
+
</div>
|
|
1459
|
+
)}
|
|
1460
|
+
</>
|
|
1461
|
+
) : (
|
|
1462
|
+
<div className="detail-section">
|
|
1463
|
+
<h4>状态</h4>
|
|
1464
|
+
<div style={{padding: '12px 14px', background: 'var(--surface-2)', borderRadius: 8, fontSize: 12.5}}>
|
|
1465
|
+
<span className={`status-badge status-${detail.row.status}`}>{detail.row.status}</span>
|
|
1466
|
+
<p style={{margin: '10px 0 0', lineHeight: 1.6}}>{detail.row.message}</p>
|
|
1467
|
+
</div>
|
|
1468
|
+
</div>
|
|
1469
|
+
)}
|
|
1470
|
+
</div>
|
|
1471
|
+
</>
|
|
1472
|
+
)}
|
|
1473
|
+
</div>
|
|
1474
|
+
</>
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// Small sparkline for drawer
|
|
1479
|
+
function DrillSpark({ dates, values }) {
|
|
1480
|
+
if (!dates.length) return <div className="empty">无数据</div>;
|
|
1481
|
+
const w = 480, h = 120;
|
|
1482
|
+
const max = Math.max(...values, 1);
|
|
1483
|
+
const pad = 16;
|
|
1484
|
+
const pts = values.map((v, i) => {
|
|
1485
|
+
const x = pad + (i / Math.max(1, values.length - 1)) * (w - pad * 2);
|
|
1486
|
+
const y = h - pad - (v / max) * (h - pad * 2);
|
|
1487
|
+
return [x, y];
|
|
1488
|
+
});
|
|
1489
|
+
const d = pts.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(' ');
|
|
1490
|
+
const dArea = d + ` L${w-pad},${h-pad} L${pad},${h-pad} Z`;
|
|
1491
|
+
|
|
1492
|
+
return (
|
|
1493
|
+
<svg viewBox={`0 0 ${w} ${h}`} style={{width: '100%', height: 120, display: 'block'}}>
|
|
1494
|
+
<defs>
|
|
1495
|
+
<linearGradient id="drillGrad" x1="0" y1="0" x2="0" y2="1">
|
|
1496
|
+
<stop offset="0%" stopColor="oklch(0.55 0.16 265)" stopOpacity="0.25"/>
|
|
1497
|
+
<stop offset="100%" stopColor="oklch(0.55 0.16 265)" stopOpacity="0"/>
|
|
1498
|
+
</linearGradient>
|
|
1499
|
+
</defs>
|
|
1500
|
+
<path d={dArea} fill="url(#drillGrad)"/>
|
|
1501
|
+
<path d={d} fill="none" stroke="oklch(0.55 0.16 265)" strokeWidth="2" strokeLinejoin="round"/>
|
|
1502
|
+
{pts.map((p, i) => (
|
|
1503
|
+
<circle key={i} cx={p[0]} cy={p[1]} r="2" fill="oklch(0.55 0.16 265)" opacity={i === pts.length - 1 ? 1 : 0}/>
|
|
1504
|
+
))}
|
|
1505
|
+
<text x={pad} y={h - 2} fontSize="9" fill="oklch(0.62 0.005 80)" style={{fontFamily: 'var(--font-mono)'}}>{dates[0]}</text>
|
|
1506
|
+
<text x={w - pad} y={h - 2} textAnchor="end" fontSize="9" fill="oklch(0.62 0.005 80)" style={{fontFamily: 'var(--font-mono)'}}>{dates[dates.length - 1]}</text>
|
|
1507
|
+
</svg>
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
async function copyText(text) {
|
|
1512
|
+
if (navigator.clipboard?.writeText) {
|
|
1513
|
+
try {
|
|
1514
|
+
await navigator.clipboard.writeText(text);
|
|
1515
|
+
return;
|
|
1516
|
+
} catch {
|
|
1517
|
+
// Fall through to textarea copy for restrictive browser contexts.
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
const el = document.createElement('textarea');
|
|
1521
|
+
el.value = text;
|
|
1522
|
+
el.setAttribute('readonly', '');
|
|
1523
|
+
el.style.position = 'fixed';
|
|
1524
|
+
el.style.left = '-9999px';
|
|
1525
|
+
document.body.appendChild(el);
|
|
1526
|
+
el.select();
|
|
1527
|
+
document.execCommand('copy');
|
|
1528
|
+
document.body.removeChild(el);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
export { TablePanel, DrillDrawer };
|