Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
c228a41
feat: add backend list, detail, and related APIs for skills
Feb 21, 2026
d6669d0
feat: wire skills nav entry and frontend routes
Feb 21, 2026
2a33b6c
feat: build skills list page with filters, sorting, and cards
Feb 21, 2026
d00db5b
feat: build skills detail page with SKILL.md file list and related items
Feb 21, 2026
caebe7c
feat: add skills file content API with path safety checks
Feb 23, 2026
982027c
feat: add skills zip download API for wget
Feb 23, 2026
da284b1
feat: redesign skills detail page with file tree and linked preview
Feb 23, 2026
7ab8c74
feat: add conversion panel and wget download on skills detail page
Feb 23, 2026
9f5fe23
feat: add skills import entry on list page and import API
Feb 23, 2026
032d645
fix: parse skills source from skill-lock and generate remote install …
Feb 23, 2026
101dae2
feat: parse skills frontmatter as structured table
Feb 23, 2026
1e64279
style: align skills frontmatter table with project visual style
Feb 23, 2026
7fc50a4
feat: use platform category and tags from skills import metadata
Feb 23, 2026
8348193
feat: add fixed category and custom tags in skills import form
Feb 23, 2026
3902ed9
feat: refresh github stars once after skills import
Feb 23, 2026
197bc58
feat: import all skills from root source by default
Feb 23, 2026
a58a36d
fix: fallback stars fetch and refresh on reimport
Feb 23, 2026
47fa128
feat: remove favorite action and polish copy button style
Feb 23, 2026
7c636f1
fix: clamp card description to prevent layout overlap
Feb 23, 2026
61d0dfc
fix: increase left padding for search input
Feb 23, 2026
04f4cd1
feat: add db models for skills source storage
Feb 24, 2026
8cd209e
feat: migrate import and read flow to db storage
Feb 24, 2026
740474a
fix: reduce model index length for mysql
Feb 24, 2026
026f427
fix: bypass proxy for internal git sources
Feb 24, 2026
ab2f10d
feat: support internal gitlab token auth and nested skills discovery
Feb 24, 2026
5478fc5
feat: support .skill upload import flow
Feb 25, 2026
716fc99
fix: speed up subpath import via sparse clone fallback
Feb 25, 2026
b7ab8da
feat(skills): add install-meta api for cli install protocol
Mar 21, 2026
11ae9f0
feat(cli): implement doraemon-skills install/list wp2
Mar 21, 2026
26f30d4
feat(skills): modalize detail and add cli install panel
Mar 21, 2026
9c965e0
feat(skills): add installKey-based cli install flow
Mar 22, 2026
6351953
feat(skills): split lightweight install modal from detail route
Mar 22, 2026
93a7d07
feat(skills): support editing metadata and archive updates
Mar 22, 2026
0f072db
feat(skills): add figma-based detail page assets and layout styles
Mar 22, 2026
383f308
feat(skills): add like/star functionality with IP tracking
Mar 22, 2026
1f34aa0
feat(skills): simplify import flow, remove URL import
Mar 22, 2026
db49973
refactor(api): remove redundant method-name validation
Mar 24, 2026
40f4aed
chore: update mcpDeployDir to /opt path
Mar 24, 2026
b5402d3
chore: remove docs/research from history and ignore future research f…
Mar 24, 2026
d6423b7
fix(skills): preserve skill item ids on source sync
Mar 24, 2026
f4ddec1
fix(skills): reject oversized files during import
Mar 24, 2026
ae698e0
style: fix prettier issues in skills pages
Mar 25, 2026
63b45d4
fix: resolve skills detail CI and icon assets
Mar 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ cache
public
resources
app/view

# Research docs (working notes, not for commit)
docs/research
29 changes: 29 additions & 0 deletions app/controller/skillLike.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const Controller = require('egg').Controller;

class SkillLikeController extends Controller {
async like() {
const { app, ctx } = this;
const { slug } = ctx.request.body || {};
const ip = ctx.service.skillLike.resolveClientIp();
const data = await ctx.service.skillLike.like(slug, ip);
ctx.body = app.utils.response(true, data);
}

async unlike() {
const { app, ctx } = this;
const { slug } = ctx.request.body || {};
const ip = ctx.service.skillLike.resolveClientIp();
const data = await ctx.service.skillLike.unlike(slug, ip);
ctx.body = app.utils.response(true, data);
}

async getLikeStatus() {
const { app, ctx } = this;
const { slug } = ctx.query || {};
const ip = ctx.service.skillLike.resolveClientIp();
const data = await ctx.service.skillLike.getLikeStatus(slug, ip);
ctx.body = app.utils.response(true, data);
}
}

module.exports = SkillLikeController;
108 changes: 108 additions & 0 deletions app/controller/skills.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
const Controller = require('egg').Controller;
const fs = require('fs');

class SkillsController extends Controller {
async getSkillList() {
const { app, ctx } = this;
const params = ctx.query;
const data = await ctx.service.skills.querySkillList(params);
ctx.body = app.utils.response(true, data);
}

async getSkillDetail() {
const { app, ctx } = this;
const { slug } = ctx.query;
const data = await ctx.service.skills.getSkillDetail(slug);
ctx.body = app.utils.response(true, data);
}

async getRelatedSkills() {
const { app, ctx } = this;
const { slug, limit = 6 } = ctx.query;
const data = await ctx.service.skills.getRelatedSkills(slug, limit);
ctx.body = app.utils.response(true, data);
}

async getSkillFileContent() {
const { app, ctx } = this;
const { slug, path: filePath } = ctx.query;
const data = await ctx.service.skills.getSkillFileContent(slug, filePath);
ctx.body = app.utils.response(true, data);
}

async downloadSkillArchive() {
const { ctx } = this;
const { slug } = ctx.query;
const { fileName, content } = await ctx.service.skills.getSkillArchive(slug);
ctx.set('Content-Type', 'application/zip');
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
ctx.body = content;
}

async getSkillInstallMeta() {
const { app, ctx } = this;
const identifier = ctx.query.installKey || ctx.query.slug;
const data = await ctx.service.skills.getInstallMeta(identifier);
ctx.body = app.utils.response(true, data);
}

async importSkillFile() {
const { app, ctx } = this;
const params = ctx.request.body || {};
const files = ctx.request.files
? Array.isArray(ctx.request.files)
? ctx.request.files
: [ctx.request.files]
: [];
const file = files[0];

if (!file) {
ctx.throw(400, '缺少上传文件');
}

try {
const data = await ctx.service.skills.importSkillFile(params, file);
ctx.body = app.utils.response(true, data);
} finally {
if (file.filepath && fs.existsSync(file.filepath)) {
try {
fs.unlinkSync(file.filepath);
} catch (error) {
ctx.logger.warn(`[skills] 清理上传文件失败: ${error.message}`);
}
}
}
}

async updateSkill() {
const { app, ctx } = this;
const params = ctx.request.body || {};
const files = ctx.request.files
? Array.isArray(ctx.request.files)
? ctx.request.files
: [ctx.request.files]
: [];
const file = files[0] || null;

try {
const data = await ctx.service.skills.updateSkill(params, file);
ctx.body = app.utils.response(true, data, '更新成功');
} finally {
if (file?.filepath && fs.existsSync(file.filepath)) {
try {
fs.unlinkSync(file.filepath);
} catch (error) {
ctx.logger.warn(`[skills] 清理更新上传文件失败: ${error.message}`);
}
}
}
}

async deleteSkill() {
const { app, ctx } = this;
const data = await ctx.service.skills.deleteSkill(ctx.request.body || {});
ctx.body = app.utils.response(true, data, '删除成功');
}
}

module.exports = SkillsController;
41 changes: 41 additions & 0 deletions app/model/skill_like.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module.exports = (app) => {
const { INTEGER, STRING, DATE } = app.Sequelize;

const SkillLike = app.model.define(
'skill_like',
{
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
},
skill_id: {
type: INTEGER,
allowNull: false,
comment: '技能ID',
},
ip: {
type: STRING(64),
allowNull: false,
comment: '点赞用户IP',
},
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
},
},
{
freezeTableName: true,
tableName: 'skill_likes',
timestamps: false,
indexes: [
{ fields: ['skill_id', 'ip'], unique: true },
{ fields: ['skill_id'] },
{ fields: ['ip'] },
],
}
);

return SkillLike;
};
77 changes: 77 additions & 0 deletions app/model/skills_file.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
module.exports = (app) => {
const { INTEGER, STRING, TEXT, DATE, TINYINT } = app.Sequelize;

const SkillsFile = app.model.define(
'skills_file',
{
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
},
skill_id: {
type: INTEGER,
allowNull: false,
comment: 'skills_items.id',
},
file_path: {
type: STRING(512),
allowNull: false,
comment: '文件相对路径',
},
language: {
type: STRING(64),
allowNull: false,
defaultValue: 'text',
},
size: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
},
is_binary: {
type: TINYINT,
allowNull: false,
defaultValue: 0,
},
encoding: {
type: STRING(16),
allowNull: false,
defaultValue: 'utf8',
},
content: {
type: TEXT('long'),
comment: '文本内容或base64内容',
},
updated_at_remote: {
type: DATE,
comment: '源仓库文件更新时间',
},
is_delete: {
type: TINYINT,
allowNull: false,
defaultValue: 0,
},
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
},
},
{
freezeTableName: true,
tableName: 'skills_files',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [{ fields: ['skill_id'] }],
}
);

return SkillsFile;
};
112 changes: 112 additions & 0 deletions app/model/skills_item.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
module.exports = (app) => {
const { INTEGER, STRING, TEXT, DATE, TINYINT } = app.Sequelize;

const SkillsItem = app.model.define(
'skills_item',
{
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
},
source_id: {
type: INTEGER,
allowNull: false,
comment: '来源记录ID',
},
slug: {
type: STRING(255),
allowNull: false,
unique: true,
comment: '详情页唯一标识',
},
name: {
type: STRING(255),
allowNull: false,
},
description: {
type: TEXT,
},
category: {
type: STRING(64),
allowNull: false,
defaultValue: '通用',
},
version: {
type: STRING(128),
allowNull: false,
defaultValue: '',
comment: '技能版本号',
},
tags: {
type: TEXT('long'),
comment: 'JSON字符串数组',
},
allowed_tools: {
type: TEXT('long'),
comment: 'JSON字符串数组',
},
stars: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
},
updated_at_remote: {
type: DATE,
comment: '源仓库文件更新时间',
},
source_repo: {
type: STRING(1000),
comment: '仓库地址',
},
source_path: {
type: STRING(1000),
comment: '仓库内 skill 相对路径',
},
skill_md: {
type: TEXT('long'),
comment: 'SKILL.md 原文',
},
install_command: {
type: TEXT,
comment: '推荐安装命令',
},
file_count: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
},
is_delete: {
type: TINYINT,
allowNull: false,
defaultValue: 0,
},
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
},
},
{
freezeTableName: true,
tableName: 'skills_items',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{ unique: true, fields: ['slug'] },
{ fields: ['source_id'] },
{ fields: ['category'] },
{ fields: ['stars'] },
{ fields: ['updated_at_remote'] },
],
}
);

return SkillsItem;
};
Loading
Loading