Skip to content

Commit 203a3fd

Browse files
TreeTreeDiliuxy0551claudehuaiju
authored
feat(skills): downloads count/sort, update scope, and Skills Hub docs (#91)
* fix: raise multipart files limit from 10 to 50 for skill upload Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): improve detail page install panel UI - Add copy link button next to download .zip button with tooltip - Use terminal style (BASH) for all command displays - Simplify human install section to single command - Remove SOON badge from install section Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): show full datetime for last updated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): search debounce, pagination, and cleanup dead code - Add lodash debounce (300ms) to search input to prevent excessive API calls - Fix pagination/sort/category not fetching by calling fetchSkills in updateQueryAndFetch - Remove dead code renderInlineCommand function - Remove orphaned CSS selectors .human-command-card and .human-command-title Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: fix prettier formatting and eslint import sorting Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): count zip downloads and sort by download volume Track successful zip deliveries on skills_items.downloads (web download and registry CLI install), surface counts in the market UI, and support sortBy=downloads. Aligns registry stats.downloads with the real counter. * feat(dt-skill): align update scope with vercel skills UX Add Project/Global/Both selection for update (-g/-p, interactive prompt, named-slug both, -y auto-detect), with design docs and domain glossary. * docs(skills): add Skills Hub wiki and market help entry Document install/list/update/uninstall/upload with screenshots, and open the docsify guide from the skills page help icon (same pattern as proxy). * refactor(skills): address review on downloads and update scope Extract shared count helpers, use MySQL errno for duplicate index, drop as-assertions in scope prompts, and tighten count display defaults. * fix(skills): pure zip build and drop remaining count/type residue Move download counting out of buildSkillZip into the registry controller so CLI and web share one seam. Prefer coerceCount and typed mocks over truthy fallbacks and as const in tests. * chore: drop local design notes from PR Remove CONTEXT.md and the update-scope design draft; keep product code only. * style: fix prettier and stylelint for CI Align changed sources with repo prettier/stylelint so GitHub CI passes. * fix(skills): address PR review on downloads display and plans Match detail downloads fallback to stars (|| 0) and drop the local update-scope plan file from the branch. * chore(dt-skill): bump to 0.18.7 Ship CLI version for update-scope and related Skills Hub work on this branch. * chore: add Copilot code review instructions in Chinese Ask Copilot PR reviews to comment in Simplified Chinese while keeping identifiers and paths untranslated. --------- Co-authored-by: liuxy0551 <liuxy0551@qq.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: huaiju <huaiju1210085279@gmail.com>
1 parent 7c1d50c commit 203a3fd

28 files changed

Lines changed: 878 additions & 106 deletions

.github/copilot-instructions.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Copilot Code Review
2+
3+
When performing a code review, respond in Simplified Chinese (简体中文).
4+
5+
- Write all review comments, summaries, and suggestions in 简体中文.
6+
- Keep code identifiers, file paths, command names, API routes, and package names in their original form (do not translate them).
7+
- Prefer concrete, actionable feedback over generic praise.
8+
- Call out regressions, security issues, and missing tests when relevant.

app/controller/skills.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ class SkillsController extends Controller {
3333
async downloadSkillArchive() {
3434
const { ctx } = this;
3535
const { slug } = ctx.query;
36-
const { fileName, content } = await ctx.service.skills.getSkillArchive(slug);
36+
const {
37+
slug: resolvedSlug,
38+
fileName,
39+
content,
40+
} = await ctx.service.skills.getSkillArchive(slug);
41+
// Count only on actual zip download (not install-meta sha256 rebuild).
42+
await ctx.service.skills.incrementDownloads(resolvedSlug);
3743
ctx.set('Content-Type', 'application/zip');
3844
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
3945
ctx.body = content;

app/controller/skillsRegistry.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ class SkillsRegistryController extends Controller {
8787
ctx.body = { error: '技能不存在' };
8888
return;
8989
}
90+
// Count only on successful zip download (same seam as web controller).
91+
await ctx.service.skills.incrementDownloads(result.slug);
9092
ctx.set('Content-Type', 'application/zip');
9193
ctx.set(
9294
'Content-Disposition',

app/model/skills_item.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ module.exports = (app) => {
5151
allowNull: false,
5252
defaultValue: 0,
5353
},
54+
downloads: {
55+
type: INTEGER,
56+
allowNull: false,
57+
defaultValue: 0,
58+
comment: 'zip 成功下发次数(Web 下载 + CLI install)',
59+
},
5460
updated_at_remote: {
5561
type: DATE,
5662
comment: '源仓库文件更新时间',
@@ -119,6 +125,8 @@ module.exports = (app) => {
119125
{ fields: ['source_id'] },
120126
{ fields: ['category'] },
121127
{ fields: ['stars'] },
128+
// downloads index is created in ensureSkillsItemDownloadsColumn
129+
// after the column exists (sync cannot add index for a missing column).
122130
{ fields: ['updated_at_remote'] },
123131
],
124132
}

app/service/skills.js

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ const {
1414
extractSkillMdDescription,
1515
resolveMarketCardDescription,
1616
} = require('../utils/skill-utils');
17+
const {
18+
coerceCount,
19+
sumCounts,
20+
aggregateCountsByParent,
21+
isDuplicateIndexError,
22+
} = require('../utils/skill-stats');
1723
const GitHubStarsClient = require('../utils/github-stars');
1824
const CommandRunner = require('../utils/command-runner');
1925

@@ -122,6 +128,7 @@ class SkillsService extends Service {
122128
await this.ensureSkillsItemVersionColumn();
123129
await this.ensureSkillsItemPackageColumns();
124130
await this.ensureSkillsItemContributorColumn();
131+
await this.ensureSkillsItemDownloadsColumn();
125132
this.storageReady = true;
126133
})();
127134

@@ -177,6 +184,53 @@ class SkillsService extends Service {
177184
});
178185
}
179186

187+
async ensureSkillsItemDownloadsColumn() {
188+
const queryInterface = this.app.model.getQueryInterface();
189+
const table = await queryInterface.describeTable('skills_items');
190+
if (!table.downloads) {
191+
await queryInterface.addColumn('skills_items', 'downloads', {
192+
type: this.app.Sequelize.INTEGER,
193+
allowNull: false,
194+
defaultValue: 0,
195+
comment: 'zip 成功下发次数(Web 下载 + CLI install)',
196+
});
197+
}
198+
199+
// Index is not on the model: sync would ADD INDEX before the column exists
200+
// on upgraded DBs. Create once the column is present; ignore duplicate index.
201+
try {
202+
await queryInterface.addIndex('skills_items', ['downloads'], {
203+
name: 'idx_skills_downloads',
204+
});
205+
} catch (error) {
206+
if (!isDuplicateIndexError(error)) throw error;
207+
}
208+
}
209+
210+
/**
211+
* Count one successful zip delivery for a skill slug.
212+
* Failures are logged only — download response must not depend on this.
213+
*/
214+
async incrementDownloads(slug) {
215+
const value = String(slug || '').trim();
216+
if (!value) return;
217+
218+
try {
219+
await this.ensureStorageReady();
220+
const { SkillsItem } = this.app.model;
221+
await SkillsItem.increment('downloads', {
222+
by: 1,
223+
where: { slug: value, is_delete: 0 },
224+
// Do not bump updated_at — downloads must not reorder "recent" sort.
225+
silent: true,
226+
});
227+
} catch (error) {
228+
this.ctx.logger.warn(
229+
`[skills] increment downloads failed for ${value}: ${error.message}`
230+
);
231+
}
232+
}
233+
180234
parseJsonArray(value) {
181235
if (!value) return [];
182236
if (Array.isArray(value)) return value;
@@ -201,6 +255,7 @@ class SkillsService extends Service {
201255
tags: skill.tags,
202256
allowedTools: skill.allowedTools,
203257
stars: skill.stars,
258+
downloads: skill.downloads,
204259
updatedAt: skill.updatedAt,
205260
sourceRepo: skill.sourceRepo,
206261
sourcePath: skill.sourcePath,
@@ -223,7 +278,8 @@ class SkillsService extends Service {
223278
version: row.version || '',
224279
tags: this.parseJsonArray(row.tags),
225280
allowedTools: this.parseJsonArray(row.allowed_tools),
226-
stars: Number(row.stars) || 0,
281+
stars: coerceCount(row.stars),
282+
downloads: coerceCount(row.downloads),
227283
updatedAt: (
228284
row.updated_at ||
229285
row.updated_at_remote ||
@@ -283,22 +339,24 @@ class SkillsService extends Service {
283339
const safePageSize = Math.max(parseInt(pageSize, 10) || 20, 1);
284340
const { skills, categories } = this.skillCache;
285341

286-
// Aggregate child stars by parent slug for package star totals
287-
const childStarsByParent = new Map();
288-
for (const item of skills) {
289-
if (item.parentSlug) {
290-
const current = childStarsByParent.get(item.parentSlug) || 0;
291-
childStarsByParent.set(item.parentSlug, current + (Number(item.stars) || 0));
292-
}
293-
}
342+
const { stars: childStarsByParent, downloads: childDownloadsByParent } =
343+
aggregateCountsByParent(skills, {
344+
parentKey: 'parentSlug',
345+
fields: ['stars', 'downloads'],
346+
});
294347

295348
let list = [...skills]
296349
.filter((item) => !item.parentSlug)
297350
.map((item) => {
298-
if (item.isPackage === 1 && childStarsByParent.has(item.slug)) {
299-
return { ...item, stars: childStarsByParent.get(item.slug) };
351+
if (item.isPackage !== 1) return item;
352+
const next = { ...item };
353+
if (childStarsByParent.has(item.slug)) {
354+
next.stars = childStarsByParent.get(item.slug);
300355
}
301-
return item;
356+
if (childDownloadsByParent.has(item.slug)) {
357+
next.downloads = childDownloadsByParent.get(item.slug);
358+
}
359+
return next;
302360
});
303361
if (keyword) {
304362
const value = String(keyword).toLowerCase();
@@ -320,6 +378,10 @@ class SkillsService extends Service {
320378
if (sortBy === 'recent') {
321379
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
322380
}
381+
if (sortBy === 'downloads') {
382+
if (b.downloads !== a.downloads) return b.downloads - a.downloads;
383+
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
384+
}
323385
if (b.stars !== a.stars) return b.stars - a.stars;
324386
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
325387
});
@@ -397,10 +459,8 @@ class SkillsService extends Service {
397459
],
398460
});
399461
detail.children = children.map((row) => this.toPublicSkill(this.toSkillDto(row)));
400-
detail.stars = detail.children.reduce(
401-
(sum, child) => sum + (Number(child.stars) || 0),
402-
0
403-
);
462+
detail.stars = sumCounts(detail.children, (child) => child.stars);
463+
detail.downloads = sumCounts(detail.children, (child) => child.downloads);
404464
}
405465

406466
return detail;
@@ -491,7 +551,10 @@ class SkillsService extends Service {
491551
});
492552
}
493553

554+
// Pure zip build — do not count here. getInstallMeta also builds zip for sha256;
555+
// counting belongs only on the real download HTTP handler.
494556
return {
557+
slug: skill.slug,
495558
fileName: `${rootFolder}.zip`,
496559
content: zip.toBuffer(),
497560
};

app/service/skillsRegistry.js

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const fs = require('fs');
44
const ignore = require('ignore');
55
const path = require('path');
66
const skillUtils = require('../utils/skill-utils');
7+
const { coerceCount, sumCounts } = require('../utils/skill-stats');
78
const skillFingerprint = require('../../contracts/skill-fingerprint');
89
const {
910
SKILL_CATEGORY_OPTIONS,
@@ -67,7 +68,7 @@ class SkillsRegistryService extends Service {
6768
newest: { key: 'newest', field: 'updated_at', type: 'date' },
6869
createdAt: { key: 'newest', field: 'updated_at', type: 'date' },
6970
updated: { key: 'newest', field: 'updated_at', type: 'date' },
70-
downloads: { key: 'stars', field: 'stars', type: 'number' },
71+
downloads: { key: 'downloads', field: 'downloads', type: 'number' },
7172
stars: { key: 'stars', field: 'stars', type: 'number' },
7273
};
7374
const sortConfig = sortMap[sort] || sortMap.newest;
@@ -107,7 +108,10 @@ class SkillsRegistryService extends Service {
107108
return {
108109
items: items.map((skill) => {
109110
const tags = this.parseJsonArray(skill.tags);
110-
const stats = { stars: skill.stars || 0, downloads: 0 };
111+
const stats = {
112+
stars: coerceCount(skill.stars),
113+
downloads: coerceCount(skill.downloads),
114+
};
111115
const item = {
112116
slug: skill.slug,
113117
displayName: skill.name,
@@ -161,7 +165,10 @@ class SkillsRegistryService extends Service {
161165

162166
const version = skill.version || '';
163167
const tags = this.parseJsonArray(skill.tags);
164-
const stats = { stars: skill.stars || 0, downloads: 0 };
168+
const stats = {
169+
stars: coerceCount(skill.stars),
170+
downloads: coerceCount(skill.downloads),
171+
};
165172
const createdAt = skill.created_at ? new Date(skill.created_at).getTime() : 0;
166173
const updatedAt = skill.updated_at ? new Date(skill.updated_at).getTime() : 0;
167174
let fingerprint = null;
@@ -210,12 +217,23 @@ class SkillsRegistryService extends Service {
210217
summary: child.description || null,
211218
version: child.version || null,
212219
tags: this.parseJsonArray(child.tags),
213-
stats: { stars: child.stars || 0, downloads: 0 },
220+
stats: {
221+
stars: coerceCount(child.stars),
222+
downloads: coerceCount(child.downloads),
223+
},
214224
createdAt: child.created_at ? new Date(child.created_at).getTime() : 0,
215225
updatedAt: child.updated_at ? new Date(child.updated_at).getTime() : 0,
216226
isPackage: false,
217227
parentSlug: child.parent_slug,
218228
}));
229+
detail.skill.stats.downloads = sumCounts(
230+
detail.skill.children,
231+
(child) => child.stats.downloads
232+
);
233+
detail.skill.stats.stars = sumCounts(
234+
detail.skill.children,
235+
(child) => child.stats.stars
236+
);
219237
}
220238

221239
return detail;
@@ -360,6 +378,7 @@ class SkillsRegistryService extends Service {
360378

361379
const version = skill.version || 'latest';
362380
return {
381+
slug: skill.slug,
363382
fileName: `${slug}-${version}.zip`,
364383
content: zip.toBuffer(),
365384
};
@@ -759,7 +778,7 @@ class SkillsRegistryService extends Service {
759778
encodeListCursor(skill, sortConfig) {
760779
const rawValue = skill[sortConfig.field];
761780
const value =
762-
sortConfig.type === 'date' ? new Date(rawValue).getTime() : Number(rawValue) || 0;
781+
sortConfig.type === 'date' ? new Date(rawValue).getTime() : coerceCount(rawValue);
763782
return Buffer.from(
764783
JSON.stringify({
765784
sort: sortConfig.key,

app/utils/skill-stats.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
function coerceCount(value) {
2+
const n = Number(value);
3+
if (!Number.isFinite(n) || n <= 0) return 0;
4+
return Math.floor(n);
5+
}
6+
7+
function sumCounts(items, getCount) {
8+
let sum = 0;
9+
for (const item of items || []) {
10+
sum += coerceCount(getCount(item));
11+
}
12+
return sum;
13+
}
14+
15+
/**
16+
* @param {Array<object>} items
17+
* @param {{ parentKey: string, fields: string[] }} opts
18+
* @returns {Record<string, Map<string, number>>}
19+
*/
20+
function aggregateCountsByParent(items, { parentKey, fields }) {
21+
const maps = {};
22+
for (const field of fields) {
23+
maps[field] = new Map();
24+
}
25+
for (const item of items || []) {
26+
const parent = item[parentKey];
27+
if (parent == null || parent === '') continue;
28+
for (const field of fields) {
29+
const current = maps[field].get(parent) || 0;
30+
maps[field].set(parent, current + coerceCount(item[field]));
31+
}
32+
}
33+
return maps;
34+
}
35+
36+
function isDuplicateIndexError(error) {
37+
const code = error?.original?.code || error?.parent?.code || error?.code;
38+
if (code === 'ER_DUP_KEYNAME') return true;
39+
const errno = error?.original?.errno ?? error?.parent?.errno ?? error?.errno;
40+
return errno === 1061;
41+
}
42+
43+
module.exports = {
44+
coerceCount,
45+
sumCounts,
46+
aggregateCountsByParent,
47+
isDuplicateIndexError,
48+
};

app/web/components/skills/SkillCard.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import React from 'react';
2-
import { FileTextOutlined, FolderOutlined, StarOutlined } from '@ant-design/icons';
2+
import {
3+
DownloadOutlined,
4+
FileTextOutlined,
5+
FolderOutlined,
6+
StarOutlined,
7+
} from '@ant-design/icons';
38
import { Card, Checkbox, Tag } from 'antd';
9+
import moment from 'moment';
410

511
import type { SkillItem } from '@/pages/skills/types';
612
import './style.scss';
@@ -64,8 +70,11 @@ export const SkillCard: React.FC<SkillCardProps> = ({
6470
技能包
6571
</Tag>
6672
)}
67-
<span className="stars-badge">
68-
<StarOutlined /> {skill.stars || 0}
73+
<span className="stars-badge" title="Stars">
74+
<StarOutlined /> {skill.stars}
75+
</span>
76+
<span className="downloads-badge" title="下载量">
77+
<DownloadOutlined /> {skill.downloads}
6978
</span>
7079
{onEdit && (
7180
<button
@@ -126,7 +135,7 @@ export const SkillCard: React.FC<SkillCardProps> = ({
126135
<span className="meta-label">更新</span>
127136
<span className="meta-value">
128137
{skill.updatedAt
129-
? new Date(skill.updatedAt).toLocaleDateString('zh-CN')
138+
? moment(skill.updatedAt).format('YYYY-MM-DD HH:mm:ss')
130139
: '-'}
131140
</span>
132141
</span>

app/web/components/skills/style.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@
114114
height: 20px;
115115
line-height: 18px;
116116
}
117-
.stars-badge {
117+
.stars-badge,
118+
.downloads-badge {
118119
display: inline-flex;
119120
align-items: center;
120121
gap: 4px;

0 commit comments

Comments
 (0)