cc-insight 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/README.zh.md +106 -0
- package/bin/cc-insight.js +51 -0
- package/package.json +51 -0
- package/public/index.html +239 -0
- package/public/js/app.js +129 -0
- package/public/js/charts.js +0 -0
- package/public/js/heatmap.js +0 -0
- package/public/js/insights.js +326 -0
- package/public/js/mcp.js +143 -0
- package/public/js/overview.js +421 -0
- package/public/js/poster.js +1025 -0
- package/public/js/skills.js +637 -0
- package/public/js/theme.js +4 -0
- package/src/api.js +501 -0
- package/src/classifiers/topic-rules.js +100 -0
- package/src/config.js +28 -0
- package/src/db/db.js +39 -0
- package/src/db/queries.js +314 -0
- package/src/db/schema.js +46 -0
- package/src/indexer.js +158 -0
- package/src/parsers/jsonl.js +70 -0
- package/src/parsers/security.js +23 -0
- package/src/parsers/skill-md.js +38 -0
- package/src/poster.js +265 -0
- package/src/server.js +67 -0
- package/src/watcher.js +28 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
// public/js/skills.js
|
|
2
|
+
import { setRange } from './app.js'
|
|
3
|
+
|
|
4
|
+
// 跨 renderSkills 调用保持翻页位置(切换 tab 时由 app.js 重置)
|
|
5
|
+
const _state = { topPage: 0, unusedPage: 0, listPage: 0, listFilter: 'all' }
|
|
6
|
+
export function resetSkillsState() {
|
|
7
|
+
_state.topPage = 0; _state.unusedPage = 0; _state.listPage = 0; _state.listFilter = 'all'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function rangeFilter(current) {
|
|
11
|
+
const ranges = [
|
|
12
|
+
{ value: '7d', label: '7 天' },
|
|
13
|
+
{ value: '30d', label: '30 天' },
|
|
14
|
+
{ value: '90d', label: '90 天' },
|
|
15
|
+
{ value: 'all', label: '全部' },
|
|
16
|
+
]
|
|
17
|
+
return `
|
|
18
|
+
<div class="range-filter">
|
|
19
|
+
<span>时间范围:</span>
|
|
20
|
+
${ranges.map(r => `
|
|
21
|
+
<button class="range-btn ${r.value === current ? 'active' : ''}"
|
|
22
|
+
data-range="${r.value}">${r.label}</button>
|
|
23
|
+
`).join('')}
|
|
24
|
+
</div>`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function usageCard(label, color, total, used) {
|
|
28
|
+
const pct = total > 0 ? Math.round(used / total * 100) : 0
|
|
29
|
+
const unused = total - used
|
|
30
|
+
return `
|
|
31
|
+
<div class="card">
|
|
32
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
|
33
|
+
<span class="card-label" style="color:${color};">${label}</span>
|
|
34
|
+
<span style="font-size:22px;font-weight:bold;color:${color};">${total}</span>
|
|
35
|
+
</div>
|
|
36
|
+
<div style="background:var(--bg3);border-radius:3px;height:4px;margin-bottom:8px;">
|
|
37
|
+
<div style="background:${color};height:4px;border-radius:3px;width:${pct}%;
|
|
38
|
+
transition:width 0.4s ease;"></div>
|
|
39
|
+
</div>
|
|
40
|
+
<div style="display:flex;justify-content:space-between;">
|
|
41
|
+
<span style="font-size:14px;color:var(--muted);">
|
|
42
|
+
使用率 <span style="color:${color};">${used}/${total}</span>
|
|
43
|
+
</span>
|
|
44
|
+
${total === 0
|
|
45
|
+
? `<span style="font-size:14px;color:var(--red);">未用 —</span>`
|
|
46
|
+
: unused > 0
|
|
47
|
+
? `<span style="font-size:14px;color:var(--red);">未用 ${unused}</span>`
|
|
48
|
+
: `<span style="font-size:14px;color:var(--green);">全部使用</span>`}
|
|
49
|
+
</div>
|
|
50
|
+
</div>`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildOverviewCards(tools, usageMap) {
|
|
54
|
+
const types = ['skill', 'agent', 'plugin']
|
|
55
|
+
const labels = { skill: 'SKILL', agent: 'AGENT', plugin: 'PLUGIN' }
|
|
56
|
+
const colors = { skill: 'var(--green)', agent: 'var(--cyan)', plugin: 'var(--purple)' }
|
|
57
|
+
|
|
58
|
+
return `<div class="grid-3" style="margin-bottom:14px;">
|
|
59
|
+
${types.map(type => {
|
|
60
|
+
const all = tools.filter(t => t.type === type)
|
|
61
|
+
const used = all.filter(t => (usageMap[t.name]?.allTimeUseCount ?? 0) > 0).length
|
|
62
|
+
return usageCard(labels[type], colors[type], all.length, used)
|
|
63
|
+
}).join('')}
|
|
64
|
+
</div>`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function renderSkills(container, range, preserveScroll = true) {
|
|
68
|
+
// 保存滚动位置,避免 WS refresh 导致跳回顶部(tab 切换时不恢复)
|
|
69
|
+
const savedScroll = preserveScroll ? (container.querySelector('.split-right')?.scrollTop ?? 0) : 0
|
|
70
|
+
|
|
71
|
+
const tools = await fetch(`/api/tools?range=${range}`).then(r => r.json())
|
|
72
|
+
const usageMap = Object.fromEntries(tools.map(t => [t.name, t]))
|
|
73
|
+
|
|
74
|
+
container.innerHTML = `
|
|
75
|
+
${rangeFilter(range)}
|
|
76
|
+
${buildOverviewCards(tools, usageMap)}
|
|
77
|
+
<div class="split">
|
|
78
|
+
<div class="split-left">
|
|
79
|
+
<div id="top-tools-panel"></div>
|
|
80
|
+
<div id="unused-tools-panel"></div>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="split-right" id="tools-list-panel"></div>
|
|
83
|
+
</div>
|
|
84
|
+
`
|
|
85
|
+
|
|
86
|
+
container.querySelectorAll('.range-btn').forEach(btn => {
|
|
87
|
+
btn.addEventListener('click', () => setRange(btn.dataset.range))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
renderTopTools(document.getElementById('top-tools-panel'), tools, range)
|
|
91
|
+
renderUnusedTools(document.getElementById('unused-tools-panel'), tools,
|
|
92
|
+
() => renderSkills(container, range))
|
|
93
|
+
renderToolsList(document.getElementById('tools-list-panel'), tools, range,
|
|
94
|
+
() => renderSkills(container, range))
|
|
95
|
+
|
|
96
|
+
// 恢复滚动位置
|
|
97
|
+
if (savedScroll > 0) {
|
|
98
|
+
const splitRight = container.querySelector('.split-right')
|
|
99
|
+
if (splitRight) splitRight.scrollTop = savedScroll
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 颜色映射 ──
|
|
104
|
+
const TYPE_COLOR = {
|
|
105
|
+
skill: 'var(--green)',
|
|
106
|
+
agent: 'var(--cyan)',
|
|
107
|
+
plugin: 'var(--purple)',
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function daysSince(isoStr) {
|
|
111
|
+
if (!isoStr) return 0
|
|
112
|
+
return Math.floor((Date.now() - new Date(isoStr).getTime()) / 86400000)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const LIST_PAGE_SIZE = 5
|
|
116
|
+
|
|
117
|
+
function pageBtns(page, totalPages, prevClass, nextClass) {
|
|
118
|
+
return `
|
|
119
|
+
<div style="display:flex;align-items:center;gap:2px;">
|
|
120
|
+
<button class="${prevClass}" style="background:transparent;border:none;cursor:pointer;
|
|
121
|
+
color:var(--muted);font-size:14px;padding:0 4px;font-family:var(--font);
|
|
122
|
+
${page === 0 ? 'opacity:0.3;pointer-events:none;' : ''}"><</button>
|
|
123
|
+
<span style="font-size:12px;color:var(--muted);min-width:28px;text-align:center;">${page + 1}/${totalPages}</span>
|
|
124
|
+
<button class="${nextClass}" style="background:transparent;border:none;cursor:pointer;
|
|
125
|
+
color:var(--muted);font-size:14px;padding:0 4px;font-family:var(--font);
|
|
126
|
+
${page >= totalPages - 1 ? 'opacity:0.3;pointer-events:none;' : ''}">></button>
|
|
127
|
+
</div>`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── 最常用标签云 ──
|
|
131
|
+
export function buildTopToolsHtml(tools, range) {
|
|
132
|
+
const rangeLabel = { '7d': '7 天', '30d': '30 天', '90d': '90 天', all: '全部时间' }
|
|
133
|
+
const used = tools
|
|
134
|
+
.filter(t => (t.useCount ?? 0) > 0)
|
|
135
|
+
.sort((a, b) => b.useCount - a.useCount)
|
|
136
|
+
|
|
137
|
+
if (used.length === 0) {
|
|
138
|
+
return `
|
|
139
|
+
<div class="card" style="margin-bottom:10px;">
|
|
140
|
+
<div class="section-title" style="margin-bottom:8px;">近期最常用</div>
|
|
141
|
+
<div style="color:var(--muted);font-size:14px;padding:8px 0;">暂无使用记录</div>
|
|
142
|
+
</div>`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const rows = used.map(t => {
|
|
146
|
+
const color = TYPE_COLOR[t.type] ?? 'var(--green)'
|
|
147
|
+
return `
|
|
148
|
+
<div style="display:flex;justify-content:space-between;align-items:center;
|
|
149
|
+
padding:5px 0;border-bottom:1px solid var(--border);">
|
|
150
|
+
<span style="display:flex;align-items:center;gap:6px;min-width:0;">
|
|
151
|
+
<span style="width:8px;height:8px;border-radius:50%;background:${color};
|
|
152
|
+
flex-shrink:0;"></span>
|
|
153
|
+
<span style="color:${color};font-size:14px;overflow:hidden;
|
|
154
|
+
text-overflow:ellipsis;white-space:nowrap;">${t.name}</span>
|
|
155
|
+
</span>
|
|
156
|
+
<span style="color:var(--cyan);font-size:14px;white-space:nowrap;
|
|
157
|
+
margin-left:8px;">${t.useCount}次</span>
|
|
158
|
+
</div>`
|
|
159
|
+
}).join('')
|
|
160
|
+
|
|
161
|
+
return `
|
|
162
|
+
<div class="card" style="margin-bottom:10px;">
|
|
163
|
+
<div class="section-title" style="margin-bottom:8px;">${range === 'all' ? '全部时间' : `近 ${rangeLabel[range]}`} 最常用</div>
|
|
164
|
+
<div style="overflow-y:auto;max-height:200px;">${rows}</div>
|
|
165
|
+
</div>`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── 从未使用列表 ──
|
|
169
|
+
export function buildUnusedToolsHtml(tools) {
|
|
170
|
+
// 当前时间范围内未使用的工具
|
|
171
|
+
const unused = tools
|
|
172
|
+
.filter(t => (t.useCount ?? 0) === 0)
|
|
173
|
+
.sort((a, b) => daysSince(b.installedAt) - daysSince(a.installedAt))
|
|
174
|
+
if (unused.length === 0) return null
|
|
175
|
+
|
|
176
|
+
const rows = unused.map(t => {
|
|
177
|
+
const days = daysSince(t.installedAt)
|
|
178
|
+
const color = TYPE_COLOR[t.type] ?? 'var(--green)'
|
|
179
|
+
return `
|
|
180
|
+
<div style="display:flex;justify-content:space-between;align-items:center;
|
|
181
|
+
padding:5px 0;border-bottom:1px solid var(--border);">
|
|
182
|
+
<span style="color:var(--muted);font-size:14px;">
|
|
183
|
+
<span style="color:${color};font-size:11px;margin-right:4px;">${t.type[0].toUpperCase()}</span>${t.name}
|
|
184
|
+
</span>
|
|
185
|
+
<span style="color:var(--red);font-size:14px;white-space:nowrap;">闲置 ${days} 天</span>
|
|
186
|
+
</div>`
|
|
187
|
+
}).join('')
|
|
188
|
+
|
|
189
|
+
return `
|
|
190
|
+
<div class="card" style="margin-bottom:10px;">
|
|
191
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
|
192
|
+
<div class="section-title" style="color:var(--red);">从未使用(${unused.length})</div>
|
|
193
|
+
<button class="unused-clean-btn"
|
|
194
|
+
style="background:transparent;border:1px solid var(--red);color:var(--red);
|
|
195
|
+
border-radius:3px;padding:2px 8px;font-size:12px;cursor:pointer;
|
|
196
|
+
font-family:var(--font);">一键清理</button>
|
|
197
|
+
</div>
|
|
198
|
+
<div style="overflow-y:auto;max-height:200px;">${rows}</div>
|
|
199
|
+
</div>`
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderTopTools(el, tools, range) {
|
|
203
|
+
el.innerHTML = buildTopToolsHtml(tools, range)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function renderUnusedTools(el, tools, onDeleted) {
|
|
207
|
+
const html = buildUnusedToolsHtml(tools)
|
|
208
|
+
el.innerHTML = html ?? ''
|
|
209
|
+
el.style.display = html ? '' : 'none'
|
|
210
|
+
|
|
211
|
+
el.querySelector('.unused-clean-btn')?.addEventListener('click', async (e) => {
|
|
212
|
+
const btn = e.currentTarget
|
|
213
|
+
const unused = tools.filter(t => (t.useCount ?? 0) === 0)
|
|
214
|
+
const confirmed = await showConfirm(`确认删除全部 ${unused.length} 个从未使用的工具?此操作不可撤销。`)
|
|
215
|
+
if (!confirmed) return
|
|
216
|
+
btn.textContent = '清理中…'
|
|
217
|
+
btn.disabled = true
|
|
218
|
+
for (const t of unused) {
|
|
219
|
+
await fetch(`/api/tools/${encodeURIComponent(t.name)}?type=${t.type}`, { method: 'DELETE' })
|
|
220
|
+
}
|
|
221
|
+
if (typeof onDeleted === 'function') onDeleted()
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── 工具完整列表(右侧面板)──
|
|
226
|
+
|
|
227
|
+
const TYPE_BADGE = {
|
|
228
|
+
skill: { label: 'S', color: 'var(--green)' },
|
|
229
|
+
agent: { label: 'A', color: 'var(--cyan)' },
|
|
230
|
+
plugin: { label: 'P', color: 'var(--purple)' },
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const SECURITY_BADGE = {
|
|
234
|
+
safe: { text: '✓ 安全', color: 'var(--green)' },
|
|
235
|
+
warning: { text: '⚠ 警告', color: 'var(--amber)' },
|
|
236
|
+
unscanned: { text: '未审查', color: 'var(--muted)' },
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const SOURCE_LABEL = { downloaded: '下载', self: '自建' }
|
|
240
|
+
|
|
241
|
+
function fmtDate(iso) {
|
|
242
|
+
if (!iso) return '—'
|
|
243
|
+
const cst = new Date(new Date(iso).getTime() + 8 * 3600_000)
|
|
244
|
+
return `${cst.getUTCFullYear()}-${String(cst.getUTCMonth()+1).padStart(2,'0')}-${String(cst.getUTCDate()).padStart(2,'0')}`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function toMs(v) { return v ? (typeof v === 'number' ? v : new Date(v).getTime()) : null }
|
|
248
|
+
|
|
249
|
+
function isDust(t) {
|
|
250
|
+
return (t.useCount ?? 0) === 0
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function relativeTime(ts) {
|
|
254
|
+
if (!ts) return null
|
|
255
|
+
const ms = typeof ts === 'number' ? ts : new Date(ts).getTime()
|
|
256
|
+
const days = Math.floor((Date.now() - ms) / 86400_000)
|
|
257
|
+
if (days === 0) return '今天'
|
|
258
|
+
if (days === 1) return '1天前'
|
|
259
|
+
return `${days}天前`
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function toolCard(t, range = '7d') {
|
|
263
|
+
const badge = TYPE_BADGE[t.type] ?? { label: '?', color: 'var(--muted)' }
|
|
264
|
+
const secBadge = SECURITY_BADGE[t.securityScanResult] ?? SECURITY_BADGE.unscanned
|
|
265
|
+
const dust = isDust(t)
|
|
266
|
+
|
|
267
|
+
const borderColor = dust ? 'var(--red)' : badge.color
|
|
268
|
+
const opacity = dust ? 'opacity:0.65;' : ''
|
|
269
|
+
|
|
270
|
+
// 顶部标签行
|
|
271
|
+
const typeTag = `<span style="font-size:12px;padding:1px 6px;border-radius:3px;
|
|
272
|
+
background:${badge.color}18;color:${badge.color};border:1px solid ${badge.color}40;">
|
|
273
|
+
${(t.type ?? '').toUpperCase()}</span>`
|
|
274
|
+
|
|
275
|
+
const sourceTag = ''
|
|
276
|
+
|
|
277
|
+
const secTag = `<span style="font-size:12px;color:${secBadge.color};">${secBadge.text}</span>`
|
|
278
|
+
|
|
279
|
+
// 描述(只显示含中文字符的描述)
|
|
280
|
+
const hasChinese = s => /[\u4e00-\u9fff]/.test(s)
|
|
281
|
+
const descText = t.description && hasChinese(t.description) ? t.description : null
|
|
282
|
+
const descRow = descText
|
|
283
|
+
? `<div style="margin-top:5px;font-size:14px;color:var(--muted);line-height:1.5;
|
|
284
|
+
word-break:break-word;max-width:66%;">${descText}</div>`
|
|
285
|
+
: ''
|
|
286
|
+
|
|
287
|
+
// 来源链接
|
|
288
|
+
const sourceLink = t.sourceUrl
|
|
289
|
+
? `<div style="margin-top:4px;">
|
|
290
|
+
<a href="${t.sourceUrl}" target="_blank" rel="noopener"
|
|
291
|
+
style="font-size:14px;color:var(--cyan);text-decoration:none;word-break:break-all;"
|
|
292
|
+
title="${t.sourceUrl}">${t.sourceUrl.replace(/^https?:\/\//, '')}</a>
|
|
293
|
+
</div>`
|
|
294
|
+
: ''
|
|
295
|
+
|
|
296
|
+
// AI 建议框(只在有实际风险时显示,正向情况不提示)
|
|
297
|
+
const aiSuggestion = (() => {
|
|
298
|
+
const count = t.useCount ?? 0
|
|
299
|
+
const lastMs = toMs(t.lastUsedAt)
|
|
300
|
+
const instMs = toMs(t.installedAt)
|
|
301
|
+
const lastDays = lastMs !== null ? Math.floor((Date.now() - lastMs) / 86400_000) : null
|
|
302
|
+
const instDays = instMs !== null ? Math.floor((Date.now() - instMs) / 86400_000) : null
|
|
303
|
+
// 曾使用但停用超 30 天
|
|
304
|
+
if (count > 0 && lastDays !== null && lastDays > 30) return `已 ${lastDays} 天未使用,建议评估是否仍有需求`
|
|
305
|
+
// 从未使用且安装超 30 天
|
|
306
|
+
if (count === 0 && instDays !== null && instDays > 30) return `安装 ${instDays} 天从未使用,建议评估是否需要`
|
|
307
|
+
return null
|
|
308
|
+
})()
|
|
309
|
+
const aiBox = aiSuggestion
|
|
310
|
+
? `<div style="margin-top:6px;padding:6px 10px;
|
|
311
|
+
background:color-mix(in srgb,var(--green) 8%,transparent);
|
|
312
|
+
border:1px solid color-mix(in srgb,var(--green) 30%,transparent);
|
|
313
|
+
border-radius:4px;display:flex;align-items:flex-start;gap:6px;">
|
|
314
|
+
<span style="font-size:12px;color:var(--green);font-weight:600;white-space:nowrap;padding-top:1px;">AI建议</span>
|
|
315
|
+
<span style="font-size:14px;color:var(--text);">${aiSuggestion}</span>
|
|
316
|
+
</div>`
|
|
317
|
+
: ''
|
|
318
|
+
|
|
319
|
+
// 底部统计行
|
|
320
|
+
const usedStr = t.useCount > 0 ? `<span style="color:var(--cyan);">${t.useCount}次</span>` : `<span style="color:var(--red);">未使用</span>`
|
|
321
|
+
const lastStr = t.lastUsedAt ? `<span>${relativeTime(t.lastUsedAt)}使用</span>` : ''
|
|
322
|
+
const installStr = t.installedAt ? `<span>安装 ${fmtDate(t.installedAt)}</span>` : ''
|
|
323
|
+
const updateStr = t.updatedAt ? `<span>更新 ${fmtDate(t.updatedAt)}</span>` : ''
|
|
324
|
+
const pathStr = t.localPath
|
|
325
|
+
? `<span style="color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
|
326
|
+
title="${t.localPath}">${t.localPath.replace(/^.*\.claude\//, '~/.claude/')}</span>`
|
|
327
|
+
: ''
|
|
328
|
+
const dustTag = dust ? `<span style="color:var(--red);">· 闲置</span>` : ''
|
|
329
|
+
|
|
330
|
+
const statsRow = [usedStr, lastStr, installStr, updateStr, dustTag].filter(Boolean).join(
|
|
331
|
+
`<span style="color:var(--border);margin:0 4px;">·</span>`)
|
|
332
|
+
|
|
333
|
+
return `
|
|
334
|
+
<div class="tool-card" data-name="${t.name}" data-type="${t.type}" data-dust="${dust}"
|
|
335
|
+
style="border-left:3px solid ${borderColor};padding-left:10px;${opacity}
|
|
336
|
+
padding-top:10px;padding-bottom:10px;border-bottom:1px solid var(--border);">
|
|
337
|
+
|
|
338
|
+
<!-- 第一行:徽章 + 名称 + 信息标签 + 删除 -->
|
|
339
|
+
<div style="display:flex;align-items:center;gap:6px;min-width:0;flex-wrap:wrap;">
|
|
340
|
+
<span style="width:20px;height:20px;border-radius:3px;background:${badge.color}22;
|
|
341
|
+
color:${badge.color};font-size:12px;font-weight:bold;flex-shrink:0;
|
|
342
|
+
display:flex;align-items:center;justify-content:center;">${badge.label}</span>
|
|
343
|
+
<span style="font-size:14px;font-weight:600;" title="${t.name}">${t.name}</span>
|
|
344
|
+
${typeTag}${sourceTag}${secTag}
|
|
345
|
+
<div style="flex:1;"></div>
|
|
346
|
+
${t.type === 'plugin' ? `
|
|
347
|
+
<button class="detail-btn" data-name="${t.name}" data-range="${range}"
|
|
348
|
+
style="background:transparent;border:1px solid var(--cyan);color:var(--cyan);
|
|
349
|
+
border-radius:3px;padding:2px 8px;font-size:14px;cursor:pointer;
|
|
350
|
+
flex-shrink:0;font-family:var(--font);">技能</button>` : ''}
|
|
351
|
+
<button class="del-btn" data-name="${t.name}" data-type="${t.type}"
|
|
352
|
+
style="background:transparent;border:1px solid var(--red);color:var(--red);
|
|
353
|
+
border-radius:3px;padding:2px 8px;font-size:14px;cursor:pointer;
|
|
354
|
+
flex-shrink:0;font-family:var(--font);">删除</button>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<!-- 描述 -->
|
|
358
|
+
${descRow}
|
|
359
|
+
|
|
360
|
+
<!-- 来源链接 -->
|
|
361
|
+
${sourceLink}
|
|
362
|
+
|
|
363
|
+
<!-- AI 建议框 -->
|
|
364
|
+
${aiBox}
|
|
365
|
+
|
|
366
|
+
<!-- 底部统计 + 本地路径 -->
|
|
367
|
+
<div style="margin-top:6px;display:flex;justify-content:space-between;
|
|
368
|
+
align-items:center;gap:8px;min-width:0;">
|
|
369
|
+
<div style="font-size:14px;color:var(--muted);display:flex;align-items:center;
|
|
370
|
+
gap:2px;flex-shrink:0;">${statsRow}</div>
|
|
371
|
+
<div style="font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;
|
|
372
|
+
white-space:nowrap;min-width:0;text-align:right;">${pathStr}</div>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<!-- 技能展开区(plugin 专用) -->
|
|
376
|
+
${t.type === 'plugin' ? `<div class="subskill-panel" style="display:none;"></div>` : ''}
|
|
377
|
+
</div>`
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const PAGE_SIZE = 10
|
|
381
|
+
|
|
382
|
+
const RANGE_TITLE = { '7d': '近 7 天安装', '30d': '近 30 天安装', '90d': '近 90 天安装', all: '全部时间' }
|
|
383
|
+
|
|
384
|
+
// allTools 用于 tab 计数,displayTools 用于当前页显示
|
|
385
|
+
export function buildToolsListHtml(allTools, displayTools, currentFilter, page = 0, range = '7d') {
|
|
386
|
+
const dustCount = allTools.filter(isDust).length
|
|
387
|
+
const tabs = [
|
|
388
|
+
{ key: 'all', label: `全部 (${allTools.length})` },
|
|
389
|
+
{ key: 'skill', label: `Skill (${allTools.filter(t=>t.type==='skill').length})` },
|
|
390
|
+
{ key: 'agent', label: `Agent (${allTools.filter(t=>t.type==='agent').length})` },
|
|
391
|
+
{ key: 'plugin', label: `Plugin (${allTools.filter(t=>t.type==='plugin').length})` },
|
|
392
|
+
{ key: 'dust', label: `闲置 (${dustCount})` },
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
const tabHtml = tabs.map(tab => {
|
|
396
|
+
const active = tab.key === currentFilter
|
|
397
|
+
return `
|
|
398
|
+
<button class="filter-tab" data-filter="${tab.key}"
|
|
399
|
+
style="background:${active?'var(--bg3)':'transparent'};border:none;cursor:pointer;
|
|
400
|
+
padding:4px 10px;font-size:14px;border-radius:3px;font-family:var(--font);
|
|
401
|
+
color:${active?'var(--text)':'var(--muted)'};">${tab.label}</button>`
|
|
402
|
+
}).join('')
|
|
403
|
+
|
|
404
|
+
const totalPages = Math.ceil(displayTools.length / PAGE_SIZE)
|
|
405
|
+
const paged = displayTools.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
|
|
406
|
+
|
|
407
|
+
const bodyHtml = displayTools.length === 0
|
|
408
|
+
? `<div style="color:var(--muted);font-size:14px;padding:20px 0;text-align:center;">该分类暂无工具</div>`
|
|
409
|
+
: paged.map(t => toolCard(t, range)).join('')
|
|
410
|
+
|
|
411
|
+
const paginationHtml = totalPages > 1 ? `
|
|
412
|
+
<div style="display:flex;justify-content:space-between;align-items:center;
|
|
413
|
+
padding-top:10px;border-top:1px solid var(--border);">
|
|
414
|
+
<button class="page-btn" data-dir="-1"
|
|
415
|
+
style="background:transparent;border:1px solid var(--border);color:var(--muted);
|
|
416
|
+
border-radius:3px;padding:3px 10px;font-size:14px;cursor:pointer;font-family:var(--font);
|
|
417
|
+
${page === 0 ? 'opacity:0.3;pointer-events:none;' : ''}">上一页</button>
|
|
418
|
+
<span style="font-size:14px;color:var(--muted);">${page + 1} / ${totalPages}</span>
|
|
419
|
+
<button class="page-btn" data-dir="1"
|
|
420
|
+
style="background:transparent;border:1px solid var(--border);color:var(--muted);
|
|
421
|
+
border-radius:3px;padding:3px 10px;font-size:14px;cursor:pointer;font-family:var(--font);
|
|
422
|
+
${page >= totalPages - 1 ? 'opacity:0.3;pointer-events:none;' : ''}">下一页</button>
|
|
423
|
+
</div>` : ''
|
|
424
|
+
|
|
425
|
+
return `
|
|
426
|
+
<div class="card" style="min-height:0;display:flex;flex-direction:column;">
|
|
427
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;flex-wrap:wrap;gap:4px;">
|
|
428
|
+
<div class="section-title">${RANGE_TITLE[range] ?? '全部时间'}</div>
|
|
429
|
+
<div style="display:flex;gap:4px;flex-wrap:wrap;">${tabHtml}</div>
|
|
430
|
+
</div>
|
|
431
|
+
<div id="tools-card-list">${bodyHtml}</div>
|
|
432
|
+
${paginationHtml}
|
|
433
|
+
</div>`
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
function showConfirm(message) {
|
|
438
|
+
return new Promise(resolve => {
|
|
439
|
+
const modal = document.getElementById('confirm-modal')
|
|
440
|
+
const okBtn = document.getElementById('confirm-ok')
|
|
441
|
+
const cancelBtn = document.getElementById('confirm-cancel')
|
|
442
|
+
document.getElementById('confirm-msg').textContent = message
|
|
443
|
+
modal.style.display = 'flex'
|
|
444
|
+
|
|
445
|
+
const onOk = () => { modal.style.display = 'none'; resolve(true) }
|
|
446
|
+
const onCancel = () => { modal.style.display = 'none'; resolve(false) }
|
|
447
|
+
okBtn.addEventListener('click', onOk, { once: true })
|
|
448
|
+
cancelBtn.addEventListener('click', onCancel, { once: true })
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function bindDeleteButtons(container, onDeleted) {
|
|
453
|
+
container.querySelectorAll('.del-btn').forEach(btn => {
|
|
454
|
+
btn.addEventListener('click', async () => {
|
|
455
|
+
const { name, type } = btn.dataset
|
|
456
|
+
const confirmed = await showConfirm(`确认删除「${name}」?此操作不可撤销。`)
|
|
457
|
+
if (!confirmed) return
|
|
458
|
+
|
|
459
|
+
btn.disabled = true
|
|
460
|
+
btn.textContent = '删除中…'
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
const res = await fetch(`/api/tools/${encodeURIComponent(name)}?type=${type}`, {
|
|
464
|
+
method: 'DELETE',
|
|
465
|
+
})
|
|
466
|
+
const data = await res.json()
|
|
467
|
+
if (!res.ok) throw new Error(data.error ?? '删除失败')
|
|
468
|
+
btn.closest('.tool-card')?.remove()
|
|
469
|
+
if (typeof onDeleted === 'function') onDeleted(name, type)
|
|
470
|
+
} catch (err) {
|
|
471
|
+
alert(`删除失败:${err.message}`)
|
|
472
|
+
btn.disabled = false
|
|
473
|
+
btn.textContent = '删除'
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function bindDetailButtons(container) {
|
|
480
|
+
container.querySelectorAll('.detail-btn').forEach(btn => {
|
|
481
|
+
btn.addEventListener('click', async () => {
|
|
482
|
+
const { name, range } = btn.dataset
|
|
483
|
+
const card = btn.closest('.tool-card')
|
|
484
|
+
const panel = card?.querySelector('.subskill-panel')
|
|
485
|
+
if (!panel) return
|
|
486
|
+
|
|
487
|
+
// 切换展开/收起
|
|
488
|
+
if (panel.style.display !== 'none') {
|
|
489
|
+
panel.style.display = 'none'
|
|
490
|
+
btn.textContent = '技能'
|
|
491
|
+
btn.style.color = 'var(--cyan)'
|
|
492
|
+
btn.style.borderColor = 'var(--cyan)'
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
btn.textContent = '加载中…'
|
|
497
|
+
btn.disabled = true
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
const rows = await fetch(`/api/tools/${encodeURIComponent(name)}/subskills?range=${range}`)
|
|
501
|
+
.then(r => r.json())
|
|
502
|
+
|
|
503
|
+
if (!rows || rows.length === 0) {
|
|
504
|
+
panel.innerHTML = `<div style="font-size:13px;color:var(--muted);padding:8px 0;">
|
|
505
|
+
当前时间段内无调用记录</div>`
|
|
506
|
+
} else {
|
|
507
|
+
const total = rows.reduce((s, r) => s + r.count, 0)
|
|
508
|
+
const rowsHtml = rows.map(r => {
|
|
509
|
+
const pct = Math.round(r.count / total * 100)
|
|
510
|
+
const subName = r.toolName.replace(name + ':', '')
|
|
511
|
+
return `
|
|
512
|
+
<div style="display:flex;align-items:center;gap:10px;
|
|
513
|
+
padding:5px 8px;border-bottom:1px solid var(--border);">
|
|
514
|
+
<span style="flex:1;font-size:13px;font-family:var(--font);
|
|
515
|
+
color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
|
516
|
+
title="${r.toolName}">${subName}</span>
|
|
517
|
+
<span style="font-size:13px;color:var(--muted);white-space:nowrap;min-width:36px;text-align:right;">${pct}%</span>
|
|
518
|
+
<span style="font-size:13px;color:var(--cyan);white-space:nowrap;min-width:48px;text-align:right;">${r.count.toLocaleString()}次</span>
|
|
519
|
+
</div>`
|
|
520
|
+
}).join('')
|
|
521
|
+
|
|
522
|
+
panel.innerHTML = `
|
|
523
|
+
<div style="margin-top:8px;background:var(--bg3);border-radius:4px;
|
|
524
|
+
border:1px solid var(--border);overflow:hidden;">
|
|
525
|
+
<div style="display:flex;align-items:center;gap:10px;
|
|
526
|
+
padding:5px 8px;border-bottom:1px solid var(--border);">
|
|
527
|
+
<span style="flex:1;font-size:11px;letter-spacing:1px;
|
|
528
|
+
color:var(--muted);text-transform:uppercase;">子技能</span>
|
|
529
|
+
<span style="font-size:11px;color:var(--muted);min-width:36px;text-align:right;">占比</span>
|
|
530
|
+
<span style="font-size:11px;color:var(--muted);min-width:48px;text-align:right;">次数</span>
|
|
531
|
+
</div>
|
|
532
|
+
${rowsHtml}
|
|
533
|
+
</div>`
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
panel.style.display = ''
|
|
537
|
+
btn.textContent = '收起'
|
|
538
|
+
btn.style.color = 'var(--cyan)'
|
|
539
|
+
btn.style.borderColor = 'var(--cyan)'
|
|
540
|
+
} catch {
|
|
541
|
+
panel.innerHTML = `<div style="font-size:13px;color:var(--red);padding:8px 0;">加载失败</div>`
|
|
542
|
+
panel.style.display = ''
|
|
543
|
+
} finally {
|
|
544
|
+
btn.disabled = false
|
|
545
|
+
}
|
|
546
|
+
})
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function renderToolsList(el, tools, range, onDeleted) {
|
|
551
|
+
function filtered() {
|
|
552
|
+
if (_state.listFilter === 'all') return tools
|
|
553
|
+
if (_state.listFilter === 'dust') return tools.filter(isDust)
|
|
554
|
+
return tools.filter(t => t.type === _state.listFilter)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function render(scrollToTop = false) {
|
|
558
|
+
const savedScroll = scrollToTop ? 0 : (el.scrollTop ?? 0)
|
|
559
|
+
el.innerHTML = buildToolsListHtml(tools, filtered(), _state.listFilter, _state.listPage, range)
|
|
560
|
+
el.scrollTop = savedScroll
|
|
561
|
+
// tab 点击 → 重置到第一页
|
|
562
|
+
el.querySelectorAll('.filter-tab').forEach(btn => {
|
|
563
|
+
btn.addEventListener('click', () => {
|
|
564
|
+
_state.listFilter = btn.dataset.filter
|
|
565
|
+
_state.listPage = 0
|
|
566
|
+
render(true)
|
|
567
|
+
})
|
|
568
|
+
})
|
|
569
|
+
// 翻页 → 滚到顶部
|
|
570
|
+
el.querySelectorAll('.page-btn').forEach(btn => {
|
|
571
|
+
btn.addEventListener('click', () => {
|
|
572
|
+
_state.listPage += parseInt(btn.dataset.dir)
|
|
573
|
+
render(true)
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
bindDeleteButtons(el, onDeleted)
|
|
577
|
+
bindDetailButtons(el)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// 恢复时 clamp 到有效页范围
|
|
581
|
+
const total = Math.ceil(filtered().length / PAGE_SIZE)
|
|
582
|
+
_state.listPage = Math.min(_state.listPage, Math.max(total - 1, 0))
|
|
583
|
+
render()
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ── RECOMMENDATIONS 面板 ──
|
|
587
|
+
export function buildRecommendationsHtml(tools) {
|
|
588
|
+
const dust = tools.filter(t => isDust(t))
|
|
589
|
+
if (dust.length === 0) return null
|
|
590
|
+
|
|
591
|
+
const rows = dust.map(t => {
|
|
592
|
+
const color = TYPE_COLOR[t.type] ?? 'var(--green)'
|
|
593
|
+
return `<div style="display:flex;justify-content:space-between;padding:3px 0;
|
|
594
|
+
border-bottom:1px solid var(--border);">
|
|
595
|
+
<span style="font-size:14px;color:var(--muted);">
|
|
596
|
+
<span style="color:${color};font-size:11px;margin-right:4px;">${t.type[0].toUpperCase()}</span>${t.name}
|
|
597
|
+
</span>
|
|
598
|
+
<span style="font-size:12px;color:var(--red);">闲置</span>
|
|
599
|
+
</div>`
|
|
600
|
+
}).join('')
|
|
601
|
+
|
|
602
|
+
return `
|
|
603
|
+
<div class="card">
|
|
604
|
+
<div class="section-title" style="margin-bottom:8px;">建议清理</div>
|
|
605
|
+
<div style="font-size:14px;color:var(--muted);margin-bottom:8px;">
|
|
606
|
+
发现 <span style="color:var(--red);">${dust.length}</span> 个闲置工具
|
|
607
|
+
</div>
|
|
608
|
+
<div style="margin-bottom:10px;">${rows}</div>
|
|
609
|
+
<button id="bulk-clean-btn"
|
|
610
|
+
style="width:100%;padding:6px;background:transparent;border:1px solid var(--red);
|
|
611
|
+
color:var(--red);border-radius:var(--radius);cursor:pointer;font-size:14px;
|
|
612
|
+
font-family:var(--font);">一键清理</button>
|
|
613
|
+
</div>`
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function renderRecommendations(el, tools, container, range) {
|
|
617
|
+
const html = buildRecommendationsHtml(tools)
|
|
618
|
+
el.innerHTML = html ?? ''
|
|
619
|
+
if (!html) return
|
|
620
|
+
|
|
621
|
+
el.querySelector('#bulk-clean-btn')?.addEventListener('click', async () => {
|
|
622
|
+
const btn = el.querySelector('#bulk-clean-btn')
|
|
623
|
+
if (!confirm(`确认批量删除 ${tools.filter(isDust).length} 个闲置工具?`)) return
|
|
624
|
+
btn.disabled = true
|
|
625
|
+
btn.textContent = '清理中…'
|
|
626
|
+
try {
|
|
627
|
+
const res = await fetch(`/api/tools/bulk-dust`, { method: 'DELETE' })
|
|
628
|
+
const data = await res.json()
|
|
629
|
+
if (!res.ok) throw new Error(data.error ?? '清理失败')
|
|
630
|
+
renderSkills(container, range)
|
|
631
|
+
} catch (err) {
|
|
632
|
+
alert(`清理失败:${err.message}`)
|
|
633
|
+
btn.disabled = false
|
|
634
|
+
btn.textContent = '一键清理'
|
|
635
|
+
}
|
|
636
|
+
})
|
|
637
|
+
}
|