Compare commits
27
Commits
4651781bc3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db8cea02f8 | ||
|
|
6d015bb56d | ||
|
|
ab54b0137d | ||
|
|
cab4d6a9bd | ||
|
|
076812f1aa | ||
|
|
93b1d725e3 | ||
|
|
45137c2f4f | ||
|
|
8571066931 | ||
|
|
0a85f99d3a | ||
|
|
fde86b90d2 | ||
|
|
b5194c4965 | ||
|
|
1eeadb815b | ||
|
|
0009aa7e88 | ||
|
|
f0b07bd854 | ||
|
|
e203305bac | ||
|
|
c55d883524 | ||
|
|
595394fcb7 | ||
|
|
aecf1f1f15 | ||
|
|
ceed63fcb0 | ||
|
|
0b43973236 | ||
|
|
12426d8478 | ||
|
|
bec6f50182 | ||
|
|
69670eb402 | ||
|
|
a233cab970 | ||
|
|
93c12edefc | ||
|
|
f28c413351 | ||
|
|
0892e6aa04 |
@@ -6,7 +6,15 @@
|
||||
"Skill(subagent-driven-development)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(python -c \"from app.models import Joke, JokeType, JokeCrowd, AdminUser; print\\('All models imported successfully'\\)\")"
|
||||
"Bash(python -c \"from app.models import Joke, JokeType, JokeCrowd, AdminUser; print\\('All models imported successfully'\\)\")",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8001/health)",
|
||||
"Bash(curl -s http://localhost:8001/api/auth/login -H \"Content-Type: application/json\" -d \"{\\\\\"username\\\\\":\\\\\"admin\\\\\",\\\\\"password\\\\\":\\\\\"admin123\\\\\"}\")",
|
||||
"Bash(python -c \"import sys,json;d=json.load\\(sys.stdin\\);print\\(f'Jokes: {d[\\\\\"total\\\\\"]} total'\\)\")",
|
||||
"Bash(npm run *)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000)",
|
||||
"Bash(python -m uvicorn main:app --reload --port 8001)",
|
||||
"Bash(python -c \"from app.schemas.joke import GenerateRequest, GenerateResponse; print\\('OK'\\)\")",
|
||||
"Bash(python -c \"from main import app; print\\('API imports OK'\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# CLAUDE.md
|
||||
|
||||
本文件为 Claude Code (claude.ai/code) 在此仓库中工作时提供指导。
|
||||
|
||||
## 启动命令
|
||||
|
||||
### API 后端 (port 8001)
|
||||
```bash
|
||||
cd api && python -m uvicorn main:app --reload --port 8001
|
||||
```
|
||||
|
||||
### Web 前台 (port 3000)
|
||||
```bash
|
||||
cd web && npm run dev # 开发模式
|
||||
cd web && npm run build # 生产构建
|
||||
```
|
||||
|
||||
### Admin 后台管理 (port 3001)
|
||||
```bash
|
||||
cd admin && npm run dev # 开发模式
|
||||
cd admin && npm run build # 生产构建
|
||||
```
|
||||
|
||||
### 数据库初始化
|
||||
```bash
|
||||
cd api && python init_db.py
|
||||
```
|
||||
|
||||
### 爬虫 (AI 自动采集)
|
||||
```bash
|
||||
cd crawler && python main.py # 持续采集(无头浏览器)
|
||||
cd crawler && python main.py --no-headless # 显示浏览器窗口(调试用)
|
||||
```
|
||||
|
||||
### 站点发现(独立工具)
|
||||
```bash
|
||||
python crawler/site_finder.py # 搜索笑话站点,保存到 joke_sites.json
|
||||
python crawler/site_crawler.py --site https://... # 采集指定站点
|
||||
python crawler/site_crawler.py --file joke_sites.json # 批量采集已发现站点
|
||||
```
|
||||
|
||||
### 笑话优化(质量检测 / AI 润色 / 评价分类)
|
||||
```bash
|
||||
python optimizer/main.py # 处理所有笑话
|
||||
python optimizer/main.py --limit 10 # 只处理前 10 条(调试用)
|
||||
python optimizer/main.py --status rejected # 只处理已拒绝的
|
||||
python optimizer/main.py --id 1,2,3 # 指定 ID 处理
|
||||
```
|
||||
|
||||
### 启动顺序
|
||||
1. API 后端 (port 8001) — 自动建表,必须先启动
|
||||
2. Web 前台 (port 3000) — 依赖 API
|
||||
3. Admin 后台 (port 3001) — 依赖 API
|
||||
4. 爬虫 / 优化器 (可选) — 连接 API 的 8001 端口
|
||||
|
||||
## 开发备注
|
||||
|
||||
- **无测试**:本项目当前没有自动化测试
|
||||
- **数据库迁移**:新增字段需手动 `ALTER TABLE` 或重建,不支持自动迁移
|
||||
- **JWT Secret**:开发环境使用默认值,生产环境需设置 `JWT_SECRET_KEY` 环境变量
|
||||
|
||||
## 项目架构
|
||||
|
||||
### 目录结构
|
||||
```
|
||||
joke/
|
||||
├── api/ # FastAPI 后端 (Python, port 8001)
|
||||
│ └── app/
|
||||
│ ├── models/ # SQLAlchemy 模型(自动建表)
|
||||
│ ├── routers/ # API 路由
|
||||
│ └── schemas/ # Pydantic 数据模型
|
||||
├── web/ # Vue3 前台 (port 3000)
|
||||
│ └── src/
|
||||
│ ├── components/ # 通用组件(三栏布局)
|
||||
│ ├── views/ # 页面(首页/分类/详情/搜索)
|
||||
│ ├── stores/ # Pinia(joke/category/theme)
|
||||
│ └── api/ # Axios 封装
|
||||
├── admin/ # Vue3 管理后台 (port 3001)
|
||||
│ └── src/
|
||||
│ ├── layout/ # 后台布局(侧边栏+顶部导航)
|
||||
│ ├── views/ # 页面(登录/仪表盘/笑话管理/分类管理/设置)
|
||||
│ ├── stores/ # Pinia(auth/joke)
|
||||
│ └── api/ # Axios 封装
|
||||
├── crawler/ # AI 爬虫(crawl4ai + NVIDIA NIM)
|
||||
│ ├── main.py # 持续采集主入口
|
||||
│ ├── processor.py # 流程编排(搜索→深度翻页→AI提取→入库)
|
||||
│ ├── crawler_service.py # 页面抓取(Bing搜索 + crawl4ai)
|
||||
│ ├── ai_service.py # NVIDIA NIM API(OpenAI 兼容)调用
|
||||
│ ├── prompts.py # AI 提示词模板
|
||||
│ ├── site_finder.py # 站点发现工具(搜索 Bing 找笑话站)
|
||||
│ └── site_crawler.py # 单站点批量采集工具
|
||||
├── optimizer/ # 笑话优化工具(质量检测→润色→评价分类)
|
||||
│ ├── main.py # CLI 入口
|
||||
│ ├── optimizer.py # 三阶段处理管道
|
||||
│ └── prompts.py # AI 提示词模板
|
||||
└── docs/ # 文档
|
||||
```
|
||||
|
||||
### 关键约定
|
||||
|
||||
- **API 代理**:web 和 admin 的 Vite 配置都将 `/api` 代理到 `http://localhost:8001`,不重写路径
|
||||
- **JWT 认证**:登录接口 `/api/auth/login`,token 存 localStorage,请求头 `Authorization: Bearer <token>`
|
||||
- **笑话审核流**:`pending`(待审核)→ `approved`(已通过)。公开 API 只返回 `approved` 的笑话
|
||||
- **双主题系统**:CSS 变量通过 `:root[data-theme="light"]` / `:root[data-theme="dark"]` 控制,使用 `data-theme` 属性切换
|
||||
- **数据库**:SQLite (`api/joke.db`),SQLAlchemy ORM,API 启动时自动建表(`Base.metadata.create_all`),但**不自动修改已有表结构**——新增字段需手动 `ALTER TABLE` 或重建
|
||||
- **爬虫**:使用 crawl4ai 的 `AsyncWebCrawler`(统一 headless/visible 模式),crawl4ai 不可用时回退到 requests
|
||||
- **AI 服务**:使用 NVIDIA NIM API(兼容 OpenAI),配置通过后台设置页面管理,存储在 `AiSetting` 表
|
||||
|
||||
### 数据模型
|
||||
|
||||
| 表名 | 说明 | 关键字段 |
|
||||
|------|------|----------|
|
||||
| `jokes` | 笑话 | title, content, polished_content, type_id, crowd_id, status, view_count, like_count |
|
||||
| `joke_types` | 类型 | name, icon, sort_order |
|
||||
| `joke_crowds` | 人群 | name, icon, sort_order |
|
||||
| `admin_users` | 管理员 | username, password_hash(bcrypt) |
|
||||
| `ai_settings` | AI 配置 | api_base, api_key, model_name, temperature, is_active |
|
||||
| `links` | 友链 | name, url, status |
|
||||
| `feedback` | 用户反馈 | content, contact, status |
|
||||
|
||||
### API 接口一览
|
||||
|
||||
- `GET /api/jokes/` — 公开:获取已审核笑话列表(支持 `type_id`/`crowd_id` 筛选,分页)
|
||||
- `GET /api/jokes/{id}` — 公开:获取单条笑话(自动增加浏览次数)
|
||||
- `GET /api/jokes/random` — 公开:随机获取一条
|
||||
- `POST /api/jokes/{id}/like` — 公开:为笑话点赞(增加 like_count)
|
||||
- `POST /api/generate` — 公开:AI 生成笑话(keywords + scenarios)
|
||||
- `GET /api/categories/types` — 公开:类型列表
|
||||
- `GET /api/categories/crowds` — 公开:人群列表
|
||||
- `POST /api/auth/login` — 公开:管理员登录,返回 JWT token
|
||||
- `GET/POST /api/admin/jokes` — 需认证:列表(支持 `status` 筛选)/ 创建
|
||||
- `GET/PUT/DELETE /api/admin/jokes/{id}` — 需认证:单条 CRUD
|
||||
- `PUT /api/admin/jokes/batch-approve` — 需认证:批量审核通过
|
||||
- `GET /api/admin/stats` — 需认证:统计数据
|
||||
- `GET/PUT /api/admin/settings/active` — 需认证:AI 配置管理
|
||||
- `GET/POST /api/admin/links` — 需认证:友链管理
|
||||
- `GET/POST /api/admin/feedback` — 需认证:反馈管理
|
||||
|
||||
**API 文档**:访问 `/docs` 查看交互式 Swagger UI(FastAPI 自动生成)
|
||||
|
||||
### 前端三栏布局(web)
|
||||
|
||||
- **AppSidebar**:左侧宽频/点击/搞笑段子排行
|
||||
- **AppHeader**:顶部导航 + 分类标签 + 搜索框 + 主题切换
|
||||
- **AppSubNav**:内容区顶部二级导航(最新/最热/随机)
|
||||
- **主内容区**:JokeCard 列表(无限滚动 + 点赞 + 分类/人群标签)
|
||||
- **AppRightbar**:右侧类型/人群分类导航
|
||||
- **AppFooter**:底部信息
|
||||
|
||||
### 爬虫深度采集流程
|
||||
|
||||
1. Bing 搜索关键词 → 发现笑话聚合站
|
||||
2. 抓取首页 → AI 提取笑话 → 入库
|
||||
3. 发现翻页链接(`page_N.html`、`?page=N` 等)→ 逐页抓取 + AI 提取
|
||||
4. 发现分类链接(`category-N.html`)→ 逐类深度采集(含分类翻页 `category-N_M.html`)
|
||||
5. 站点容错:连续失败 3 次自动跳过
|
||||
6. 去重:基于 content MD5 哈希
|
||||
|
||||
### 优化器三阶段流程
|
||||
|
||||
1. **质量检测** (temperature=0.3) — AI 判断是否有笑点,无则标记 `rejected`
|
||||
2. **AI 润色** (temperature=0.8) — 优化语言表达,保存到 `polished_content` 字段
|
||||
3. **评价分类** (temperature=0.3) — 分配 type/crowd,评分 1-10,决定 `approved`/`pending`
|
||||
|
||||
### 爬虫/优化器通用模式(复用方式)
|
||||
|
||||
所有 Python CLI 工具遵循相同模式:
|
||||
- 通过 API(非直连数据库)读写数据
|
||||
- 复用 `httpx` 进行 REST 调用 + Bearer token 认证
|
||||
- AI 调用使用 `openai.OpenAI` 客户端
|
||||
- 分类映射:通过 `GET /api/categories/types` 和 `GET /api/categories/crowds` 获取 name→id 映射
|
||||
|
||||
---
|
||||
|
||||
如果有任何问题或需要帮助,请随时告知!
|
||||
@@ -0,0 +1 @@
|
||||
{"title":"程序员的幽默","content":"程序员去相亲,女方问:你有什么优点?程序员答:我bug少。女方:...","type_id":2,"crowd_id":2,"id":1,"status":"approved","view_count":2,"like_count":0,"created_at":"2026-05-21T14:05:38","updated_at":"2026-05-22T10:40:03","type_name":"谐音梗","crowd_name":"职场"}
|
||||
@@ -0,0 +1 @@
|
||||
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImV4cCI6MTc4MDM1NDE1OX0.jqZHtndPbRhPYY7ZrkRQGqLzAMtfbejzkhRCq7fwqGY","token_type":"bearer"}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"hash": "529e4e6a",
|
||||
"configHash": "47bdc112",
|
||||
"lockfileHash": "81bd2f5f",
|
||||
"browserHash": "e99f2664",
|
||||
"optimized": {
|
||||
"axios": {
|
||||
"src": "../../axios/index.js",
|
||||
"file": "axios.js",
|
||||
"fileHash": "64a84452",
|
||||
"needsInterop": false
|
||||
},
|
||||
"element-plus": {
|
||||
"src": "../../element-plus/es/index.mjs",
|
||||
"file": "element-plus.js",
|
||||
"fileHash": "d8579f02",
|
||||
"needsInterop": false
|
||||
},
|
||||
"pinia": {
|
||||
"src": "../../pinia/dist/pinia.mjs",
|
||||
"file": "pinia.js",
|
||||
"fileHash": "cc8debcd",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue": {
|
||||
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
|
||||
"file": "vue.js",
|
||||
"fileHash": "7c6ca729",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue-router": {
|
||||
"src": "../../vue-router/dist/vue-router.mjs",
|
||||
"file": "vue-router.js",
|
||||
"fileHash": "bb6a478b",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
"chunks": {
|
||||
"chunk-YFT6OQ5R": {
|
||||
"file": "chunk-YFT6OQ5R.js"
|
||||
},
|
||||
"chunk-UC4PQXLF": {
|
||||
"file": "chunk-UC4PQXLF.js"
|
||||
},
|
||||
"chunk-G3PMV62Z": {
|
||||
"file": "chunk-G3PMV62Z.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
+3108
File diff suppressed because it is too large
Load Diff
+7
File diff suppressed because one or more lines are too long
+36
@@ -0,0 +1,36 @@
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __commonJS = (cb, mod) => function __require() {
|
||||
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
||||
};
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
|
||||
export {
|
||||
__commonJS,
|
||||
__export,
|
||||
__toESM
|
||||
};
|
||||
//# sourceMappingURL=chunk-G3PMV62Z.js.map
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
+13045
File diff suppressed because it is too large
Load Diff
+7
File diff suppressed because one or more lines are too long
+162
@@ -0,0 +1,162 @@
|
||||
// node_modules/@vue/devtools-api/lib/esm/env.js
|
||||
function getDevtoolsGlobalHook() {
|
||||
return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;
|
||||
}
|
||||
function getTarget() {
|
||||
return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : {};
|
||||
}
|
||||
var isProxyAvailable = typeof Proxy === "function";
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/const.js
|
||||
var HOOK_SETUP = "devtools-plugin:setup";
|
||||
var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set";
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/time.js
|
||||
var supported;
|
||||
var perf;
|
||||
function isPerformanceSupported() {
|
||||
var _a;
|
||||
if (supported !== void 0) {
|
||||
return supported;
|
||||
}
|
||||
if (typeof window !== "undefined" && window.performance) {
|
||||
supported = true;
|
||||
perf = window.performance;
|
||||
} else if (typeof globalThis !== "undefined" && ((_a = globalThis.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {
|
||||
supported = true;
|
||||
perf = globalThis.perf_hooks.performance;
|
||||
} else {
|
||||
supported = false;
|
||||
}
|
||||
return supported;
|
||||
}
|
||||
function now() {
|
||||
return isPerformanceSupported() ? perf.now() : Date.now();
|
||||
}
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/proxy.js
|
||||
var ApiProxy = class {
|
||||
constructor(plugin, hook) {
|
||||
this.target = null;
|
||||
this.targetQueue = [];
|
||||
this.onQueue = [];
|
||||
this.plugin = plugin;
|
||||
this.hook = hook;
|
||||
const defaultSettings = {};
|
||||
if (plugin.settings) {
|
||||
for (const id in plugin.settings) {
|
||||
const item = plugin.settings[id];
|
||||
defaultSettings[id] = item.defaultValue;
|
||||
}
|
||||
}
|
||||
const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;
|
||||
let currentSettings = Object.assign({}, defaultSettings);
|
||||
try {
|
||||
const raw = localStorage.getItem(localSettingsSaveId);
|
||||
const data = JSON.parse(raw);
|
||||
Object.assign(currentSettings, data);
|
||||
} catch (e) {
|
||||
}
|
||||
this.fallbacks = {
|
||||
getSettings() {
|
||||
return currentSettings;
|
||||
},
|
||||
setSettings(value) {
|
||||
try {
|
||||
localStorage.setItem(localSettingsSaveId, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
}
|
||||
currentSettings = value;
|
||||
},
|
||||
now() {
|
||||
return now();
|
||||
}
|
||||
};
|
||||
if (hook) {
|
||||
hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {
|
||||
if (pluginId === this.plugin.id) {
|
||||
this.fallbacks.setSettings(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.proxiedOn = new Proxy({}, {
|
||||
get: (_target, prop) => {
|
||||
if (this.target) {
|
||||
return this.target.on[prop];
|
||||
} else {
|
||||
return (...args) => {
|
||||
this.onQueue.push({
|
||||
method: prop,
|
||||
args
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
this.proxiedTarget = new Proxy({}, {
|
||||
get: (_target, prop) => {
|
||||
if (this.target) {
|
||||
return this.target[prop];
|
||||
} else if (prop === "on") {
|
||||
return this.proxiedOn;
|
||||
} else if (Object.keys(this.fallbacks).includes(prop)) {
|
||||
return (...args) => {
|
||||
this.targetQueue.push({
|
||||
method: prop,
|
||||
args,
|
||||
resolve: () => {
|
||||
}
|
||||
});
|
||||
return this.fallbacks[prop](...args);
|
||||
};
|
||||
} else {
|
||||
return (...args) => {
|
||||
return new Promise((resolve) => {
|
||||
this.targetQueue.push({
|
||||
method: prop,
|
||||
args,
|
||||
resolve
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async setRealTarget(target) {
|
||||
this.target = target;
|
||||
for (const item of this.onQueue) {
|
||||
this.target.on[item.method](...item.args);
|
||||
}
|
||||
for (const item of this.targetQueue) {
|
||||
item.resolve(await this.target[item.method](...item.args));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/index.js
|
||||
function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
|
||||
const descriptor = pluginDescriptor;
|
||||
const target = getTarget();
|
||||
const hook = getDevtoolsGlobalHook();
|
||||
const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;
|
||||
if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {
|
||||
hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);
|
||||
} else {
|
||||
const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;
|
||||
const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];
|
||||
list.push({
|
||||
pluginDescriptor: descriptor,
|
||||
setupFn,
|
||||
proxy
|
||||
});
|
||||
if (proxy) {
|
||||
setupFn(proxy.proxiedTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
setupDevtoolsPlugin
|
||||
};
|
||||
//# sourceMappingURL=chunk-YFT6OQ5R.js.map
|
||||
+7
File diff suppressed because one or more lines are too long
+71932
File diff suppressed because it is too large
Load Diff
+7
File diff suppressed because one or more lines are too long
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
+1561
File diff suppressed because it is too large
Load Diff
+7
File diff suppressed because one or more lines are too long
+2247
File diff suppressed because it is too large
Load Diff
+7
File diff suppressed because one or more lines are too long
+348
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
BaseTransition,
|
||||
BaseTransitionPropsValidators,
|
||||
Comment,
|
||||
DeprecationTypes,
|
||||
EffectScope,
|
||||
ErrorCodes,
|
||||
ErrorTypeStrings,
|
||||
Fragment,
|
||||
KeepAlive,
|
||||
ReactiveEffect,
|
||||
Static,
|
||||
Suspense,
|
||||
Teleport,
|
||||
Text,
|
||||
TrackOpTypes,
|
||||
Transition,
|
||||
TransitionGroup,
|
||||
TriggerOpTypes,
|
||||
VueElement,
|
||||
assertNumber,
|
||||
callWithAsyncErrorHandling,
|
||||
callWithErrorHandling,
|
||||
camelize,
|
||||
capitalize,
|
||||
cloneVNode,
|
||||
compatUtils,
|
||||
compile,
|
||||
computed,
|
||||
createApp,
|
||||
createBaseVNode,
|
||||
createBlock,
|
||||
createCommentVNode,
|
||||
createElementBlock,
|
||||
createHydrationRenderer,
|
||||
createPropsRestProxy,
|
||||
createRenderer,
|
||||
createSSRApp,
|
||||
createSlots,
|
||||
createStaticVNode,
|
||||
createTextVNode,
|
||||
createVNode,
|
||||
customRef,
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
defineCustomElement,
|
||||
defineEmits,
|
||||
defineExpose,
|
||||
defineModel,
|
||||
defineOptions,
|
||||
defineProps,
|
||||
defineSSRCustomElement,
|
||||
defineSlots,
|
||||
devtools,
|
||||
effect,
|
||||
effectScope,
|
||||
getCurrentInstance,
|
||||
getCurrentScope,
|
||||
getCurrentWatcher,
|
||||
getTransitionRawChildren,
|
||||
guardReactiveProps,
|
||||
h,
|
||||
handleError,
|
||||
hasInjectionContext,
|
||||
hydrate,
|
||||
hydrateOnIdle,
|
||||
hydrateOnInteraction,
|
||||
hydrateOnMediaQuery,
|
||||
hydrateOnVisible,
|
||||
initCustomFormatter,
|
||||
initDirectivesForSSR,
|
||||
inject,
|
||||
isMemoSame,
|
||||
isProxy,
|
||||
isReactive,
|
||||
isReadonly,
|
||||
isRef,
|
||||
isRuntimeOnly,
|
||||
isShallow,
|
||||
isVNode,
|
||||
markRaw,
|
||||
mergeDefaults,
|
||||
mergeModels,
|
||||
mergeProps,
|
||||
nextTick,
|
||||
nodeOps,
|
||||
normalizeClass,
|
||||
normalizeProps,
|
||||
normalizeStyle,
|
||||
onActivated,
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onBeforeUpdate,
|
||||
onDeactivated,
|
||||
onErrorCaptured,
|
||||
onMounted,
|
||||
onRenderTracked,
|
||||
onRenderTriggered,
|
||||
onScopeDispose,
|
||||
onServerPrefetch,
|
||||
onUnmounted,
|
||||
onUpdated,
|
||||
onWatcherCleanup,
|
||||
openBlock,
|
||||
patchProp,
|
||||
popScopeId,
|
||||
provide,
|
||||
proxyRefs,
|
||||
pushScopeId,
|
||||
queuePostFlushCb,
|
||||
reactive,
|
||||
readonly,
|
||||
ref,
|
||||
registerRuntimeCompiler,
|
||||
render,
|
||||
renderList,
|
||||
renderSlot,
|
||||
resolveComponent,
|
||||
resolveDirective,
|
||||
resolveDynamicComponent,
|
||||
resolveFilter,
|
||||
resolveTransitionHooks,
|
||||
setBlockTracking,
|
||||
setDevtoolsHook,
|
||||
setTransitionHooks,
|
||||
shallowReactive,
|
||||
shallowReadonly,
|
||||
shallowRef,
|
||||
ssrContextKey,
|
||||
ssrUtils,
|
||||
stop,
|
||||
toDisplayString,
|
||||
toHandlerKey,
|
||||
toHandlers,
|
||||
toRaw,
|
||||
toRef,
|
||||
toRefs,
|
||||
toValue,
|
||||
transformVNodeArgs,
|
||||
triggerRef,
|
||||
unref,
|
||||
useAttrs,
|
||||
useCssModule,
|
||||
useCssVars,
|
||||
useHost,
|
||||
useId,
|
||||
useModel,
|
||||
useSSRContext,
|
||||
useShadowRoot,
|
||||
useSlots,
|
||||
useTemplateRef,
|
||||
useTransitionState,
|
||||
vModelCheckbox,
|
||||
vModelDynamic,
|
||||
vModelRadio,
|
||||
vModelSelect,
|
||||
vModelText,
|
||||
vShow,
|
||||
version,
|
||||
warn,
|
||||
watch,
|
||||
watchEffect,
|
||||
watchPostEffect,
|
||||
watchSyncEffect,
|
||||
withAsyncContext,
|
||||
withCtx,
|
||||
withDefaults,
|
||||
withDirectives,
|
||||
withKeys,
|
||||
withMemo,
|
||||
withModifiers,
|
||||
withScopeId
|
||||
} from "./chunk-UC4PQXLF.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
export {
|
||||
BaseTransition,
|
||||
BaseTransitionPropsValidators,
|
||||
Comment,
|
||||
DeprecationTypes,
|
||||
EffectScope,
|
||||
ErrorCodes,
|
||||
ErrorTypeStrings,
|
||||
Fragment,
|
||||
KeepAlive,
|
||||
ReactiveEffect,
|
||||
Static,
|
||||
Suspense,
|
||||
Teleport,
|
||||
Text,
|
||||
TrackOpTypes,
|
||||
Transition,
|
||||
TransitionGroup,
|
||||
TriggerOpTypes,
|
||||
VueElement,
|
||||
assertNumber,
|
||||
callWithAsyncErrorHandling,
|
||||
callWithErrorHandling,
|
||||
camelize,
|
||||
capitalize,
|
||||
cloneVNode,
|
||||
compatUtils,
|
||||
compile,
|
||||
computed,
|
||||
createApp,
|
||||
createBlock,
|
||||
createCommentVNode,
|
||||
createElementBlock,
|
||||
createBaseVNode as createElementVNode,
|
||||
createHydrationRenderer,
|
||||
createPropsRestProxy,
|
||||
createRenderer,
|
||||
createSSRApp,
|
||||
createSlots,
|
||||
createStaticVNode,
|
||||
createTextVNode,
|
||||
createVNode,
|
||||
customRef,
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
defineCustomElement,
|
||||
defineEmits,
|
||||
defineExpose,
|
||||
defineModel,
|
||||
defineOptions,
|
||||
defineProps,
|
||||
defineSSRCustomElement,
|
||||
defineSlots,
|
||||
devtools,
|
||||
effect,
|
||||
effectScope,
|
||||
getCurrentInstance,
|
||||
getCurrentScope,
|
||||
getCurrentWatcher,
|
||||
getTransitionRawChildren,
|
||||
guardReactiveProps,
|
||||
h,
|
||||
handleError,
|
||||
hasInjectionContext,
|
||||
hydrate,
|
||||
hydrateOnIdle,
|
||||
hydrateOnInteraction,
|
||||
hydrateOnMediaQuery,
|
||||
hydrateOnVisible,
|
||||
initCustomFormatter,
|
||||
initDirectivesForSSR,
|
||||
inject,
|
||||
isMemoSame,
|
||||
isProxy,
|
||||
isReactive,
|
||||
isReadonly,
|
||||
isRef,
|
||||
isRuntimeOnly,
|
||||
isShallow,
|
||||
isVNode,
|
||||
markRaw,
|
||||
mergeDefaults,
|
||||
mergeModels,
|
||||
mergeProps,
|
||||
nextTick,
|
||||
nodeOps,
|
||||
normalizeClass,
|
||||
normalizeProps,
|
||||
normalizeStyle,
|
||||
onActivated,
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onBeforeUpdate,
|
||||
onDeactivated,
|
||||
onErrorCaptured,
|
||||
onMounted,
|
||||
onRenderTracked,
|
||||
onRenderTriggered,
|
||||
onScopeDispose,
|
||||
onServerPrefetch,
|
||||
onUnmounted,
|
||||
onUpdated,
|
||||
onWatcherCleanup,
|
||||
openBlock,
|
||||
patchProp,
|
||||
popScopeId,
|
||||
provide,
|
||||
proxyRefs,
|
||||
pushScopeId,
|
||||
queuePostFlushCb,
|
||||
reactive,
|
||||
readonly,
|
||||
ref,
|
||||
registerRuntimeCompiler,
|
||||
render,
|
||||
renderList,
|
||||
renderSlot,
|
||||
resolveComponent,
|
||||
resolveDirective,
|
||||
resolveDynamicComponent,
|
||||
resolveFilter,
|
||||
resolveTransitionHooks,
|
||||
setBlockTracking,
|
||||
setDevtoolsHook,
|
||||
setTransitionHooks,
|
||||
shallowReactive,
|
||||
shallowReadonly,
|
||||
shallowRef,
|
||||
ssrContextKey,
|
||||
ssrUtils,
|
||||
stop,
|
||||
toDisplayString,
|
||||
toHandlerKey,
|
||||
toHandlers,
|
||||
toRaw,
|
||||
toRef,
|
||||
toRefs,
|
||||
toValue,
|
||||
transformVNodeArgs,
|
||||
triggerRef,
|
||||
unref,
|
||||
useAttrs,
|
||||
useCssModule,
|
||||
useCssVars,
|
||||
useHost,
|
||||
useId,
|
||||
useModel,
|
||||
useSSRContext,
|
||||
useShadowRoot,
|
||||
useSlots,
|
||||
useTemplateRef,
|
||||
useTransitionState,
|
||||
vModelCheckbox,
|
||||
vModelDynamic,
|
||||
vModelRadio,
|
||||
vModelSelect,
|
||||
vModelText,
|
||||
vShow,
|
||||
version,
|
||||
warn,
|
||||
watch,
|
||||
watchEffect,
|
||||
watchPostEffect,
|
||||
watchSyncEffect,
|
||||
withAsyncContext,
|
||||
withCtx,
|
||||
withDefaults,
|
||||
withDirectives,
|
||||
withKeys,
|
||||
withMemo,
|
||||
withModifiers,
|
||||
withScopeId
|
||||
};
|
||||
//# sourceMappingURL=vue.js.map
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import request from './request'
|
||||
|
||||
export const getTypes = () => request.get('/types')
|
||||
export const getCrowds = () => request.get('/crowds')
|
||||
export const getTypes = () => request.get('/categories/types')
|
||||
export const getCrowds = () => request.get('/categories/crowds')
|
||||
export const createType = data => request.post('/admin/types', data)
|
||||
export const updateType = (id, data) => request.put(`/admin/types/${id}`, data)
|
||||
export const deleteType = id => request.delete(`/admin/types/${id}`)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import request from './request'
|
||||
|
||||
export const getFeedbacks = () => request.get('/admin/feedbacks')
|
||||
export const deleteFeedback = id => request.delete('/admin/feedbacks/' + id)
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from './request'
|
||||
|
||||
export const getLinks = () => request.get('/admin/links')
|
||||
export const getLink = id => request.get('/admin/links/' + id)
|
||||
export const createLink = data => request.post('/admin/links', data)
|
||||
export const updateLink = (id, data) => request.put('/admin/links/' + id, data)
|
||||
export const deleteLink = id => request.delete('/admin/links/' + id)
|
||||
@@ -13,15 +13,15 @@ request.interceptors.request.use(config => {
|
||||
return config
|
||||
})
|
||||
|
||||
request.interceptors.response.use({
|
||||
successHandler: res => res.data,
|
||||
errorHandler: err => {
|
||||
request.interceptors.response.use(
|
||||
res => res.data,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export default request
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import request from './request'
|
||||
|
||||
export const getSettings = () => request.get('/admin/settings')
|
||||
export const getActiveSetting = () => request.get('/admin/settings/active')
|
||||
export const createSetting = data => request.post('/admin/settings', data)
|
||||
export const updateSetting = (id, data) => request.put(`/admin/settings/${id}`, data)
|
||||
export const toggleSetting = id => request.put(`/admin/settings/${id}/toggle`)
|
||||
export const deleteSetting = id => request.delete(`/admin/settings/${id}`)
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from './request'
|
||||
|
||||
export const getUsers = () => request.get('/admin/users')
|
||||
|
||||
export const createUser = (data) => request.post('/admin/users', data)
|
||||
|
||||
export const deleteUser = (id) => request.delete(`/admin/users/${id}`)
|
||||
+45
-13
@@ -1,26 +1,40 @@
|
||||
<template>
|
||||
<el-container style="min-height: 100vh">
|
||||
<el-aside width="200px" style="background: #304156">
|
||||
<el-aside width="220px" style="background: #304156">
|
||||
<div class="logo">笑话管理后台</div>
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
:unique-opened="true"
|
||||
background-color="#304156"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409EFF"
|
||||
router
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/jokes">
|
||||
<span>笑话管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/category/type">
|
||||
<span>类型管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/category/crowd">
|
||||
<span>人群管理</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="/dashboard">
|
||||
<template #title>
|
||||
<span>首页</span>
|
||||
</template>
|
||||
<el-menu-item index="/dashboard">仪表盘</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="/content">
|
||||
<template #title>
|
||||
<span>内容管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/jokes">笑话管理</el-menu-item>
|
||||
<el-menu-item index="/category/type">类型管理</el-menu-item>
|
||||
<el-menu-item index="/category/crowd">人群管理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="/system">
|
||||
<template #title>
|
||||
<span>系统管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/settings">AI 配置</el-menu-item>
|
||||
<el-menu-item index="/links">友链管理</el-menu-item>
|
||||
<el-menu-item index="/feedbacks">反馈管理</el-menu-item>
|
||||
<el-menu-item index="/users">用户管理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
@@ -56,8 +70,26 @@ const handleLogout = () => {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
background: #2b3a4a;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.el-aside {
|
||||
background: #304156;
|
||||
}
|
||||
/* 多级菜单样式 */
|
||||
:deep(.el-sub-menu__title) {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
:deep(.el-menu-item) {
|
||||
font-size: 13px;
|
||||
padding-left: 54px !important;
|
||||
}
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background-color: #263445 !important;
|
||||
}
|
||||
:deep(.el-sub-menu .el-menu-item) {
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,12 +16,17 @@ const routes = [
|
||||
{ path: 'jokes/edit/:id?', name: 'JokeEdit', component: () => import('@/views/joke/Edit.vue') },
|
||||
{ path: 'category/type', name: 'CategoryType', component: () => import('@/views/category/Type.vue') },
|
||||
{ path: 'category/crowd', name: 'CategoryCrowd', component: () => import('@/views/category/Crowd.vue') },
|
||||
{ path: 'settings', name: 'Settings', component: () => import('@/views/setting/Index.vue') },
|
||||
{ path: 'links', name: 'LinkList', component: () => import('@/views/links/List.vue') },
|
||||
{ path: 'links/edit/:id?', name: 'LinkEdit', component: () => import('@/views/links/Edit.vue') },
|
||||
{ path: 'feedbacks', name: 'FeedbackList', component: () => import('@/views/feedback/List.vue') },
|
||||
{ path: 'users', name: 'UserList', component: () => import('@/views/user/List.vue') },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory('/admin'),
|
||||
routes
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ export const useJokeStore = defineStore('joke', {
|
||||
async fetchJokes(params) {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await getJokes(params)
|
||||
const cleanParams = Object.fromEntries(
|
||||
Object.entries(params).filter(([_, v]) => v != null && v !== '')
|
||||
)
|
||||
const res = await getJokes(cleanParams)
|
||||
this.jokes = res.items
|
||||
this.total = res.total
|
||||
} finally {
|
||||
|
||||
@@ -4,7 +4,23 @@
|
||||
<h2>人群管理</h2>
|
||||
<el-button type="primary" @click="dialogVisible = true; form = {}">添加</el-button>
|
||||
</div>
|
||||
<el-table :data="crowds" style="margin-top: 20px">
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索名称..."
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<span>🔍</span>
|
||||
</template>
|
||||
</el-input>
|
||||
<span class="result-count">共 {{ filteredCrowds.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredCrowds" style="margin-top: 16px">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="sort_order" label="排序" width="100" />
|
||||
@@ -34,15 +50,23 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getCrowds, createCrowd, updateCrowd, deleteCrowd } from '@/api/category'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const crowds = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const form = ref({})
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const filteredCrowds = computed(() => {
|
||||
if (!searchKeyword.value) return crowds.value
|
||||
const kw = searchKeyword.value.toLowerCase()
|
||||
return crowds.value.filter(c => c.name.toLowerCase().includes(kw))
|
||||
})
|
||||
|
||||
const loadData = async () => { crowds.value = await getCrowds() }
|
||||
const handleSearch = () => { /* computed property handles filtering */ }
|
||||
const handleEdit = (row) => { form.value = { ...row }; dialogVisible.value = true }
|
||||
const handleSave = async () => {
|
||||
form.value.id ? await updateCrowd(form.value.id, form.value) : await createCrowd(form.value)
|
||||
@@ -60,4 +84,14 @@ onMounted(() => loadData())
|
||||
|
||||
<style scoped>
|
||||
.header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.result-count {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,23 @@
|
||||
<h2>类型管理</h2>
|
||||
<el-button type="primary" @click="dialogVisible = true; form = {}">添加</el-button>
|
||||
</div>
|
||||
<el-table :data="types" style="margin-top: 20px">
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索名称..."
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<span>🔍</span>
|
||||
</template>
|
||||
</el-input>
|
||||
<span class="result-count">共 {{ filteredTypes.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="paginatedTypes" style="margin-top: 16px">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="sort_order" label="排序" width="100" />
|
||||
@@ -34,15 +50,26 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getTypes, createType, updateType, deleteType } from '@/api/category'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const types = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const form = ref({})
|
||||
const searchKeyword = ref('')
|
||||
const searchResults = ref([])
|
||||
|
||||
const filteredTypes = computed(() => {
|
||||
if (!searchKeyword.value) return types.value
|
||||
const kw = searchKeyword.value.toLowerCase()
|
||||
return types.value.filter(t => t.name.toLowerCase().includes(kw))
|
||||
})
|
||||
|
||||
const paginatedTypes = computed(() => filteredTypes.value)
|
||||
|
||||
const loadData = async () => { types.value = await getTypes() }
|
||||
const handleSearch = () => { /* computed property handles filtering */ }
|
||||
const handleEdit = (row) => { form.value = { ...row }; dialogVisible.value = true }
|
||||
const handleSave = async () => {
|
||||
form.value.id ? await updateType(form.value.id, form.value) : await createType(form.value)
|
||||
@@ -60,4 +87,14 @@ onMounted(() => loadData())
|
||||
|
||||
<style scoped>
|
||||
.header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.result-count {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,14 +10,6 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.pending }}</div>
|
||||
<div class="stat-label">待审核</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<div class="stat-card">
|
||||
@@ -26,6 +18,24 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.pending }}</div>
|
||||
<div class="stat-label">待审核</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.rejected }}</div>
|
||||
<div class="stat-label">已拒绝</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<div class="stat-card">
|
||||
@@ -34,6 +44,14 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.likes }}</div>
|
||||
<div class="stat-label">总点赞</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
@@ -42,11 +60,22 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getStats } from '@/api/joke'
|
||||
|
||||
const stats = ref({ total: 0, pending: 0, approved: 0, views: 0 })
|
||||
const stats = ref({ total: 0, pending: 0, approved: 0, rejected: 0, views: 0, likes: 0 })
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getStats()
|
||||
stats.value = res
|
||||
try {
|
||||
const res = await getStats()
|
||||
stats.value = {
|
||||
total: res.total_jokes || 0,
|
||||
pending: res.pending_jokes || 0,
|
||||
approved: res.approved_jokes || 0,
|
||||
rejected: res.rejected_jokes || 0,
|
||||
views: res.total_views || 0,
|
||||
likes: res.total_likes || 0,
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取统计数据失败', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="feedback-list">
|
||||
<div class="header">
|
||||
<h2>反馈管理</h2>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索名称/内容..."
|
||||
clearable
|
||||
style="width: 240px"
|
||||
>
|
||||
<template #prefix><span>🔍</span></template>
|
||||
</el-input>
|
||||
<span class="result-count">共 {{ filteredFeedbacks.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredFeedbacks" v-loading="loading" style="margin-top: 16px">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" width="120" />
|
||||
<el-table-column prop="email" label="邮箱" width="200" />
|
||||
<el-table-column prop="content" label="内容" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="提交时间" width="180" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getFeedbacks, deleteFeedback } from '@/api/feedback'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const feedbacks = ref([])
|
||||
const loading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const filteredFeedbacks = computed(() => {
|
||||
if (!searchKeyword.value) return feedbacks.value
|
||||
const kw = searchKeyword.value.toLowerCase()
|
||||
return feedbacks.value.filter(f =>
|
||||
(f.name && f.name.toLowerCase().includes(kw)) ||
|
||||
(f.content && f.content.toLowerCase().includes(kw))
|
||||
)
|
||||
})
|
||||
|
||||
const loadFeedbacks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
feedbacks.value = await getFeedbacks()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确定删除?')
|
||||
await deleteFeedback(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadFeedbacks()
|
||||
}
|
||||
|
||||
onMounted(() => loadFeedbacks())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.result-count {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +1,40 @@
|
||||
<template>
|
||||
<div class="joke-edit">
|
||||
<h2>{{ isEdit ? '编辑笑话' : '添加笑话' }}</h2>
|
||||
<el-form :model="form" ref="formRef" label-width="100px" style="margin-top: 20px; max-width: 600px">
|
||||
<el-form :model="form" ref="formRef" label-width="100px" style="margin-top: 20px; max-width: 800px">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input v-model="form.content" type="textarea" :rows="6" />
|
||||
<el-input v-model="form.content" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="type_id">
|
||||
<el-select v-model="form.type_id" placeholder="选择类型">
|
||||
<el-form-item label="AI润色版本" prop="polished_content">
|
||||
<el-input v-model="form.polished_content" type="textarea" :rows="4" placeholder="AI 润色后的笑话内容" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="AI评分" prop="ai_score">
|
||||
<el-input-number v-model="form.ai_score" :min="1" :max="10" :step="0.1" precision="1" placeholder="1-10分" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<el-form-item label="AI等级" prop="ai_level">
|
||||
<el-select v-model="form.ai_level" placeholder="AI 质量评价" style="width: 100%">
|
||||
<el-option label="⭐⭐⭐ 精品 (excellent)" value="excellent" />
|
||||
<el-option label="⭐⭐ 良好 (good)" value="good" />
|
||||
<el-option label="⭐ 普通 (ordinary)" value="ordinary" />
|
||||
<el-option label="⚠️ 待优化 (poor)" value="poor" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="类型" prop="type_ids">
|
||||
<el-select v-model="form.type_ids" multiple placeholder="选择类型(可多选)">
|
||||
<el-option v-for="t in types" :key="t.id" :label="t.name" :value="t.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="人群" prop="crowd_id">
|
||||
<el-select v-model="form.crowd_id" placeholder="选择人群">
|
||||
<el-form-item label="人群" prop="crowd_ids">
|
||||
<el-select v-model="form.crowd_ids" multiple placeholder="选择人群(可多选)">
|
||||
<el-option v-for="c in crowds" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -46,7 +66,7 @@ const formRef = ref()
|
||||
const isEdit = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '', content: '', type_id: null, crowd_id: null, status: 'pending'
|
||||
title: '', content: '', polished_content: '', ai_score: null, ai_level: '', type_ids: [], crowd_ids: [], status: 'pending'
|
||||
})
|
||||
|
||||
const types = ref([])
|
||||
@@ -59,7 +79,16 @@ const loadData = async () => {
|
||||
if (route.params.id) {
|
||||
isEdit.value = true
|
||||
const res = await getJoke(route.params.id)
|
||||
Object.assign(form, res)
|
||||
Object.assign(form, {
|
||||
title: res.title,
|
||||
content: res.content,
|
||||
polished_content: res.polished_content || '',
|
||||
ai_score: res.ai_score || null,
|
||||
ai_level: res.ai_level || '',
|
||||
type_ids: res.type_ids || [],
|
||||
crowd_ids: res.crowd_ids || [],
|
||||
status: res.status
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+159
-16
@@ -4,11 +4,64 @@
|
||||
<h2>笑话管理</h2>
|
||||
<el-button type="primary" @click="$router.push('/jokes/edit')">添加笑话</el-button>
|
||||
</div>
|
||||
<el-table :data="jokeStore.jokes" v-loading="jokeStore.loading" style="margin-top: 20px">
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索标题/内容..."
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="search-icon">🔍</span>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 120px" @change="handleSearch">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="已发布" value="approved" />
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已拒绝" value="rejected" />
|
||||
</el-select>
|
||||
<el-select v-model="filterLevel" placeholder="AI等级" clearable style="width: 130px" @change="handleSearch">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="⭐⭐⭐ 精品" value="excellent" />
|
||||
<el-option label="⭐⭐ 良好" value="good" />
|
||||
<el-option label="⭐ 普通" value="ordinary" />
|
||||
<el-option label="⚠️ 待优化" value="poor" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="jokeStore.jokes" v-loading="jokeStore.loading" style="margin-top: 16px">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column prop="type_name" label="类型" width="100" />
|
||||
<el-table-column prop="crowd_name" label="人群" width="100" />
|
||||
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="AI等级" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.ai_level" size="small" :type="getLevelType(row.ai_level)">
|
||||
{{ getLevelText(row.ai_level) }}
|
||||
</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="润色" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.polished_content" type="success" size="small">已润色</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="name in (row.type_names || [])" :key="name" size="small" style="margin:1px">{{ name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="人群" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="name in (row.crowd_names || [])" :key="name" size="small" type="success" style="margin:1px">{{ name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'approved' ? 'success' : row.status === 'pending' ? 'warning' : 'danger'">
|
||||
@@ -16,8 +69,8 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="view_count" label="浏览" width="80" />
|
||||
<el-table-column prop="like_count" label="点赞" width="80" />
|
||||
<el-table-column prop="view_count" label="浏览" width="70" />
|
||||
<el-table-column prop="like_count" label="点赞" width="70" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="$router.push(`/jokes/edit/${row.id}`)">编辑</el-button>
|
||||
@@ -25,28 +78,69 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
style="margin-top: 20px"
|
||||
:current-page="page"
|
||||
:page-size="20"
|
||||
:total="jokeStore.total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadJokes"
|
||||
/>
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
:current-page="page"
|
||||
:page-size="20"
|
||||
:total="jokeStore.total"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
@current-change="loadJokes"
|
||||
/>
|
||||
<div class="page-jump">
|
||||
跳至 <el-input-number
|
||||
v-model="jumpPage"
|
||||
:min="1"
|
||||
:max="maxPage"
|
||||
size="small"
|
||||
controls-position="right"
|
||||
@change="handleJump"
|
||||
/> 页
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useJokeStore } from '@/stores/joke'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const jokeStore = useJokeStore()
|
||||
const page = ref(1)
|
||||
const jumpPage = ref(1)
|
||||
const searchKeyword = ref('')
|
||||
const filterStatus = ref('')
|
||||
const filterLevel = ref('')
|
||||
|
||||
const maxPage = computed(() => Math.ceil(jokeStore.total / 20) || 1)
|
||||
|
||||
const loadJokes = (p = 1) => {
|
||||
page.value = p
|
||||
jokeStore.fetchJokes({ page: p, page_size: 20 })
|
||||
jumpPage.value = p
|
||||
jokeStore.fetchJokes({
|
||||
page: p,
|
||||
page_size: 20,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
status: filterStatus.value || undefined,
|
||||
ai_level: filterLevel.value || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const handleJump = (val) => {
|
||||
if (val && val >= 1 && val <= maxPage.value) {
|
||||
loadJokes(val)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
loadJokes(1)
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchKeyword.value = ''
|
||||
filterStatus.value = ''
|
||||
filterLevel.value = ''
|
||||
loadJokes(1)
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
@@ -56,6 +150,26 @@ const handleDelete = async (id) => {
|
||||
loadJokes(page.value)
|
||||
}
|
||||
|
||||
function getLevelText(level) {
|
||||
const map = {
|
||||
'excellent': '⭐⭐⭐',
|
||||
'good': '⭐⭐',
|
||||
'ordinary': '⭐',
|
||||
'poor': '待优化'
|
||||
}
|
||||
return map[level] || level
|
||||
}
|
||||
|
||||
function getLevelType(level) {
|
||||
const map = {
|
||||
'excellent': 'warning',
|
||||
'good': 'success',
|
||||
'ordinary': 'info',
|
||||
'poor': 'danger'
|
||||
}
|
||||
return map[level] || 'info'
|
||||
}
|
||||
|
||||
onMounted(() => loadJokes())
|
||||
</script>
|
||||
|
||||
@@ -65,4 +179,33 @@ onMounted(() => loadJokes())
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
.text-muted {
|
||||
color: #999;
|
||||
}
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.page-jump {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
:deep(.el-input-number) {
|
||||
width: 80px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="link-edit">
|
||||
<h2>{{ isEdit ? '编辑友链' : '添加友链' }}</h2>
|
||||
<el-form :model="form" ref="formRef" label-width="120px" style="margin-top: 20px; max-width: 600px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="URL" prop="url">
|
||||
<el-input v-model="form.url" placeholder="https://" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort_order">
|
||||
<el-input-number v-model="form.sort_order" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
<el-button @click="$router.back()">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getLink, createLink, updateLink } from '@/api/link'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formRef = ref()
|
||||
const isEdit = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '', url: '', description: '', sort_order: 0
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
if (route.params.id) {
|
||||
isEdit.value = true
|
||||
const res = await getLink(route.params.id)
|
||||
Object.assign(form, res)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isEdit.value) {
|
||||
await updateLink(route.params.id, form)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createLink(form)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
router.push('/links')
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="link-list">
|
||||
<div class="header">
|
||||
<h2>友链管理</h2>
|
||||
<el-button type="primary" @click="$router.push('/links/edit')">添加友链</el-button>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索名称/URL..."
|
||||
clearable
|
||||
style="width: 240px"
|
||||
>
|
||||
<template #prefix><span>🔍</span></template>
|
||||
</el-input>
|
||||
<span class="result-count">共 {{ filteredLinks.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredLinks" v-loading="loading" style="margin-top: 16px">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="url" label="URL" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<a :href="row.url" target="_blank">{{ row.url }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" />
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="$router.push(`/links/edit/${row.id}`)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getLinks, deleteLink } from '@/api/link'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const links = ref([])
|
||||
const loading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const filteredLinks = computed(() => {
|
||||
if (!searchKeyword.value) return links.value
|
||||
const kw = searchKeyword.value.toLowerCase()
|
||||
return links.value.filter(l =>
|
||||
l.name.toLowerCase().includes(kw) || l.url.toLowerCase().includes(kw)
|
||||
)
|
||||
})
|
||||
|
||||
const loadLinks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
links.value = await getLinks()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确定删除?')
|
||||
await deleteLink(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadLinks()
|
||||
}
|
||||
|
||||
onMounted(() => loadLinks())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.result-count {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="setting-page">
|
||||
<h2>AI 配置</h2>
|
||||
|
||||
<!-- AI 基础配置 -->
|
||||
<el-card style="margin-top: 20px">
|
||||
<template #header>
|
||||
<span>API 基础配置</span>
|
||||
</template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="提供商">
|
||||
<el-select v-model="form.provider" style="width: 200px">
|
||||
<el-option label="NVIDIA NIM" value="nvidia" />
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="Anthropic" value="anthropic" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="API 地址">
|
||||
<el-input v-model="form.api_base" placeholder="https://integrate.api.nvidia.com/v1" style="width: 400px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API 密钥">
|
||||
<el-input v-model="form.api_key" type="password" show-password style="width: 400px" placeholder="nvapi-..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="模型名称">
|
||||
<el-input v-model="form.model_name" style="width: 300px" placeholder="nvidia/llama-3.1-nemotron-70b-instruct" />
|
||||
</el-form-item>
|
||||
<el-form-item label="温度">
|
||||
<el-input-number v-model="form.temperature" :min="0" :max="2" :step="0.1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大 Token">
|
||||
<el-input-number v-model="form.max_tokens" :min="1" :max="8192" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 爬虫配置 -->
|
||||
<el-card style="margin-top: 20px">
|
||||
<template #header>
|
||||
<span>爬虫配置</span>
|
||||
</template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="启用爬虫">
|
||||
<el-switch v-model="form.crawl_enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="搜索关键词">
|
||||
<el-input
|
||||
v-model="form.crawl_keywords"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
style="width: 400px"
|
||||
placeholder="多个关键词用逗号分隔,如:冷笑话,段子,谐音梗"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="每轮页数">
|
||||
<el-input-number v-model="form.max_pages_per_run" :min="1" :max="20" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 提示词配置 -->
|
||||
<el-card style="margin-top: 20px">
|
||||
<template #header>
|
||||
<span>提示词模板</span>
|
||||
</template>
|
||||
<el-form :model="form" label-width="140px">
|
||||
<el-form-item label="AI 生成笑话">
|
||||
<el-input
|
||||
v-model="form.generate_prompt"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="使用 {context}/{style}/{length_requirement} 作为占位符"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-divider />
|
||||
|
||||
<el-form-item label="优化器-质量检测">
|
||||
<el-input
|
||||
v-model="form.optimizer_quality_prompt"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="质量检测温度">
|
||||
<el-input-number v-model="form.optimizer_quality_temperature" :min="0" :max="2" :step="0.1" />
|
||||
</el-form-item>
|
||||
<el-divider />
|
||||
|
||||
<el-form-item label="优化器-润色">
|
||||
<el-input
|
||||
v-model="form.optimizer_polish_prompt"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="润色温度">
|
||||
<el-input-number v-model="form.optimizer_polish_temperature" :min="0" :max="2" :step="0.1" />
|
||||
</el-form-item>
|
||||
<el-divider />
|
||||
|
||||
<el-form-item label="优化器-评价分类">
|
||||
<el-input
|
||||
v-model="form.optimizer_evaluate_prompt"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="评价温度">
|
||||
<el-input-number v-model="form.optimizer_evaluate_temperature" :min="0" :max="2" :step="0.1" />
|
||||
</el-form-item>
|
||||
<el-divider />
|
||||
|
||||
<el-form-item label="爬虫-提取笑话">
|
||||
<el-input
|
||||
v-model="form.crawler_extract_prompt"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="爬虫-改写">
|
||||
<el-input
|
||||
v-model="form.crawler_rewrite_prompt"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div style="margin-top: 20px">
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存配置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 历史配置 -->
|
||||
<el-card style="margin-top: 20px">
|
||||
<template #header>
|
||||
<span>历史配置</span>
|
||||
</template>
|
||||
<el-table :data="allSettings" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="provider" label="提供商" width="100" />
|
||||
<el-table-column prop="model_name" label="模型" />
|
||||
<el-table-column prop="api_base" label="API 地址" show-overflow-tooltip />
|
||||
<el-table-column prop="is_active" label="激活" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="warning" @click="handleToggle(row)" v-if="!row.is_active">激活</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getSettings, getActiveSetting, createSetting, updateSetting, toggleSetting, deleteSetting } from '@/api/setting'
|
||||
|
||||
const form = ref({
|
||||
provider: 'nvidia',
|
||||
api_base: 'https://integrate.api.nvidia.com/v1',
|
||||
api_key: '',
|
||||
model_name: 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048,
|
||||
crawl_enabled: false,
|
||||
crawl_keywords: '冷笑话,段子,谐音梗',
|
||||
max_pages_per_run: 3,
|
||||
generate_prompt: '',
|
||||
optimizer_quality_prompt: '',
|
||||
optimizer_polish_prompt: '',
|
||||
optimizer_evaluate_prompt: '',
|
||||
optimizer_quality_temperature: 0.3,
|
||||
optimizer_polish_temperature: 0.8,
|
||||
optimizer_evaluate_temperature: 0.3,
|
||||
crawler_extract_prompt: '',
|
||||
crawler_rewrite_prompt: '',
|
||||
})
|
||||
|
||||
const allSettings = ref([])
|
||||
const saving = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const active = await getActiveSetting()
|
||||
editingId.value = active.id
|
||||
form.value = {
|
||||
provider: active.provider,
|
||||
api_base: active.api_base,
|
||||
api_key: active.api_key,
|
||||
model_name: active.model_name,
|
||||
temperature: active.temperature,
|
||||
max_tokens: active.max_tokens,
|
||||
crawl_enabled: active.crawl_enabled,
|
||||
crawl_keywords: active.crawl_keywords || '',
|
||||
max_pages_per_run: active.max_pages_per_run,
|
||||
generate_prompt: active.generate_prompt || '',
|
||||
optimizer_quality_prompt: active.optimizer_quality_prompt || '',
|
||||
optimizer_polish_prompt: active.optimizer_polish_prompt || '',
|
||||
optimizer_evaluate_prompt: active.optimizer_evaluate_prompt || '',
|
||||
optimizer_quality_temperature: active.optimizer_quality_temperature ?? 0.3,
|
||||
optimizer_polish_temperature: active.optimizer_polish_temperature ?? 0.8,
|
||||
optimizer_evaluate_temperature: active.optimizer_evaluate_temperature ?? 0.3,
|
||||
crawler_extract_prompt: active.crawler_extract_prompt || '',
|
||||
crawler_rewrite_prompt: active.crawler_rewrite_prompt || '',
|
||||
}
|
||||
} catch (e) {
|
||||
// 没有激活配置,使用默认值
|
||||
}
|
||||
|
||||
try {
|
||||
allSettings.value = await getSettings()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateSetting(editingId.value, form.value)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
const created = await createSetting(form.value)
|
||||
editingId.value = created.id
|
||||
await toggleSetting(created.id)
|
||||
ElMessage.success('创建并激活成功')
|
||||
}
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败: ' + (e.message || ''))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
provider: row.provider,
|
||||
api_base: row.api_base,
|
||||
api_key: row.api_key,
|
||||
model_name: row.model_name,
|
||||
temperature: row.temperature,
|
||||
max_tokens: row.max_tokens,
|
||||
crawl_enabled: row.crawl_enabled,
|
||||
crawl_keywords: row.crawl_keywords || '',
|
||||
max_pages_per_run: row.max_pages_per_run,
|
||||
generate_prompt: row.generate_prompt || '',
|
||||
optimizer_quality_prompt: row.optimizer_quality_prompt || '',
|
||||
optimizer_polish_prompt: row.optimizer_polish_prompt || '',
|
||||
optimizer_evaluate_prompt: row.optimizer_evaluate_prompt || '',
|
||||
optimizer_quality_temperature: row.optimizer_quality_temperature ?? 0.3,
|
||||
optimizer_polish_temperature: row.optimizer_polish_temperature ?? 0.8,
|
||||
optimizer_evaluate_temperature: row.optimizer_evaluate_temperature ?? 0.3,
|
||||
crawler_extract_prompt: row.crawler_extract_prompt || '',
|
||||
crawler_rewrite_prompt: row.crawler_rewrite_prompt || '',
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (row) => {
|
||||
await ElMessageBox.confirm('确定激活此配置?')
|
||||
await toggleSetting(row.id)
|
||||
ElMessage.success('激活成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确定删除此配置?')
|
||||
await deleteSetting(id)
|
||||
ElMessage.success('删除成功')
|
||||
if (editingId.value === id) {
|
||||
editingId.value = null
|
||||
}
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.setting-page { max-width: 900px }
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="user-list">
|
||||
<div class="header">
|
||||
<h2>用户管理</h2>
|
||||
<el-button type="primary" @click="showDialog = true; form = {}">添加用户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="users" v-loading="loading" style="margin-top: 16px">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="username" label="用户名" />
|
||||
<el-table-column label="角色" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'danger' : 'success'" size="small">
|
||||
{{ row.role === 'admin' ? '管理员' : '提交者' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)"
|
||||
:disabled="row.id === currentUserId">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 添加用户弹窗 -->
|
||||
<el-dialog v-model="showDialog" :title="form.id ? '编辑用户' : '添加用户'">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="form.username" placeholder="输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" required>
|
||||
<el-input v-model="form.password" type="password" placeholder="输入密码" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role" style="width: 100%">
|
||||
<el-option label="管理员 (admin)" value="admin" />
|
||||
<el-option label="提交者 (submitter)" value="submitter" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getUsers, createUser, deleteUser } from '@/api/user'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const showDialog = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = ref({ role: 'submitter' })
|
||||
|
||||
// 当前登录用户的 ID(从 token 解析)
|
||||
const currentUserId = ref(1) // 假设登录用户是 admin
|
||||
|
||||
const loadUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
users.value = await getUsers()
|
||||
// 从 localStorage 的 token 解析用户 ID(如果有)
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
currentUserId.value = parseInt(payload.sub)
|
||||
} catch (e) {}
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.value.username || !form.value.password) {
|
||||
ElMessage.warning('请填写用户名和密码')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await createUser(form.value)
|
||||
ElMessage.success('添加成功')
|
||||
showDialog.value = false
|
||||
loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.detail || '添加失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确定删除此用户?')
|
||||
await deleteUser(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
onMounted(() => loadUsers())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -9,11 +9,12 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
base: '/admin/',
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# API 操作文档 - 笑话管理系统
|
||||
|
||||
## 基础信息
|
||||
|
||||
- **API 地址**: `http://<服务器IP>:8001`
|
||||
- **默认管理员**: `admin` / `admin123`
|
||||
- **认证方式**: JWT Bearer Token
|
||||
|
||||
---
|
||||
|
||||
## 一、登录获取 Token
|
||||
|
||||
```bash
|
||||
curl -X POST http://<服务器IP>:8001/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "admin", "password": "admin123"}'
|
||||
```
|
||||
|
||||
**返回示例**:
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"token_type": "bearer"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、提交笑话
|
||||
|
||||
```bash
|
||||
curl -X POST http://<服务器IP>:8001/api/admin/jokes \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <TOKEN>" \
|
||||
-d '{
|
||||
"title": "笑话标题",
|
||||
"content": "笑话内容正文",
|
||||
"type_id": 1,
|
||||
"crowd_id": 2
|
||||
}'
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `title` | string | 是 | 笑话标题 |
|
||||
| `content` | string | 是 | 笑话正文 |
|
||||
| `type_id` | int | 否 | 类型 ID(查分类列表获取) |
|
||||
| `crowd_id` | int | 否 | 人群 ID(查分类列表获取) |
|
||||
|
||||
提交后 status 默认为 `pending`(待审核)。
|
||||
|
||||
---
|
||||
|
||||
## 三、查询分类 ID
|
||||
|
||||
```bash
|
||||
# 查看所有类型
|
||||
curl http://<服务器IP>:8001/api/categories/types
|
||||
|
||||
# 查看所有人群
|
||||
curl http://<服务器IP>:8001/api/categories/crowds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、完整脚本示例(批量提交)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
SERVER="http://192.168.1.10:8001"
|
||||
|
||||
# 登录获取 Token
|
||||
TOKEN=$(curl -s -X POST "$SERVER/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin123"}' \
|
||||
| python -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
|
||||
|
||||
echo "Token: $TOKEN"
|
||||
|
||||
# 批量提交笑话
|
||||
jokes=(
|
||||
'{"title":"笑话1","content":"内容1","type_id":1,"crowd_id":2}'
|
||||
'{"title":"笑话2","content":"内容2","type_id":1,"crowd_id":null}'
|
||||
)
|
||||
|
||||
for joke in "${jokes[@]}"; do
|
||||
curl -X POST "$SERVER/api/admin/jokes" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "$joke"
|
||||
echo ""
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、其他管理接口
|
||||
|
||||
### 查询笑话列表
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <TOKEN>" \
|
||||
"http://<服务器IP>:8001/api/admin/jokes?page=1&page_size=20&status=pending"
|
||||
```
|
||||
|
||||
### 审核通过
|
||||
```bash
|
||||
curl -X PUT http://<服务器IP>:8001/api/admin/jokes/batch-approve \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <TOKEN>" \
|
||||
-d '[1, 2, 3]'
|
||||
```
|
||||
|
||||
### 删除笑话
|
||||
```bash
|
||||
curl -X DELETE http://<服务器IP>:8001/api/admin/jokes/1 \
|
||||
-H "Authorization: Bearer <TOKEN>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、注意事项
|
||||
|
||||
1. 将 `<服务器IP>` 替换为实际的服务器 IP 地址
|
||||
2. 确保服务器防火墙已放行 **8001** 端口
|
||||
3. Token 有过期时间,过期后需重新登录获取
|
||||
Binary file not shown.
@@ -1,5 +1,8 @@
|
||||
from app.models.joke import Joke
|
||||
from app.models.category import JokeType, JokeCrowd
|
||||
from app.models.user import AdminUser
|
||||
from app.models.setting import AiSetting
|
||||
from app.models.link import Link
|
||||
from app.models.feedback import Feedback
|
||||
|
||||
__all__ = ["Joke", "JokeType", "JokeCrowd", "AdminUser"]
|
||||
__all__ = ["Joke", "JokeType", "JokeCrowd", "AdminUser", "AiSetting", "Link", "Feedback"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Feedback(Base):
|
||||
__tablename__ = "feedbacks"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(50), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
content = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
+10
-1
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Float, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
@@ -12,11 +12,20 @@ class Joke(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
# AI 润色后的内容
|
||||
polished_content = Column(Text, nullable=True)
|
||||
# AI 评分 (1-10)
|
||||
ai_score = Column(Float, nullable=True)
|
||||
# AI 质量等级: excellent/good/ordinary/poor
|
||||
ai_level = Column(String(20), nullable=True)
|
||||
type_ids = Column(Text, nullable=True)
|
||||
crowd_ids = Column(Text, nullable=True)
|
||||
type_id = Column(Integer, ForeignKey("joke_types.id"), nullable=True)
|
||||
crowd_id = Column(Integer, ForeignKey("joke_crowds.id"), nullable=True)
|
||||
status = Column(String(20), default="pending")
|
||||
view_count = Column(Integer, default=0)
|
||||
like_count = Column(Integer, default=0)
|
||||
dislike_count = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Link(Base):
|
||||
__tablename__ = "links"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
url = Column(String(500), nullable=False)
|
||||
description = Column(String(200), nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
status = Column(String(20), default="approved") # approved=已通过, pending=待审核
|
||||
contact = Column(String(100), nullable=True) # 申请联系方式
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, Text, func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AiSetting(Base):
|
||||
__tablename__ = "ai_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
provider = Column(String(50), nullable=False, default="nvidia")
|
||||
api_base = Column(String(500), nullable=False, default="https://integrate.api.nvidia.com/v1")
|
||||
api_key = Column(String(500), nullable=False, default="")
|
||||
model_name = Column(String(100), nullable=False, default="nvidia/llama-3.1-nemotron-70b-instruct")
|
||||
temperature = Column(Float, nullable=False, default=0.7)
|
||||
max_tokens = Column(Integer, nullable=False, default=2048)
|
||||
|
||||
# 提示词模板
|
||||
generate_prompt = Column(Text, nullable=True, comment="AI 笑话生成提示词")
|
||||
optimizer_quality_prompt = Column(Text, nullable=True, comment="优化器-质量检测提示词")
|
||||
optimizer_polish_prompt = Column(Text, nullable=True, comment="优化器-润色提示词")
|
||||
optimizer_evaluate_prompt = Column(Text, nullable=True, comment="优化器-评价分类提示词")
|
||||
crawler_extract_prompt = Column(Text, nullable=True, comment="爬虫-笑话提取提示词")
|
||||
crawler_rewrite_prompt = Column(Text, nullable=True, comment="爬虫-改写提示词")
|
||||
|
||||
# 优化器各阶段温度
|
||||
optimizer_quality_temperature = Column(Float, nullable=False, default=0.3)
|
||||
optimizer_polish_temperature = Column(Float, nullable=False, default=0.8)
|
||||
optimizer_evaluate_temperature = Column(Float, nullable=False, default=0.3)
|
||||
|
||||
crawl_enabled = Column(Boolean, nullable=False, default=False)
|
||||
crawl_keywords = Column(Text, nullable=True, default="")
|
||||
max_pages_per_run = Column(Integer, nullable=False, default=3)
|
||||
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
@@ -5,10 +5,11 @@ from app.database import Base
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
"""管理员用户表模型"""
|
||||
"""用户表 - 支持角色:admin(管理员), submitter(提交者)"""
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(String(50), unique=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
role = Column(String(20), default="submitter") # admin=管理员, submitter=提交者
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
@@ -2,5 +2,9 @@ from .jokes import router as jokes_router
|
||||
from .categories import router as categories_router
|
||||
from .auth import router as auth_router
|
||||
from .admin import router as admin_router
|
||||
from .settings import router as settings_router
|
||||
from .links import router as links_router
|
||||
from .feedback import router as feedback_router
|
||||
from .generate import router as generate_router
|
||||
|
||||
__all__ = ["jokes_router", "categories_router", "auth_router", "admin_router"]
|
||||
__all__ = ["jokes_router", "categories_router", "auth_router", "admin_router", "settings_router", "links_router", "feedback_router", "generate_router"]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from .jokes import router as jokes_router
|
||||
from .categories import router as categories_router
|
||||
from .auth import router as auth_router
|
||||
from .admin import router as admin_router
|
||||
from .settings import router as settings_router
|
||||
|
||||
__all__ = ["jokes_router", "categories_router", "auth_router", "admin_router", "settings_router"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+246
-14
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import func
|
||||
@@ -8,8 +10,14 @@ from app.database import get_db
|
||||
from app.models.joke import Joke
|
||||
from app.models.category import JokeCrowd, JokeType
|
||||
from app.models.user import AdminUser
|
||||
from app.models.link import Link
|
||||
from app.models.feedback import Feedback
|
||||
from app.schemas.category import JokeCrowdCreate, JokeCrowdResponse, JokeTypeCreate, JokeTypeResponse
|
||||
from app.schemas.joke import JokeCreate, JokeResponse, JokeUpdate, PaginatedJokeResponse
|
||||
from app.schemas.link import LinkCreate, LinkResponse
|
||||
from app.schemas.feedback import FeedbackResponse
|
||||
from app.schemas.user import UserCreate, UserResponse
|
||||
from app.routers.auth import hash_password
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["管理后台"])
|
||||
|
||||
@@ -39,21 +47,51 @@ def get_current_admin_user(
|
||||
return user
|
||||
|
||||
|
||||
def joke_to_response(joke: Joke) -> JokeResponse:
|
||||
def _parse_ids(raw) -> list[int]:
|
||||
"""解析 DB 中的 JSON 列表字符串为 Python list"""
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return raw
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _get_type_names(db: Session, ids: list[int]) -> list[str]:
|
||||
rows = db.query(JokeType).filter(JokeType.id.in_(ids)).all()
|
||||
return [r.name for r in rows]
|
||||
|
||||
|
||||
def _get_crowd_names(db: Session, ids: list[int]) -> list[str]:
|
||||
rows = db.query(JokeCrowd).filter(JokeCrowd.id.in_(ids)).all()
|
||||
return [r.name for r in rows]
|
||||
|
||||
|
||||
def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
|
||||
"""Convert Joke model to JokeResponse schema."""
|
||||
ids = _parse_ids(joke.type_ids)
|
||||
crowd_ids = _parse_ids(joke.crowd_ids)
|
||||
type_names = _get_type_names(db, ids) if db else []
|
||||
crowd_names = _get_crowd_names(db, crowd_ids) if db else []
|
||||
return JokeResponse(
|
||||
id=joke.id,
|
||||
title=joke.title,
|
||||
content=joke.content,
|
||||
type_id=joke.type_id,
|
||||
crowd_id=joke.crowd_id,
|
||||
polished_content=joke.polished_content,
|
||||
ai_score=joke.ai_score,
|
||||
ai_level=joke.ai_level,
|
||||
type_ids=ids,
|
||||
crowd_ids=crowd_ids,
|
||||
status=joke.status,
|
||||
view_count=joke.view_count,
|
||||
like_count=joke.like_count,
|
||||
dislike_count=joke.dislike_count,
|
||||
created_at=joke.created_at,
|
||||
updated_at=joke.updated_at,
|
||||
type_name=joke.type.name if joke.type else None,
|
||||
crowd_name=joke.crowd.name if joke.crowd else None,
|
||||
type_names=type_names,
|
||||
crowd_names=crowd_names,
|
||||
)
|
||||
|
||||
|
||||
@@ -63,18 +101,29 @@ def admin_list_jokes(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status: str = None,
|
||||
keyword: str = None,
|
||||
ai_level: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""获取所有笑话(支持状态筛选)"""
|
||||
"""获取所有笑话(支持状态筛选、关键词搜索)"""
|
||||
# 限制 page_size 防止 DoS
|
||||
page_size = max(1, min(page_size, 100))
|
||||
query = db.query(Joke)
|
||||
if status:
|
||||
query = query.filter(Joke.status == status)
|
||||
if keyword:
|
||||
keyword_filter = f"%{keyword}%"
|
||||
query = query.filter(
|
||||
(Joke.title.like(keyword_filter)) | (Joke.content.like(keyword_filter))
|
||||
)
|
||||
if ai_level:
|
||||
query = query.filter(Joke.ai_level == ai_level)
|
||||
total = query.count()
|
||||
offset = (page - 1) * page_size
|
||||
jokes = query.order_by(Joke.created_at.desc()).offset(offset).limit(page_size).all()
|
||||
return PaginatedJokeResponse(
|
||||
items=[joke_to_response(j) for j in jokes],
|
||||
items=[joke_to_response(j, db) for j in jokes],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
@@ -88,11 +137,29 @@ def admin_create_joke(
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""创建笑话"""
|
||||
db_joke = Joke(**joke.model_dump())
|
||||
data = joke.model_dump()
|
||||
if data.get("type_ids") is not None:
|
||||
data["type_ids"] = json.dumps(data["type_ids"])
|
||||
if data.get("crowd_ids") is not None:
|
||||
data["crowd_ids"] = json.dumps(data["crowd_ids"])
|
||||
db_joke = Joke(**data)
|
||||
db.add(db_joke)
|
||||
db.commit()
|
||||
db.refresh(db_joke)
|
||||
return joke_to_response(db_joke)
|
||||
return joke_to_response(db_joke, db)
|
||||
|
||||
|
||||
@router.get("/jokes/{joke_id}", response_model=JokeResponse)
|
||||
def admin_get_joke(
|
||||
joke_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""获取单个笑话"""
|
||||
joke = db.query(Joke).filter(Joke.id == joke_id).first()
|
||||
if not joke:
|
||||
raise HTTPException(status_code=404, detail="笑话不存在")
|
||||
return joke_to_response(joke, db)
|
||||
|
||||
|
||||
@router.put("/jokes/{joke_id}", response_model=JokeResponse)
|
||||
@@ -106,12 +173,15 @@ def admin_update_joke(
|
||||
db_joke = db.query(Joke).filter(Joke.id == joke_id).first()
|
||||
if not db_joke:
|
||||
raise HTTPException(status_code=404, detail="笑话不存在")
|
||||
|
||||
update_data = joke.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
if key in ("type_ids", "crowd_ids") and value is not None:
|
||||
value = json.dumps(value)
|
||||
setattr(db_joke, key, value)
|
||||
db.commit()
|
||||
db.refresh(db_joke)
|
||||
return joke_to_response(db_joke)
|
||||
return joke_to_response(db_joke, db)
|
||||
|
||||
|
||||
@router.delete("/jokes/{joke_id}")
|
||||
@@ -152,14 +222,18 @@ def admin_stats(
|
||||
total_jokes = db.query(Joke).count()
|
||||
approved_jokes = db.query(Joke).filter(Joke.status == "approved").count()
|
||||
pending_jokes = db.query(Joke).filter(Joke.status == "pending").count()
|
||||
rejected_jokes = db.query(Joke).filter(Joke.status == "rejected").count()
|
||||
total_views = db.query(Joke).with_entities(func.sum(Joke.view_count)).scalar() or 0
|
||||
total_likes = db.query(Joke).with_entities(func.sum(Joke.like_count)).scalar() or 0
|
||||
total_dislikes = db.query(Joke).with_entities(func.sum(Joke.dislike_count)).scalar() or 0
|
||||
return {
|
||||
"total_jokes": total_jokes,
|
||||
"approved_jokes": approved_jokes,
|
||||
"pending_jokes": pending_jokes,
|
||||
"rejected_jokes": rejected_jokes,
|
||||
"total_views": total_views,
|
||||
"total_likes": total_likes,
|
||||
"total_dislikes": total_dislikes,
|
||||
}
|
||||
|
||||
|
||||
@@ -209,8 +283,11 @@ def admin_delete_type(
|
||||
db_type = db.query(JokeType).filter(JokeType.id == type_id).first()
|
||||
if not db_type:
|
||||
raise HTTPException(status_code=404, detail="类型不存在")
|
||||
# Check if there are jokes using this type
|
||||
joke_count = db.query(Joke).filter(Joke.type_id == type_id).count()
|
||||
# Check both old single-field and new array-field associations
|
||||
old_count = db.query(Joke).filter(Joke.type_id == type_id).count()
|
||||
all_jokes = db.query(Joke.type_ids).filter(Joke.type_ids.isnot(None)).all()
|
||||
new_count = sum(1 for (raw,) in all_jokes if _parse_ids(raw) and type_id in _parse_ids(raw))
|
||||
joke_count = old_count + new_count
|
||||
if joke_count > 0:
|
||||
raise HTTPException(status_code=400, detail=f"有 {joke_count} 条笑话使用此类型,无法删除")
|
||||
db.delete(db_type)
|
||||
@@ -263,10 +340,165 @@ def admin_delete_crowd(
|
||||
db_crowd = db.query(JokeCrowd).filter(JokeCrowd.id == crowd_id).first()
|
||||
if not db_crowd:
|
||||
raise HTTPException(status_code=404, detail="人群分类不存在")
|
||||
# Check if there are jokes using this crowd
|
||||
joke_count = db.query(Joke).filter(Joke.crowd_id == crowd_id).count()
|
||||
# Check both old single-field and new array-field associations
|
||||
old_count = db.query(Joke).filter(Joke.crowd_id == crowd_id).count()
|
||||
all_jokes = db.query(Joke.crowd_ids).filter(Joke.crowd_ids.isnot(None)).all()
|
||||
new_count = sum(1 for (raw,) in all_jokes if _parse_ids(raw) and crowd_id in _parse_ids(raw))
|
||||
joke_count = old_count + new_count
|
||||
if joke_count > 0:
|
||||
raise HTTPException(status_code=400, detail=f"有 {joke_count} 条笑话使用此人群,无法删除")
|
||||
db.delete(db_crowd)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 友情链接管理
|
||||
# ============================================================
|
||||
|
||||
@router.get("/links", response_model=list[LinkResponse])
|
||||
def admin_list_links(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""获取所有友情链接"""
|
||||
return db.query(Link).order_by(Link.sort_order, Link.id).all()
|
||||
|
||||
|
||||
@router.post("/links", response_model=LinkResponse)
|
||||
def admin_create_link(
|
||||
link: LinkCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""创建友情链接"""
|
||||
db_link = Link(**link.model_dump())
|
||||
db.add(db_link)
|
||||
db.commit()
|
||||
db.refresh(db_link)
|
||||
return db_link
|
||||
|
||||
|
||||
@router.put("/links/{link_id}", response_model=LinkResponse)
|
||||
def admin_update_link(
|
||||
link_id: int,
|
||||
link: LinkCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""更新友情链接"""
|
||||
db_link = db.query(Link).filter(Link.id == link_id).first()
|
||||
if not db_link:
|
||||
raise HTTPException(status_code=404, detail="链接不存在")
|
||||
for key, value in link.model_dump().items():
|
||||
setattr(db_link, key, value)
|
||||
db.commit()
|
||||
db.refresh(db_link)
|
||||
return db_link
|
||||
|
||||
|
||||
@router.delete("/links/{link_id}")
|
||||
def admin_delete_link(
|
||||
link_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""删除友情链接"""
|
||||
db_link = db.query(Link).filter(Link.id == link_id).first()
|
||||
if not db_link:
|
||||
raise HTTPException(status_code=404, detail="链接不存在")
|
||||
db.delete(db_link)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 反馈建议管理
|
||||
# ============================================================
|
||||
|
||||
@router.get("/feedbacks", response_model=list[FeedbackResponse])
|
||||
def admin_list_feedbacks(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""获取所有反馈建议"""
|
||||
return db.query(Feedback).order_by(Feedback.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.delete("/feedbacks/{feedback_id}")
|
||||
def admin_delete_feedback(
|
||||
feedback_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""删除反馈"""
|
||||
db_feedback = db.query(Feedback).filter(Feedback.id == feedback_id).first()
|
||||
if not db_feedback:
|
||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||
db.delete(db_feedback)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 用户管理
|
||||
# ============================================================
|
||||
|
||||
@router.get("/users", response_model=list[UserResponse])
|
||||
def admin_list_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""获取所有用户"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作")
|
||||
return db.query(AdminUser).order_by(AdminUser.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserResponse)
|
||||
def admin_create_user(
|
||||
user: UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""创建用户"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作")
|
||||
|
||||
# 检查用户名是否已存在
|
||||
existing = db.query(AdminUser).filter(AdminUser.username == user.username).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
|
||||
db_user = AdminUser(
|
||||
username=user.username,
|
||||
password_hash=hash_password(user.password),
|
||||
role=user.role,
|
||||
)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
def admin_delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""删除用户"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作")
|
||||
|
||||
db_user = db.query(AdminUser).filter(AdminUser.id == user_id).first()
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 不允许删除自己
|
||||
if db_user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="不能删除自己")
|
||||
|
||||
db.delete(db_user)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
+14
-3
@@ -9,10 +9,11 @@ from app.config import JWT_ALGORITHM, JWT_EXPIRATION_HOURS, JWT_SECRET_KEY
|
||||
from app.database import get_db
|
||||
from app.models.user import AdminUser
|
||||
from app.schemas.auth import LoginRequest, TokenResponse
|
||||
from app.schemas.user import UserCreate, UserResponse, UserRegister
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
# 已存在的函数保持不变...
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证密码"""
|
||||
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||
@@ -27,11 +28,21 @@ def create_access_token(data: dict) -> str:
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""哈希密码"""
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def get_current_user():
|
||||
"""获取当前用户(保留用于后续权限控制)"""
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(req: LoginRequest, db: Session = Depends(get_db)):
|
||||
"""管理员登录"""
|
||||
"""登录"""
|
||||
user = db.query(AdminUser).filter(AdminUser.username == req.username).first()
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
|
||||
access_token = create_access_token(data={"sub": str(user.id), "username": user.username, "role": user.role})
|
||||
return TokenResponse(access_token=access_token)
|
||||
@@ -0,0 +1,18 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.feedback import Feedback
|
||||
from app.schemas.feedback import FeedbackCreate, FeedbackResponse
|
||||
|
||||
router = APIRouter(tags=["反馈建议"])
|
||||
|
||||
|
||||
@router.post("/feedback", response_model=FeedbackResponse)
|
||||
def create_feedback(feedback: FeedbackCreate, db: Session = Depends(get_db)):
|
||||
"""公开:提交反馈建议"""
|
||||
db_feedback = Feedback(**feedback.model_dump())
|
||||
db.add(db_feedback)
|
||||
db.commit()
|
||||
db.refresh(db_feedback)
|
||||
return db_feedback
|
||||
@@ -0,0 +1,187 @@
|
||||
"""AI 笑话生成器 API — 提示词从数据库读取"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from openai import OpenAI
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.setting import AiSetting
|
||||
from app.schemas.joke import GenerateRequest, GenerateResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/generate", tags=["生成器"])
|
||||
|
||||
STYLE_MAP = {
|
||||
"cold": "冷幽默 / 无厘头",
|
||||
"warm": "温馨幽默 / 暖心搞笑",
|
||||
"twist": "反转 / 神转折",
|
||||
"pun": "谐音梗 / 文字游戏",
|
||||
"sketch": "段子 / 吐槽调侃",
|
||||
"irony": "讽刺幽默 / 黑色幽默",
|
||||
}
|
||||
|
||||
LENGTH_MAP = {
|
||||
"short": "30-80字,非常简短",
|
||||
"medium": "80-150字,正常长度",
|
||||
"long": "150-300字,可以描述一个小场景",
|
||||
}
|
||||
|
||||
|
||||
def _load_prompt(setting: AiSetting) -> str:
|
||||
"""从数据库读取生成提示词,没有则返回默认值"""
|
||||
prompt = setting.generate_prompt
|
||||
if not prompt or not prompt.strip():
|
||||
prompt = GENERATE_PROMPT_DEFAULT
|
||||
return prompt
|
||||
|
||||
|
||||
GENERATE_PROMPT_DEFAULT = """你是一位幽默大师,专门创作轻松搞笑的短笑话。
|
||||
|
||||
{context}
|
||||
|
||||
要求:
|
||||
1. 根据场景和关键词创作一条原创笑话
|
||||
2. 笑话要有反转或意外结局
|
||||
3. 语言风格:{style}
|
||||
4. 字数要求:{length_requirement}
|
||||
5. 直接输出笑话内容,不需要解释
|
||||
|
||||
请严格按照以下 JSON 格式输出,不要加任何额外说明:
|
||||
{{
|
||||
"title": "笑话标题",
|
||||
"content": "笑话正文",
|
||||
"score": 8,
|
||||
"reason": "这个笑话巧妙结合了场景和关键词,结尾有反转"
|
||||
}}
|
||||
|
||||
score 是 1-10 的整数评分,reason 是用一句话说明亮点。"""
|
||||
|
||||
|
||||
def _build_prompt(
|
||||
scenarios: list[str],
|
||||
keywords: list[str],
|
||||
style: str = "twist",
|
||||
length: str = "medium",
|
||||
setting: AiSetting | None = None,
|
||||
) -> str:
|
||||
"""构建 AI prompt,支持风格和长度控制"""
|
||||
parts = []
|
||||
if scenarios:
|
||||
parts.append(f"场景:{', '.join(scenarios)}")
|
||||
if keywords:
|
||||
parts.append(f"关键词:{', '.join(keywords)}")
|
||||
if not parts:
|
||||
parts.append("场景:日常生活的各种趣事(不指定具体场景)")
|
||||
|
||||
prompt_template = _load_prompt(setting) if setting else GENERATE_PROMPT_DEFAULT
|
||||
return prompt_template.format(
|
||||
context="\n".join(parts),
|
||||
style=STYLE_MAP.get(style, "反转 / 神转折"),
|
||||
length_requirement=LENGTH_MAP.get(length, "80-150字,正常长度"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=GenerateResponse)
|
||||
def generate_joke(
|
||||
req: GenerateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""调用 AI 生成笑话,提示词从数据库读取"""
|
||||
# 获取激活的 AI 配置
|
||||
setting = db.query(AiSetting).filter(AiSetting.is_active == True).first()
|
||||
if not setting:
|
||||
setting = db.query(AiSetting).first()
|
||||
|
||||
if not setting or not setting.api_key:
|
||||
raise HTTPException(status_code=503, detail="AI 服务未配置,请联系管理员")
|
||||
|
||||
try:
|
||||
client = OpenAI(base_url=setting.api_base, api_key=setting.api_key)
|
||||
prompt = _build_prompt(req.scenarios, req.keywords, req.style, req.length, setting)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=setting.model_name,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=req.temperature,
|
||||
max_tokens=setting.max_tokens,
|
||||
)
|
||||
|
||||
raw = response.choices[0].message.content
|
||||
return _parse_json_response(raw)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=500, detail="AI 返回格式异常,请重新生成")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||
|
||||
|
||||
def _parse_json_response(raw: str | None) -> GenerateResponse:
|
||||
"""解析 AI 返回的 JSON 格式"""
|
||||
if not raw or not raw.strip():
|
||||
return GenerateResponse(title="生成的笑话", content="(内容生成失败,请重新生成)", score=0)
|
||||
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```"):
|
||||
lines = raw.split("\n")
|
||||
if len(lines) >= 3:
|
||||
raw = "\n".join(lines[1:-1]).strip()
|
||||
|
||||
if raw.endswith(","):
|
||||
raw = raw[:-1]
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
start = raw.index("{")
|
||||
end = raw.rindex("}") + 1
|
||||
raw = raw[start:end]
|
||||
data = json.loads(raw)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return _parse_response(raw)
|
||||
|
||||
return GenerateResponse(
|
||||
title=data.get("title", "生成的笑话"),
|
||||
content=data.get("content", ""),
|
||||
score=data.get("score", 0),
|
||||
reason=data.get("reason", ""),
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
def _parse_response(raw: str | None) -> GenerateResponse:
|
||||
"""解析 AI 返回内容(非 JSON 回退)"""
|
||||
if not raw or not raw.strip():
|
||||
raise ValueError("AI 返回内容为空")
|
||||
|
||||
title = ""
|
||||
content = raw
|
||||
|
||||
for line in raw.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("标题:") or line.startswith("标题:"):
|
||||
title = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
||||
lines = content.split("\n")
|
||||
for i, l in enumerate(lines):
|
||||
if l.strip() == line:
|
||||
lines[i] = ""
|
||||
break
|
||||
content = "\n".join(lines).strip()
|
||||
break
|
||||
|
||||
if not title:
|
||||
first_line = raw.split("\n")[0].strip()
|
||||
if first_line.startswith("标题"):
|
||||
first_line = first_line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
||||
title = first_line[:30] if len(first_line) > 30 else first_line
|
||||
|
||||
if not content.strip():
|
||||
content = "(内容生成失败,请重新生成)"
|
||||
|
||||
return GenerateResponse(
|
||||
title=title or "生成的笑话",
|
||||
content=content.strip(),
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
+234
-27
@@ -1,78 +1,285 @@
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import func, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.joke import Joke
|
||||
from app.models.category import JokeCrowd, JokeType
|
||||
from app.schemas.joke import JokeResponse, PaginatedJokeResponse
|
||||
|
||||
router = APIRouter(prefix="/jokes", tags=["笑话"])
|
||||
|
||||
|
||||
def joke_to_response(joke: Joke) -> JokeResponse:
|
||||
"""Convert Joke model to JokeResponse schema."""
|
||||
def _parse_ids(raw) -> list[int]:
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return raw
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _batch_load_categories(db: Session, jokes: list[Joke]) -> tuple[dict, dict]:
|
||||
"""批量加载类型和人群名称,避免 N+1 查询"""
|
||||
# 收集所有需要的 id
|
||||
all_type_ids = set()
|
||||
all_crowd_ids = set()
|
||||
for joke in jokes:
|
||||
all_type_ids.update(_parse_ids(joke.type_ids))
|
||||
all_crowd_ids.update(_parse_ids(joke.crowd_ids))
|
||||
|
||||
# 批量查询
|
||||
type_map = {}
|
||||
if all_type_ids:
|
||||
types = db.query(JokeType).filter(JokeType.id.in_(all_type_ids)).all()
|
||||
type_map = {t.id: t.name for t in types}
|
||||
|
||||
crowd_map = {}
|
||||
if all_crowd_ids:
|
||||
crowds = db.query(JokeCrowd).filter(JokeCrowd.id.in_(all_crowd_ids)).all()
|
||||
crowd_map = {c.id: c.name for c in crowds}
|
||||
|
||||
return type_map, crowd_map
|
||||
|
||||
|
||||
def joke_to_response(joke: Joke, type_map: dict = None, crowd_map: dict = None) -> JokeResponse:
|
||||
"""Convert Joke model to JokeResponse schema.
|
||||
|
||||
Args:
|
||||
type_map: 预加载的类型 id→name 映射
|
||||
crowd_map: 预加载的人群 id→name 映射
|
||||
"""
|
||||
ids = _parse_ids(joke.type_ids)
|
||||
crowd_ids = _parse_ids(joke.crowd_ids)
|
||||
|
||||
if type_map is not None:
|
||||
type_names = [type_map[i] for i in ids if i in type_map]
|
||||
else:
|
||||
type_names = []
|
||||
|
||||
if crowd_map is not None:
|
||||
crowd_names = [crowd_map[i] for i in crowd_ids if i in crowd_map]
|
||||
else:
|
||||
crowd_names = []
|
||||
|
||||
return JokeResponse(
|
||||
id=joke.id,
|
||||
title=joke.title,
|
||||
content=joke.content,
|
||||
type_id=joke.type_id,
|
||||
crowd_id=joke.crowd_id,
|
||||
type_ids=ids,
|
||||
crowd_ids=crowd_ids,
|
||||
status=joke.status,
|
||||
view_count=joke.view_count,
|
||||
like_count=joke.like_count,
|
||||
dislike_count=joke.dislike_count,
|
||||
created_at=joke.created_at,
|
||||
updated_at=joke.updated_at,
|
||||
type_name=joke.type.name if joke.type else None,
|
||||
crowd_name=joke.crowd.name if joke.crowd else None,
|
||||
type_names=type_names,
|
||||
crowd_names=crowd_names,
|
||||
polished_content=joke.polished_content,
|
||||
ai_score=joke.ai_score,
|
||||
ai_level=joke.ai_level,
|
||||
)
|
||||
|
||||
|
||||
def _build_json_filter(column, target_ids: set[int]) -> list:
|
||||
"""构建精确的 JSON 数组匹配过滤器(SQLite)"""
|
||||
filters = []
|
||||
for tid in target_ids:
|
||||
# 匹配 [n,...] 或 [...,n,...] 或 [...,n]
|
||||
# 使用 JSON_EACH 函数检查
|
||||
json_str = json.dumps(tid)
|
||||
filters.append(
|
||||
func.json_extract(column, '$').cast(type(target_ids)).contains(tid)
|
||||
)
|
||||
return filters
|
||||
|
||||
|
||||
@router.get("/", response_model=PaginatedJokeResponse)
|
||||
def list_jokes(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
type_id: int | None = None,
|
||||
crowd_id: int | None = None,
|
||||
type_ids: str | None = Query(None, description="逗号分隔的类型 ID,如 1,3,5"),
|
||||
crowd_ids: str | None = Query(None, description="逗号分隔的人群 ID,如 2,4"),
|
||||
keyword: str | None = Query(None, description="关键词搜索(标题+内容)"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取笑话列表(仅返回已审核通过的笑话)"""
|
||||
query = db.query(Joke).filter(Joke.status == "approved")
|
||||
|
||||
if type_id is not None:
|
||||
query = query.filter(Joke.type_id == type_id)
|
||||
if crowd_id is not None:
|
||||
query = query.filter(Joke.crowd_id == crowd_id)
|
||||
# 关键词搜索:标题和内容模糊匹配
|
||||
if keyword:
|
||||
kw = f"%{keyword}%"
|
||||
query = query.filter(
|
||||
(Joke.title.like(kw)) | (Joke.content.like(kw))
|
||||
)
|
||||
|
||||
# 支持多选过滤:逗号分隔的 ID
|
||||
# 使用自定义 JSON 匹配函数避免 LIKE %N% 的错误匹配
|
||||
if type_ids:
|
||||
filter_set = {int(x.strip()) for x in type_ids.split(",") if x.strip().isdigit()}
|
||||
if filter_set:
|
||||
# 使用文本匹配,确保精确匹配 JSON 数组中的 id
|
||||
# 匹配模式:[1,2,3] 或 [1] 或带尾部逗号的
|
||||
type_filters = []
|
||||
for tid in filter_set:
|
||||
# 构建精确匹配的正则:在逗号、引号、方括号包围中的数字
|
||||
import re
|
||||
pattern = rf'["\s\[,]{tid}[",\s\]]'
|
||||
type_filters.append(Joke.type_ids.regexp_match(pattern))
|
||||
# 只要匹配任一类型即可
|
||||
type_filter = type_filters[0]
|
||||
for f in type_filters[1:]:
|
||||
type_filter = type_filter | f
|
||||
# 还需要兼容旧的单值 type_id 字段
|
||||
old_filter = Joke.type_id.in_(filter_set)
|
||||
query = query.filter(old_filter | type_filter)
|
||||
|
||||
if crowd_ids:
|
||||
filter_set = {int(x.strip()) for x in crowd_ids.split(",") if x.strip().isdigit()}
|
||||
if filter_set:
|
||||
import re
|
||||
crowd_filters = []
|
||||
for cid in filter_set:
|
||||
pattern = rf'["\s\[,]{cid}[",\s\]]'
|
||||
crowd_filters.append(Joke.crowd_ids.regexp_match(pattern))
|
||||
crowd_filter = crowd_filters[0]
|
||||
for f in crowd_filters[1:]:
|
||||
crowd_filter = crowd_filter | f
|
||||
old_filter = Joke.crowd_id.in_(filter_set)
|
||||
query = query.filter(old_filter | crowd_filter)
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * page_size
|
||||
jokes = query.order_by(Joke.created_at.desc()).offset(offset).limit(page_size).all()
|
||||
|
||||
# 批量预加载类型和人群名称
|
||||
type_map, crowd_map = _batch_load_categories(db, jokes)
|
||||
|
||||
return PaginatedJokeResponse(
|
||||
items=[joke_to_response(j) for j in jokes],
|
||||
items=[joke_to_response(j, type_map, crowd_map) for j in jokes],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{joke_id}", response_model=JokeResponse)
|
||||
def get_joke(joke_id: int, db: Session = Depends(get_db)):
|
||||
"""获取单条笑话详情"""
|
||||
joke = db.query(Joke).filter(Joke.id == joke_id).first()
|
||||
if not joke:
|
||||
raise HTTPException(status_code=404, detail="笑话不存在")
|
||||
# 增加浏览次数
|
||||
joke.view_count += 1
|
||||
db.commit()
|
||||
return joke_to_response(joke)
|
||||
|
||||
|
||||
@router.get("/random", response_model=JokeResponse)
|
||||
def get_random_joke(db: Session = Depends(get_db)):
|
||||
"""随机获取一条已审核通过的笑话"""
|
||||
# 使用效率更高的方式:随机 ID 取模
|
||||
max_id = db.query(func.max(Joke.id)).filter(Joke.status == "approved").scalar()
|
||||
if not max_id:
|
||||
raise HTTPException(status_code=404, detail="暂无笑话")
|
||||
|
||||
# 尝试最多 10 次找到有效笑话
|
||||
for _ in range(10):
|
||||
random_id = random.randint(1, max_id)
|
||||
joke = db.query(Joke).filter(
|
||||
Joke.id >= random_id,
|
||||
Joke.status == "approved"
|
||||
).first()
|
||||
if joke:
|
||||
type_map, crowd_map = _batch_load_categories(db, [joke])
|
||||
return joke_to_response(joke, type_map, crowd_map)
|
||||
|
||||
# 兜底:全表随机
|
||||
joke = db.query(Joke).filter(Joke.status == "approved").order_by(func.random()).first()
|
||||
if not joke:
|
||||
raise HTTPException(status_code=404, detail="暂无笑话")
|
||||
return joke_to_response(joke)
|
||||
type_map, crowd_map = _batch_load_categories(db, [joke])
|
||||
return joke_to_response(joke, type_map, crowd_map)
|
||||
|
||||
|
||||
@router.get("/hot-monthly")
|
||||
def hot_monthly_jokes(db: Session = Depends(get_db)):
|
||||
"""当月热门笑话:本月浏览量最高的前 10 条"""
|
||||
now = datetime.now()
|
||||
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
jokes = (
|
||||
db.query(Joke)
|
||||
.filter(
|
||||
Joke.status == "approved",
|
||||
Joke.created_at >= start_of_month,
|
||||
)
|
||||
.order_by(Joke.view_count.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
type_map, crowd_map = _batch_load_categories(db, jokes)
|
||||
return [joke_to_response(j, type_map, crowd_map) for j in jokes]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def public_stats(db: Session = Depends(get_db)):
|
||||
"""公开统计数据:笑话总数、审核/待审/拒绝数量、总浏览/赞/踩"""
|
||||
total = db.query(Joke).count()
|
||||
approved = db.query(Joke).filter(Joke.status == "approved").count()
|
||||
pending = db.query(Joke).filter(Joke.status == "pending").count()
|
||||
rejected = db.query(Joke).filter(Joke.status == "rejected").count()
|
||||
total_views = db.query(Joke).with_entities(func.sum(Joke.view_count)).scalar() or 0
|
||||
total_likes = db.query(Joke).with_entities(func.sum(Joke.like_count)).scalar() or 0
|
||||
total_dislikes = db.query(Joke).with_entities(func.sum(Joke.dislike_count)).scalar() or 0
|
||||
return {
|
||||
"total_jokes": total,
|
||||
"approved_jokes": approved,
|
||||
"pending_jokes": pending,
|
||||
"rejected_jokes": rejected,
|
||||
"total_views": total_views,
|
||||
"total_likes": total_likes,
|
||||
"total_dislikes": total_dislikes,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{joke_id}", response_model=JokeResponse)
|
||||
def get_joke(joke_id: int, db: Session = Depends(get_db)):
|
||||
"""获取单条笑话详情"""
|
||||
# 只返回已审核通过的笑话
|
||||
joke = db.query(Joke).filter(
|
||||
Joke.id == joke_id,
|
||||
Joke.status == "approved"
|
||||
).first()
|
||||
if not joke:
|
||||
raise HTTPException(status_code=404, detail="笑话不存在")
|
||||
# 使用原子更新避免并发竞态
|
||||
db.query(Joke).filter(Joke.id == joke_id).update({Joke.view_count: Joke.view_count + 1})
|
||||
db.commit()
|
||||
# 重新查询获取更新后的数据
|
||||
db.refresh(joke)
|
||||
# 批量加载类型和人群名称
|
||||
type_map, crowd_map = _batch_load_categories(db, [joke])
|
||||
return joke_to_response(joke, type_map, crowd_map)
|
||||
|
||||
|
||||
def _vote_joke(joke_id: int, field: str, db: Session):
|
||||
"""通用投票逻辑:点赞或点踩(仅允许已审核通过的笑话)"""
|
||||
joke = db.query(Joke).filter(
|
||||
Joke.id == joke_id,
|
||||
Joke.status == "approved"
|
||||
).first()
|
||||
if not joke:
|
||||
raise HTTPException(status_code=404, detail="笑话不存在")
|
||||
# 使用原子更新避免并发竞态
|
||||
update_data = {getattr(Joke, field): getattr(Joke, field) + 1}
|
||||
db.query(Joke).filter(Joke.id == joke_id).update(update_data)
|
||||
db.commit()
|
||||
db.refresh(joke)
|
||||
return {"message": "操作成功", field: getattr(joke, field)}
|
||||
|
||||
|
||||
@router.post("/{joke_id}/like")
|
||||
def like_joke(joke_id: int, db: Session = Depends(get_db)):
|
||||
"""为笑话点赞(仅允许已审核通过的笑话)"""
|
||||
return _vote_joke(joke_id, "like_count", db)
|
||||
|
||||
|
||||
@router.post("/{joke_id}/dislike")
|
||||
def dislike_joke(joke_id: int, db: Session = Depends(get_db)):
|
||||
"""为笑话点踩(仅允许已审核通过的笑话)"""
|
||||
return _vote_joke(joke_id, "dislike_count", db)
|
||||
@@ -0,0 +1,38 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.link import Link
|
||||
from app.schemas.link import LinkCreate, LinkResponse, LinkApply
|
||||
|
||||
router = APIRouter(tags=["友情链接"])
|
||||
|
||||
|
||||
@router.get("/links", response_model=list[LinkResponse])
|
||||
def list_links(db: Session = Depends(get_db)):
|
||||
"""公开:获取已通过审核的友情链接"""
|
||||
return db.query(Link).filter(Link.status == "approved").order_by(Link.sort_order, Link.id).all()
|
||||
|
||||
|
||||
@router.post("/links/apply", response_model=LinkResponse)
|
||||
def apply_link(link_data: LinkApply, db: Session = Depends(get_db)):
|
||||
"""公开:申请友链"""
|
||||
# 检查是否已存在同名或同URL的申请/已通过友链
|
||||
existing = db.query(Link).filter(
|
||||
(Link.name == link_data.name) | (Link.url == link_data.url)
|
||||
).first()
|
||||
if existing:
|
||||
return existing # 已存在则返回已有记录
|
||||
|
||||
link = Link(
|
||||
name=link_data.name,
|
||||
url=link_data.url,
|
||||
description=link_data.description,
|
||||
contact=link_data.contact,
|
||||
status="pending",
|
||||
sort_order=999, # 新申请放在最后
|
||||
)
|
||||
db.add(link)
|
||||
db.commit()
|
||||
db.refresh(link)
|
||||
return link
|
||||
@@ -0,0 +1,114 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.setting import AiSetting
|
||||
from app.schemas.setting import AiSettingCreate
|
||||
from app.routers.admin import get_current_admin_user
|
||||
from app.models.user import AdminUser
|
||||
|
||||
router = APIRouter(prefix="/admin/settings", tags=["AI设置"])
|
||||
|
||||
|
||||
@router.get("", response_model=list)
|
||||
def list_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""列出所有 AI 配置"""
|
||||
return db.query(AiSetting).order_by(AiSetting.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.get("/active")
|
||||
def get_active_setting(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取当前激活的 AI 配置(无需认证,供爬虫/优化器使用)"""
|
||||
setting = db.query(AiSetting).filter(AiSetting.is_active == True).first()
|
||||
if not setting:
|
||||
from app.routers.settings import _ensure_default_settings
|
||||
setting = _ensure_default_settings(db)
|
||||
return setting
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_setting(
|
||||
setting: AiSettingCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""新建 AI 配置"""
|
||||
db_setting = AiSetting(**setting.model_dump())
|
||||
db.add(db_setting)
|
||||
db.commit()
|
||||
db.refresh(db_setting)
|
||||
return db_setting
|
||||
|
||||
|
||||
@router.put("/{setting_id}")
|
||||
def update_setting(
|
||||
setting_id: int,
|
||||
setting: AiSettingCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""更新 AI 配置"""
|
||||
db_setting = db.query(AiSetting).filter(AiSetting.id == setting_id).first()
|
||||
if not db_setting:
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
for key, value in setting.model_dump().items():
|
||||
setattr(db_setting, key, value)
|
||||
db.commit()
|
||||
db.refresh(db_setting)
|
||||
return db_setting
|
||||
|
||||
|
||||
@router.put("/{setting_id}/toggle")
|
||||
def toggle_setting(
|
||||
setting_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""切换激活状态(只能有一个活跃)"""
|
||||
db_setting = db.query(AiSetting).filter(AiSetting.id == setting_id).first()
|
||||
if not db_setting:
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
|
||||
# 先全部设为非活跃
|
||||
db.query(AiSetting).update({AiSetting.is_active: False})
|
||||
db_setting.is_active = True
|
||||
db.commit()
|
||||
return {"message": "已激活", "id": setting_id}
|
||||
|
||||
|
||||
@router.delete("/{setting_id}")
|
||||
def delete_setting(
|
||||
setting_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: AdminUser = Depends(get_current_admin_user),
|
||||
):
|
||||
"""删除 AI 配置"""
|
||||
db_setting = db.query(AiSetting).filter(AiSetting.id == setting_id).first()
|
||||
if not db_setting:
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
db.delete(db_setting)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
def _ensure_default_settings(db: Session) -> AiSetting:
|
||||
"""当没有激活配置时,创建一条默认配置"""
|
||||
existing = db.query(AiSetting).first()
|
||||
if existing:
|
||||
existing.is_active = True
|
||||
db.commit()
|
||||
return existing
|
||||
|
||||
defaults = AiSetting(
|
||||
is_active=True,
|
||||
# 默认提示词会在 main.py 迁移时填充
|
||||
)
|
||||
db.add(defaults)
|
||||
db.commit()
|
||||
db.refresh(defaults)
|
||||
return defaults
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
公开提交流程 - 供外部用户(submitter)使用。
|
||||
无后台管理权限,仅能提交笑话到 pending 状态。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.joke import Joke
|
||||
|
||||
router = APIRouter(tags=["笑话提交"])
|
||||
|
||||
|
||||
class JokeSubmit(BaseModel):
|
||||
"""笑话提交"""
|
||||
title: str
|
||||
content: str
|
||||
|
||||
|
||||
class JokeBatchSubmit(BaseModel):
|
||||
"""批量提交"""
|
||||
jokes: list[JokeSubmit]
|
||||
|
||||
|
||||
@router.post("/submit", response_model=dict)
|
||||
def submit_joke(data: JokeSubmit, db: Session = Depends(get_db)):
|
||||
"""公开接口:提交单条笑话(自动进入待审核状态)"""
|
||||
joke = Joke(
|
||||
title=data.title,
|
||||
content=data.content,
|
||||
status="pending",
|
||||
)
|
||||
db.add(joke)
|
||||
db.commit()
|
||||
db.refresh(joke)
|
||||
return {
|
||||
"success": True,
|
||||
"id": joke.id,
|
||||
"message": f"已提交,ID: {joke.id}"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/submit/batch", response_model=dict)
|
||||
def submit_jokes_batch(data: JokeBatchSubmit, db: Session = Depends(get_db)):
|
||||
"""公开接口:批量提交笑话"""
|
||||
added = []
|
||||
for item in data.jokes:
|
||||
joke = Joke(
|
||||
title=item.title,
|
||||
content=item.content,
|
||||
status="pending",
|
||||
)
|
||||
db.add(joke)
|
||||
added.append(item.title)
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(added),
|
||||
"message": f"已提交 {len(added)} 条笑话"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class FeedbackCreate(BaseModel):
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
content: str
|
||||
|
||||
|
||||
class FeedbackResponse(FeedbackCreate):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
+66
-7
@@ -6,8 +6,8 @@ from pydantic import BaseModel
|
||||
class JokeBase(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
type_id: int | None = None
|
||||
crowd_id: int | None = None
|
||||
type_ids: list[int] | None = None
|
||||
crowd_ids: list[int] | None = None
|
||||
|
||||
|
||||
class JokeCreate(JokeBase):
|
||||
@@ -17,27 +17,86 @@ class JokeCreate(JokeBase):
|
||||
class JokeUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
content: str | None = None
|
||||
type_id: int | None = None
|
||||
crowd_id: int | None = None
|
||||
polished_content: str | None = None
|
||||
ai_score: float | None = None
|
||||
ai_level: str | None = None
|
||||
type_ids: list[int] | None = None
|
||||
crowd_ids: list[int] | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class JokeResponse(JokeBase):
|
||||
id: int
|
||||
status: str
|
||||
polished_content: str | None = None
|
||||
# AI 评价字段
|
||||
ai_score: float | None = None
|
||||
ai_level: str | None = None
|
||||
view_count: int
|
||||
like_count: int
|
||||
dislike_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
type_name: str | None = None
|
||||
crowd_name: str | None = None
|
||||
type_names: list[str] | None = None
|
||||
crowd_names: list[str] | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@property
|
||||
def display_content(self) -> str:
|
||||
"""优先显示润色后的内容"""
|
||||
return self.polished_content if self.polished_content else self.content
|
||||
|
||||
def get_level_display(self) -> str:
|
||||
"""获取 AI 评级的中文显示"""
|
||||
level_map = {
|
||||
'excellent': '⭐⭐⭐ 精品',
|
||||
'good': '⭐⭐ 良好',
|
||||
'ordinary': '⭐ 普通',
|
||||
'poor': '⚠️ 待优化'
|
||||
}
|
||||
return level_map.get(self.ai_level, '') if self.ai_level else ''
|
||||
|
||||
|
||||
class PaginatedJokeResponse(BaseModel):
|
||||
items: list[JokeResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 笑话生成器 Schema
|
||||
# ============================================================
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
"""笑话生成请求"""
|
||||
keywords: list[str] = []
|
||||
scenarios: list[str] = []
|
||||
style: str = "twist"
|
||||
length: str = "medium"
|
||||
temperature: float = 0.8
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"keywords": ["加班"],
|
||||
"scenarios": ["职场"],
|
||||
"style": "twist",
|
||||
"length": "medium",
|
||||
"temperature": 0.8,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
"""笑话生成响应"""
|
||||
title: str
|
||||
content: str
|
||||
score: int = 0
|
||||
reason: str = ""
|
||||
created_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,28 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
|
||||
|
||||
class LinkCreate(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
description: str | None = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class LinkApply(BaseModel):
|
||||
"""友链申请"""
|
||||
name: str
|
||||
url: str
|
||||
description: str | None = None
|
||||
contact: str | None = None # 联系方式
|
||||
|
||||
|
||||
class LinkResponse(LinkCreate):
|
||||
id: int
|
||||
status: str
|
||||
contact: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,31 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AiSettingBase(BaseModel):
|
||||
provider: str = Field(default="nvidia")
|
||||
api_base: str = Field(default="https://integrate.api.nvidia.com/v1")
|
||||
api_key: str = Field(default="")
|
||||
model_name: str = Field(default="nvidia/llama-3.1-nemotron-70b-instruct")
|
||||
temperature: float = Field(default=0.7, ge=0, le=2)
|
||||
max_tokens: int = Field(default=2048, ge=1)
|
||||
|
||||
# 提示词
|
||||
generate_prompt: str = Field(default="")
|
||||
optimizer_quality_prompt: str = Field(default="")
|
||||
optimizer_polish_prompt: str = Field(default="")
|
||||
optimizer_evaluate_prompt: str = Field(default="")
|
||||
crawler_extract_prompt: str = Field(default="")
|
||||
crawler_rewrite_prompt: str = Field(default="")
|
||||
|
||||
# 优化器各阶段温度
|
||||
optimizer_quality_temperature: float = Field(default=0.3, ge=0, le=2)
|
||||
optimizer_polish_temperature: float = Field(default=0.8, ge=0, le=2)
|
||||
optimizer_evaluate_temperature: float = Field(default=0.3, ge=0, le=2)
|
||||
|
||||
crawl_enabled: bool = Field(default=False)
|
||||
crawl_keywords: str = Field(default="")
|
||||
max_pages_per_run: int = Field(default=3, ge=1)
|
||||
|
||||
|
||||
class AiSettingCreate(AiSettingBase):
|
||||
pass
|
||||
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""创建用户"""
|
||||
username: str
|
||||
password: str
|
||||
role: str = "submitter" # admin=管理员, submitter=提交者
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""用户信息"""
|
||||
id: int
|
||||
username: str
|
||||
role: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
"""公开注册(仅限提交者)"""
|
||||
username: str
|
||||
password: str
|
||||
BIN
Binary file not shown.
+133
-5
@@ -2,7 +2,130 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import API_TITLE, API_VERSION
|
||||
from app.routers import jokes_router, categories_router, auth_router, admin_router
|
||||
from app.routers import jokes_router, categories_router, auth_router, admin_router, settings_router, links_router, feedback_router, generate_router
|
||||
from app.routers.submit import router as submit_router
|
||||
from app.database import Base, engine
|
||||
from app.models.setting import AiSetting
|
||||
from app.models.link import Link
|
||||
from app.models.feedback import Feedback # 注册模型,确保 create_all 能看到
|
||||
|
||||
# 创建新表(如果不存在)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 对已有表新增字段的兼容迁移(SQLite 不支持 ALTER TABLE ADD COLUMN IF NOT EXISTS)
|
||||
# 默认提示词
|
||||
_GENERATE_PROMPT_DEFAULT = """你是一位幽默大师,专门创作轻松搞笑的短笑话。
|
||||
|
||||
{context}
|
||||
|
||||
要求:
|
||||
1. 根据场景和关键词创作一条原创笑话
|
||||
2. 笑话要有反转或意外结局
|
||||
3. 语言风格:{style}
|
||||
4. 字数要求:{length_requirement}
|
||||
5. 直接输出笑话内容,不需要解释
|
||||
|
||||
请严格按照以下 JSON 格式输出,不要加任何额外说明:
|
||||
{{
|
||||
"title": "笑话标题",
|
||||
"content": "笑话正文",
|
||||
"score": 8,
|
||||
"reason": "这个笑话巧妙结合了场景和关键词,结尾有反转"
|
||||
}}
|
||||
|
||||
score 是 1-10 的整数评分,reason 是用一句话说明亮点。"""
|
||||
|
||||
_OPTIMIZER_QUALITY_DEFAULT = """你是一个幽默内容审核专家。判断以下内容是否是一个合格的笑话/段子。
|
||||
|
||||
合格标准(满足任一即可):
|
||||
1. 有明确的笑点或反转(punchline)
|
||||
2. 有幽默的语言表达或双关
|
||||
3. 有意外结局或情理之中意料之外
|
||||
|
||||
不合格标准(符合任一即判定不合格):
|
||||
1. 纯粹的事实陈述,没有任何幽默元素
|
||||
2. 只是对话片段,没有笑点
|
||||
3. 普通故事或叙事,没有幽默设计
|
||||
4. 说教或道理阐述
|
||||
5. 内容不完整或难以理解
|
||||
|
||||
始终返回 JSON 格式:{"has_punchline": true/false, "reason": "简要说明判断理由"}"""
|
||||
|
||||
_OPTIMIZER_POLISH_DEFAULT = """你是一个专业的幽默文案编辑。请润色以下笑话,要求:
|
||||
1. 保持核心笑点不变
|
||||
2. 优化语言表达,使其更通顺、更精炼
|
||||
3. 增强节奏感和幽默效果,但不改变原意
|
||||
4. 字数控制在原内容的 80%-120%
|
||||
5. 不要添加额外解释或评论
|
||||
6. 直接输出润色后的内容,不要加任何前缀"""
|
||||
|
||||
_OPTIMIZER_EVALUATE_DEFAULT = """你是一个笑话分类和评价专家。对给定的笑话进行分析,返回 JSON 格式的分类和评分结果。
|
||||
|
||||
要求:
|
||||
1. types: 从提供的类型列表中选择所有匹配的类型名称(数组,可以选多个)
|
||||
2. crowds: 从提供的人群列表中选择所有匹配的人群名称(数组,可以选多个)
|
||||
3. score: 1-10 分,基于幽默程度、创意和表达效果
|
||||
4. comment: 简短评语(10字以内)
|
||||
|
||||
始终返回 JSON 格式。"""
|
||||
|
||||
_CRAWLER_EXTRACT_DEFAULT = """你是一个笑话提取专家。从给定的网页文本中识别并提取所有笑话、幽默段子或有趣内容。
|
||||
要求:
|
||||
1. 只返回真正的笑话内容,不要提取普通文章或新闻
|
||||
2. 每条笑话需要包含:title(简短标题)、content(完整笑话内容)、type(类型)、crowd(人群)
|
||||
3. 如果网页中没有笑话,返回空数组 []
|
||||
4. 永远返回合法的 JSON 格式,根节点为数组或包含 jokes 键的对象"""
|
||||
|
||||
_CRAWLER_REWRITE_DEFAULT = """你是一个幽默作家,负责润色和改写笑话。
|
||||
要求:
|
||||
1. 保持笑话的核心笑点不变
|
||||
2. 语言更通顺、更幽默
|
||||
3. 字数控制在原内容的 80%-120% 之间
|
||||
4. 不要添加任何解释说明"""
|
||||
|
||||
|
||||
def _migrate_db():
|
||||
from sqlalchemy import inspect, text
|
||||
inspector = inspect(engine)
|
||||
columns = [c["name"] for c in inspector.get_columns("ai_settings")]
|
||||
|
||||
# 新增提示词字段
|
||||
prompt_fields = {
|
||||
"generate_prompt": _GENERATE_PROMPT_DEFAULT,
|
||||
"optimizer_quality_prompt": _OPTIMIZER_QUALITY_DEFAULT,
|
||||
"optimizer_polish_prompt": _OPTIMIZER_POLISH_DEFAULT,
|
||||
"optimizer_evaluate_prompt": _OPTIMIZER_EVALUATE_DEFAULT,
|
||||
"crawler_extract_prompt": _CRAWLER_EXTRACT_DEFAULT,
|
||||
"crawler_rewrite_prompt": _CRAWLER_REWRITE_DEFAULT,
|
||||
}
|
||||
for field, default_val in prompt_fields.items():
|
||||
if field not in columns:
|
||||
from sqlalchemy import Text
|
||||
col_type = "TEXT"
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(f"ALTER TABLE ai_settings ADD COLUMN {field} {col_type}"))
|
||||
conn.commit()
|
||||
|
||||
# 新增 temperature 字段
|
||||
temp_fields = [
|
||||
("optimizer_quality_temperature", "FLOAT", "0.3"),
|
||||
("optimizer_polish_temperature", "FLOAT", "0.8"),
|
||||
("optimizer_evaluate_temperature", "FLOAT", "0.3"),
|
||||
]
|
||||
for field, col_type, default_val in temp_fields:
|
||||
if field not in columns:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(f"ALTER TABLE ai_settings ADD COLUMN {field} {col_type} DEFAULT {default_val}"))
|
||||
conn.commit()
|
||||
|
||||
# 给新增字段填充默认值(已有记录)
|
||||
with engine.connect() as conn:
|
||||
for field, default_val in prompt_fields.items():
|
||||
conn.execute(text(f"UPDATE ai_settings SET {field} = :val WHERE {field} IS NULL"),
|
||||
{"val": default_val})
|
||||
conn.commit()
|
||||
|
||||
_migrate_db()
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(title=API_TITLE, version=API_VERSION)
|
||||
@@ -17,10 +140,15 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
# Mount routers
|
||||
app.include_router(jokes_router)
|
||||
app.include_router(categories_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(jokes_router, prefix="/api")
|
||||
app.include_router(categories_router, prefix="/api")
|
||||
app.include_router(auth_router, prefix="/api")
|
||||
app.include_router(admin_router, prefix="/api")
|
||||
app.include_router(settings_router, prefix="/api")
|
||||
app.include_router(links_router, prefix="/api")
|
||||
app.include_router(feedback_router, prefix="/api")
|
||||
app.include_router(generate_router, prefix="/api")
|
||||
app.include_router(submit_router, prefix="/api/jokes") # 公开提交接口
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import API_TITLE, API_VERSION
|
||||
from app.routers import jokes_router, categories_router, auth_router, admin_router, settings_router
|
||||
from app.database import Base, engine
|
||||
from app.models.setting import AiSetting # 注册模型,确保 create_all 能看到
|
||||
|
||||
# 创建新表(如果不存在)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(title=API_TITLE, version=API_VERSION)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount routers
|
||||
app.include_router(jokes_router, prefix="/api")
|
||||
app.include_router(categories_router, prefix="/api")
|
||||
app.include_router(auth_router, prefix="/api")
|
||||
app.include_router(admin_router, prefix="/api")
|
||||
app.include_router(settings_router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "欢迎使用笑话大全 API"}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "healthy"}
|
||||
@@ -1,7 +1,11 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
gunicorn==21.2.0
|
||||
sqlalchemy==2.0.25
|
||||
pydantic==2.5.3
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.6
|
||||
python-multipart==0.0.6
|
||||
openai==1.12.0
|
||||
crawl4ai==0.3.0
|
||||
httpx==0.27.0
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
INFO: Will watch for changes in these directories: ['D:\\bwstudio\\joke\\api']
|
||||
INFO: Uvicorn running on http://127.0.0.1:8001 (Press CTRL+C to quit)
|
||||
INFO: Started reloader process [2236] using WatchFiles
|
||||
INFO: Started server process [1824]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
INFO: 127.0.0.1:61949 - "GET /health HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:61961 - "POST /api/auth/login HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:61964 - "GET /api/admin/jokes?page=1&page_size=3 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62046 - "GET / HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62046 - "GET /favicon.ico HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:62086 - "GET /api/admin/stats HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62088 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62090 - "GET /api/admin/stats HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62151 - "GET /api/admin/stats HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62154 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62158 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62161 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62164 - "GET /api/admin/jokes/1278 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62167 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62173 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62176 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62179 - "GET /api/admin/jokes/1275 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62182 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62198 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62201 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62204 - "GET /api/admin/jokes/1273 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62208 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62213 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62216 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62219 - "GET /api/admin/jokes/1279 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62223 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62230 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62233 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62236 - "GET /api/admin/jokes/1274 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62239 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62242 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62245 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62261 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62264 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62267 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62270 - "GET /api/admin/jokes/1262 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62273 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62283 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62286 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62289 - "GET /api/admin/jokes/1265 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62292 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62295 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62298 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62302 - "GET /api/admin/settings/active HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62304 - "GET /api/admin/settings HTTP/1.1" 500 Internal Server Error
|
||||
ERROR: Exception in ASGI application
|
||||
Traceback (most recent call last):
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\protocols\http\httptools_impl.py", line 419, in run_asgi
|
||||
result = await app( # type: ignore[func-returns-value]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\middleware\proxy_headers.py", line 84, in __call__
|
||||
return await self.app(scope, receive, send)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\applications.py", line 1054, in __call__
|
||||
await super().__call__(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\applications.py", line 123, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 186, in __call__
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\cors.py", line 83, in __call__
|
||||
await self.app(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 762, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 782, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 297, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 77, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 72, in app
|
||||
response = await func(request)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 315, in app
|
||||
content = await serialize_response(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 160, in serialize_response
|
||||
return field.serialize(
|
||||
^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\_compat.py", line 147, in serialize
|
||||
return self._type_adapter.dump_python(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\pydantic\type_adapter.py", line 607, in dump_python
|
||||
return self.serializer.to_python(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'app.models.setting.AiSetting'>
|
||||
INFO: 127.0.0.1:62308 - "GET /api/admin/stats HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62324 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62327 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62330 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62356 - "GET /api/admin/settings/active HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62359 - "GET /api/admin/settings HTTP/1.1" 500 Internal Server Error
|
||||
ERROR: Exception in ASGI application
|
||||
Traceback (most recent call last):
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\protocols\http\httptools_impl.py", line 419, in run_asgi
|
||||
result = await app( # type: ignore[func-returns-value]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\middleware\proxy_headers.py", line 84, in __call__
|
||||
return await self.app(scope, receive, send)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\applications.py", line 1054, in __call__
|
||||
await super().__call__(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\applications.py", line 123, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 186, in __call__
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\cors.py", line 83, in __call__
|
||||
await self.app(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 762, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 782, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 297, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 77, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 72, in app
|
||||
response = await func(request)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 315, in app
|
||||
content = await serialize_response(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 160, in serialize_response
|
||||
return field.serialize(
|
||||
^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\_compat.py", line 147, in serialize
|
||||
return self._type_adapter.dump_python(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\pydantic\type_adapter.py", line 607, in dump_python
|
||||
return self.serializer.to_python(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'app.models.setting.AiSetting'>
|
||||
INFO: 127.0.0.1:62362 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62365 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62368 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62371 - "GET /api/admin/stats HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62383 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62386 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62389 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62392 - "GET /api/admin/settings/active HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62395 - "GET /api/admin/settings HTTP/1.1" 500 Internal Server Error
|
||||
ERROR: Exception in ASGI application
|
||||
Traceback (most recent call last):
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\protocols\http\httptools_impl.py", line 419, in run_asgi
|
||||
result = await app( # type: ignore[func-returns-value]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\middleware\proxy_headers.py", line 84, in __call__
|
||||
return await self.app(scope, receive, send)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\applications.py", line 1054, in __call__
|
||||
await super().__call__(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\applications.py", line 123, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 186, in __call__
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\cors.py", line 83, in __call__
|
||||
await self.app(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 762, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 782, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 297, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 77, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 72, in app
|
||||
response = await func(request)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 315, in app
|
||||
content = await serialize_response(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 160, in serialize_response
|
||||
return field.serialize(
|
||||
^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\_compat.py", line 147, in serialize
|
||||
return self._type_adapter.dump_python(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\pydantic\type_adapter.py", line 607, in dump_python
|
||||
return self.serializer.to_python(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'app.models.setting.AiSetting'>
|
||||
INFO: 127.0.0.1:62398 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62401 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62404 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62407 - "GET /api/admin/stats HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62414 - "GET /api/admin/stats HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62419 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62422 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62425 - "GET /api/admin/stats HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62429 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62432 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62435 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62438 - "GET /api/admin/stats HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62441 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62444 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62447 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62451 - "GET /api/admin/settings/active HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62453 - "GET /api/admin/settings HTTP/1.1" 500 Internal Server Error
|
||||
ERROR: Exception in ASGI application
|
||||
Traceback (most recent call last):
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\protocols\http\httptools_impl.py", line 419, in run_asgi
|
||||
result = await app( # type: ignore[func-returns-value]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\middleware\proxy_headers.py", line 84, in __call__
|
||||
return await self.app(scope, receive, send)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\applications.py", line 1054, in __call__
|
||||
await super().__call__(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\applications.py", line 123, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 186, in __call__
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\cors.py", line 83, in __call__
|
||||
await self.app(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 762, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 782, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 297, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 77, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 72, in app
|
||||
response = await func(request)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 315, in app
|
||||
content = await serialize_response(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 160, in serialize_response
|
||||
return field.serialize(
|
||||
^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\_compat.py", line 147, in serialize
|
||||
return self._type_adapter.dump_python(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\pydantic\type_adapter.py", line 607, in dump_python
|
||||
return self.serializer.to_python(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'app.models.setting.AiSetting'>
|
||||
INFO: 127.0.0.1:62456 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62459 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62462 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62465 - "GET /api/admin/stats HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62468 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62471 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62474 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62477 - "GET /api/admin/settings/active HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62480 - "GET /api/admin/settings HTTP/1.1" 500 Internal Server Error
|
||||
ERROR: Exception in ASGI application
|
||||
Traceback (most recent call last):
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\protocols\http\httptools_impl.py", line 419, in run_asgi
|
||||
result = await app( # type: ignore[func-returns-value]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\middleware\proxy_headers.py", line 84, in __call__
|
||||
return await self.app(scope, receive, send)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\applications.py", line 1054, in __call__
|
||||
await super().__call__(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\applications.py", line 123, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 186, in __call__
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\cors.py", line 83, in __call__
|
||||
await self.app(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 762, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 782, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 297, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 77, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 72, in app
|
||||
response = await func(request)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 315, in app
|
||||
content = await serialize_response(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 160, in serialize_response
|
||||
return field.serialize(
|
||||
^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\_compat.py", line 147, in serialize
|
||||
return self._type_adapter.dump_python(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\pydantic\type_adapter.py", line 607, in dump_python
|
||||
return self.serializer.to_python(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'app.models.setting.AiSetting'>
|
||||
INFO: 127.0.0.1:62483 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62486 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62489 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62492 - "GET /api/admin/stats HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62883 - "GET /api/admin/stats HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62885 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62887 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62889 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62892 - "GET /api/admin/jokes/1279 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62895 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62898 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62901 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62904 - "GET /api/admin/jokes/1279 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62907 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62992 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62994 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62997 - "GET /api/jokes/?page=1&page_size=30 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62999 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63002 - "GET /api/jokes/1275 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63005 - "GET /api/jokes/?page=1&page_size=50 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63008 - "GET /api/jokes/1246 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63011 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63014 - "GET /api/jokes/?page=1&page_size=20&type_ids=1 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63017 - "GET /api/jokes/1279 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63021 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63034 - "GET /api/jokes/?page=1&page_size=20&type_ids=12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63037 - "GET /api/jokes/?page=1&page_size=20&type_ids=12,1 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63040 - "GET /api/jokes/1279 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63043 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63046 - "GET /api/jokes/1279 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63049 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63052 - "GET /api/jokes/1279 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63055 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63059 - "GET /api/jokes/1277 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63062 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
WARNING: WatchFiles detected changes in 'app\models\link.py'. Reloading...
|
||||
INFO: 127.0.0.1:63122 - "GET /api/jokes/?page=2&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63125 - "GET /api/jokes/?page=3&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63128 - "GET /api/jokes/?page=4&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63131 - "GET /api/jokes/?page=5&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63134 - "GET /api/jokes/?page=7&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63138 - "GET /api/jokes/1140 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63141 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63144 - "GET /api/jokes/?page=1&page_size=20&type_ids=4 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63147 - "GET /api/jokes/923 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63150 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63161 - "GET /api/jokes/?page=1&page_size=20&type_ids=3 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63164 - "GET /api/jokes/?page=1&page_size=20&type_ids=3,4 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63167 - "GET /api/jokes/?page=1&page_size=20&type_ids=3,4,5 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63170 - "GET /api/jokes/?page=1&page_size=20&type_ids=3,4,5,6 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63173 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63176 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63179 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63182 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63185 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1,8 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63188 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1,8,7 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63191 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1,8,7 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63194 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1,8,7 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63197 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1,8,7 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63200 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1,8,7 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63204 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63208 - "GET /api/jokes/1279 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63211 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63214 - "GET /api/jokes/?page=1&page_size=20&type_ids=3 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63217 - "GET /api/jokes/?page=1&page_size=20&type_ids=3,4 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63220 - "GET /api/jokes/?page=1&page_size=20&type_ids=3,4,5 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63223 - "GET /api/jokes/?page=1&page_size=20&type_ids=3,4,5,6 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63226 - "GET /api/jokes/1251 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63229 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63232 - "GET /api/jokes/?page=1&page_size=20&type_ids=2 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63235 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63238 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63241 - "GET /api/jokes/?page=1&page_size=20&crowd_ids=15 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63244 - "GET /api/jokes/?page=1&page_size=20&crowd_ids=15,16 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63264 - "GET /api/jokes/?page=1&page_size=20&type_ids=2 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63267 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63270 - "GET /api/jokes/1273 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63273 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63276 - "GET /api/jokes/1277 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63279 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63295 - "GET /api/jokes/?page=2&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63298 - "GET /api/jokes/?page=3&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63301 - "GET /api/jokes/?page=1&page_size=20&type_ids=1 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63304 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63307 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63310 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63313 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4,5 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63316 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4,5,6 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63319 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4,5,6,7 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63322 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4,5,6,7,8 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63325 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4,5,6,7,8,9 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63328 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4,5,6,7,8,9,10 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63331 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4,5,6,7,8,9,10 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63334 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4,5,6,7,8,9 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63337 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4,5,6,7,8 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63340 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4,5,6,7 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63343 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4,5,6 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63346 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4,5 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63349 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63352 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63369 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63371 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63373 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63376 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63379 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63390 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63392 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63394 - "GET /api/jokes/?page=1&page_size=30 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63396 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63427 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63446 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63448 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63452 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63450 - "GET /api/jokes/?page=1&page_size=30 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63454 - "GET /api/jokes/1263 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63456 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63466 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63468 - "GET /api/admin/feedbacks HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63471 - "GET /api/admin/links HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63473 - "GET /api/admin/feedbacks HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63476 - "GET /api/admin/links HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63479 - "GET /api/admin/feedbacks HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63482 - "GET /api/admin/links HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63486 - "GET /api/admin/links HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63495 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63498 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63503 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63501 - "GET /api/jokes/?page=1&page_size=30 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63508 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63511 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63514 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63517 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63520 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63524 - "GET /api/jokes/?page=1&page_size=30 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63565 - "GET /api/admin/feedbacks HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63568 - "GET /api/admin/links HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63571 - "GET /api/admin/links HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63574 - "GET /api/admin/links HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63577 - "GET /api/admin/feedbacks HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63580 - "GET /api/admin/links HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63583 - "GET /api/admin/feedbacks HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63592 - "GET /api/categories/types HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63594 - "GET /api/categories/crowds HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63598 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63596 - "GET /api/jokes/?page=1&page_size=30 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63607 - "GET /api/links HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63609 - "GET /api/links HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:63612 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:63615 - "GET /api/links HTTP/1.1" 404 Not Found
|
||||
@@ -0,0 +1 @@
|
||||
"""AI 提示词模板"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,131 @@
|
||||
"""LLM 处理:调用 NVIDIA NIM(OpenAI 兼容 API)进行笑话提取、改写。"""
|
||||
|
||||
import json
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
|
||||
class AiService:
|
||||
def __init__(self, api_base: str, username: str, password: str):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.token = None
|
||||
self.client = None
|
||||
self.model = ""
|
||||
self.ai_config = None
|
||||
|
||||
def _login(self) -> str:
|
||||
resp = httpx.post(
|
||||
f"{self.api_base}/api/auth/login",
|
||||
json={"username": self.username, "password": self.password},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["access_token"]
|
||||
|
||||
def _get(self, path: str) -> dict | list:
|
||||
resp = httpx.get(
|
||||
f"{self.api_base}{path}",
|
||||
headers={"Authorization": f"Bearer {self.token}"},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def setup(self):
|
||||
"""登录并从 API 获取 AI 配置(含提示词和温度)"""
|
||||
self.token = self._login()
|
||||
self.ai_config = self._get("/api/admin/settings/active")
|
||||
self.client = OpenAI(
|
||||
base_url=self.ai_config["api_base"],
|
||||
api_key=self.ai_config["api_key"],
|
||||
)
|
||||
self.model = self.ai_config["model_name"]
|
||||
|
||||
def _get_prompt(self, key: str, default: str) -> str:
|
||||
if self.ai_config:
|
||||
val = self.ai_config.get(key)
|
||||
if val and val.strip():
|
||||
return val
|
||||
return default
|
||||
|
||||
def extract_jokes(self, page_content: str, known_types: list[str], known_crowds: list[str]) -> list[dict]:
|
||||
"""从页面内容中提取笑话,返回结构化数据。"""
|
||||
system_prompt = self._get_prompt("crawler_extract_prompt",
|
||||
"""你是一个笑话提取专家。从给定的网页文本中识别并提取所有笑话、幽默段子或有趣内容。
|
||||
要求:
|
||||
1. 只返回真正的笑话内容,不要提取普通文章或新闻
|
||||
2. 每条笑话需要包含:title(简短标题)、content(完整笑话内容)、type(类型)、crowd(人群)
|
||||
3. 如果网页中没有笑话,返回空数组 []
|
||||
4. 永远返回合法的 JSON 格式,根节点为数组或包含 jokes 键的对象""")
|
||||
|
||||
user_prompt = f"""网页内容:
|
||||
---
|
||||
{page_content[:8000]}
|
||||
---
|
||||
|
||||
已知笑话类型:{', '.join(known_types)}
|
||||
已知人群分类:{', '.join(known_crowds)}
|
||||
|
||||
请提取所有笑话,以 JSON 格式返回,示例:
|
||||
[
|
||||
{{"title": "程序员的幽默", "content": "程序员去相亲...", "types": ["谐音梗", "段子"], "crowds": ["职场", "大学生"]}},
|
||||
{{"title": "...", "content": "...", "types": ["..."], "crowds": ["..."]}}
|
||||
]
|
||||
|
||||
注意:types 和 crowds 是数组,可以填多个。
|
||||
只返回 JSON,不要其他文字。"""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=self.ai_config.get("temperature", 0.7) if self.ai_config else 0.7,
|
||||
max_tokens=self.ai_config.get("max_tokens", 2048) if self.ai_config else 2048,
|
||||
)
|
||||
|
||||
raw = response.choices[0].message.content
|
||||
return self._parse_json(raw)
|
||||
|
||||
def rewrite_joke(self, content: str) -> str:
|
||||
"""润色单条笑话内容。"""
|
||||
system_prompt = self._get_prompt("crawler_rewrite_prompt",
|
||||
"""你是一个幽默作家,负责润色和改写笑话。
|
||||
要求:
|
||||
1. 保持笑话的核心笑点不变
|
||||
2. 语言更通顺、更幽默
|
||||
3. 字数控制在原内容的 80%-120% 之间
|
||||
4. 不要添加任何解释说明""")
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"请润色以下笑话:\n\n{content}"},
|
||||
],
|
||||
temperature=0.8,
|
||||
max_tokens=500,
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
def _parse_json(self, raw: str) -> list[dict]:
|
||||
"""安全解析 LLM 返回的 JSON。"""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
jokes = data.get("jokes") or data.get("items") or [data]
|
||||
else:
|
||||
jokes = data
|
||||
return [j for j in jokes if isinstance(j, dict) and j.get("content")]
|
||||
except json.JSONDecodeError:
|
||||
if "```json" in raw:
|
||||
raw = raw.split("```json")[1].split("```")[0]
|
||||
elif "```" in raw:
|
||||
raw = raw.split("```")[1].split("```")[0]
|
||||
try:
|
||||
return json.loads(raw.strip())
|
||||
except Exception:
|
||||
return []
|
||||
@@ -0,0 +1,439 @@
|
||||
"""统一网页获取:搜索笑话站点 + 深度翻页抓取。"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import quote, urljoin, urlparse
|
||||
|
||||
# Windows 下禁用 rich 控制台输出,避免 GBK 编码错误
|
||||
if sys.platform == "win32":
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
os.environ["TERM"] = "dumb"
|
||||
|
||||
try:
|
||||
from crawl4ai import AsyncWebCrawler
|
||||
from crawl4ai.async_configs import CrawlerRunConfig, BrowserConfig
|
||||
HAS_CRAWL4AI = True
|
||||
except ImportError:
|
||||
HAS_CRAWL4AI = False
|
||||
print("[!] crawl4ai 未安装,将使用 requests 替代(不支持 JS 渲染)")
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class CrawlerService:
|
||||
def __init__(self, headless: bool = True):
|
||||
self.headless = headless
|
||||
self.session = None
|
||||
self._crawler = None
|
||||
# 站点级别容错跟踪
|
||||
self.site_failures: dict[str, int] = {}
|
||||
# 最近一次抓取的原始 HTML(供翻页链接发现使用)
|
||||
self._last_raw_html: str | None = None
|
||||
|
||||
def _get_session(self) -> httpx.Client:
|
||||
if self.session is None:
|
||||
self.session = httpx.Client(
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Referer": "https://www.baidu.com/",
|
||||
},
|
||||
timeout=30,
|
||||
follow_redirects=True,
|
||||
)
|
||||
return self.session
|
||||
|
||||
async def _get_crawler(self):
|
||||
"""获取或创建 crawl4ai 实例"""
|
||||
if HAS_CRAWL4AI and self._crawler is None:
|
||||
browser_cfg = BrowserConfig(headless=self.headless, verbose=False)
|
||||
self._crawler = AsyncWebCrawler(config=browser_cfg)
|
||||
await self._crawler.__aenter__()
|
||||
if not self.headless:
|
||||
print(" [*] 浏览器窗口已打开(crawl4ai 控制)")
|
||||
return self._crawler
|
||||
|
||||
async def close(self):
|
||||
if self._crawler:
|
||||
await self._crawler.__aexit__(None, None, None)
|
||||
self._crawler = None
|
||||
if self.session:
|
||||
self.session.close()
|
||||
self.session = None
|
||||
|
||||
# ============================================================
|
||||
# 页面抓取(带重试)
|
||||
# ============================================================
|
||||
|
||||
async def crawl_page_with_retry(self, url: str, max_retries: int = 2, timeout: int = 30000) -> tuple[str, bool]:
|
||||
"""抓取页面,返回 (html内容, 是否成功),失败自动重试"""
|
||||
for attempt in range(max_retries + 1):
|
||||
print(f" [~] 正在获取页面... (尝试 {attempt+1}/{max_retries+1})")
|
||||
try:
|
||||
html = await self._crawl_one(url, timeout)
|
||||
if html:
|
||||
print(f" [~] 页面获取成功,内容长度: {len(html)} 字符")
|
||||
return html, True
|
||||
print(f" [!] 页面内容为空")
|
||||
except Exception as e:
|
||||
print(f" [!] 抓取失败 (尝试 {attempt+1}/{max_retries+1}): {url[:60]} - {e}")
|
||||
|
||||
if attempt < max_retries:
|
||||
wait = 3 * (attempt + 1)
|
||||
print(f" [~] 等待 {wait} 秒后重试...")
|
||||
await asyncio.sleep(wait)
|
||||
return "", False
|
||||
|
||||
async def _crawl_one(self, url: str, timeout: int) -> str:
|
||||
"""单次页面抓取,返回纯文本内容(同时保存原始 HTML 供翻页发现)"""
|
||||
if HAS_CRAWL4AI:
|
||||
# crawl4ai 统一处理 headless / visible 两种模式
|
||||
crawler = await self._get_crawler()
|
||||
result = await crawler.arun(url, config=CrawlerRunConfig(verbose=False))
|
||||
if result and result.success:
|
||||
# fit_html 是 AI 清洗后的正文(翻页链接常被清洗掉)
|
||||
# cleaned_html 保留完整结构(用于翻页发现)
|
||||
self._last_raw_html = result.cleaned_html or result.html or ""
|
||||
html = result.fit_html or result.cleaned_html or ""
|
||||
return self._html_to_text(html)
|
||||
return ""
|
||||
else:
|
||||
# 无 crawl4ai 时 fallback 到 requests
|
||||
resp = self._get_session().get(url, timeout=timeout / 1000)
|
||||
if resp.status_code == 200:
|
||||
self._last_raw_html = resp.text
|
||||
return self._html_to_text(resp.text)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _html_to_text(html: str) -> str:
|
||||
"""简易 HTML 转纯文本"""
|
||||
# 移除 script/style 标签内容
|
||||
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
|
||||
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||||
# 移除 HTML 标签
|
||||
text = re.sub(r'<[^>]+>', ' ', text)
|
||||
# 合并空白
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text
|
||||
|
||||
# ============================================================
|
||||
# 搜索笑话站点
|
||||
# ============================================================
|
||||
|
||||
# 已知的笑话聚合站域名黑/白名单
|
||||
JOKE_SITE_KEYWORDS = [
|
||||
"笑话大全", "冷笑话", "搞笑段子", "笑话集锦",
|
||||
"幽默笑话", "爆笑笑话", "成人笑话", "小笑话",
|
||||
]
|
||||
|
||||
async def search_joke_sites(self, keywords: list[str], max_results: int = 10) -> list[dict]:
|
||||
"""搜索笑话站点,返回 [{url, title, domain}]"""
|
||||
all_results = []
|
||||
|
||||
for keyword in keywords:
|
||||
results = await self._search_and_filter(keyword, max_results=max_results)
|
||||
all_results.extend(results)
|
||||
|
||||
# 去重(按域名)
|
||||
seen_domains = set()
|
||||
unique = []
|
||||
for r in all_results:
|
||||
domain = r["domain"]
|
||||
if domain not in seen_domains:
|
||||
seen_domains.add(domain)
|
||||
unique.append(r)
|
||||
|
||||
print(f" [*] 搜索到 {len(unique)} 个唯一站点")
|
||||
for s in unique:
|
||||
print(f" - {s['domain']}: {s['title'][:40]}")
|
||||
return unique[:max_results]
|
||||
|
||||
async def _search_and_filter(self, keyword: str, max_results: int = 10) -> list[dict]:
|
||||
"""搜索并过滤出疑似笑话聚合站的结果"""
|
||||
# 用多个搜索词提高覆盖率
|
||||
search_queries = [
|
||||
f"{keyword} 网站",
|
||||
f"{keyword} 大全",
|
||||
f"{keyword} 列表",
|
||||
]
|
||||
|
||||
seen = set()
|
||||
sites = []
|
||||
|
||||
for q in search_queries:
|
||||
if len(sites) >= max_results:
|
||||
break
|
||||
|
||||
search_results = await self._search_bing(q, max_pages=2)
|
||||
|
||||
for r in search_results:
|
||||
if len(sites) >= max_results:
|
||||
break
|
||||
|
||||
url = r["url"]
|
||||
domain = urlparse(url).netloc.lower()
|
||||
|
||||
if domain in seen:
|
||||
continue
|
||||
seen.add(domain)
|
||||
|
||||
# 过滤:排除已知的单篇文章站点和搜索引擎
|
||||
if self._is_joke_collection_site(url, r.get("title", "")):
|
||||
r["domain"] = domain
|
||||
sites.append(r)
|
||||
|
||||
return sites
|
||||
|
||||
def _is_joke_collection_site(self, url: str, title: str) -> bool:
|
||||
"""判断URL是否疑似笑话聚合站(不是单篇文章)"""
|
||||
domain = urlparse(url).netloc.lower()
|
||||
path = urlparse(url).path.lower()
|
||||
|
||||
# 排除项
|
||||
exclude_domains = [
|
||||
"bing.com", "microsoft.com", "baidu.com", "google.com",
|
||||
"sohu.com", "sina.com", "163.com", "qq.com", "toutiao.com",
|
||||
"weibo.com", "zhihu.com", "bilibili.com", "douban.com",
|
||||
]
|
||||
if any(d in domain for d in exclude_domains):
|
||||
return False
|
||||
|
||||
# 排除明显的单篇文章模式
|
||||
single_article_patterns = [
|
||||
r'/p/\d+', r'/article/\d+', r'/post/\d+', r'/archives/\d+',
|
||||
r'/a/\d+', r'/\d{5,}', r'/detail/\d+', r'/read/\d+',
|
||||
r'\.html$', # 静态 html 文章页
|
||||
]
|
||||
# 但如果域名本身含 joke 特征,不排除
|
||||
is_joke_domain = any(kw in domain or kw in title for kw in
|
||||
["joke", "xiaohua", "笑话", "段子", "幽默", "搞笑"])
|
||||
|
||||
for p in single_article_patterns:
|
||||
if re.search(p, path) and not is_joke_domain:
|
||||
return False
|
||||
|
||||
# 聚合站特征:域名或标题含特定词,或URL有分类/列表模式
|
||||
collection_patterns = [
|
||||
"joke", "xiaohua", "笑话", "段子", "幽默", "搞笑",
|
||||
"/page/", "/list/", "/category/", "/tag/", "joke",
|
||||
]
|
||||
for p in collection_patterns:
|
||||
if p in domain or p in path or p.lower() in title:
|
||||
return True
|
||||
|
||||
# 有列表/目录模式的也认为是聚合站
|
||||
if re.search(r'(page|list|category|tag|index)', path):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# ============================================================
|
||||
# 翻页链接发现
|
||||
# ============================================================
|
||||
|
||||
def discover_page_links(self, html: str, base_url: str) -> list[str]:
|
||||
"""从页面 HTML 中发现翻页链接,返回去重排序后的 URL 列表"""
|
||||
# 优先使用 _last_raw_html(完整 HTML 而非纯文本),兜底用传入的 html
|
||||
raw = self._last_raw_html or html
|
||||
base_parsed = urlparse(base_url)
|
||||
base_domain = f"{base_parsed.scheme}://{base_parsed.netloc}"
|
||||
|
||||
links = set()
|
||||
|
||||
# 1. <link rel="next">
|
||||
for m in re.finditer(r'<link[^>]*rel="next"[^>]*href="([^"]+)"', raw, re.IGNORECASE):
|
||||
links.add(urljoin(base_domain, m.group(1)))
|
||||
|
||||
# 2. 翻页文字链接(下一页、下页、>、» 等)
|
||||
page_text_patterns = [
|
||||
r'<a[^>]*href="([^"]*page[^"]*)"[^>]*>\s*(?:下一页|下页|下一页»|»|›|>|Next|last)\s*</a>',
|
||||
r'<a[^>]*>\s*(?:下一页|下页|»|›|>)\s*</a>\s*<a[^>]*href="([^"]*)"',
|
||||
]
|
||||
for pattern in page_text_patterns:
|
||||
for m in re.finditer(pattern, raw, re.IGNORECASE):
|
||||
href = m.group(1).strip()
|
||||
if href and href not in ("#", "javascript:void(0)"):
|
||||
links.add(urljoin(base_url, href))
|
||||
|
||||
# 3. 提取所有带数字的翻页 link(?page=N, /page/N/, index_N.html, page_N.html, &page=N)
|
||||
page_link_patterns = [
|
||||
r'href="([^"]*[?&]page=(\d+)[^"]*)"', # ?page=2 &page=2
|
||||
r'href="([^"]*/page/(\d+)[^"]*)"', # /page/2/
|
||||
r'href="([^"]*[?&]p=(\d+)[^"]*)"', # ?p=2
|
||||
r'href="([^"]*[?&]pn=(\d+)[^"]*)"', # ?pn=2
|
||||
r'href="([^"]*[?&]offset=(\d+)[^"]*)"', # ?offset=10
|
||||
r'href="([^"]*[?&]start=(\d+)[^"]*)"', # ?start=10
|
||||
r'href="([^"]*[?&]page_index=(\d+)[^"]*)"', # ?page_index=2
|
||||
# 匹配 xxx_N.html / page_N.html / list_2.html
|
||||
r'href="([^"]*(?:page|list|index)[-_]?(\d+)\.html?)"',
|
||||
# 匹配 /page_N/ 格式
|
||||
r'href="([^"]*/page[-_]?(\d+)/?)"',
|
||||
]
|
||||
for pattern in page_link_patterns:
|
||||
for m in re.finditer(pattern, raw, re.IGNORECASE):
|
||||
full_url = urljoin(base_url, m.group(1))
|
||||
links.add(full_url)
|
||||
|
||||
# 4. 翻页数字链接
|
||||
for m in re.finditer(r'<a[^>]*href="([^"]*page=(\d+)[^"]*)"[^>]*>\s*\d+\s*</a>', raw, re.IGNORECASE):
|
||||
links.add(urljoin(base_url, m.group(1)))
|
||||
|
||||
# 过滤:只保留同一域名下的链接
|
||||
result = []
|
||||
for link in links:
|
||||
parsed = urlparse(link)
|
||||
if parsed.netloc and parsed.netloc != base_parsed.netloc:
|
||||
continue # 跨域排除
|
||||
if parsed.path == base_parsed.path and parsed.query == base_parsed.query:
|
||||
continue # 排除自身
|
||||
result.append(link)
|
||||
|
||||
# 去重排序
|
||||
return sorted(set(result))
|
||||
|
||||
def _extract_page_number(self, url: str) -> int:
|
||||
"""从 URL 中提取页码,用于排序"""
|
||||
nums = re.findall(r'page[=/](\d+)|[?&]p=(\d+)|index[-_]?(\d+)|/page[-_]?(\d+)', url, re.IGNORECASE)
|
||||
for n in nums:
|
||||
for g in n:
|
||||
if g:
|
||||
return int(g)
|
||||
return 99 # 没识别到页码的排最后
|
||||
|
||||
# ============================================================
|
||||
# 分类/标签链接发现
|
||||
# ============================================================
|
||||
|
||||
def discover_category_links(self, html: str, base_url: str) -> list[str]:
|
||||
"""从页面 HTML 中发现分类/标签链接,返回去重排序后的 URL 列表"""
|
||||
raw = self._last_raw_html or html
|
||||
base_parsed = urlparse(base_url)
|
||||
base_domain = f"{base_parsed.scheme}://{base_parsed.netloc}"
|
||||
|
||||
links = set()
|
||||
|
||||
# 1. 匹配分类链接(category-N.html, category-N_M.html, tag-N.html 等)
|
||||
cat_patterns = [
|
||||
r'href="([^"]*/(?:category|cat|sort|type)[-_]?\d+(?:[-_]\d+)?\.html?)"',
|
||||
r'href="([^"]*/(?:tag|tags)/?[-_]?\d*)["\s>]',
|
||||
r'href="([^"]*/tags?[-_]?\d+\.html?)"',
|
||||
]
|
||||
for pattern in cat_patterns:
|
||||
for m in re.finditer(pattern, raw, re.IGNORECASE):
|
||||
full_url = urljoin(base_domain, m.group(1))
|
||||
parsed = urlparse(full_url)
|
||||
# 只保留同域链接
|
||||
if parsed.netloc and parsed.netloc != base_parsed.netloc:
|
||||
continue
|
||||
links.add(full_url)
|
||||
|
||||
# 2. 过滤:排除单篇文章、首页、搜索页
|
||||
single_article = re.compile(
|
||||
r'/(?:p|post|article|archives|detail|read|xiaohua)/\d+',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
result = []
|
||||
for link in links:
|
||||
parsed = urlparse(link)
|
||||
path = parsed.path.rstrip("/")
|
||||
# 排除自身
|
||||
if path == base_parsed.path.rstrip("/") and parsed.query == base_parsed.query:
|
||||
continue
|
||||
# 排除单篇文章
|
||||
if single_article.search(path):
|
||||
continue
|
||||
# 排除明显的非分类路径(首页翻页)
|
||||
if re.search(r'/page[-_]?\d+\.html?$', path) and 'category' not in path and 'tag' not in path:
|
||||
continue
|
||||
result.append(link)
|
||||
|
||||
return sorted(set(result))
|
||||
|
||||
# ============================================================
|
||||
# Bing 搜索(复用旧逻辑)
|
||||
# ============================================================
|
||||
|
||||
async def _search_bing(self, keyword: str, max_pages: int = 3) -> list[dict]:
|
||||
"""搜索 Bing,返回结果 URL 列表"""
|
||||
results = []
|
||||
session = self._get_session()
|
||||
|
||||
for page in range(max_pages):
|
||||
first = page * 10
|
||||
url = f"https://www.bing.com/search?q={quote(keyword)}&first={first}"
|
||||
print(f" [*] Bing 搜索: {keyword[:20]}")
|
||||
|
||||
html = None
|
||||
try:
|
||||
if HAS_CRAWL4AI:
|
||||
crawler = await self._get_crawler()
|
||||
result = await crawler.arun(url, config=CrawlerRunConfig(verbose=False))
|
||||
if result.success:
|
||||
html = result.html if result.html else result.cleaned_html
|
||||
else:
|
||||
resp = session.get(url)
|
||||
html = resp.text if resp.status_code == 200 else None
|
||||
|
||||
if html:
|
||||
urls = self._extract_bing_urls(html)
|
||||
for item in urls:
|
||||
item["keyword"] = keyword
|
||||
results.append(item)
|
||||
else:
|
||||
print(f" [!] 获取搜索页面失败")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
except Exception as e:
|
||||
print(f" [!] 搜索异常: {e}")
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
def _extract_bing_urls(self, html: str) -> list[dict]:
|
||||
"""从 Bing 搜索结果 HTML 中提取链接和标题"""
|
||||
results = []
|
||||
pattern = re.compile(
|
||||
r'<h2[^>]*>\s*<a[^>]*href="(https?[^"]+)"[^>]*>(.*?)</a>',
|
||||
re.DOTALL,
|
||||
)
|
||||
for match in pattern.finditer(html):
|
||||
href = match.group(1).strip()
|
||||
title = re.sub(r'<[^>]+>', '', match.group(2)).strip()
|
||||
if href and title and len(title) > 5 and "bing.com" not in href and "microsoft.com" not in href:
|
||||
results.append({"url": href, "title": title})
|
||||
return results
|
||||
|
||||
# 别名兼容
|
||||
async def search_bing(self, keyword: str, max_pages: int = 3) -> list[dict]:
|
||||
return await self._search_bing(keyword, max_pages)
|
||||
|
||||
async def search(self, keyword: str, max_pages: int = 3) -> list[dict]:
|
||||
return await self._search_bing(keyword, max_pages)
|
||||
|
||||
async def search_baidu(self, keyword: str, max_pages: int = 3) -> list[dict]:
|
||||
return await self._search_bing(keyword, max_pages)
|
||||
|
||||
# ============================================================
|
||||
# 旧接口兼容(单页抓取)
|
||||
# ============================================================
|
||||
|
||||
async def crawl_page(self, url: str) -> str:
|
||||
"""抓取单个页面(兼容旧接口)"""
|
||||
content, ok = await self.crawl_page_with_retry(url)
|
||||
if ok:
|
||||
return content[:8000]
|
||||
return ""
|
||||
|
||||
async def crawl_batch(self, urls: list[str]) -> list[tuple[str, str]]:
|
||||
"""批量抓取(兼容旧接口)"""
|
||||
contents = []
|
||||
for url in urls:
|
||||
content, ok = await self.crawl_page_with_retry(url)
|
||||
contents.append((url, content[:8000] if ok else ""))
|
||||
await asyncio.sleep(1.5)
|
||||
return contents
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
爬虫持续采集入口(深度采集模式)。
|
||||
流程:搜索笑话站点 → 翻页深度采集 → AI 提取 → 入库。
|
||||
每批采集 20 条后休息 2-10 分钟,出错休息 3-11 分钟后继续。
|
||||
按 Ctrl+C 中断。
|
||||
|
||||
用法:
|
||||
python crawler/main.py # 默认,无头浏览器
|
||||
python crawler/main.py --no-headless # 显示浏览器窗口(方便测试)
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
import shutil
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
os.environ["TERM"] = "dumb"
|
||||
try:
|
||||
shutil.get_terminal_size()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from crawler.processor import Processor
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="笑话爬虫 - 深度采集模式")
|
||||
parser.add_argument(
|
||||
"--no-headless",
|
||||
action="store_true",
|
||||
help="显示浏览器窗口(默认无头模式)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
headless = not args.no_headless
|
||||
|
||||
api_base = os.getenv("API_BASE", "http://localhost:8001")
|
||||
username = os.getenv("CRAWL_USERNAME", "admin")
|
||||
password = os.getenv("CRAWL_PASSWORD", "admin123")
|
||||
keyword_str = os.getenv("CRAWL_KEYWORD", "笑话大全,搞笑段子,冷笑话,幽默笑话,爆笑笑话,笑话集锦")
|
||||
keywords = [k.strip() for k in keyword_str.split(",") if k.strip()]
|
||||
|
||||
if not keywords:
|
||||
print("错误: 未设置关键词")
|
||||
return
|
||||
|
||||
print(f"=" * 50)
|
||||
print(f"笑话爬虫 - 深度采集模式")
|
||||
print(f"搜索词: {keywords}")
|
||||
print(f"API 地址: {api_base}")
|
||||
print(f"浏览器: {'显示窗口' if not headless else '无头模式'}")
|
||||
print(f"每批 20 条后休息 2-10 分钟")
|
||||
print(f"出错后休息 3-11 分钟后重试")
|
||||
print(f"按 Ctrl+C 终止")
|
||||
print(f"=" * 50)
|
||||
|
||||
try:
|
||||
import crawl4ai
|
||||
print(f"crawl4ai 版本: {crawl4ai.__version__}")
|
||||
except ImportError:
|
||||
print("错误: crawl4ai 未安装,请先运行: pip install crawl4ai")
|
||||
return
|
||||
|
||||
processor = Processor(
|
||||
api_base=api_base,
|
||||
username=username,
|
||||
password=password,
|
||||
headless=headless,
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.run(processor.run_continuous(keywords=keywords, max_pages=3, batch_size=20))
|
||||
except KeyboardInterrupt:
|
||||
print("\n用户中断,退出")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,468 @@
|
||||
"""爬虫流程编排:搜索笑话站点 → 深度翻页采集 → AI 提取 → 入库"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import random
|
||||
import re
|
||||
import httpx
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from crawler.ai_service import AiService
|
||||
from crawler.crawler_service import CrawlerService
|
||||
|
||||
|
||||
class Processor:
|
||||
def __init__(self, api_base: str, username: str, password: str, headless: bool = True):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.token = None
|
||||
self.ai = None
|
||||
self.crawler = CrawlerService(headless=headless)
|
||||
self.types = []
|
||||
self.crowds = []
|
||||
|
||||
# === API 认证 ===
|
||||
def _login(self) -> str:
|
||||
resp = httpx.post(
|
||||
f"{self.api_base}/api/auth/login",
|
||||
json={"username": self.username, "password": self.password},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["access_token"]
|
||||
|
||||
def _get(self, path: str) -> dict | list:
|
||||
resp = httpx.get(
|
||||
f"{self.api_base}{path}",
|
||||
headers={"Authorization": f"Bearer {self.token}"},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def _post(self, path: str, data: dict) -> dict:
|
||||
resp = httpx.post(
|
||||
f"{self.api_base}{path}",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {self.token}"},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# === 初始化 ===
|
||||
def setup(self):
|
||||
print("[*] 正在登录...")
|
||||
self.token = self._login()
|
||||
print("[*] 登录成功")
|
||||
|
||||
self.ai = AiService(
|
||||
api_base=self.api_base,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
)
|
||||
self.ai.setup()
|
||||
print(f"[*] AI 配置: {self.ai.model}")
|
||||
|
||||
self.types = self._get("/api/categories/types")
|
||||
self.crowds = self._get("/api/categories/crowds")
|
||||
print(f"[*] 分类: {len(self.types)} 种类型, {len(self.crowds)} 种人群")
|
||||
|
||||
def _get_existing_hashes(self) -> set[str]:
|
||||
"""获取库里已有笑话的 content hash,用于去重"""
|
||||
try:
|
||||
data = self._get("/api/admin/jokes?page=1&page_size=1000")
|
||||
hashes = set()
|
||||
for j in data.get("items", []):
|
||||
content = j.get("content", "")
|
||||
if content:
|
||||
hashes.add(hashlib.md5(content.encode()).hexdigest())
|
||||
return hashes
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
def _submit_joke(self, joke: dict) -> bool:
|
||||
"""提交单条笑话至 API(status=pending 待审核)"""
|
||||
try:
|
||||
# Support both old format (type/crowd) and new format (types/crowds arrays)
|
||||
type_names = joke.get("types", [joke.get("type", "")])
|
||||
crowd_names = joke.get("crowds", [joke.get("crowd", "")])
|
||||
if isinstance(type_names, str):
|
||||
type_names = [type_names] if type_names else []
|
||||
if isinstance(crowd_names, str):
|
||||
crowd_names = [crowd_names] if crowd_names else []
|
||||
|
||||
type_ids = []
|
||||
crowd_ids = []
|
||||
for n in type_names:
|
||||
for t in self.types:
|
||||
if t.get("name") == n:
|
||||
type_ids.append(t.get("id"))
|
||||
break
|
||||
for n in crowd_names:
|
||||
for c in self.crowds:
|
||||
if c.get("name") == n:
|
||||
crowd_ids.append(c.get("id"))
|
||||
break
|
||||
|
||||
payload = {
|
||||
"title": joke.get("title", "无标题"),
|
||||
"content": joke.get("content", ""),
|
||||
"type_ids": type_ids if type_ids else None,
|
||||
"crowd_ids": crowd_ids if crowd_ids else None,
|
||||
"status": "pending",
|
||||
}
|
||||
self._post("/api/admin/jokes", payload)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" [!] 提交失败: {e}")
|
||||
return False
|
||||
|
||||
# === 主循环 ===
|
||||
|
||||
async def run_continuous(self, keywords: list[str], max_pages: int = 3, batch_size: int = 20):
|
||||
"""持续深度采集循环"""
|
||||
print(f"\n>> 深度采集模式启动")
|
||||
print(f" 搜索词: {keywords}")
|
||||
print(f" 每批目标: {batch_size} 条")
|
||||
print(f" API: {self.api_base}")
|
||||
print(f" 按 Ctrl+C 中断\n")
|
||||
|
||||
self.setup()
|
||||
|
||||
existing_hashes = self._get_existing_hashes()
|
||||
print(f"[*] 当前库中已有 {len(existing_hashes)} 条笑话(用于去重)")
|
||||
|
||||
type_names = [t.get("name", "") for t in self.types]
|
||||
crowd_names = [c.get("name", "") for c in self.crowds]
|
||||
total_saved = 0
|
||||
visited_urls: set[str] = set()
|
||||
|
||||
try:
|
||||
while True:
|
||||
batch_saved = 0
|
||||
print(f"\n{'='*50}")
|
||||
print(f" 开始新一批深度采集 (已累计 {total_saved} 条)")
|
||||
print(f"{'='*50}")
|
||||
|
||||
try:
|
||||
# Step 1: 搜索笑话站点
|
||||
print(f"\n[*] 搜索笑话站点...")
|
||||
sites = await self.crawler.search_joke_sites(keywords, max_results=8)
|
||||
|
||||
if not sites:
|
||||
print(f" [!] 未找到笑话站点,休息后重试")
|
||||
rest = random.randint(120, 600)
|
||||
print(f" 休息 {rest//60} 分 {rest%60} 秒...")
|
||||
await asyncio.sleep(rest)
|
||||
continue
|
||||
|
||||
# Step 2: 逐个站点深度采集
|
||||
for site in sites:
|
||||
if batch_saved >= batch_size:
|
||||
break
|
||||
|
||||
domain = site["domain"]
|
||||
site_url = site["url"]
|
||||
|
||||
print(f"\n{'─'*40}")
|
||||
print(f" 开始采集站点: {domain}")
|
||||
print(f"{'─'*40}")
|
||||
|
||||
# 检查该站点失败次数
|
||||
fail_count = self.crawler.site_failures.get(domain, 0)
|
||||
if fail_count >= 3:
|
||||
print(f" [!] 站点 {domain} 已连续失败 {fail_count} 次,跳过")
|
||||
continue
|
||||
|
||||
saved_from_site = await self._crawl_site_deep(
|
||||
site_url=site_url,
|
||||
domain=domain,
|
||||
type_names=type_names,
|
||||
crowd_names=crowd_names,
|
||||
existing_hashes=existing_hashes,
|
||||
visited_urls=visited_urls,
|
||||
target=batch_size - batch_saved,
|
||||
)
|
||||
batch_saved += saved_from_site
|
||||
total_saved += saved_from_site
|
||||
|
||||
# Step 3: 休息
|
||||
rest = random.randint(120, 600)
|
||||
print(f"\n[OK] 本批入库 {batch_saved} 条,休息 {rest//60} 分 {rest%60} 秒...")
|
||||
print(f" 按 Ctrl+C 中断\n")
|
||||
|
||||
except Exception as e:
|
||||
rest = random.randint(180, 660)
|
||||
print(f"\n[!] 出错: {e}")
|
||||
print(f" 休息 {rest//60} 分 {rest%60} 秒后重试...")
|
||||
|
||||
await asyncio.sleep(rest)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print("\n用户中断,退出")
|
||||
finally:
|
||||
await self.crawler.close()
|
||||
|
||||
async def _crawl_site_deep(
|
||||
self,
|
||||
site_url: str,
|
||||
domain: str,
|
||||
type_names: list[str],
|
||||
crowd_names: list[str],
|
||||
existing_hashes: set[str],
|
||||
visited_urls: set[str],
|
||||
target: int,
|
||||
) -> int:
|
||||
"""深度采集一个站点:抓取首页 → 发现翻页链接 → 逐页抓取提取笑话"""
|
||||
saved = 0
|
||||
pages_to_crawl = []
|
||||
|
||||
# 1. 抓取首页
|
||||
print(f" [*] 抓取首页: {site_url[:60]}")
|
||||
html, ok = await self.crawler.crawl_page_with_retry(site_url)
|
||||
if not ok:
|
||||
self.crawler.site_failures[domain] = self.crawler.site_failures.get(domain, 0) + 1
|
||||
print(f" [!] 首页抓取失败 ({self.crawler.site_failures[domain]}/3)")
|
||||
return 0
|
||||
|
||||
visited_urls.add(site_url)
|
||||
|
||||
# 2. 从首页提取笑话
|
||||
try:
|
||||
jokes = self.ai.extract_jokes(html[:8000], type_names, crowd_names)
|
||||
saved += self._save_jokes(jokes, existing_hashes, target - saved)
|
||||
print(f" [+] 首页提取 {len(jokes)} 条,入库 {saved} 条")
|
||||
except Exception as e:
|
||||
print(f" [!] 首页 AI 提取失败: {e}")
|
||||
|
||||
if saved >= target:
|
||||
self.crawler.site_failures[domain] = 0
|
||||
return saved
|
||||
|
||||
# 3. 发现翻页链接
|
||||
page_links = self.crawler.discover_page_links(html, site_url)
|
||||
# 过滤已访问的链接
|
||||
page_links = [l for l in page_links if l not in visited_urls]
|
||||
# 按页码排序
|
||||
page_links.sort(key=lambda l: self.crawler._extract_page_number(l))
|
||||
|
||||
# 限制翻页深度,避免无限抓取
|
||||
max_pages_per_site = 30
|
||||
page_links = page_links[:max_pages_per_site]
|
||||
print(f" [*] 发现 {len(page_links)} 个翻页链接,开始逐页采集...")
|
||||
|
||||
# 4. 逐页翻页采集
|
||||
for idx, page_url in enumerate(page_links):
|
||||
if saved >= target:
|
||||
break
|
||||
|
||||
# 检查站点是否已失效
|
||||
if self.crawler.site_failures.get(domain, 0) >= 3:
|
||||
print(f" [!] 站点 {domain} 失败过多,跳过")
|
||||
break
|
||||
|
||||
print(f" [*] 翻页 {idx+1}/{len(page_links)}: {page_url[:60]}")
|
||||
|
||||
html, ok = await self.crawler.crawl_page_with_retry(page_url)
|
||||
visited_urls.add(page_url)
|
||||
|
||||
if not ok:
|
||||
self.crawler.site_failures[domain] = self.crawler.site_failures.get(domain, 0) + 1
|
||||
print(f" [!] 抓取失败 ({self.crawler.site_failures[domain]}/3)")
|
||||
continue
|
||||
|
||||
# 重置失败计数
|
||||
self.crawler.site_failures[domain] = 0
|
||||
|
||||
try:
|
||||
jokes = self.ai.extract_jokes(html[:8000], type_names, crowd_names)
|
||||
new_saved = self._save_jokes(jokes, existing_hashes, target - saved)
|
||||
if new_saved > 0:
|
||||
saved += new_saved
|
||||
print(f" [+] 提取 {len(jokes)} 条,入库 {new_saved} 条 (累计 {saved}/{target})")
|
||||
else:
|
||||
print(f" [*] 提取 {len(jokes)} 条(均为重复)")
|
||||
except Exception as e:
|
||||
print(f" [!] AI 提取失败: {e}")
|
||||
|
||||
await asyncio.sleep(random.uniform(1, 3))
|
||||
|
||||
self.crawler.site_failures[domain] = 0
|
||||
|
||||
# 5. 发现分类链接并逐个深度采集
|
||||
cat_links = self.crawler.discover_category_links(html, site_url)
|
||||
cat_links = [l for l in cat_links if l not in visited_urls]
|
||||
# 限制分类数量
|
||||
max_categories = 20
|
||||
cat_links = cat_links[:max_categories]
|
||||
if cat_links:
|
||||
print(f" [*] 发现 {len(cat_links)} 个分类链接,开始逐类采集...")
|
||||
for cat_url in cat_links:
|
||||
if saved >= target:
|
||||
break
|
||||
if self.crawler.site_failures.get(domain, 0) >= 3:
|
||||
print(f" [!] 站点 {domain} 失败过多,跳过分类")
|
||||
break
|
||||
|
||||
saved += await self._crawl_category(
|
||||
cat_url=cat_url,
|
||||
domain=domain,
|
||||
type_names=type_names,
|
||||
crowd_names=crowd_names,
|
||||
existing_hashes=existing_hashes,
|
||||
visited_urls=visited_urls,
|
||||
target=target - saved,
|
||||
)
|
||||
|
||||
print(f" [*] 站点 {domain} 采集完成,共入库 {saved} 条")
|
||||
return saved
|
||||
|
||||
async def _crawl_category(
|
||||
self,
|
||||
cat_url: str,
|
||||
domain: str,
|
||||
type_names: list[str],
|
||||
crowd_names: list[str],
|
||||
existing_hashes: set[str],
|
||||
visited_urls: set[str],
|
||||
target: int,
|
||||
) -> int:
|
||||
"""深度采集一个分类页及其翻页"""
|
||||
saved = 0
|
||||
cat_name = cat_url.split("/")[-1].split(".")[0]
|
||||
print(f"\n {'─'*36}")
|
||||
print(f" 分类采集 [{cat_name}]: {cat_url[:60]}")
|
||||
print(f" {'─'*36}")
|
||||
|
||||
# 1. 抓取分类首页
|
||||
html, ok = await self.crawler.crawl_page_with_retry(cat_url)
|
||||
visited_urls.add(cat_url)
|
||||
if not ok:
|
||||
self.crawler.site_failures[domain] = self.crawler.site_failures.get(domain, 0) + 1
|
||||
print(f" [!] 分类首页抓取失败 ({self.crawler.site_failures[domain]}/3)")
|
||||
return 0
|
||||
|
||||
# 2. AI 提取笑话
|
||||
try:
|
||||
jokes = self.ai.extract_jokes(html[:8000], type_names, crowd_names)
|
||||
saved += self._save_jokes(jokes, existing_hashes, target - saved)
|
||||
print(f" [+] 分类首页提取 {len(jokes)} 条,入库 {saved} 条")
|
||||
except Exception as e:
|
||||
print(f" [!] 分类首页 AI 提取失败: {e}")
|
||||
|
||||
if saved >= target:
|
||||
return saved
|
||||
|
||||
# 3. 发现该分类的翻页链接
|
||||
# 先尝试通用翻页模式,再尝试分类特定翻页(category-5_2.html)
|
||||
page_links = self.crawler.discover_page_links(html, cat_url)
|
||||
# 从当前分类 URL 派生出分类翻页模式(e.g. category-5 → category-5_2.html)
|
||||
cat_base = cat_url.split("/")[-1].replace(".html", "")
|
||||
cat_page_pattern = re.compile(
|
||||
rf'href="([^"]*{re.escape(cat_base)}[-_]?(\d+)\.html?)"',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
raw = self.crawler._last_raw_html or html
|
||||
for m in cat_page_pattern.finditer(raw):
|
||||
full_url = urljoin(cat_url, m.group(1))
|
||||
page_links.append(full_url)
|
||||
|
||||
page_links = [l for l in page_links if l not in visited_urls]
|
||||
page_links = list(set(page_links)) # 去重
|
||||
page_links.sort(key=lambda l: self.crawler._extract_page_number(l))
|
||||
|
||||
# 限制翻页深度
|
||||
max_pages_per_cat = 20
|
||||
page_links = page_links[:max_pages_per_cat]
|
||||
if page_links:
|
||||
print(f" [*] 发现 {len(page_links)} 个翻页链接,开始逐页采集...")
|
||||
|
||||
# 4. 逐页抓取
|
||||
for idx, page_url in enumerate(page_links):
|
||||
if saved >= target:
|
||||
break
|
||||
if self.crawler.site_failures.get(domain, 0) >= 3:
|
||||
print(f" [!] 站点 {domain} 失败过多,跳过本分类")
|
||||
break
|
||||
|
||||
print(f" [*] 翻页 {idx+1}/{len(page_links)}: {page_url[:60]}")
|
||||
|
||||
html, ok = await self.crawler.crawl_page_with_retry(page_url)
|
||||
visited_urls.add(page_url)
|
||||
|
||||
if not ok:
|
||||
self.crawler.site_failures[domain] = self.crawler.site_failures.get(domain, 0) + 1
|
||||
print(f" [!] 分类翻页抓取失败 ({self.crawler.site_failures[domain]}/3)")
|
||||
continue
|
||||
|
||||
self.crawler.site_failures[domain] = 0
|
||||
|
||||
try:
|
||||
jokes = self.ai.extract_jokes(html[:8000], type_names, crowd_names)
|
||||
new_saved = self._save_jokes(jokes, existing_hashes, target - saved)
|
||||
if new_saved > 0:
|
||||
saved += new_saved
|
||||
print(f" [+] 提取 {len(jokes)} 条,入库 {new_saved} 条 (累计 {saved}/{target})")
|
||||
else:
|
||||
print(f" [*] 提取 {len(jokes)} 条(均为重复)")
|
||||
except Exception as e:
|
||||
print(f" [!] AI 提取失败: {e}")
|
||||
|
||||
await asyncio.sleep(random.uniform(1, 3))
|
||||
|
||||
print(f" [*] 分类 [{cat_name}] 采集完成,入库 {saved} 条")
|
||||
return saved
|
||||
|
||||
def _save_jokes(self, jokes: list[dict], existing_hashes: set[str], limit: int) -> int:
|
||||
"""去重并入库笑话,返回成功入库数"""
|
||||
saved = 0
|
||||
for joke in jokes:
|
||||
if saved >= limit:
|
||||
break
|
||||
content_text = joke.get("content", "")
|
||||
if not content_text:
|
||||
continue
|
||||
h = hashlib.md5(content_text.encode()).hexdigest()
|
||||
if h in existing_hashes:
|
||||
continue
|
||||
existing_hashes.add(h)
|
||||
if self._submit_joke(joke):
|
||||
saved += 1
|
||||
print(f" [+] 入库: {joke.get('title', '')[:30]}")
|
||||
return saved
|
||||
|
||||
# === 单站点采集(供 site_crawler.py 调用) ===
|
||||
|
||||
async def crawl_site(self, site_url: str, batch_size: int = 9999):
|
||||
"""初始化后深度采集单个站点"""
|
||||
print(f"\n>> 单站点采集: {site_url}\n")
|
||||
self.setup()
|
||||
|
||||
existing_hashes = self._get_existing_hashes()
|
||||
print(f"[*] 当前库中已有 {len(existing_hashes)} 条笑话(用于去重)")
|
||||
|
||||
type_names = [t.get("name", "") for t in self.types]
|
||||
crowd_names = [c.get("name", "") for c in self.crowds]
|
||||
domain = urlparse(site_url).netloc.lower()
|
||||
visited_urls: set[str] = set()
|
||||
|
||||
try:
|
||||
saved = await self._crawl_site_deep(
|
||||
site_url=site_url,
|
||||
domain=domain,
|
||||
type_names=type_names,
|
||||
crowd_names=crowd_names,
|
||||
existing_hashes=existing_hashes,
|
||||
visited_urls=visited_urls,
|
||||
target=batch_size,
|
||||
)
|
||||
print(f"\n[OK] 站点采集完成,共入库 {saved} 条笑话")
|
||||
finally:
|
||||
await self.crawler.close()
|
||||
|
||||
# === 旧接口兼容 ===
|
||||
async def run(self, keywords: list[str], max_pages: int = 3):
|
||||
"""单轮爬取(旧接口,内部调用 run_continuous)"""
|
||||
await self.run_continuous(keywords, max_pages, batch_size=9999)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
单站点深度采集工具。
|
||||
从 JSON 文件按编号加载站点,或直接指定 URL,深度采集该站点所有笑话。
|
||||
|
||||
用法:
|
||||
python crawler/site_crawler.py --id 1 # 采集 site_finder 发现的 #1 站点
|
||||
python crawler/site_crawler.py --id 1,2,3 # 批量采集多个站点
|
||||
python crawler/site_crawler.py --url https://... # 直接采集指定 URL
|
||||
python crawler/site_crawler.py --id 1 --no-headless # 显示浏览器窗口
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
import shutil
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
os.environ["TERM"] = "dumb"
|
||||
try:
|
||||
shutil.get_terminal_size()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from crawler.processor import Processor
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="单站点深度采集工具")
|
||||
parser.add_argument(
|
||||
"--id",
|
||||
type=str,
|
||||
default=None,
|
||||
help="站点编号(从 site_finder 生成的 JSON 读取),多个用逗号分隔",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--url",
|
||||
type=str,
|
||||
default=None,
|
||||
help="直接指定站点 URL(与 --id 二选一)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sites-file",
|
||||
type=str,
|
||||
default="joke_sites.json",
|
||||
help="站点列表 JSON 文件路径(默认: joke_sites.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-base",
|
||||
type=str,
|
||||
default=os.getenv("API_BASE", "http://localhost:8001"),
|
||||
help="API 服务地址",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--username",
|
||||
type=str,
|
||||
default=os.getenv("CRAWL_USERNAME", "admin"),
|
||||
help="管理员用户名",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password",
|
||||
type=str,
|
||||
default=os.getenv("CRAWL_PASSWORD", "admin123"),
|
||||
help="管理员密码",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-headless",
|
||||
action="store_true",
|
||||
help="显示浏览器窗口",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_sites(path: str) -> list[dict]:
|
||||
"""从 JSON 文件加载站点列表"""
|
||||
if not os.path.exists(path):
|
||||
print(f"错误: 站点文件 {path} 不存在,请先运行 site_finder.py")
|
||||
sys.exit(1)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not data:
|
||||
print(f"错误: 站点文件 {path} 为空")
|
||||
sys.exit(1)
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"错误: 读取站点文件失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def resolve_sites(args) -> list[str]:
|
||||
"""解析 --id 或 --url 参数,返回待采集的 URL 列表"""
|
||||
if args.url:
|
||||
return [args.url]
|
||||
|
||||
if not args.id:
|
||||
print("错误: 请指定 --id 或 --url")
|
||||
print(" 例如: python crawler/site_crawler.py --id 1")
|
||||
print(" 例如: python crawler/site_crawler.py --url https://xiaohua.com")
|
||||
sys.exit(1)
|
||||
|
||||
# 解析编号列表 "1,2,3" → [1, 2, 3]
|
||||
try:
|
||||
ids = [int(x.strip()) for x in args.id.split(",") if x.strip()]
|
||||
except ValueError:
|
||||
print("错误: --id 参数必须是数字,多个用逗号分隔")
|
||||
sys.exit(1)
|
||||
|
||||
sites = load_sites(args.sites_file)
|
||||
found = []
|
||||
for sid in ids:
|
||||
match = [s for s in sites if s["id"] == sid]
|
||||
if match:
|
||||
found.append(match[0])
|
||||
print(f" [*] 站点 #{sid}: {match[0]['domain']} — {match[0]['title'][:40]}")
|
||||
else:
|
||||
print(f" [!] 站点 #{sid} 未找到(可用编号: {[s['id'] for s in sites[:10]]}...)")
|
||||
|
||||
if not found:
|
||||
print("错误: 没有找到有效的站点编号")
|
||||
sys.exit(1)
|
||||
|
||||
return [s["url"] for s in found]
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
headless = not args.no_headless
|
||||
urls = resolve_sites(args)
|
||||
|
||||
print(f"=" * 50)
|
||||
print(f"单站点深度采集工具")
|
||||
print(f"目标站点: {len(urls)} 个")
|
||||
for u in urls:
|
||||
print(f" - {u}")
|
||||
print(f"API 地址: {args.api_base}")
|
||||
print(f"浏览器: {'显示窗口' if not headless else '无头模式'}")
|
||||
print(f"=" * 50)
|
||||
|
||||
try:
|
||||
import crawl4ai
|
||||
print(f"crawl4ai 版本: {crawl4ai.__version__}")
|
||||
except ImportError:
|
||||
print("错误: crawl4ai 未安装,请先运行: pip install crawl4ai")
|
||||
return
|
||||
|
||||
processor = Processor(
|
||||
api_base=args.api_base,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
headless=headless,
|
||||
)
|
||||
|
||||
try:
|
||||
for url in urls:
|
||||
asyncio.run(processor.crawl_site(url))
|
||||
except KeyboardInterrupt:
|
||||
print("\n用户中断,退出")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user