feat: v4.0 - 添加 Skills 管理功能

- 新增 Skill 模型,支持手动添加和 GitHub 抓取
- 后台添加 Skills 管理界面
- 前台添加 /skills 列表页和 /skills/<slug> 详情页
- 首页增加 Skills Tab 入口
- 添加数据库迁移脚本

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jowe
2026-03-25 22:39:50 +08:00
parent 76ae3cbb5a
commit 731f0d3822
7 changed files with 611 additions and 1 deletions

103
app.py
View File

@@ -11,7 +11,7 @@ from flask_admin.contrib.sqla import ModelView
from wtforms import fields from wtforms import fields
from datetime import datetime, timedelta from datetime import datetime, timedelta
from config import config from config import config
from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate, User, Folder, Collection, ApiKey from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate, User, Folder, Collection, ApiKey, Skill
from utils.website_fetcher import WebsiteFetcher from utils.website_fetcher import WebsiteFetcher
from utils.tag_generator import TagGenerator from utils.tag_generator import TagGenerator
from utils.news_searcher import NewsSearcher from utils.news_searcher import NewsSearcher
@@ -173,6 +173,41 @@ def create_app(config_name='default'):
pagination=pagination, tag_counts=tag_counts, pagination=pagination, tag_counts=tag_counts,
current_tab=current_tab) current_tab=current_tab)
# ========== Skills 前台路由 ==========
@app.route('/skills')
def skills_list():
"""Skills 列表页"""
page = request.args.get('page', 1, type=int)
per_page = 20
# 获取推荐的和所有的 Skills
featured_skills = Skill.query.filter_by(is_active=True, is_featured=True).order_by(Skill.sort_order.desc()).all()
pagination = Skill.query.filter_by(is_active=True).order_by(Skill.sort_order.desc(), Skill.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return render_template('skills/list.html',
featured_skills=featured_skills,
skills=pagination.items,
pagination=pagination)
@app.route('/skills/<slug>')
def skill_detail(slug):
"""Skill 详情页"""
skill = Skill.query.filter_by(slug=slug, is_active=True).first_or_404()
# 更新浏览次数
try:
db.session.execute(
db.text("UPDATE skills SET view_count = view_count + 1 WHERE id = :id"),
{"id": skill.id}
)
db.session.commit()
except:
db.session.rollback()
return render_template('skills/detail.html', skill=skill)
@app.route('/site/<code>') @app.route('/site/<code>')
def site_detail(code): def site_detail(code):
"""网站详情页 - v3.1性能优化""" """网站详情页 - v3.1性能优化"""
@@ -3380,6 +3415,71 @@ Sitemap: {}sitemap.xml
db.orm.joinedload(News.site) db.orm.joinedload(News.site)
) )
# Skills管理视图
class SkillAdmin(SecureModelView):
can_edit = True
can_delete = True
can_create = True
can_view_details = False
can_set_page_size = True
page_size = 20
column_display_actions = True
column_list = ['id', 'name', 'slug', 'short_desc', 'source_type', 'is_active', 'is_featured', 'view_count', 'created_at']
column_default_sort = ('created_at', True)
column_searchable_list = ['name', 'slug', 'description']
column_filters = ['is_active', 'is_featured', 'source_type']
column_labels = {
'id': 'ID',
'name': 'Skill名称',
'slug': 'URL别名',
'short_desc': '简短描述',
'description': '详细介绍',
'logo': 'Logo',
'github_url': 'GitHub链接',
'source_type': '来源类型',
'source_repo': '来源仓库',
'usage': '使用方法',
'examples': '使用示例',
'is_active': '是否启用',
'is_featured': '是否推荐',
'view_count': '浏览次数',
'sort_order': '排序权重',
'created_at': '创建时间',
'updated_at': '更新时间'
}
form_columns = ['name', 'slug', 'short_desc', 'description', 'logo', 'github_url', 'source_type', 'source_repo', 'usage', 'examples', 'is_active', 'is_featured', 'sort_order']
def on_model_change(self, form, model, is_created):
"""保存前自动生成slug如果为空"""
import re
from pypinyin import lazy_pinyin
# 如果slug为空从name自动生成
if not model.slug or model.slug.strip() == '':
slug = ''.join(lazy_pinyin(model.name))
slug = slug.lower()
slug = re.sub(r'[^\w\s-]', '', slug)
slug = re.sub(r'[-\s]+', '-', slug).strip('-')
if not slug:
slug = f"skill-{model.id}"
# 确保slug唯一
base_slug = slug[:50]
counter = 1
final_slug = slug
while counter < 100:
existing = Skill.query.filter(Skill.slug == final_slug).first()
if not existing or existing.id == model.id:
break
final_slug = f"{base_slug}-{counter}"
counter += 1
model.slug = final_slug
# Prompt模板管理视图 # Prompt模板管理视图
class PromptAdmin(SecureModelView): class PromptAdmin(SecureModelView):
can_edit = True can_edit = True
@@ -3436,6 +3536,7 @@ Sitemap: {}sitemap.xml
admin.add_view(SiteAdmin(Site, db.session, name='网站管理')) admin.add_view(SiteAdmin(Site, db.session, name='网站管理'))
admin.add_view(ApiKeyAdmin(ApiKey, db.session, name='API密钥', endpoint='api_keys')) admin.add_view(ApiKeyAdmin(ApiKey, db.session, name='API密钥', endpoint='api_keys'))
admin.add_view(SkillAdmin(Skill, db.session, name='Skills管理', endpoint='skills'))
admin.add_view(TagAdmin(Tag, db.session, name='标签管理')) admin.add_view(TagAdmin(Tag, db.session, name='标签管理'))
admin.add_view(NewsAdmin(News, db.session, name='新闻管理')) admin.add_view(NewsAdmin(News, db.session, name='新闻管理'))
admin.add_view(PromptAdmin(PromptTemplate, db.session, name='Prompt管理')) admin.add_view(PromptAdmin(PromptTemplate, db.session, name='Prompt管理'))

View File

@@ -0,0 +1,26 @@
"""
Skills 表迁移脚本
执行方式: python migrations/add_skills_table.py
"""
import os
import sys
# 添加项目根目录到路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app, db
from models import Skill
def create_skills_table():
"""创建 skills 表"""
app = create_app('development')
with app.app_context():
db.create_all()
print('skills 表创建成功')
if __name__ == '__main__':
create_skills_table()

View File

@@ -311,3 +311,51 @@ class Collection(db.Model):
def __repr__(self): def __repr__(self):
return f'<Collection user={self.user_id} site={self.site_id}>' return f'<Collection user={self.user_id} site={self.site_id}>'
class Skill(db.Model):
"""Skills 模型"""
__tablename__ = 'skills'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, comment='Skill名称')
slug = db.Column(db.String(100), unique=True, nullable=False, comment='URL别名')
description = db.Column(db.Text, comment='详细介绍')
short_desc = db.Column(db.String(200), comment='简短描述')
logo = db.Column(db.String(500), comment='Logo图片路径')
github_url = db.Column(db.String(500), comment='GitHub 链接')
source_type = db.Column(db.String(20), default='manual', comment='来源类型: manual/github')
source_repo = db.Column(db.String(200), comment='来源仓库')
usage = db.Column(db.Text, comment='使用方法')
examples = db.Column(db.Text, comment='使用示例')
is_active = db.Column(db.Boolean, default=True, comment='是否启用')
is_featured = db.Column(db.Boolean, default=False, comment='是否推荐')
view_count = db.Column(db.Integer, default=0, comment='浏览次数')
sort_order = db.Column(db.Integer, default=0, comment='排序权重')
created_at = db.Column(db.DateTime, default=datetime.now, comment='创建时间')
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, comment='更新时间')
def __repr__(self):
return f'<Skill {self.name}>'
def to_dict(self):
"""转换为字典"""
return {
'id': self.id,
'name': self.name,
'slug': self.slug,
'description': self.description,
'short_desc': self.short_desc,
'logo': self.logo,
'github_url': self.github_url,
'source_type': self.source_type,
'source_repo': self.source_repo,
'usage': self.usage,
'examples': self.examples,
'is_active': self.is_active,
'is_featured': self.is_featured,
'view_count': self.view_count,
'sort_order': self.sort_order,
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None
}

View File

@@ -47,6 +47,12 @@
<span class="nav-text">管理员</span> <span class="nav-text">管理员</span>
</a> </a>
</li> </li>
<li class="nav-item {% if active_page == 'skill' %}active{% endif %}">
<a href="{{ url_for('skills.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">handyman</span>
<span class="nav-text">Skills管理</span>
</a>
</li>
<li class="nav-item {% if active_page == 'api_key' %}active{% endif %}"> <li class="nav-item {% if active_page == 'api_key' %}active{% endif %}">
<a href="{{ url_for('api_keys.index_view') }}" class="nav-link"> <a href="{{ url_for('api_keys.index_view') }}" class="nav-link">
<span class="material-symbols-outlined nav-icon">key</span> <span class="material-symbols-outlined nav-icon">key</span>

View File

@@ -458,6 +458,11 @@
<span class="icon"></span> <span class="icon"></span>
推荐 推荐
</a> </a>
<a href="/skills"
class="sort-tab {% if current_tab == 'skills' %}active{% endif %}">
<span class="icon">🛠️</span>
Skills
</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,215 @@
{% extends 'base_new.html' %}
{% block title %}{{ skill.name }} - Skills | ZJPB{% endblock %}
{% block extra_head %}
<meta name="description" content="{{ skill.short_desc or (skill.description[:150] if skill.description else '') }}">
<meta name="keywords" content="{{ skill.name }},Skills,AI工具,自己品吧,ZJPB">
<link rel="canonical" href="{{ request.url }}">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="{{ skill.name }} - Skills">
<meta property="og:description" content="{{ skill.short_desc or skill.description[:150] }}">
<meta property="og:url" content="{{ request.url }}">
{% if skill.logo %}<meta property="og:image" content="{{ request.url_root.rstrip('/') }}{{ skill.logo }}">{% endif %}
{% endblock %}
{% block extra_css %}
<style>
.skill-detail-header {
padding: 40px 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.skill-detail-content {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px;
}
.skill-detail-logo {
width: 80px;
height: 80px;
border-radius: 16px;
background: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
margin-bottom: 20px;
}
.skill-detail-logo img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 16px;
}
.skill-detail-title {
font-size: 32px;
font-weight: 700;
margin-bottom: 12px;
}
.skill-detail-desc {
font-size: 18px;
opacity: 0.9;
margin-bottom: 20px;
}
.skill-detail-meta {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.skill-meta-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: rgba(255,255,255,0.2);
border-radius: 20px;
font-size: 14px;
}
.skill-section {
margin-bottom: 32px;
}
.skill-section-title {
font-size: 20px;
font-weight: 600;
color: #1e293b;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #e2e8f0;
}
.skill-content-box {
background: #f8fafc;
border-radius: 12px;
padding: 24px;
}
.skill-content-box pre {
background: #1e293b;
color: #e2e8f0;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 14px;
line-height: 1.6;
}
.skill-content-box code {
font-family: 'Monaco', 'Menlo', monospace;
}
.github-link {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: #24292e;
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
}
.github-link:hover {
background: #1b1f23;
transform: translateY(-2px);
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #64748b;
text-decoration: none;
margin-bottom: 24px;
}
.back-link:hover {
color: #0ea5e9;
}
</style>
{% endblock %}
{% block main %}
<div class="skill-detail-header">
<div class="skill-detail-content">
<a href="/skills" class="back-link" style="color: white;">
← 返回 Skills 列表
</a>
<div class="skill-detail-logo">
{% if skill.logo %}
<img src="{{ skill.logo }}" alt="{{ skill.name }}">
{% else %}
🛠️
{% endif %}
</div>
<h1 class="skill-detail-title">{{ skill.name }}</h1>
<p class="skill-detail-desc">{{ skill.short_desc or skill.description }}</p>
<div class="skill-detail-meta">
{% if skill.source_type == 'github' %}
<span class="skill-meta-item">📦 来源: GitHub</span>
{% if skill.source_repo %}
<span class="skill-meta-item">{{ skill.source_repo }}</span>
{% endif %}
{% else %}
<span class="skill-meta-item">✏️ 来源: 手动添加</span>
{% endif %}
<span class="skill-meta-item">👁 浏览: {{ skill.view_count or 0 }}</span>
</div>
</div>
</div>
<div class="skill-detail-content">
{% if skill.description %}
<div class="skill-section">
<h2 class="skill-section-title">📖 介绍</h2>
<div class="skill-content-box">
{{ skill.description | replace('\n', '<br>') | safe }}
</div>
</div>
{% endif %}
{% if skill.usage %}
<div class="skill-section">
<h2 class="skill-section-title">🚀 使用方法</h2>
<div class="skill-content-box">
{{ skill.usage | replace('\n', '<br>') | safe }}
</div>
</div>
{% endif %}
{% if skill.examples %}
<div class="skill-section">
<h2 class="skill-section-title">💡 使用示例</h2>
<div class="skill-content-box">
<pre>{{ skill.examples }}</pre>
</div>
</div>
{% endif %}
{% if skill.github_url %}
<div class="skill-section">
<h2 class="skill-section-title">🔗 GitHub</h2>
<a href="{{ skill.github_url }}" target="_blank" class="github-link">
<svg height="20" width="20" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
在 GitHub 查看
</a>
</div>
{% endif %}
</div>
{% endblock %}

209
templates/skills/list.html Normal file
View File

@@ -0,0 +1,209 @@
{% extends 'base_new.html' %}
{% block title %}Skills - AI 工具技能库 | ZJPB{% endblock %}
{% block extra_head %}
<meta name="description" content="ZJPB Skills - 发现和使用各种 AI 工具 Skills提升工作效率">
<meta name="keywords" content="AI Skills,Claude Code,OpenClaw,工具技能,ZJPB">
<link rel="canonical" href="{{ request.url }}">
{% endblock %}
{% block extra_css %}
<style>
.skills-hero {
padding: 40px 0;
text-align: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
margin-bottom: 40px;
}
.skills-hero h1 {
font-size: 36px;
font-weight: 700;
margin-bottom: 12px;
}
.skills-hero p {
font-size: 18px;
opacity: 0.9;
}
.featured-skills {
padding: 0 24px;
margin-bottom: 40px;
}
.featured-skills h2 {
font-size: 24px;
margin-bottom: 20px;
color: #1e293b;
}
.skill-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
transition: all 0.3s ease;
text-decoration: none;
display: block;
}
.skill-card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 40px rgba(0,0,0,0.15);
}
.skill-card-logo {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
margin-bottom: 16px;
}
.skill-card-logo img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 12px;
}
.skill-card-name {
font-size: 18px;
font-weight: 600;
color: #1e293b;
margin-bottom: 8px;
}
.skill-card-desc {
font-size: 14px;
color: #64748b;
line-height: 1.6;
margin-bottom: 12px;
}
.skill-card-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #94a3b8;
}
.skill-card-badge {
display: inline-block;
padding: 4px 8px;
background: #f1f5f9;
border-radius: 4px;
font-size: 12px;
color: #64748b;
}
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
padding: 0 24px;
}
</style>
{% endblock %}
{% block main %}
<div class="skills-hero">
<div class="hero-content">
<h1>🛠️ Skills</h1>
<p>发现和使用各种 AI 工具 Skills提升工作效率</p>
</div>
</div>
<div class="tools-section" style="max-width: 1280px; margin: 0 auto;">
{% if featured_skills %}
<div class="featured-skills">
<h2>⭐ 推荐 Skills</h2>
<div class="skills-grid">
{% for skill in featured_skills %}
<a href="{{ url_for('skill_detail', slug=skill.slug) }}" class="skill-card">
<div class="skill-card-logo">
{% if skill.logo %}
<img src="{{ skill.logo }}" alt="{{ skill.name }}">
{% else %}
🛠️
{% endif %}
</div>
<div class="skill-card-name">{{ skill.name }}</div>
<div class="skill-card-desc">{{ skill.short_desc or skill.description[:100] }}</div>
<div class="skill-card-meta">
{% if skill.source_type == 'github' %}
<span class="skill-card-badge">📦 GitHub</span>
{% else %}
<span class="skill-card-badge">✏️ 手动添加</span>
{% endif %}
<span>👁 {{ skill.view_count or 0 }}</span>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<div class="featured-skills">
<h2>📋 所有 Skills</h2>
{% if skills %}
<div class="skills-grid">
{% for skill in skills %}
<a href="{{ url_for('skill_detail', slug=skill.slug) }}" class="skill-card">
<div class="skill-card-logo">
{% if skill.logo %}
<img src="{{ skill.logo }}" alt="{{ skill.name }}">
{% else %}
🛠️
{% endif %}
</div>
<div class="skill-card-name">{{ skill.name }}</div>
<div class="skill-card-desc">{{ skill.short_desc or skill.description[:100] }}</div>
<div class="skill-card-meta">
{% if skill.source_type == 'github' %}
<span class="skill-card-badge">📦 GitHub</span>
{% else %}
<span class="skill-card-badge">✏️ 手动添加</span>
{% endif %}
<span>👁 {{ skill.view_count or 0 }}</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div style="text-align: center; padding: 60px 20px; color: #64748b;">
<p style="font-size: 18px;">暂无 Skills</p>
<p>请在后台添加 Skills</p>
</div>
{% endif %}
</div>
{% if pagination and pagination.pages > 1 %}
<div class="pagination" style="justify-content: center; margin-top: 40px; display: flex; gap: 8px;">
{% if pagination.has_prev %}
<a href="?page={{ pagination.prev_num }}" class="page-btn">上一页</a>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=2, right_edge=2, left_current=2, right_current=2) %}
{% if page_num %}
<a href="?page={{ page_num }}" class="page-btn {% if page_num == pagination.page %}active{% endif %}">{{ page_num }}</a>
{% else %}
<span>...</span>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<a href="?page={{ pagination.next_num }}" class="page-btn">下一页</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}