2439 lines
89 KiB
Diff
2439 lines
89 KiB
Diff
From 30b1ef75d655f962da385ad8630e06562a8b5bdf Mon Sep 17 00:00:00 2001
|
||
From: Jowe <123822645+Selei1983@users.noreply.github.com>
|
||
Date: Tue, 30 Dec 2025 00:45:39 +0800
|
||
Subject: [PATCH] =?UTF-8?q?release:=20v2.1=20-=20Prompt=E7=AE=A1=E7=90=86?=
|
||
=?UTF-8?q?=E7=B3=BB=E7=BB=9F=E3=80=81=E9=A1=B5=E8=84=9A=E4=BC=98=E5=8C=96?=
|
||
=?UTF-8?q?=E3=80=81=E5=9B=BE=E6=A0=87=E4=BF=AE=E5=A4=8D?=
|
||
MIME-Version: 1.0
|
||
Content-Type: text/plain; charset=UTF-8
|
||
Content-Transfer-Encoding: 8bit
|
||
|
||
新增功能:
|
||
- Prompt管理:后台新增Prompt模板管理功能
|
||
- 数据库迁移:新增prompt_templates表及默认数据
|
||
- 页脚优化:添加ICP备案号(浙ICP备2025154782号-1)和Microsoft Clarity统计
|
||
- 图标修复:详情页Material Icons替换为Emoji
|
||
- 标签显示:修复编辑页标签名称无法显示的问题
|
||
|
||
技术改进:
|
||
- 添加正则表达式提取标签名称
|
||
- 优化页脚样式和链接
|
||
- 完善增量部署文档
|
||
|
||
🚀 Generated with Claude Code
|
||
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
|
||
---
|
||
INCREMENTAL_DEPLOY.md | 208 ++++++++++++
|
||
app.py | 313 +++++++++++++++++-
|
||
export_data.py | 58 ++++
|
||
migrate_db.py | 49 +++
|
||
migrate_prompts.py | 141 +++++++++
|
||
models.py | 32 ++
|
||
templates/admin/site/create.html | 455 ++++++++++++++++++++++++++-
|
||
templates/admin/site/edit.html | 522 +++++++++++++++++++++++++++++--
|
||
templates/base_new.html | 40 ++-
|
||
templates/detail_new.html | 165 ++++++++--
|
||
10 files changed, 1887 insertions(+), 96 deletions(-)
|
||
create mode 100644 INCREMENTAL_DEPLOY.md
|
||
create mode 100644 export_data.py
|
||
create mode 100644 migrate_db.py
|
||
create mode 100644 migrate_prompts.py
|
||
|
||
diff --git a/INCREMENTAL_DEPLOY.md b/INCREMENTAL_DEPLOY.md
|
||
new file mode 100644
|
||
index 0000000..dc18d04
|
||
--- /dev/null
|
||
+++ b/INCREMENTAL_DEPLOY.md
|
||
@@ -0,0 +1,208 @@
|
||
+# ZJPB v2.1 增量部署指南
|
||
+
|
||
+## 📋 本次更新内容
|
||
+
|
||
+### 新增功能
|
||
+1. **Prompt管理系统** - 后台可管理AI提示词模板
|
||
+2. **详情页图标优化** - Material Icons替换为Emoji
|
||
+3. **标签显示修复** - 修复编辑页标签名称无法显示问题
|
||
+4. **页脚优化** - 添加ICP备案号和Microsoft Clarity统计
|
||
+
|
||
+### 数据库变更
|
||
+- 新增表:`prompt_templates` (Prompt模板表)
|
||
+- 无现有表结构变更,完全兼容旧数据
|
||
+
|
||
+---
|
||
+
|
||
+## 🔒 增量部署步骤
|
||
+
|
||
+### 第一步:备份生产数据库(必须!)
|
||
+
|
||
+在1Panel中备份数据库:
|
||
+```bash
|
||
+# 方法1:使用1Panel面板
|
||
+1. 进入1Panel -> 数据库
|
||
+2. 找到 ai_nav 数据库
|
||
+3. 点击"备份"按钮
|
||
+4. 下载备份文件到本地保存
|
||
+
|
||
+# 方法2:使用命令行
|
||
+mysqldump -h 112.124.42.38 -u ai_nav_user -p ai_nav > backup_$(date +%Y%m%d_%H%M%S).sql
|
||
+```
|
||
+
|
||
+### 第二步:停止生产应用
|
||
+
|
||
+```bash
|
||
+# SSH登录到生产服务器
|
||
+ssh root@your-server-ip
|
||
+
|
||
+# 进入应用目录
|
||
+cd /www/wwwroot/zjpb
|
||
+
|
||
+# 停止应用
|
||
+./manage.sh stop
|
||
+```
|
||
+
|
||
+### 第三步:备份现有代码
|
||
+
|
||
+```bash
|
||
+# 创建备份目录
|
||
+cd /www/wwwroot
|
||
+cp -r zjpb zjpb_backup_$(date +%Y%m%d_%H%M%S)
|
||
+```
|
||
+
|
||
+### 第四步:上传新代码
|
||
+
|
||
+**方法1:使用Git(推荐)**
|
||
+
|
||
+```bash
|
||
+# 在本地提交所有修改
|
||
+git add .
|
||
+git commit -m "release: v2.1 - Prompt管理系统、页脚优化、图标修复"
|
||
+git push origin master
|
||
+
|
||
+# 在服务器上拉取
|
||
+cd /www/wwwroot/zjpb
|
||
+git pull origin master
|
||
+```
|
||
+
|
||
+**方法2:手动上传**
|
||
+
|
||
+```bash
|
||
+# 在本地压缩(排除不需要的文件)
|
||
+zip -r zjpb_v2.1.zip . -x "*.pyc" "*__pycache__*" "*.git*" ".env" "venv/*" "test_*.py" "*.db" "nul"
|
||
+
|
||
+# 上传到服务器 /www/wwwroot/zjpb_new.zip
|
||
+# 然后解压覆盖
|
||
+cd /www/wwwroot/zjpb
|
||
+unzip -o ../zjpb_new.zip
|
||
+```
|
||
+
|
||
+### 第五步:安装新依赖(如有)
|
||
+
|
||
+```bash
|
||
+cd /www/wwwroot/zjpb
|
||
+source venv/bin/activate
|
||
+pip install -r requirements.txt
|
||
+```
|
||
+
|
||
+### 第六步:运行数据库迁移
|
||
+
|
||
+```bash
|
||
+# 激活虚拟环境
|
||
+source venv/bin/activate
|
||
+
|
||
+# 运行迁移脚本(创建 prompt_templates 表)
|
||
+python migrate_prompts.py
|
||
+```
|
||
+
|
||
+**预期输出:**
|
||
+```
|
||
+正在创建 prompt_templates 表...
|
||
+[OK] 表创建成功
|
||
+正在初始化默认prompt模板...
|
||
+[OK] 默认prompt模板初始化成功
|
||
+ - 标签生成: 1
|
||
+ - 主要功能生成: 2
|
||
+ - 详细介绍生成: 3
|
||
+```
|
||
+
|
||
+### 第七步:重启应用
|
||
+
|
||
+```bash
|
||
+./manage.sh start
|
||
+```
|
||
+
|
||
+### 第八步:验证部署
|
||
+
|
||
+**检查项:**
|
||
+
|
||
+1. **访问前台首页**
|
||
+ - 检查页脚是否显示ICP备案号
|
||
+ - 检查Clarity统计是否加载(F12查看Network)
|
||
+
|
||
+2. **访问详情页**
|
||
+ - 检查图标是否正常显示(不是Material Icons文本)
|
||
+
|
||
+3. **登录后台**
|
||
+ - 检查是否有"Prompt管理"菜单
|
||
+ - 进入Prompt管理,查看是否有3条默认数据
|
||
+
|
||
+4. **测试网站编辑**
|
||
+ - 编辑任意网站,检查标签是否正常显示(不是空白蓝框)
|
||
+
|
||
+5. **测试AI功能**
|
||
+ - 创建新网站,测试"AI生成标签"功能
|
||
+ - 测试"AI生成详细介绍"功能
|
||
+ - 测试"AI生成主要功能"功能
|
||
+
|
||
+---
|
||
+
|
||
+## 🔄 回滚方案(如出现问题)
|
||
+
|
||
+### 快速回滚代码
|
||
+
|
||
+```bash
|
||
+cd /www/wwwroot
|
||
+./zjpb/manage.sh stop
|
||
+
|
||
+# 删除新版本
|
||
+rm -rf zjpb
|
||
+
|
||
+# 恢复备份
|
||
+mv zjpb_backup_YYYYMMDD_HHMMSS zjpb
|
||
+
|
||
+# 重启
|
||
+cd zjpb
|
||
+./manage.sh start
|
||
+```
|
||
+
|
||
+### 恢复数据库
|
||
+
|
||
+```bash
|
||
+# 如果新表导致问题,可以删除新表
|
||
+mysql -h 112.124.42.38 -u ai_nav_user -p ai_nav
|
||
+
|
||
+# 在MySQL中执行
|
||
+DROP TABLE IF EXISTS prompt_templates;
|
||
+```
|
||
+
|
||
+---
|
||
+
|
||
+## 📝 注意事项
|
||
+
|
||
+1. ✅ **本次更新不会影响现有数据** - 只是新增表,不修改现有表
|
||
+2. ✅ **完全向后兼容** - 即使不运行迁移脚本,前台也能正常访问
|
||
+3. ✅ **可以随时回滚** - 保留了完整备份
|
||
+4. ⚠️ **必须备份数据库** - 虽然风险很低,但备份是必须的
|
||
+5. ⚠️ **检查.env配置** - 确保生产环境的.env配置正确
|
||
+
|
||
+---
|
||
+
|
||
+## 🐛 常见问题
|
||
+
|
||
+### Q1: 迁移脚本报错 "表已存在"
|
||
+**A:** 说明之前已经运行过迁移,可以跳过此步骤
|
||
+
|
||
+### Q2: Prompt管理菜单看不到
|
||
+**A:** 清除浏览器缓存,重新登录后台
|
||
+
|
||
+### Q3: 标签还是显示不出来
|
||
+**A:** 清除浏览器缓存,强制刷新(Ctrl+F5)
|
||
+
|
||
+### Q4: Clarity统计没加载
|
||
+**A:** 检查网络是否能访问 clarity.ms,可能被墙
|
||
+
|
||
+---
|
||
+
|
||
+## 📞 技术支持
|
||
+
|
||
+如遇问题,请检查日志:
|
||
+
|
||
+```bash
|
||
+# 查看应用日志
|
||
+./manage.sh logs
|
||
+
|
||
+# 查看错误日志
|
||
+tail -f logs/error.log
|
||
+```
|
||
diff --git a/app.py b/app.py
|
||
index 14288f1..7a2e392 100644
|
||
--- a/app.py
|
||
+++ b/app.py
|
||
@@ -1,11 +1,12 @@
|
||
import os
|
||
+import markdown
|
||
from flask import Flask, render_template, redirect, url_for, request, flash, jsonify
|
||
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
||
from flask_admin import Admin, AdminIndexView, expose
|
||
from flask_admin.contrib.sqla import ModelView
|
||
from datetime import datetime
|
||
from config import config
|
||
-from models import db, Site, Tag, Admin as AdminModel, News
|
||
+from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate
|
||
from utils.website_fetcher import WebsiteFetcher
|
||
from utils.tag_generator import TagGenerator
|
||
|
||
@@ -19,6 +20,14 @@ def create_app(config_name='default'):
|
||
# 初始化数据库
|
||
db.init_app(app)
|
||
|
||
+ # 添加Markdown过滤器
|
||
+ @app.template_filter('markdown')
|
||
+ def markdown_filter(text):
|
||
+ """将Markdown文本转换为HTML"""
|
||
+ if not text:
|
||
+ return ''
|
||
+ return markdown.markdown(text, extensions=['nl2br', 'fenced_code'])
|
||
+
|
||
# 初始化登录管理
|
||
login_manager = LoginManager()
|
||
login_manager.init_app(app)
|
||
@@ -36,6 +45,22 @@ def create_app(config_name='default'):
|
||
# 获取所有启用的标签
|
||
tags = Tag.query.order_by(Tag.sort_order.desc(), Tag.id).all()
|
||
|
||
+ # 优化:使用一次SQL查询统计所有标签的网站数量
|
||
+ tag_counts = {}
|
||
+ if tags:
|
||
+ # 使用JOIN查询一次性获取所有标签的网站数量
|
||
+ from sqlalchemy import func
|
||
+ counts_query = db.session.query(
|
||
+ site_tags.c.tag_id,
|
||
+ func.count(site_tags.c.site_id).label('count')
|
||
+ ).join(
|
||
+ Site, site_tags.c.site_id == Site.id
|
||
+ ).filter(
|
||
+ Site.is_active == True
|
||
+ ).group_by(site_tags.c.tag_id).all()
|
||
+
|
||
+ tag_counts = {tag_id: count for tag_id, count in counts_query}
|
||
+
|
||
# 获取筛选参数
|
||
tag_slug = request.args.get('tag')
|
||
search_query = request.args.get('q', '').strip()
|
||
@@ -57,7 +82,7 @@ def create_app(config_name='default'):
|
||
pagination = None
|
||
return render_template('index_new.html', sites=sites, tags=tags,
|
||
selected_tag=selected_tag, search_query=search_query,
|
||
- pagination=pagination)
|
||
+ pagination=pagination, tag_counts=tag_counts)
|
||
|
||
# 搜索功能
|
||
if search_query:
|
||
@@ -79,7 +104,7 @@ def create_app(config_name='default'):
|
||
|
||
return render_template('index_new.html', sites=sites, tags=tags,
|
||
selected_tag=selected_tag, search_query=search_query,
|
||
- pagination=pagination)
|
||
+ pagination=pagination, tag_counts=tag_counts)
|
||
|
||
@app.route('/site/<code>')
|
||
def site_detail(code):
|
||
@@ -138,6 +163,51 @@ def create_app(config_name='default'):
|
||
logout_user()
|
||
return redirect(url_for('index'))
|
||
|
||
+ @app.route('/admin/change-password', methods=['GET', 'POST'])
|
||
+ @login_required
|
||
+ def change_password():
|
||
+ """修改密码"""
|
||
+ if request.method == 'POST':
|
||
+ old_password = request.form.get('old_password', '').strip()
|
||
+ new_password = request.form.get('new_password', '').strip()
|
||
+ confirm_password = request.form.get('confirm_password', '').strip()
|
||
+
|
||
+ # 验证旧密码
|
||
+ if not current_user.check_password(old_password):
|
||
+ flash('旧密码错误', 'error')
|
||
+ return render_template('admin/change_password.html')
|
||
+
|
||
+ # 验证新密码
|
||
+ if not new_password:
|
||
+ flash('新密码不能为空', 'error')
|
||
+ return render_template('admin/change_password.html')
|
||
+
|
||
+ if len(new_password) < 6:
|
||
+ flash('新密码长度至少6位', 'error')
|
||
+ return render_template('admin/change_password.html')
|
||
+
|
||
+ if new_password != confirm_password:
|
||
+ flash('两次输入的新密码不一致', 'error')
|
||
+ return render_template('admin/change_password.html')
|
||
+
|
||
+ if old_password == new_password:
|
||
+ flash('新密码不能与旧密码相同', 'error')
|
||
+ return render_template('admin/change_password.html')
|
||
+
|
||
+ # 修改密码
|
||
+ try:
|
||
+ current_user.set_password(new_password)
|
||
+ db.session.commit()
|
||
+ flash('密码修改成功,请重新登录', 'success')
|
||
+ logout_user()
|
||
+ return redirect(url_for('admin_login'))
|
||
+ except Exception as e:
|
||
+ db.session.rollback()
|
||
+ flash(f'密码修改失败:{str(e)}', 'error')
|
||
+ return render_template('admin/change_password.html')
|
||
+
|
||
+ return render_template('admin/change_password.html')
|
||
+
|
||
# ========== API路由 ==========
|
||
@app.route('/api/fetch-website-info', methods=['POST'])
|
||
@login_required
|
||
@@ -165,17 +235,18 @@ def create_app(config_name='default'):
|
||
'message': '无法获取网站信息,请检查URL是否正确或手动填写'
|
||
})
|
||
|
||
- # 下载Logo(如果有)
|
||
+ # 下载Logo到本地(如果有)
|
||
logo_path = None
|
||
if info.get('logo_url'):
|
||
- logo_path = fetcher.download_logo(info['logo_url'])
|
||
+ logo_path = fetcher.download_logo(info['logo_url'], save_dir='static/logos')
|
||
|
||
+ # 如果下载失败,不返回远程URL,让用户手动上传
|
||
return jsonify({
|
||
'success': True,
|
||
'data': {
|
||
'name': info.get('name', ''),
|
||
'description': info.get('description', ''),
|
||
- 'logo': logo_path or info.get('logo_url', '')
|
||
+ 'logo': logo_path if logo_path else ''
|
||
}
|
||
})
|
||
|
||
@@ -185,6 +256,148 @@ def create_app(config_name='default'):
|
||
'message': f'抓取失败: {str(e)}'
|
||
}), 500
|
||
|
||
+ @app.route('/api/upload-logo', methods=['POST'])
|
||
+ @login_required
|
||
+ def upload_logo():
|
||
+ """上传Logo图片API"""
|
||
+ try:
|
||
+ # 检查文件是否存在
|
||
+ if 'logo' not in request.files:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': '请选择要上传的图片'
|
||
+ }), 400
|
||
+
|
||
+ file = request.files['logo']
|
||
+
|
||
+ # 检查文件名
|
||
+ if file.filename == '':
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': '未选择文件'
|
||
+ }), 400
|
||
+
|
||
+ # 检查文件类型
|
||
+ allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp'}
|
||
+ filename = file.filename.lower()
|
||
+ if not any(filename.endswith('.' + ext) for ext in allowed_extensions):
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': '不支持的文件格式,请上传图片文件(png, jpg, gif, svg, ico, webp)'
|
||
+ }), 400
|
||
+
|
||
+ # 创建保存目录
|
||
+ save_dir = 'static/logos'
|
||
+ os.makedirs(save_dir, exist_ok=True)
|
||
+
|
||
+ # 生成安全的文件名
|
||
+ import time
|
||
+ import hashlib
|
||
+ ext = os.path.splitext(filename)[1]
|
||
+ timestamp = str(int(time.time() * 1000))
|
||
+ hash_name = hashlib.md5(f"{filename}{timestamp}".encode()).hexdigest()[:16]
|
||
+ safe_filename = f"logo_{hash_name}{ext}"
|
||
+ filepath = os.path.join(save_dir, safe_filename)
|
||
+
|
||
+ # 保存文件
|
||
+ file.save(filepath)
|
||
+
|
||
+ # 返回相对路径
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'path': f'/{filepath.replace(os.sep, "/")}'
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': f'上传失败: {str(e)}'
|
||
+ }), 500
|
||
+
|
||
+ @app.route('/api/generate-features', methods=['POST'])
|
||
+ @login_required
|
||
+ def generate_features():
|
||
+ """使用DeepSeek自动生成网站主要功能"""
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+ name = data.get('name', '').strip()
|
||
+ description = data.get('description', '').strip()
|
||
+ url = data.get('url', '').strip()
|
||
+
|
||
+ if not name or not description:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': '请提供网站名称和描述'
|
||
+ }), 400
|
||
+
|
||
+ # 生成功能列表
|
||
+ generator = TagGenerator()
|
||
+ features = generator.generate_features(name, description, url)
|
||
+
|
||
+ if not features:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': 'DeepSeek功能生成失败,请检查API配置'
|
||
+ }), 500
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'features': features
|
||
+ })
|
||
+
|
||
+ except ValueError as e:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': str(e)
|
||
+ }), 400
|
||
+ except Exception as e:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': f'生成失败: {str(e)}'
|
||
+ }), 500
|
||
+
|
||
+ @app.route('/api/generate-description', methods=['POST'])
|
||
+ @login_required
|
||
+ def generate_description():
|
||
+ """使用DeepSeek自动生成网站详细介绍"""
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+ name = data.get('name', '').strip()
|
||
+ short_desc = data.get('short_desc', '').strip()
|
||
+ url = data.get('url', '').strip()
|
||
+
|
||
+ if not name:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': '请提供网站名称'
|
||
+ }), 400
|
||
+
|
||
+ # 生成详细介绍
|
||
+ generator = TagGenerator()
|
||
+ description = generator.generate_description(name, short_desc, url)
|
||
+
|
||
+ if not description:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': 'DeepSeek详细介绍生成失败,请检查API配置'
|
||
+ }), 500
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'description': description
|
||
+ })
|
||
+
|
||
+ except ValueError as e:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': str(e)
|
||
+ }), 400
|
||
+ except Exception as e:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': f'生成失败: {str(e)}'
|
||
+ }), 500
|
||
+
|
||
@app.route('/api/generate-tags', methods=['POST'])
|
||
@login_required
|
||
def generate_tags():
|
||
@@ -345,11 +558,11 @@ def create_app(config_name='default'):
|
||
})
|
||
continue
|
||
|
||
- # 4. 下载Logo(失败不影响导入)
|
||
+ # 4. 下载Logo到本地(失败不影响导入)
|
||
logo_path = None
|
||
if info.get('logo_url'):
|
||
try:
|
||
- logo_path = fetcher.download_logo(info['logo_url'])
|
||
+ logo_path = fetcher.download_logo(info['logo_url'], save_dir='static/logos')
|
||
except Exception as e:
|
||
print(f"下载Logo失败 ({url}): {str(e)}")
|
||
# Logo下载失败不影响网站导入
|
||
@@ -544,9 +757,47 @@ def create_app(config_name='default'):
|
||
import re
|
||
import random
|
||
from pypinyin import lazy_pinyin
|
||
+ from flask import request
|
||
|
||
# 使用no_autoflush防止在查询时触发提前flush
|
||
with db.session.no_autoflush:
|
||
+ # 处理手动输入的新标签
|
||
+ new_tags_str = request.form.get('new_tags', '')
|
||
+ if new_tags_str:
|
||
+ new_tag_names = [name.strip() for name in new_tags_str.split(',') if name.strip()]
|
||
+ for tag_name in new_tag_names:
|
||
+ # 检查标签是否已存在
|
||
+ existing_tag = Tag.query.filter_by(name=tag_name).first()
|
||
+ if not existing_tag:
|
||
+ # 创建新标签
|
||
+ tag_slug = ''.join(lazy_pinyin(tag_name))
|
||
+ tag_slug = tag_slug.lower()
|
||
+ tag_slug = re.sub(r'[^\w\s-]', '', tag_slug)
|
||
+ tag_slug = re.sub(r'[-\s]+', '-', tag_slug).strip('-')
|
||
+
|
||
+ # 确保slug唯一
|
||
+ base_tag_slug = tag_slug[:50]
|
||
+ counter = 1
|
||
+ final_tag_slug = tag_slug
|
||
+ while Tag.query.filter_by(slug=final_tag_slug).first():
|
||
+ final_tag_slug = f"{base_tag_slug}-{counter}"
|
||
+ counter += 1
|
||
+ if counter > 100:
|
||
+ final_tag_slug = f"{base_tag_slug}-{random.randint(1000, 9999)}"
|
||
+ break
|
||
+
|
||
+ new_tag = Tag(name=tag_name, slug=final_tag_slug)
|
||
+ db.session.add(new_tag)
|
||
+ db.session.flush() # 确保新标签有ID
|
||
+
|
||
+ # 添加到模型的标签列表
|
||
+ if new_tag not in model.tags:
|
||
+ model.tags.append(new_tag)
|
||
+ else:
|
||
+ # 添加已存在的标签
|
||
+ if existing_tag not in model.tags:
|
||
+ model.tags.append(existing_tag)
|
||
+
|
||
# 如果code为空,自动生成唯一的8位数字编码
|
||
if not model.code or model.code.strip() == '':
|
||
max_attempts = 10
|
||
@@ -684,6 +935,51 @@ def create_app(config_name='default'):
|
||
]
|
||
}
|
||
|
||
+ # Prompt模板管理视图
|
||
+ class PromptAdmin(SecureModelView):
|
||
+ can_edit = True
|
||
+ can_delete = False # 不允许删除,避免系统必需的prompt被删除
|
||
+ can_create = True
|
||
+ can_view_details = False
|
||
+
|
||
+ # 显示操作列
|
||
+ column_display_actions = True
|
||
+
|
||
+ column_list = ['id', 'key', 'name', 'description', 'is_active', 'updated_at']
|
||
+ column_searchable_list = ['key', 'name', 'description']
|
||
+ column_filters = ['is_active', 'key']
|
||
+ column_labels = {
|
||
+ 'id': 'ID',
|
||
+ 'key': '唯一标识',
|
||
+ 'name': '模板名称',
|
||
+ 'system_prompt': '系统提示词',
|
||
+ 'user_prompt_template': '用户提示词模板',
|
||
+ 'description': '模板说明',
|
||
+ 'is_active': '是否启用',
|
||
+ 'created_at': '创建时间',
|
||
+ 'updated_at': '更新时间'
|
||
+ }
|
||
+ form_columns = ['key', 'name', 'description', 'system_prompt', 'user_prompt_template', 'is_active']
|
||
+
|
||
+ # 字段说明
|
||
+ column_descriptions = {
|
||
+ 'key': '唯一标识,如: tags, features, description',
|
||
+ 'system_prompt': 'AI的系统角色设定',
|
||
+ 'user_prompt_template': '用户提示词模板,支持变量如 {name}, {description}, {url}',
|
||
+ }
|
||
+
|
||
+ # 表单字段配置
|
||
+ form_widget_args = {
|
||
+ 'system_prompt': {
|
||
+ 'rows': 3,
|
||
+ 'style': 'font-family: monospace;'
|
||
+ },
|
||
+ 'user_prompt_template': {
|
||
+ 'rows': 20,
|
||
+ 'style': 'font-family: monospace;'
|
||
+ }
|
||
+ }
|
||
+
|
||
# 初始化 Flask-Admin
|
||
admin = Admin(
|
||
app,
|
||
@@ -696,6 +992,7 @@ def create_app(config_name='default'):
|
||
admin.add_view(SiteAdmin(Site, db.session, name='网站管理'))
|
||
admin.add_view(TagAdmin(Tag, db.session, name='标签管理'))
|
||
admin.add_view(NewsAdmin(News, db.session, name='新闻管理'))
|
||
+ admin.add_view(PromptAdmin(PromptTemplate, db.session, name='Prompt管理'))
|
||
admin.add_view(AdminAdmin(AdminModel, db.session, name='管理员', endpoint='admin_users'))
|
||
|
||
return app
|
||
diff --git a/export_data.py b/export_data.py
|
||
new file mode 100644
|
||
index 0000000..df212a9
|
||
--- /dev/null
|
||
+++ b/export_data.py
|
||
@@ -0,0 +1,58 @@
|
||
+# -*- coding: utf-8 -*-
|
||
+"""
|
||
+数据导出脚本
|
||
+导出现有数据到SQL文件
|
||
+"""
|
||
+import subprocess
|
||
+import os
|
||
+from datetime import datetime
|
||
+
|
||
+def export_database():
|
||
+ """导出数据库到SQL文件"""
|
||
+ # 从.env读取数据库配置
|
||
+ from dotenv import load_dotenv
|
||
+ load_dotenv()
|
||
+
|
||
+ db_host = os.getenv('DB_HOST', 'localhost')
|
||
+ db_port = os.getenv('DB_PORT', '3306')
|
||
+ db_user = os.getenv('DB_USER')
|
||
+ db_password = os.getenv('DB_PASSWORD')
|
||
+ db_name = os.getenv('DB_NAME')
|
||
+
|
||
+ # 生成备份文件名
|
||
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
+ backup_file = f'backup_{db_name}_{timestamp}.sql'
|
||
+
|
||
+ # mysqldump命令
|
||
+ cmd = [
|
||
+ 'mysqldump',
|
||
+ '-h', db_host,
|
||
+ '-P', db_port,
|
||
+ '-u', db_user,
|
||
+ f'-p{db_password}',
|
||
+ '--single-transaction',
|
||
+ '--routines',
|
||
+ '--triggers',
|
||
+ db_name
|
||
+ ]
|
||
+
|
||
+ print(f"正在导出数据库 {db_name} ...")
|
||
+
|
||
+ try:
|
||
+ with open(backup_file, 'w', encoding='utf-8') as f:
|
||
+ subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, check=True)
|
||
+
|
||
+ file_size = os.path.getsize(backup_file) / 1024 # KB
|
||
+ print(f"✓ 数据库导出成功!")
|
||
+ print(f" 文件: {backup_file}")
|
||
+ print(f" 大小: {file_size:.2f} KB")
|
||
+ print(f"\n请将此文件上传到服务器进行恢复")
|
||
+
|
||
+ except subprocess.CalledProcessError as e:
|
||
+ print(f"✗ 导出失败: {e.stderr.decode()}")
|
||
+ except FileNotFoundError:
|
||
+ print("✗ 错误: 找不到 mysqldump 命令")
|
||
+ print(" 请确保MySQL客户端工具已安装并在PATH中")
|
||
+
|
||
+if __name__ == '__main__':
|
||
+ export_database()
|
||
diff --git a/migrate_db.py b/migrate_db.py
|
||
new file mode 100644
|
||
index 0000000..4d4bdef
|
||
--- /dev/null
|
||
+++ b/migrate_db.py
|
||
@@ -0,0 +1,49 @@
|
||
+# -*- coding: utf-8 -*-
|
||
+"""
|
||
+数据库安全迁移脚本
|
||
+只创建缺失的表,不删除现有数据
|
||
+用于生产环境部署
|
||
+"""
|
||
+
|
||
+import sys
|
||
+from app import create_app
|
||
+from models import db, Admin
|
||
+
|
||
+def migrate_database():
|
||
+ """安全迁移数据库(不删除数据)"""
|
||
+ app = create_app('production')
|
||
+
|
||
+ with app.app_context():
|
||
+ print("正在检查数据库表...")
|
||
+
|
||
+ # 只创建不存在的表(不会删除数据)
|
||
+ db.create_all()
|
||
+ print("✓ 数据库表检查完成")
|
||
+
|
||
+ # 检查是否存在管理员
|
||
+ admin_count = Admin.query.count()
|
||
+
|
||
+ if admin_count == 0:
|
||
+ print("\n未找到管理员账号,正在创建默认管理员...")
|
||
+ admin = Admin(
|
||
+ username='admin',
|
||
+ email='admin@example.com',
|
||
+ is_active=True
|
||
+ )
|
||
+ admin.set_password('admin123')
|
||
+ db.session.add(admin)
|
||
+ db.session.commit()
|
||
+
|
||
+ print("✓ 默认管理员创建成功")
|
||
+ print(" 用户名: admin")
|
||
+ print(" 密码: admin123")
|
||
+ print("\n⚠️ 请立即登录后台修改密码!")
|
||
+ else:
|
||
+ print(f"\n✓ 已存在 {admin_count} 个管理员账号")
|
||
+
|
||
+ print("\n" + "="*50)
|
||
+ print("数据库迁移完成!")
|
||
+ print("="*50)
|
||
+
|
||
+if __name__ == '__main__':
|
||
+ migrate_database()
|
||
diff --git a/migrate_prompts.py b/migrate_prompts.py
|
||
new file mode 100644
|
||
index 0000000..a6bd693
|
||
--- /dev/null
|
||
+++ b/migrate_prompts.py
|
||
@@ -0,0 +1,141 @@
|
||
+"""创建 prompt_templates 表并初始化默认数据"""
|
||
+import os
|
||
+import sys
|
||
+from app import create_app
|
||
+from models import db, PromptTemplate
|
||
+
|
||
+# 设置输出编码为UTF-8
|
||
+if sys.platform.startswith('win'):
|
||
+ sys.stdout.reconfigure(encoding='utf-8')
|
||
+
|
||
+def migrate_prompts():
|
||
+ """创建表并初始化默认prompt模板"""
|
||
+ app = create_app(os.getenv('FLASK_ENV', 'development'))
|
||
+
|
||
+ with app.app_context():
|
||
+ # 创建表
|
||
+ print("正在创建 prompt_templates 表...")
|
||
+ db.create_all()
|
||
+ print("[OK] 表创建成功")
|
||
+
|
||
+ # 检查是否已有数据
|
||
+ existing_count = PromptTemplate.query.count()
|
||
+ if existing_count > 0:
|
||
+ print(f"[WARN] 表中已有 {existing_count} 条数据,跳过初始化")
|
||
+ return
|
||
+
|
||
+ # 初始化默认prompt模板
|
||
+ print("正在初始化默认prompt模板...")
|
||
+
|
||
+ # 1. 标签生成模板
|
||
+ tags_prompt = PromptTemplate(
|
||
+ key='tags',
|
||
+ name='标签生成',
|
||
+ description='根据网站名称和描述生成3-5个分类标签',
|
||
+ system_prompt='你是一个专业的AI工具分类专家,擅长为各类AI产品生成准确的标签。',
|
||
+ user_prompt_template='''你是一个AI工具导航网站的标签生成助手。根据以下产品信息,生成3-5个最合适的标签。
|
||
+
|
||
+产品名称: {name}
|
||
+
|
||
+产品描述: {description}
|
||
+{existing_tags}
|
||
+
|
||
+要求:
|
||
+1. 标签应该准确描述产品的功能、类型或应用场景
|
||
+2. 每个标签2-4个汉字
|
||
+3. 标签要具体且有区分度
|
||
+4. 如果是AI工具,可以标注具体的AI类型(如"GPT"、"图像生成"等)
|
||
+5. 只返回标签,用逗号分隔,不要其他说明
|
||
+
|
||
+示例输出格式:写作助手,营销,GPT,内容生成
|
||
+
|
||
+请生成标签:''',
|
||
+ is_active=True
|
||
+ )
|
||
+
|
||
+ # 2. 主要功能生成模板
|
||
+ features_prompt = PromptTemplate(
|
||
+ key='features',
|
||
+ name='主要功能生成',
|
||
+ description='根据网站名称和描述生成5-8个主要功能点',
|
||
+ system_prompt='你是一个专业的AI产品文案专家,擅长提炼产品核心功能和价值点。',
|
||
+ user_prompt_template='''你是一个AI工具导航网站的内容编辑助手。根据以下产品信息,生成详细的主要功能列表。
|
||
+
|
||
+产品名称: {name}
|
||
+
|
||
+产品描述: {description}
|
||
+{url_info}
|
||
+
|
||
+要求:
|
||
+1. 生成5-8个主要功能点
|
||
+2. 每个功能点要具体、清晰、有吸引力
|
||
+3. 使用Markdown无序列表格式(以"- "开头)
|
||
+4. 每个功能点一行,简洁有力(10-30字)
|
||
+5. 突出产品的核心价值和特色功能
|
||
+6. 使用专业但易懂的语言
|
||
+7. 不要添加任何标题或额外说明,直接输出功能列表
|
||
+
|
||
+示例输出格式:
|
||
+- 智能文本生成,支持多种写作场景
|
||
+- 实时语法检查和优化建议
|
||
+- 多语言翻译,准确率高达95%
|
||
+- 一键生成营销文案和广告语
|
||
+- 团队协作,支持多人同时编辑
|
||
+
|
||
+请生成功能列表:''',
|
||
+ is_active=True
|
||
+ )
|
||
+
|
||
+ # 3. 详细介绍生成模板
|
||
+ description_prompt = PromptTemplate(
|
||
+ key='description',
|
||
+ name='详细介绍生成',
|
||
+ description='根据网站名称和简短描述生成200-400字的详细介绍',
|
||
+ system_prompt='你是一个专业的AI产品文案专家,擅长撰写准确、客观、有吸引力的产品介绍。',
|
||
+ user_prompt_template='''你是一个AI工具导航网站的内容编辑助手。根据以下产品信息,生成详细、专业且吸引人的产品介绍。
|
||
+
|
||
+产品名称: {name}
|
||
+{short_desc_info}
|
||
+{url_info}
|
||
+
|
||
+要求:
|
||
+1. 生成200-400字的详细介绍
|
||
+2. 包含以下内容:
|
||
+ - 产品定位和核心价值(这是什么产品,解决什么问题)
|
||
+ - 主要特点和优势(为什么选择这个产品)
|
||
+ - 适用场景和目标用户(谁会用,用在哪里)
|
||
+3. 使用Markdown格式,可以包含:
|
||
+ - 段落分隔(空行)
|
||
+ - 加粗重点内容(**文字**)
|
||
+ - 列表(- 列表项)
|
||
+4. 语言专业但易懂,突出产品价值
|
||
+5. 不要添加标题,直接输出正文内容
|
||
+6. 语气客观、事实性强,避免过度营销
|
||
+
|
||
+示例输出格式:
|
||
+ChatGPT是由OpenAI开发的**先进对话式AI助手**,基于GPT-4大语言模型构建。它能够理解和生成自然语言,为用户提供智能对话、内容创作、代码编写等多种服务。
|
||
+
|
||
+**核心优势:**
|
||
+- 强大的语言理解和生成能力
|
||
+- 支持多轮对话,上下文连贯
|
||
+- 覆盖编程、写作、翻译等多个领域
|
||
+
|
||
+适用于内容创作者、程序员、学生等各类用户,可用于日常问答、文案撰写、学习辅导、编程助手等多种场景。
|
||
+
|
||
+请生成详细介绍:''',
|
||
+ is_active=True
|
||
+ )
|
||
+
|
||
+ # 添加到数据库
|
||
+ db.session.add(tags_prompt)
|
||
+ db.session.add(features_prompt)
|
||
+ db.session.add(description_prompt)
|
||
+ db.session.commit()
|
||
+
|
||
+ print("[OK] 默认prompt模板初始化成功")
|
||
+ print(f" - 标签生成: {tags_prompt.id}")
|
||
+ print(f" - 主要功能生成: {features_prompt.id}")
|
||
+ print(f" - 详细介绍生成: {description_prompt.id}")
|
||
+
|
||
+if __name__ == '__main__':
|
||
+ migrate_prompts()
|
||
diff --git a/models.py b/models.py
|
||
index 57e988a..fae1c97 100644
|
||
--- a/models.py
|
||
+++ b/models.py
|
||
@@ -136,3 +136,35 @@ class Admin(UserMixin, db.Model):
|
||
|
||
def __repr__(self):
|
||
return f'<Admin {self.username}>'
|
||
+
|
||
+class PromptTemplate(db.Model):
|
||
+ """AI提示词模板模型"""
|
||
+ __tablename__ = 'prompt_templates'
|
||
+
|
||
+ id = db.Column(db.Integer, primary_key=True)
|
||
+ key = db.Column(db.String(50), unique=True, nullable=False, comment='唯一标识(tags/features/description)')
|
||
+ name = db.Column(db.String(100), nullable=False, comment='模板名称')
|
||
+ system_prompt = db.Column(db.Text, nullable=False, comment='系统提示词')
|
||
+ user_prompt_template = db.Column(db.Text, nullable=False, comment='用户提示词模板(支持变量)')
|
||
+ description = db.Column(db.String(200), comment='模板说明')
|
||
+ is_active = db.Column(db.Boolean, default=True, 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'<PromptTemplate {self.name}>'
|
||
+
|
||
+ def to_dict(self):
|
||
+ """转换为字典"""
|
||
+ return {
|
||
+ 'id': self.id,
|
||
+ 'key': self.key,
|
||
+ 'name': self.name,
|
||
+ 'system_prompt': self.system_prompt,
|
||
+ 'user_prompt_template': self.user_prompt_template,
|
||
+ 'description': self.description,
|
||
+ 'is_active': self.is_active,
|
||
+ '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
|
||
+ }
|
||
+
|
||
diff --git a/templates/admin/site/create.html b/templates/admin/site/create.html
|
||
index f32bed6..ed0597f 100644
|
||
--- a/templates/admin/site/create.html
|
||
+++ b/templates/admin/site/create.html
|
||
@@ -11,6 +11,28 @@
|
||
margin-top: 10px;
|
||
margin-bottom: 15px;
|
||
}
|
||
+ .generate-features-btn {
|
||
+ margin-top: 10px;
|
||
+ margin-bottom: 15px;
|
||
+ }
|
||
+ .upload-logo-btn {
|
||
+ margin-top: 10px;
|
||
+ margin-bottom: 15px;
|
||
+ }
|
||
+ .logo-preview {
|
||
+ margin-top: 10px;
|
||
+ max-width: 200px;
|
||
+ max-height: 200px;
|
||
+ display: none;
|
||
+ border: 1px solid #ddd;
|
||
+ border-radius: 8px;
|
||
+ padding: 10px;
|
||
+ }
|
||
+ .logo-preview img {
|
||
+ max-width: 100%;
|
||
+ max-height: 150px;
|
||
+ object-fit: contain;
|
||
+ }
|
||
.fetch-status {
|
||
margin-top: 10px;
|
||
padding: 10px;
|
||
@@ -47,6 +69,26 @@
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
+ /* 标签输入框样式 */
|
||
+ .tag-input-wrapper {
|
||
+ margin-top: 10px;
|
||
+ }
|
||
+ .tag-input-field {
|
||
+ width: 100%;
|
||
+ padding: 8px 12px;
|
||
+ border: 1px solid #DCDFE6;
|
||
+ border-radius: 4px;
|
||
+ font-size: 14px;
|
||
+ }
|
||
+ .tag-input-field:focus {
|
||
+ outline: none;
|
||
+ border-color: #0052D9;
|
||
+ }
|
||
+ .tag-input-help {
|
||
+ margin-top: 5px;
|
||
+ font-size: 12px;
|
||
+ color: #606266;
|
||
+ }
|
||
</style>
|
||
|
||
<script>
|
||
@@ -130,9 +172,238 @@ document.addEventListener('DOMContentLoaded', function() {
|
||
}
|
||
}
|
||
|
||
- // 在标签字段后添加"AI生成标签"按钮
|
||
- const tagsField = document.querySelector('select[name="tags"]');
|
||
- if (tagsField) {
|
||
+ // 在Logo字段后添加"上传Logo"功能
|
||
+ const logoField = document.querySelector('input[name="logo"]');
|
||
+ if (logoField) {
|
||
+ // 创建文件输入框
|
||
+ const fileInput = document.createElement('input');
|
||
+ fileInput.type = 'file';
|
||
+ fileInput.accept = 'image/*';
|
||
+ fileInput.style.display = 'none';
|
||
+
|
||
+ // 创建上传按钮
|
||
+ const uploadBtn = document.createElement('button');
|
||
+ uploadBtn.type = 'button';
|
||
+ uploadBtn.className = 'btn btn-warning upload-logo-btn';
|
||
+ uploadBtn.innerHTML = '📁 上传Logo图片';
|
||
+
|
||
+ // 创建预览容器
|
||
+ const previewDiv = document.createElement('div');
|
||
+ previewDiv.className = 'logo-preview';
|
||
+ previewDiv.innerHTML = '<img src="" alt="Logo预览"><p style="margin-top:5px; font-size:12px; color:#666;">Logo预览</p>';
|
||
+
|
||
+ logoField.parentNode.appendChild(fileInput);
|
||
+ logoField.parentNode.appendChild(uploadBtn);
|
||
+ logoField.parentNode.appendChild(previewDiv);
|
||
+
|
||
+ // 点击按钮触发文件选择
|
||
+ uploadBtn.addEventListener('click', function() {
|
||
+ fileInput.click();
|
||
+ });
|
||
+
|
||
+ // 文件选择后自动上传
|
||
+ fileInput.addEventListener('change', function() {
|
||
+ const file = fileInput.files[0];
|
||
+ if (!file) return;
|
||
+
|
||
+ // 验证文件类型
|
||
+ if (!file.type.startsWith('image/')) {
|
||
+ alert('请选择图片文件!');
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 验证文件大小(限制5MB)
|
||
+ if (file.size > 5 * 1024 * 1024) {
|
||
+ alert('图片文件不能超过5MB!');
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 上传文件
|
||
+ const formData = new FormData();
|
||
+ formData.append('logo', file);
|
||
+
|
||
+ uploadBtn.disabled = true;
|
||
+ uploadBtn.textContent = '上传中...';
|
||
+
|
||
+ fetch('/api/upload-logo', {
|
||
+ method: 'POST',
|
||
+ body: formData
|
||
+ })
|
||
+ .then(response => response.json())
|
||
+ .then(data => {
|
||
+ if (data.success) {
|
||
+ // 设置Logo字段值
|
||
+ logoField.value = data.path;
|
||
+
|
||
+ // 显示预览
|
||
+ const img = previewDiv.querySelector('img');
|
||
+ img.src = data.path;
|
||
+ previewDiv.style.display = 'block';
|
||
+
|
||
+ alert('✓ Logo上传成功!');
|
||
+ } else {
|
||
+ alert('✗ ' + (data.message || '上传失败'));
|
||
+ }
|
||
+ })
|
||
+ .catch(error => {
|
||
+ console.error('Error:', error);
|
||
+ alert('✗ 上传失败,请重试');
|
||
+ })
|
||
+ .finally(() => {
|
||
+ uploadBtn.disabled = false;
|
||
+ uploadBtn.innerHTML = '📁 上传Logo图片';
|
||
+ fileInput.value = '';
|
||
+ });
|
||
+ });
|
||
+
|
||
+ // 如果Logo字段有值,显示预览
|
||
+ if (logoField.value) {
|
||
+ const img = previewDiv.querySelector('img');
|
||
+ img.src = logoField.value;
|
||
+ previewDiv.style.display = 'block';
|
||
+ }
|
||
+
|
||
+ // 监听Logo字段变化,更新预览
|
||
+ logoField.addEventListener('input', function() {
|
||
+ if (logoField.value) {
|
||
+ const img = previewDiv.querySelector('img');
|
||
+ img.src = logoField.value;
|
||
+ previewDiv.style.display = 'block';
|
||
+ } else {
|
||
+ previewDiv.style.display = 'none';
|
||
+ }
|
||
+ });
|
||
+ }
|
||
+
|
||
+ // 处理标签字段 - 添加手动输入功能
|
||
+ const tagsSelect = document.querySelector('select[name="tags"]');
|
||
+ if (tagsSelect) {
|
||
+ // 隐藏原始的select字段
|
||
+ tagsSelect.style.display = 'none';
|
||
+
|
||
+ // 创建文本输入框
|
||
+ const tagInputWrapper = document.createElement('div');
|
||
+ tagInputWrapper.className = 'tag-input-wrapper';
|
||
+
|
||
+ const tagInput = document.createElement('input');
|
||
+ tagInput.type = 'text';
|
||
+ tagInput.className = 'tag-input-field';
|
||
+ tagInput.placeholder = '输入标签名称,按回车添加(如:AI工具、图像处理、免费)';
|
||
+
|
||
+ const tagHelpText = document.createElement('div');
|
||
+ tagHelpText.className = 'tag-input-help';
|
||
+ tagHelpText.textContent = '💡 提示:输入标签名称后按回车键添加,可以添加多个标签。已选标签会自动添加到下方列表。';
|
||
+
|
||
+ const selectedTagsDiv = document.createElement('div');
|
||
+ selectedTagsDiv.className = 'selected-tags';
|
||
+ selectedTagsDiv.style.marginTop = '10px';
|
||
+
|
||
+ tagInputWrapper.appendChild(tagInput);
|
||
+ tagInputWrapper.appendChild(tagHelpText);
|
||
+ tagInputWrapper.appendChild(selectedTagsDiv);
|
||
+ tagsSelect.parentNode.insertBefore(tagInputWrapper, tagsSelect.nextSibling);
|
||
+
|
||
+ // 显示已选标签
|
||
+ function updateSelectedTags() {
|
||
+ selectedTagsDiv.innerHTML = '';
|
||
+ const selectedOptions = Array.from(tagsSelect.selectedOptions);
|
||
+
|
||
+ // 如果没有选中的标签,显示提示
|
||
+ if (selectedOptions.length === 0) {
|
||
+ selectedTagsDiv.innerHTML = '<span style="color:#999; font-size:12px;">暂无已选标签</span>';
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ selectedOptions.forEach(option => {
|
||
+ const tag = document.createElement('span');
|
||
+ tag.style.cssText = 'display:inline-block; background:#0052D9; color:white; padding:4px 10px; margin:4px; border-radius:4px; font-size:12px;';
|
||
+
|
||
+ // 获取标签文本 - 兼容多种方式
|
||
+ let tagText = option.textContent || option.innerText || option.text || option.innerHTML || option.label || `标签${option.value}`;
|
||
+
|
||
+ // 处理 <Tag XXX> 格式,提取出实际标签名称
|
||
+ const match = tagText.match(/<Tag\s+(.+?)>/);
|
||
+ if (match) {
|
||
+ tagText = match[1]; // 提取标签名称
|
||
+ }
|
||
+
|
||
+ tag.innerHTML = tagText + ' <span style="cursor:pointer; margin-left:5px; font-weight:bold;" data-tag-id="' + option.value + '">×</span>';
|
||
+ selectedTagsDiv.appendChild(tag);
|
||
+
|
||
+ // 为删除按钮添加点击事件
|
||
+ const deleteBtn = tag.querySelector('span[data-tag-id]');
|
||
+ if (deleteBtn) {
|
||
+ deleteBtn.addEventListener('click', function(e) {
|
||
+ e.stopPropagation();
|
||
+ option.selected = false;
|
||
+ updateSelectedTags();
|
||
+ });
|
||
+ }
|
||
+ });
|
||
+ }
|
||
+
|
||
+ // 添加标签
|
||
+ tagInput.addEventListener('keypress', function(e) {
|
||
+ if (e.key === 'Enter') {
|
||
+ e.preventDefault();
|
||
+ const tagName = tagInput.value.trim();
|
||
+
|
||
+ if (!tagName) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 检查标签是否已存在
|
||
+ let existingOption = null;
|
||
+ for (let option of tagsSelect.options) {
|
||
+ if (option.text.toLowerCase() === tagName.toLowerCase()) {
|
||
+ existingOption = option;
|
||
+ break;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ if (existingOption) {
|
||
+ // 选中已存在的标签
|
||
+ existingOption.selected = true;
|
||
+ } else {
|
||
+ // 创建新标签选项(使用负数ID表示新标签)
|
||
+ const newOption = document.createElement('option');
|
||
+ newOption.value = 'new_' + Date.now();
|
||
+ newOption.text = tagName;
|
||
+ newOption.selected = true;
|
||
+ newOption.setAttribute('data-new-tag', 'true');
|
||
+ tagsSelect.appendChild(newOption);
|
||
+ }
|
||
+
|
||
+ tagInput.value = '';
|
||
+ updateSelectedTags();
|
||
+ }
|
||
+ });
|
||
+
|
||
+ // 初始化显示
|
||
+ updateSelectedTags();
|
||
+
|
||
+ // 表单提交时处理新标签
|
||
+ const form = tagsSelect.closest('form');
|
||
+ form.addEventListener('submit', function(e) {
|
||
+ // 收集所有新标签名称
|
||
+ const newTags = [];
|
||
+ Array.from(tagsSelect.options).forEach(option => {
|
||
+ if (option.selected && option.hasAttribute('data-new-tag')) {
|
||
+ newTags.push(option.text);
|
||
+ }
|
||
+ });
|
||
+
|
||
+ // 如果有新标签,添加到隐藏字段
|
||
+ if (newTags.length > 0) {
|
||
+ const hiddenInput = document.createElement('input');
|
||
+ hiddenInput.type = 'hidden';
|
||
+ hiddenInput.name = 'new_tags';
|
||
+ hiddenInput.value = newTags.join(',');
|
||
+ form.appendChild(hiddenInput);
|
||
+ }
|
||
+ });
|
||
+
|
||
+ // 在标签字段后添加"AI生成标签"按钮
|
||
const generateBtn = document.createElement('button');
|
||
generateBtn.type = 'button';
|
||
generateBtn.className = 'btn btn-success generate-tags-btn';
|
||
@@ -141,8 +412,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||
const tagsStatusDiv = document.createElement('div');
|
||
tagsStatusDiv.className = 'tags-status';
|
||
|
||
- tagsField.parentNode.appendChild(generateBtn);
|
||
- tagsField.parentNode.appendChild(tagsStatusDiv);
|
||
+ tagInputWrapper.appendChild(generateBtn);
|
||
+ tagInputWrapper.appendChild(tagsStatusDiv);
|
||
|
||
generateBtn.addEventListener('click', function() {
|
||
const nameField = document.querySelector('input[name="name"]');
|
||
@@ -175,9 +446,31 @@ document.addEventListener('DOMContentLoaded', function() {
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success && data.tags && data.tags.length > 0) {
|
||
- // 显示生成的标签
|
||
- const tagsText = data.tags.join(', ');
|
||
- showTagsStatus('✓ AI生成的标签建议: ' + tagsText + '\n(请在标签字段中手动选择或创建这些标签)', 'success');
|
||
+ // 自动添加生成的标签
|
||
+ data.tags.forEach(tagName => {
|
||
+ // 检查是否已存在
|
||
+ let exists = false;
|
||
+ for (let option of tagsSelect.options) {
|
||
+ if (option.text.toLowerCase() === tagName.toLowerCase()) {
|
||
+ option.selected = true;
|
||
+ exists = true;
|
||
+ break;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // 如果不存在,创建新标签
|
||
+ if (!exists) {
|
||
+ const newOption = document.createElement('option');
|
||
+ newOption.value = 'new_' + Date.now() + '_' + Math.random();
|
||
+ newOption.text = tagName;
|
||
+ newOption.selected = true;
|
||
+ newOption.setAttribute('data-new-tag', 'true');
|
||
+ tagsSelect.appendChild(newOption);
|
||
+ }
|
||
+ });
|
||
+
|
||
+ updateSelectedTags();
|
||
+ showTagsStatus('✓ AI已自动添加推荐标签:' + data.tags.join(', '), 'success');
|
||
} else {
|
||
showTagsStatus('✗ ' + (data.message || '标签生成失败'), 'error');
|
||
}
|
||
@@ -198,6 +491,152 @@ document.addEventListener('DOMContentLoaded', function() {
|
||
tagsStatusDiv.style.display = 'block';
|
||
}
|
||
}
|
||
+
|
||
+ // 在Description字段后添加"AI生成详细介绍"按钮
|
||
+ const descriptionField = document.querySelector('textarea[name="description"]');
|
||
+ if (descriptionField) {
|
||
+ // 创建生成按钮
|
||
+ const generateDescBtn = document.createElement('button');
|
||
+ generateDescBtn.type = 'button';
|
||
+ generateDescBtn.className = 'btn btn-success generate-features-btn';
|
||
+ generateDescBtn.innerHTML = '<span class="normal-icon">✨</span><span class="loading-icon">↻</span> AI生成详细介绍';
|
||
+
|
||
+ const descStatusDiv = document.createElement('div');
|
||
+ descStatusDiv.className = 'tags-status';
|
||
+
|
||
+ descriptionField.parentNode.appendChild(generateDescBtn);
|
||
+ descriptionField.parentNode.appendChild(descStatusDiv);
|
||
+
|
||
+ generateDescBtn.addEventListener('click', function() {
|
||
+ const nameField = document.querySelector('input[name="name"]');
|
||
+ const shortDescField = document.querySelector('input[name="short_desc"]');
|
||
+ const urlField = document.querySelector('input[name="url"]');
|
||
+
|
||
+ const name = nameField ? nameField.value.trim() : '';
|
||
+ const shortDesc = shortDescField ? shortDescField.value.trim() : '';
|
||
+ const url = urlField ? urlField.value.trim() : '';
|
||
+
|
||
+ if (!name) {
|
||
+ showDescStatus('请先填写网站名称', 'error');
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 显示加载状态
|
||
+ generateDescBtn.disabled = true;
|
||
+ generateDescBtn.classList.add('loading');
|
||
+ descStatusDiv.style.display = 'none';
|
||
+
|
||
+ // 调用API生成详细介绍
|
||
+ fetch('/api/generate-description', {
|
||
+ method: 'POST',
|
||
+ headers: {
|
||
+ 'Content-Type': 'application/json',
|
||
+ },
|
||
+ body: JSON.stringify({
|
||
+ name: name,
|
||
+ short_desc: shortDesc,
|
||
+ url: url
|
||
+ })
|
||
+ })
|
||
+ .then(response => response.json())
|
||
+ .then(data => {
|
||
+ if (data.success && data.description) {
|
||
+ // 自动填充到description字段
|
||
+ descriptionField.value = data.description;
|
||
+ showDescStatus('✓ AI已生成详细介绍', 'success');
|
||
+ } else {
|
||
+ showDescStatus('✗ ' + (data.message || '详细介绍生成失败'), 'error');
|
||
+ }
|
||
+ })
|
||
+ .catch(error => {
|
||
+ console.error('Error:', error);
|
||
+ showDescStatus('✗ 网络请求失败,请重试', 'error');
|
||
+ })
|
||
+ .finally(() => {
|
||
+ generateDescBtn.disabled = false;
|
||
+ generateDescBtn.classList.remove('loading');
|
||
+ });
|
||
+ });
|
||
+
|
||
+ function showDescStatus(message, type) {
|
||
+ descStatusDiv.textContent = message;
|
||
+ descStatusDiv.className = 'tags-status ' + type;
|
||
+ descStatusDiv.style.display = 'block';
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // 在Features字段后添加"AI生成功能"按钮
|
||
+ const featuresField = document.querySelector('textarea[name="features"]');
|
||
+ if (featuresField) {
|
||
+ // 创建生成按钮
|
||
+ const generateFeaturesBtn = document.createElement('button');
|
||
+ generateFeaturesBtn.type = 'button';
|
||
+ generateFeaturesBtn.className = 'btn btn-success generate-features-btn';
|
||
+ generateFeaturesBtn.innerHTML = '<span class="normal-icon">✨</span><span class="loading-icon">↻</span> AI生成主要功能';
|
||
+
|
||
+ const featuresStatusDiv = document.createElement('div');
|
||
+ featuresStatusDiv.className = 'tags-status';
|
||
+
|
||
+ featuresField.parentNode.appendChild(generateFeaturesBtn);
|
||
+ featuresField.parentNode.appendChild(featuresStatusDiv);
|
||
+
|
||
+ generateFeaturesBtn.addEventListener('click', function() {
|
||
+ const nameField = document.querySelector('input[name="name"]');
|
||
+ const descriptionField = document.querySelector('textarea[name="description"]');
|
||
+ const urlField = document.querySelector('input[name="url"]');
|
||
+
|
||
+ const name = nameField ? nameField.value.trim() : '';
|
||
+ const description = descriptionField ? descriptionField.value.trim() : '';
|
||
+ const url = urlField ? urlField.value.trim() : '';
|
||
+
|
||
+ if (!name || !description) {
|
||
+ showFeaturesStatus('请先填写网站名称和描述', 'error');
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 显示加载状态
|
||
+ generateFeaturesBtn.disabled = true;
|
||
+ generateFeaturesBtn.classList.add('loading');
|
||
+ featuresStatusDiv.style.display = 'none';
|
||
+
|
||
+ // 调用API生成功能
|
||
+ fetch('/api/generate-features', {
|
||
+ method: 'POST',
|
||
+ headers: {
|
||
+ 'Content-Type': 'application/json',
|
||
+ },
|
||
+ body: JSON.stringify({
|
||
+ name: name,
|
||
+ description: description,
|
||
+ url: url
|
||
+ })
|
||
+ })
|
||
+ .then(response => response.json())
|
||
+ .then(data => {
|
||
+ if (data.success && data.features) {
|
||
+ // 自动填充到features字段
|
||
+ featuresField.value = data.features;
|
||
+ showFeaturesStatus('✓ AI已生成主要功能列表', 'success');
|
||
+ } else {
|
||
+ showFeaturesStatus('✗ ' + (data.message || '功能生成失败'), 'error');
|
||
+ }
|
||
+ })
|
||
+ .catch(error => {
|
||
+ console.error('Error:', error);
|
||
+ showFeaturesStatus('✗ 网络请求失败,请重试', 'error');
|
||
+ })
|
||
+ .finally(() => {
|
||
+ generateFeaturesBtn.disabled = false;
|
||
+ generateFeaturesBtn.classList.remove('loading');
|
||
+ });
|
||
+ });
|
||
+
|
||
+ function showFeaturesStatus(message, type) {
|
||
+ featuresStatusDiv.textContent = message;
|
||
+ featuresStatusDiv.className = 'tags-status ' + type;
|
||
+ featuresStatusDiv.style.display = 'block';
|
||
+ }
|
||
+ }
|
||
});
|
||
</script>
|
||
{% endblock %}
|
||
diff --git a/templates/admin/site/edit.html b/templates/admin/site/edit.html
|
||
index aef6d86..6594b5f 100644
|
||
--- a/templates/admin/site/edit.html
|
||
+++ b/templates/admin/site/edit.html
|
||
@@ -11,6 +11,28 @@
|
||
margin-top: 10px;
|
||
margin-bottom: 15px;
|
||
}
|
||
+ .generate-features-btn {
|
||
+ margin-top: 10px;
|
||
+ margin-bottom: 15px;
|
||
+ }
|
||
+ .upload-logo-btn {
|
||
+ margin-top: 10px;
|
||
+ margin-bottom: 15px;
|
||
+ }
|
||
+ .logo-preview {
|
||
+ margin-top: 10px;
|
||
+ max-width: 200px;
|
||
+ max-height: 200px;
|
||
+ display: none;
|
||
+ border: 1px solid #ddd;
|
||
+ border-radius: 8px;
|
||
+ padding: 10px;
|
||
+ }
|
||
+ .logo-preview img {
|
||
+ max-width: 100%;
|
||
+ max-height: 150px;
|
||
+ object-fit: contain;
|
||
+ }
|
||
.fetch-status {
|
||
margin-top: 10px;
|
||
padding: 10px;
|
||
@@ -47,6 +69,26 @@
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
+ /* 标签输入框样式 */
|
||
+ .tag-input-wrapper {
|
||
+ margin-top: 10px;
|
||
+ }
|
||
+ .tag-input-field {
|
||
+ width: 100%;
|
||
+ padding: 8px 12px;
|
||
+ border: 1px solid #DCDFE6;
|
||
+ border-radius: 4px;
|
||
+ font-size: 14px;
|
||
+ }
|
||
+ .tag-input-field:focus {
|
||
+ outline: none;
|
||
+ border-color: #0052D9;
|
||
+ }
|
||
+ .tag-input-help {
|
||
+ margin-top: 5px;
|
||
+ font-size: 12px;
|
||
+ color: #606266;
|
||
+ }
|
||
</style>
|
||
|
||
<script>
|
||
@@ -130,72 +172,490 @@ document.addEventListener('DOMContentLoaded', function() {
|
||
}
|
||
}
|
||
|
||
- // 在标签字段后添加"AI生成标签"按钮
|
||
- const tagsField = document.querySelector('select[name="tags"]');
|
||
- if (tagsField) {
|
||
- const generateBtn = document.createElement('button');
|
||
- generateBtn.type = 'button';
|
||
- generateBtn.className = 'btn btn-success generate-tags-btn';
|
||
- generateBtn.innerHTML = '<span class="normal-icon">✨</span><span class="loading-icon">↻</span> AI生成标签';
|
||
+ // 在Logo字段后添加"上传Logo"功能
|
||
+ const logoField = document.querySelector('input[name="logo"]');
|
||
+ if (logoField) {
|
||
+ // 创建文件输入框
|
||
+ const fileInput = document.createElement('input');
|
||
+ fileInput.type = 'file';
|
||
+ fileInput.accept = 'image/*';
|
||
+ fileInput.style.display = 'none';
|
||
+
|
||
+ // 创建上传按钮
|
||
+ const uploadBtn = document.createElement('button');
|
||
+ uploadBtn.type = 'button';
|
||
+ uploadBtn.className = 'btn btn-warning upload-logo-btn';
|
||
+ uploadBtn.innerHTML = '📁 上传Logo图片';
|
||
+
|
||
+ // 创建预览容器
|
||
+ const previewDiv = document.createElement('div');
|
||
+ previewDiv.className = 'logo-preview';
|
||
+ previewDiv.innerHTML = '<img src="" alt="Logo预览"><p style="margin-top:5px; font-size:12px; color:#666;">Logo预览</p>';
|
||
+
|
||
+ logoField.parentNode.appendChild(fileInput);
|
||
+ logoField.parentNode.appendChild(uploadBtn);
|
||
+ logoField.parentNode.appendChild(previewDiv);
|
||
+
|
||
+ // 点击按钮触发文件选择
|
||
+ uploadBtn.addEventListener('click', function() {
|
||
+ fileInput.click();
|
||
+ });
|
||
+
|
||
+ // 文件选择后自动上传
|
||
+ fileInput.addEventListener('change', function() {
|
||
+ const file = fileInput.files[0];
|
||
+ if (!file) return;
|
||
+
|
||
+ // 验证文件类型
|
||
+ if (!file.type.startsWith('image/')) {
|
||
+ alert('请选择图片文件!');
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 验证文件大小(限制5MB)
|
||
+ if (file.size > 5 * 1024 * 1024) {
|
||
+ alert('图片文件不能超过5MB!');
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 上传文件
|
||
+ const formData = new FormData();
|
||
+ formData.append('logo', file);
|
||
+
|
||
+ uploadBtn.disabled = true;
|
||
+ uploadBtn.textContent = '上传中...';
|
||
+
|
||
+ fetch('/api/upload-logo', {
|
||
+ method: 'POST',
|
||
+ body: formData
|
||
+ })
|
||
+ .then(response => response.json())
|
||
+ .then(data => {
|
||
+ if (data.success) {
|
||
+ // 设置Logo字段值
|
||
+ logoField.value = data.path;
|
||
+
|
||
+ // 显示预览
|
||
+ const img = previewDiv.querySelector('img');
|
||
+ img.src = data.path;
|
||
+ previewDiv.style.display = 'block';
|
||
+
|
||
+ alert('✓ Logo上传成功!');
|
||
+ } else {
|
||
+ alert('✗ ' + (data.message || '上传失败'));
|
||
+ }
|
||
+ })
|
||
+ .catch(error => {
|
||
+ console.error('Error:', error);
|
||
+ alert('✗ 上传失败,请重试');
|
||
+ })
|
||
+ .finally(() => {
|
||
+ uploadBtn.disabled = false;
|
||
+ uploadBtn.innerHTML = '📁 上传Logo图片';
|
||
+ fileInput.value = '';
|
||
+ });
|
||
+ });
|
||
+
|
||
+ // 如果Logo字段有值,显示预览
|
||
+ if (logoField.value) {
|
||
+ const img = previewDiv.querySelector('img');
|
||
+ img.src = logoField.value;
|
||
+ previewDiv.style.display = 'block';
|
||
+ }
|
||
+
|
||
+ // 监听Logo字段变化,更新预览
|
||
+ logoField.addEventListener('input', function() {
|
||
+ if (logoField.value) {
|
||
+ const img = previewDiv.querySelector('img');
|
||
+ img.src = logoField.value;
|
||
+ previewDiv.style.display = 'block';
|
||
+ } else {
|
||
+ previewDiv.style.display = 'none';
|
||
+ }
|
||
+ });
|
||
+ }
|
||
+
|
||
+ // 处理标签字段 - 添加手动输入功能
|
||
+ const tagsSelect = document.querySelector('select[name="tags"]');
|
||
+ if (tagsSelect) {
|
||
+ // 先等待一下,确保 Flask-Admin 已经初始化好 select
|
||
+ setTimeout(function() {
|
||
+ // 保存原始选中的标签(从数据库加载的)
|
||
+ const originalSelectedOptions = Array.from(tagsSelect.selectedOptions);
|
||
+
|
||
+ // 隐藏原始的select字段
|
||
+ tagsSelect.style.display = 'none';
|
||
+
|
||
+ // 创建文本输入框
|
||
+ const tagInputWrapper = document.createElement('div');
|
||
+ tagInputWrapper.className = 'tag-input-wrapper';
|
||
+
|
||
+ const tagInput = document.createElement('input');
|
||
+ tagInput.type = 'text';
|
||
+ tagInput.className = 'tag-input-field';
|
||
+ tagInput.placeholder = '输入标签名称,按回车添加(如:AI工具、图像处理、免费)';
|
||
+
|
||
+ const tagHelpText = document.createElement('div');
|
||
+ tagHelpText.className = 'tag-input-help';
|
||
+ tagHelpText.textContent = '💡 提示:输入标签名称后按回车键添加,可以添加多个标签。已选标签会自动添加到下方列表。';
|
||
+
|
||
+ const selectedTagsDiv = document.createElement('div');
|
||
+ selectedTagsDiv.className = 'selected-tags';
|
||
+ selectedTagsDiv.style.marginTop = '10px';
|
||
+
|
||
+ tagInputWrapper.appendChild(tagInput);
|
||
+ tagInputWrapper.appendChild(tagHelpText);
|
||
+ tagInputWrapper.appendChild(selectedTagsDiv);
|
||
+ tagsSelect.parentNode.insertBefore(tagInputWrapper, tagsSelect.nextSibling);
|
||
+
|
||
+ // 显示已选标签
|
||
+ function updateSelectedTags() {
|
||
+ selectedTagsDiv.innerHTML = '';
|
||
+ const selectedOptions = Array.from(tagsSelect.selectedOptions);
|
||
+
|
||
+ // 如果没有选中的标签,显示提示
|
||
+ if (selectedOptions.length === 0) {
|
||
+ selectedTagsDiv.innerHTML = '<span style="color:#999; font-size:12px;">暂无已选标签</span>';
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ selectedOptions.forEach(option => {
|
||
+ const tag = document.createElement('span');
|
||
+ tag.style.cssText = 'display:inline-block; background:#0052D9; color:white; padding:4px 10px; margin:4px; border-radius:4px; font-size:12px;';
|
||
+
|
||
+ // 获取标签文本 - 兼容多种方式
|
||
+ let tagText = option.textContent || option.innerText || option.text || option.innerHTML || option.label || `标签${option.value}`;
|
||
+
|
||
+ // 处理 <Tag XXX> 格式,提取出实际标签名称
|
||
+ const match = tagText.match(/<Tag\s+(.+?)>/);
|
||
+ if (match) {
|
||
+ tagText = match[1]; // 提取标签名称
|
||
+ }
|
||
+
|
||
+ tag.innerHTML = tagText + ' <span style="cursor:pointer; margin-left:5px; font-weight:bold;" data-tag-id="' + option.value + '">×</span>';
|
||
+ selectedTagsDiv.appendChild(tag);
|
||
+
|
||
+ // 为删除按钮添加点击事件
|
||
+ const deleteBtn = tag.querySelector('span[data-tag-id]');
|
||
+ if (deleteBtn) {
|
||
+ deleteBtn.addEventListener('click', function(e) {
|
||
+ e.stopPropagation();
|
||
+ option.selected = false;
|
||
+ updateSelectedTags();
|
||
+ });
|
||
+ }
|
||
+ });
|
||
+ }
|
||
+
|
||
+ // 添加标签
|
||
+ tagInput.addEventListener('keypress', function(e) {
|
||
+ if (e.key === 'Enter') {
|
||
+ e.preventDefault();
|
||
+ const tagName = tagInput.value.trim();
|
||
+
|
||
+ if (!tagName) {
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 检查标签是否已存在
|
||
+ let existingOption = null;
|
||
+ for (let option of tagsSelect.options) {
|
||
+ if (option.text.toLowerCase() === tagName.toLowerCase()) {
|
||
+ existingOption = option;
|
||
+ break;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ if (existingOption) {
|
||
+ // 选中已存在的标签
|
||
+ existingOption.selected = true;
|
||
+ } else {
|
||
+ // 创建新标签选项(使用负数ID表示新标签)
|
||
+ const newOption = document.createElement('option');
|
||
+ newOption.value = 'new_' + Date.now();
|
||
+ newOption.text = tagName;
|
||
+ newOption.selected = true;
|
||
+ newOption.setAttribute('data-new-tag', 'true');
|
||
+ tagsSelect.appendChild(newOption);
|
||
+ }
|
||
+
|
||
+ tagInput.value = '';
|
||
+ updateSelectedTags();
|
||
+ }
|
||
+ });
|
||
+
|
||
+ // 初始化显示 - 使用延迟确保数据已加载
|
||
+ updateSelectedTags();
|
||
+
|
||
+ // 再次确保原始选中的标签保持选中状态
|
||
+ originalSelectedOptions.forEach(opt => {
|
||
+ // 在所有选项中找到对应的选项并设置为选中
|
||
+ for (let option of tagsSelect.options) {
|
||
+ if (option.value === opt.value) {
|
||
+ option.selected = true;
|
||
+ }
|
||
+ }
|
||
+ });
|
||
+
|
||
+ // 再次更新显示
|
||
+ setTimeout(function() {
|
||
+ updateSelectedTags();
|
||
+ }, 100);
|
||
+
|
||
+ // 表单提交时处理新标签
|
||
+ const form = tagsSelect.closest('form');
|
||
+ form.addEventListener('submit', function(e) {
|
||
+ // 收集所有新标签名称
|
||
+ const newTags = [];
|
||
+ Array.from(tagsSelect.options).forEach(option => {
|
||
+ if (option.selected && option.hasAttribute('data-new-tag')) {
|
||
+ newTags.push(option.text);
|
||
+ }
|
||
+ });
|
||
+
|
||
+ // 如果有新标签,添加到隐藏字段
|
||
+ if (newTags.length > 0) {
|
||
+ const hiddenInput = document.createElement('input');
|
||
+ hiddenInput.type = 'hidden';
|
||
+ hiddenInput.name = 'new_tags';
|
||
+ hiddenInput.value = newTags.join(',');
|
||
+ form.appendChild(hiddenInput);
|
||
+ }
|
||
+ });
|
||
+
|
||
+ // 在标签字段后添加"AI生成标签"按钮
|
||
+ const generateBtn = document.createElement('button');
|
||
+ generateBtn.type = 'button';
|
||
+ generateBtn.className = 'btn btn-success generate-tags-btn';
|
||
+ generateBtn.innerHTML = '<span class="normal-icon">✨</span><span class="loading-icon">↻</span> AI生成标签';
|
||
+
|
||
+ const tagsStatusDiv = document.createElement('div');
|
||
+ tagsStatusDiv.className = 'tags-status';
|
||
+
|
||
+ tagInputWrapper.appendChild(generateBtn);
|
||
+ tagInputWrapper.appendChild(tagsStatusDiv);
|
||
+
|
||
+ generateBtn.addEventListener('click', function() {
|
||
+ const nameField = document.querySelector('input[name="name"]');
|
||
+ const descriptionField = document.querySelector('textarea[name="description"]');
|
||
+
|
||
+ const name = nameField ? nameField.value.trim() : '';
|
||
+ const description = descriptionField ? descriptionField.value.trim() : '';
|
||
+
|
||
+ if (!name || !description) {
|
||
+ showTagsStatus('请先填写网站名称和描述', 'error');
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 显示加载状态
|
||
+ generateBtn.disabled = true;
|
||
+ generateBtn.classList.add('loading');
|
||
+ tagsStatusDiv.style.display = 'none';
|
||
+
|
||
+ // 调用API生成标签
|
||
+ fetch('/api/generate-tags', {
|
||
+ method: 'POST',
|
||
+ headers: {
|
||
+ 'Content-Type': 'application/json',
|
||
+ },
|
||
+ body: JSON.stringify({
|
||
+ name: name,
|
||
+ description: description
|
||
+ })
|
||
+ })
|
||
+ .then(response => response.json())
|
||
+ .then(data => {
|
||
+ if (data.success && data.tags && data.tags.length > 0) {
|
||
+ // 自动添加生成的标签
|
||
+ data.tags.forEach(tagName => {
|
||
+ // 检查是否已存在
|
||
+ let exists = false;
|
||
+ for (let option of tagsSelect.options) {
|
||
+ if (option.text.toLowerCase() === tagName.toLowerCase()) {
|
||
+ option.selected = true;
|
||
+ exists = true;
|
||
+ break;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // 如果不存在,创建新标签
|
||
+ if (!exists) {
|
||
+ const newOption = document.createElement('option');
|
||
+ newOption.value = 'new_' + Date.now() + '_' + Math.random();
|
||
+ newOption.text = tagName;
|
||
+ newOption.selected = true;
|
||
+ newOption.setAttribute('data-new-tag', 'true');
|
||
+ tagsSelect.appendChild(newOption);
|
||
+ }
|
||
+ });
|
||
+
|
||
+ updateSelectedTags();
|
||
+ showTagsStatus('✓ AI已自动添加推荐标签:' + data.tags.join(', '), 'success');
|
||
+ } else {
|
||
+ showTagsStatus('✗ ' + (data.message || '标签生成失败'), 'error');
|
||
+ }
|
||
+ })
|
||
+ .catch(error => {
|
||
+ console.error('Error:', error);
|
||
+ showTagsStatus('✗ 网络请求失败,请重试', 'error');
|
||
+ })
|
||
+ .finally(() => {
|
||
+ generateBtn.disabled = false;
|
||
+ generateBtn.classList.remove('loading');
|
||
+ });
|
||
+ });
|
||
+
|
||
+ function showTagsStatus(message, type) {
|
||
+ tagsStatusDiv.textContent = message;
|
||
+ tagsStatusDiv.className = 'tags-status ' + type;
|
||
+ tagsStatusDiv.style.display = 'block';
|
||
+ }
|
||
+ }, 50); // setTimeout 结束
|
||
+ }
|
||
+
|
||
+ // 在Description字段后添加"AI生成详细介绍"按钮
|
||
+ const descriptionField = document.querySelector('textarea[name="description"]');
|
||
+ if (descriptionField) {
|
||
+ // 创建生成按钮
|
||
+ const generateDescBtn = document.createElement('button');
|
||
+ generateDescBtn.type = 'button';
|
||
+ generateDescBtn.className = 'btn btn-success generate-features-btn';
|
||
+ generateDescBtn.innerHTML = '<span class="normal-icon">✨</span><span class="loading-icon">↻</span> AI生成详细介绍';
|
||
+
|
||
+ const descStatusDiv = document.createElement('div');
|
||
+ descStatusDiv.className = 'tags-status';
|
||
+
|
||
+ descriptionField.parentNode.appendChild(generateDescBtn);
|
||
+ descriptionField.parentNode.appendChild(descStatusDiv);
|
||
+
|
||
+ generateDescBtn.addEventListener('click', function() {
|
||
+ const nameField = document.querySelector('input[name="name"]');
|
||
+ const shortDescField = document.querySelector('input[name="short_desc"]');
|
||
+ const urlField = document.querySelector('input[name="url"]');
|
||
+
|
||
+ const name = nameField ? nameField.value.trim() : '';
|
||
+ const shortDesc = shortDescField ? shortDescField.value.trim() : '';
|
||
+ const url = urlField ? urlField.value.trim() : '';
|
||
+
|
||
+ if (!name) {
|
||
+ showDescStatus('请先填写网站名称', 'error');
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // 显示加载状态
|
||
+ generateDescBtn.disabled = true;
|
||
+ generateDescBtn.classList.add('loading');
|
||
+ descStatusDiv.style.display = 'none';
|
||
+
|
||
+ // 调用API生成详细介绍
|
||
+ fetch('/api/generate-description', {
|
||
+ method: 'POST',
|
||
+ headers: {
|
||
+ 'Content-Type': 'application/json',
|
||
+ },
|
||
+ body: JSON.stringify({
|
||
+ name: name,
|
||
+ short_desc: shortDesc,
|
||
+ url: url
|
||
+ })
|
||
+ })
|
||
+ .then(response => response.json())
|
||
+ .then(data => {
|
||
+ if (data.success && data.description) {
|
||
+ // 自动填充到description字段
|
||
+ descriptionField.value = data.description;
|
||
+ showDescStatus('✓ AI已生成详细介绍', 'success');
|
||
+ } else {
|
||
+ showDescStatus('✗ ' + (data.message || '详细介绍生成失败'), 'error');
|
||
+ }
|
||
+ })
|
||
+ .catch(error => {
|
||
+ console.error('Error:', error);
|
||
+ showDescStatus('✗ 网络请求失败,请重试', 'error');
|
||
+ })
|
||
+ .finally(() => {
|
||
+ generateDescBtn.disabled = false;
|
||
+ generateDescBtn.classList.remove('loading');
|
||
+ });
|
||
+ });
|
||
+
|
||
+ function showDescStatus(message, type) {
|
||
+ descStatusDiv.textContent = message;
|
||
+ descStatusDiv.className = 'tags-status ' + type;
|
||
+ descStatusDiv.style.display = 'block';
|
||
+ }
|
||
+ }
|
||
+
|
||
+ // 在Features字段后添加"AI生成功能"按钮
|
||
+ const featuresField = document.querySelector('textarea[name="features"]');
|
||
+ if (featuresField) {
|
||
+ // 创建生成按钮
|
||
+ const generateFeaturesBtn = document.createElement('button');
|
||
+ generateFeaturesBtn.type = 'button';
|
||
+ generateFeaturesBtn.className = 'btn btn-success generate-features-btn';
|
||
+ generateFeaturesBtn.innerHTML = '<span class="normal-icon">✨</span><span class="loading-icon">↻</span> AI生成主要功能';
|
||
|
||
- const tagsStatusDiv = document.createElement('div');
|
||
- tagsStatusDiv.className = 'tags-status';
|
||
+ const featuresStatusDiv = document.createElement('div');
|
||
+ featuresStatusDiv.className = 'tags-status';
|
||
|
||
- tagsField.parentNode.appendChild(generateBtn);
|
||
- tagsField.parentNode.appendChild(tagsStatusDiv);
|
||
+ featuresField.parentNode.appendChild(generateFeaturesBtn);
|
||
+ featuresField.parentNode.appendChild(featuresStatusDiv);
|
||
|
||
- generateBtn.addEventListener('click', function() {
|
||
+ generateFeaturesBtn.addEventListener('click', function() {
|
||
const nameField = document.querySelector('input[name="name"]');
|
||
const descriptionField = document.querySelector('textarea[name="description"]');
|
||
+ const urlField = document.querySelector('input[name="url"]');
|
||
|
||
const name = nameField ? nameField.value.trim() : '';
|
||
const description = descriptionField ? descriptionField.value.trim() : '';
|
||
+ const url = urlField ? urlField.value.trim() : '';
|
||
|
||
if (!name || !description) {
|
||
- showTagsStatus('请先填写网站名称和描述', 'error');
|
||
+ showFeaturesStatus('请先填写网站名称和描述', 'error');
|
||
return;
|
||
}
|
||
|
||
// 显示加载状态
|
||
- generateBtn.disabled = true;
|
||
- generateBtn.classList.add('loading');
|
||
- tagsStatusDiv.style.display = 'none';
|
||
+ generateFeaturesBtn.disabled = true;
|
||
+ generateFeaturesBtn.classList.add('loading');
|
||
+ featuresStatusDiv.style.display = 'none';
|
||
|
||
- // 调用API生成标签
|
||
- fetch('/api/generate-tags', {
|
||
+ // 调用API生成功能
|
||
+ fetch('/api/generate-features', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
name: name,
|
||
- description: description
|
||
+ description: description,
|
||
+ url: url
|
||
})
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
- if (data.success && data.tags && data.tags.length > 0) {
|
||
- // 显示生成的标签
|
||
- const tagsText = data.tags.join(', ');
|
||
- showTagsStatus('✓ AI生成的标签建议: ' + tagsText + '\n(请在标签字段中手动选择或创建这些标签)', 'success');
|
||
+ if (data.success && data.features) {
|
||
+ // 自动填充到features字段
|
||
+ featuresField.value = data.features;
|
||
+ showFeaturesStatus('✓ AI已生成主要功能列表', 'success');
|
||
} else {
|
||
- showTagsStatus('✗ ' + (data.message || '标签生成失败'), 'error');
|
||
+ showFeaturesStatus('✗ ' + (data.message || '功能生成失败'), 'error');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
- showTagsStatus('✗ 网络请求失败,请重试', 'error');
|
||
+ showFeaturesStatus('✗ 网络请求失败,请重试', 'error');
|
||
})
|
||
.finally(() => {
|
||
- generateBtn.disabled = false;
|
||
- generateBtn.classList.remove('loading');
|
||
+ generateFeaturesBtn.disabled = false;
|
||
+ generateFeaturesBtn.classList.remove('loading');
|
||
});
|
||
});
|
||
|
||
- function showTagsStatus(message, type) {
|
||
- tagsStatusDiv.textContent = message;
|
||
- tagsStatusDiv.className = 'tags-status ' + type;
|
||
- tagsStatusDiv.style.display = 'block';
|
||
+ function showFeaturesStatus(message, type) {
|
||
+ featuresStatusDiv.textContent = message;
|
||
+ featuresStatusDiv.className = 'tags-status ' + type;
|
||
+ featuresStatusDiv.style.display = 'block';
|
||
}
|
||
}
|
||
});
|
||
diff --git a/templates/base_new.html b/templates/base_new.html
|
||
index 627d8e6..1252bec 100644
|
||
--- a/templates/base_new.html
|
||
+++ b/templates/base_new.html
|
||
@@ -5,14 +5,6 @@
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{% block title %}ZJPB - 焦提示词 | AI工具导航{% endblock %}</title>
|
||
|
||
- <!-- Google Fonts -->
|
||
- <link rel="preconnect" href="https://fonts.googleapis.com">
|
||
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||
-
|
||
- <!-- Material Symbols -->
|
||
- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
|
||
-
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
@@ -38,7 +30,7 @@
|
||
}
|
||
|
||
body {
|
||
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||
background: var(--bg-page);
|
||
color: var(--text-primary);
|
||
line-height: 1.6;
|
||
@@ -139,12 +131,12 @@
|
||
background: var(--bg-white);
|
||
}
|
||
|
||
- .search-box .material-symbols-outlined {
|
||
+ .search-box .search-icon {
|
||
position: absolute;
|
||
left: 10px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
- font-size: 20px;
|
||
+ font-size: 16px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
@@ -235,7 +227,7 @@
|
||
<div class="nav-left">
|
||
<a href="/" class="nav-logo">
|
||
<div class="nav-logo-icon">
|
||
- <span class="material-symbols-outlined" style="font-size: 20px;">blur_on</span>
|
||
+ <span style="font-size: 20px;">✦</span>
|
||
</div>
|
||
<span>ZJPB</span>
|
||
</a>
|
||
@@ -247,7 +239,7 @@
|
||
</div>
|
||
<div class="nav-right">
|
||
<form action="/" method="get" class="search-box">
|
||
- <span class="material-symbols-outlined">search</span>
|
||
+ <span class="search-icon">🔍</span>
|
||
<input type="text" name="q" placeholder="搜索 AI 工具..." value="{{ search_query or '' }}">
|
||
</form>
|
||
<a href="/admin/login" class="btn btn-secondary">登录</a>
|
||
@@ -263,16 +255,30 @@
|
||
<footer class="footer">
|
||
<div class="footer-container">
|
||
<div class="footer-text">
|
||
- © 2023 ZJPB AI Directory. All rights reserved.
|
||
+ <div>© 2025 ZJPB - 焦提示词 | AI工具导航. All rights reserved.</div>
|
||
+ <div style="margin-top: 8px;">
|
||
+ <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" style="color: var(--text-secondary); text-decoration: none;">
|
||
+ 浙ICP备2025154782号-1
|
||
+ </a>
|
||
+ </div>
|
||
</div>
|
||
<div class="footer-links">
|
||
- <a href="#">Twitter</a>
|
||
- <a href="#">Discord</a>
|
||
- <a href="#">Privacy Policy</a>
|
||
+ <a href="#">关于我们</a>
|
||
+ <a href="#">隐私政策</a>
|
||
+ <a href="#">用户协议</a>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
|
||
+ <!-- Microsoft Clarity 统计代码 -->
|
||
+ <script type="text/javascript">
|
||
+ (function(c,l,a,r,i,t,y){
|
||
+ c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||
+ t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||
+ y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||
+ })(window, document, "clarity", "script", "uoa2j40sf0");
|
||
+ </script>
|
||
+
|
||
{% block extra_js %}{% endblock %}
|
||
</body>
|
||
</html>
|
||
diff --git a/templates/detail_new.html b/templates/detail_new.html
|
||
index 1033f7c..c84192f 100644
|
||
--- a/templates/detail_new.html
|
||
+++ b/templates/detail_new.html
|
||
@@ -20,12 +20,6 @@
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
- .back-link .material-symbols-outlined {
|
||
- font-size: 24px;
|
||
- line-height: 1;
|
||
- vertical-align: middle;
|
||
- margin-top: -2px;
|
||
- }
|
||
|
||
/* 产品头部区域 */
|
||
.product-header-wrapper {
|
||
@@ -84,9 +78,6 @@
|
||
text-decoration: underline;
|
||
}
|
||
|
||
- .product-link .material-symbols-outlined {
|
||
- font-size: 16px;
|
||
- }
|
||
|
||
.product-meta {
|
||
display: flex;
|
||
@@ -103,9 +94,6 @@
|
||
font-size: 14px;
|
||
}
|
||
|
||
- .meta-item .material-symbols-outlined {
|
||
- font-size: 18px;
|
||
- }
|
||
|
||
.product-tags-list {
|
||
display: flex;
|
||
@@ -195,9 +183,6 @@
|
||
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.3);
|
||
}
|
||
|
||
- .visit-btn .material-symbols-outlined {
|
||
- font-size: 18px;
|
||
- }
|
||
|
||
.visit-hint {
|
||
text-align: center;
|
||
@@ -242,10 +227,6 @@
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
- .content-block h2 .material-symbols-outlined {
|
||
- font-size: 24px;
|
||
- color: var(--primary-blue);
|
||
- }
|
||
|
||
.content-block p {
|
||
color: var(--text-secondary);
|
||
@@ -399,6 +380,126 @@
|
||
font-weight: 500;
|
||
}
|
||
|
||
+ /* Markdown内容样式 */
|
||
+ .markdown-content {
|
||
+ color: var(--text-secondary);
|
||
+ line-height: 1.8;
|
||
+ }
|
||
+
|
||
+ .markdown-content h1,
|
||
+ .markdown-content h2,
|
||
+ .markdown-content h3 {
|
||
+ color: var(--text-primary);
|
||
+ font-weight: 600;
|
||
+ margin-top: 24px;
|
||
+ margin-bottom: 16px;
|
||
+ line-height: 1.4;
|
||
+ }
|
||
+
|
||
+ .markdown-content h1 {
|
||
+ font-size: 24px;
|
||
+ }
|
||
+
|
||
+ .markdown-content h2 {
|
||
+ font-size: 20px;
|
||
+ }
|
||
+
|
||
+ .markdown-content h3 {
|
||
+ font-size: 18px;
|
||
+ }
|
||
+
|
||
+ .markdown-content p {
|
||
+ margin-bottom: 16px;
|
||
+ line-height: 1.8;
|
||
+ }
|
||
+
|
||
+ .markdown-content ul,
|
||
+ .markdown-content ol {
|
||
+ margin: 16px 0;
|
||
+ padding-left: 24px;
|
||
+ }
|
||
+
|
||
+ .markdown-content ul li {
|
||
+ list-style: none;
|
||
+ position: relative;
|
||
+ padding-left: 20px;
|
||
+ margin-bottom: 12px;
|
||
+ line-height: 1.8;
|
||
+ }
|
||
+
|
||
+ .markdown-content ul li:before {
|
||
+ content: "▸";
|
||
+ position: absolute;
|
||
+ left: 0;
|
||
+ color: var(--primary-blue);
|
||
+ font-weight: bold;
|
||
+ }
|
||
+
|
||
+ .markdown-content ol li {
|
||
+ margin-bottom: 12px;
|
||
+ line-height: 1.8;
|
||
+ padding-left: 8px;
|
||
+ }
|
||
+
|
||
+ .markdown-content code {
|
||
+ background: #f1f5f9;
|
||
+ color: #e11d48;
|
||
+ padding: 2px 6px;
|
||
+ border-radius: 4px;
|
||
+ font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||
+ font-size: 0.9em;
|
||
+ }
|
||
+
|
||
+ .markdown-content pre {
|
||
+ background: #1e293b;
|
||
+ color: #e2e8f0;
|
||
+ padding: 16px;
|
||
+ border-radius: 8px;
|
||
+ overflow-x: auto;
|
||
+ margin: 16px 0;
|
||
+ }
|
||
+
|
||
+ .markdown-content pre code {
|
||
+ background: transparent;
|
||
+ color: inherit;
|
||
+ padding: 0;
|
||
+ border-radius: 0;
|
||
+ }
|
||
+
|
||
+ .markdown-content strong {
|
||
+ font-weight: 600;
|
||
+ color: var(--text-primary);
|
||
+ }
|
||
+
|
||
+ .markdown-content em {
|
||
+ font-style: italic;
|
||
+ }
|
||
+
|
||
+ .markdown-content a {
|
||
+ color: var(--primary-blue);
|
||
+ text-decoration: none;
|
||
+ border-bottom: 1px solid transparent;
|
||
+ transition: border-color 0.2s;
|
||
+ }
|
||
+
|
||
+ .markdown-content a:hover {
|
||
+ border-bottom-color: var(--primary-blue);
|
||
+ }
|
||
+
|
||
+ .markdown-content blockquote {
|
||
+ border-left: 4px solid var(--primary-blue);
|
||
+ padding-left: 16px;
|
||
+ margin: 16px 0;
|
||
+ color: var(--text-secondary);
|
||
+ font-style: italic;
|
||
+ }
|
||
+
|
||
+ .markdown-content hr {
|
||
+ border: none;
|
||
+ border-top: 1px solid var(--border-color);
|
||
+ margin: 24px 0;
|
||
+ }
|
||
+
|
||
/* 响应式 */
|
||
@media (max-width: 968px) {
|
||
.product-header-wrapper {
|
||
@@ -432,7 +533,7 @@
|
||
|
||
<!-- 返回链接 -->
|
||
<a href="/" class="back-link">
|
||
- <span class="material-symbols-outlined">arrow_back</span>
|
||
+ <span>←</span>
|
||
返回首页
|
||
</a>
|
||
|
||
@@ -456,16 +557,16 @@
|
||
<h1>{{ site.name }}</h1>
|
||
<a href="{{ site.url }}" target="_blank" class="product-link">
|
||
{{ site.url }}
|
||
- <span class="material-symbols-outlined">open_in_new</span>
|
||
+ <span>↗</span>
|
||
</a>
|
||
|
||
<div class="product-meta">
|
||
<div class="meta-item">
|
||
- <span class="material-symbols-outlined">visibility</span>
|
||
+ <span>👁</span>
|
||
<span>{{ site.view_count | default(0) }} 次浏览</span>
|
||
</div>
|
||
<div class="meta-item">
|
||
- <span class="material-symbols-outlined">calendar_today</span>
|
||
+ <span>📅</span>
|
||
<span>添加于 {{ site.created_at.strftime('%Y年%m月%d日') }}</span>
|
||
</div>
|
||
</div>
|
||
@@ -488,7 +589,7 @@
|
||
</div>
|
||
<a href="{{ site.url }}" target="_blank" class="visit-btn">
|
||
访问网站
|
||
- <span class="material-symbols-outlined">north_east</span>
|
||
+ <span>↗</span>
|
||
</a>
|
||
<p class="visit-hint">在新标签页打开 • {{ site.url.split('/')[2] if site.url else '' }}</p>
|
||
</div>
|
||
@@ -501,20 +602,20 @@
|
||
<!-- Product Overview -->
|
||
<div class="content-block">
|
||
<h2>
|
||
- <span class="material-symbols-outlined">info</span>
|
||
+ <span>ℹ️</span>
|
||
产品概述
|
||
</h2>
|
||
- <p>{{ site.description }}</p>
|
||
+ <div class="markdown-content">{{ site.description | markdown | safe }}</div>
|
||
</div>
|
||
|
||
<!-- Detailed Description -->
|
||
{% if site.features %}
|
||
<div class="content-block">
|
||
<h2>
|
||
- <span class="material-symbols-outlined">description</span>
|
||
- 详细描述
|
||
+ <span>📋</span>
|
||
+ 主要功能
|
||
</h2>
|
||
- <div>{{ site.features | safe }}</div>
|
||
+ <div class="markdown-content">{{ site.features | markdown | safe }}</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
@@ -522,7 +623,7 @@
|
||
{% if news_list %}
|
||
<div class="content-block">
|
||
<h2>
|
||
- <span class="material-symbols-outlined">newspaper</span>
|
||
+ <span>📰</span>
|
||
相关新闻
|
||
</h2>
|
||
{% for news in news_list %}
|
||
@@ -540,7 +641,7 @@
|
||
{% if recommended_sites %}
|
||
<div class="content-block">
|
||
<h2>
|
||
- <span class="material-symbols-outlined">auto_awesome</span>
|
||
+ <span>✨</span>
|
||
相似推荐
|
||
</h2>
|
||
<div class="recommendations-grid">
|
||
@@ -560,7 +661,7 @@
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
- <span class="material-symbols-outlined arrow-icon">north_east</span>
|
||
+ <span class="arrow-icon">↗</span>
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
--
|
||
2.50.1.windows.1
|
||
|