Files
joke/docs/superpowers/plans/2026-06-02-joke-generator-plan.md
T
bwstudio ceed63fcb0 fix: resolve 10 code review issues
High priority:
- Fix concurrent race condition for view_count/like_count (atomic update)
- Add route request ID tracking to prevent race conditions
- Filter get_joke by status=approved (no pending content leak)
- Add error feedback for like button

Performance:
- Optimize random joke query (avoid full table sort)
- Limit page_size max to 100 (DoS prevention)

Medium:
- Add localStorage quota error handling
- Handle empty AI response gracefully
- Fix generate content title extraction

Low:
- Add rejected_jokes to stats API
- Update dashboard to show rejected count
2026-06-02 20:35:08 +08:00

754 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 智能笑话生成器实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现用户通过选择场景和输入关键词,调用 AI 生成笑话的功能,支持收藏到本地。
**Architecture:** 后端新增 `/api/generate` 接口调用 AI 服务;前端新增 `/generate` 页面,复用现有笑话卡片样式,支持 localStorage 收藏。
**Tech Stack:** FastAPI + OpenAI SDK (NVIDIA NIM) / Vue3 + Element Plus + localStorage
---
## 文件变更概览
### 新增文件
- `api/app/routers/generate.py` - 生成器 API 路由
- `api/app/schemas/joke.py` - 添加 GenerateRequest/GenerateResponse schema
- `web/src/views/generate/index.vue` - 生成器页面
- `web/src/api/generate.js` - 前端 API 模块
### 修改文件
- `api/main.py` - 注册新路由
- `web/src/router/index.js` - 添加 /generate 路由
- `web/src/components/AppHeader.vue` - 导航栏添加 AI 生成入口按钮
---
## Task 1: 后端 Schema 定义
**Files:**
- Modify: `api/app/schemas/joke.py:1-45`
- [ ] **Step 1: 添加 GenerateRequest 和 GenerateResponse schema**
`api/app/schemas/joke.py` 文件末尾添加:
```python
class GenerateRequest(BaseModel):
"""笑话生成请求"""
keywords: list[str] = []
scenarios: list[str] = []
class Config:
json_schema_extra = {
"example": {
"keywords": ["加班"],
"scenarios": ["职场"]
}
}
class GenerateResponse(BaseModel):
"""笑话生成响应"""
title: str
content: str
created_at: datetime | None = None
class Config:
from_attributes = True
```
- [ ] **Step 2: 验证文件语法**
Run: `cd api && python -c "from app.schemas.joke import GenerateRequest, GenerateResponse; print('OK')"`
Expected: `OK`
- [ ] **Step 3: 提交**
```bash
git add api/app/schemas/joke.py
git commit -m "feat(api): add GenerateRequest/GenerateResponse schemas"
```
---
## Task 2: 后端 AI 生成路由
**Files:**
- Create: `api/app/routers/generate.py`
- Modify: `api/main.py:26-33`
- [ ] **Step 1: 创建生成器路由文件**
创建 `api/app/routers/generate.py`:
```python
"""智能笑话生成器 API"""
from datetime import datetime
from fastapi import APIRouter, 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=["生成器"])
# AI Prompt
GENERATION_PROMPT = """你是一位幽默大师,专门创作轻松搞笑的短笑话。
{context}
要求:
1. 根据场景和关键词创作一条原创笑话
2. 笑话要有反转或意外结局
3. 语言简洁,30-150字
4. 直接输出笑话内容,不需要解释
格式:
标题:xxx
内容:xxx
"""
def _build_prompt(scenarios: list[str], keywords: list[str]) -> str:
"""构建 AI prompt"""
parts = []
if scenarios:
parts.append(f"场景:{', '.join(scenarios)}")
if keywords:
parts.append(f"关键词:{', '.join(keywords)}")
if not parts:
parts.append("场景:日常生活的各种趣事(不指定具体场景)")
return GENERATION_PROMPT.format(context="\n".join(parts))
@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 服务未配置,请联系管理员")
# 调用 AI
try:
client = OpenAI(base_url=setting.api_base, api_key=setting.api_key)
prompt = _build_prompt(req.scenarios, req.keywords)
response = client.chat.completions.create(
model=setting.model_name,
messages=[{"role": "user", "content": prompt}],
temperature=setting.temperature,
max_tokens=setting.max_tokens,
)
raw = response.choices[0].message.content
return _parse_response(raw)
except Exception as e:
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
def _parse_response(raw: str) -> GenerateResponse:
"""解析 AI 返回内容,提取标题和内容"""
title = ""
content = raw
# 尝试提取 "标题:xxx" 或 "标题:xxx"
for line in raw.split("\n"):
line = line.strip()
if line.startswith("标题:") or line.startswith("标题:"):
title = line.split("", 1)[-1].split(":", 1)[-1].strip()
content = content.replace(line, "").strip()
break
# 如果没有提取到标题,取第一行或前20字
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
return GenerateResponse(
title=title or "生成的笑话",
content=content.strip(),
created_at=datetime.now(),
)
```
- [ ] **Step 2: 修改 main.py 注册路由**
`api/main.py` 第 5 行添加导入:
```python
from app.routers import jokes_router, categories_router, auth_router, admin_router, settings_router, links_router, feedback_router, generate_router
```
`api/main.py` 第 32 行后添加:
```python
app.include_router(generate_router, prefix="/api")
```
- [ ] **Step 3: 手动验证 API**
Run: `cd api && python -m uvicorn main:app --reload --port 8001`
Expected: 服务启动无错误
访问 `http://localhost:8001/docs` 验证 `/api/generate` 接口存在
- [ ] **Step 4: 测试无 AI 配置情况**
Run: `curl -X POST http://localhost:8001/api/generate -H "Content-Type: application/json" -d "{}"`
Expected: `{"detail":"AI 服务未配置,请联系管理员"}`
- [ ] **Step 5: 提交**
```bash
git add api/app/routers/generate.py api/main.py
git commit -m "feat(api): add joke generate endpoint with AI"
```
---
## Task 3: 前端 API 模块
**Files:**
- Create: `web/src/api/generate.js`
- [ ] **Step 1: 创建前端 API 模块**
创建 `web/src/api/generate.js`:
```javascript
import request from './request'
/**
* 调用 AI 生成笑话
* @param {string[]} keywords - 关键词列表
* @param {string[]} scenarios - 场景列表
* @returns {Promise<{title: string, content: string, created_at: string}>}
*/
export const generateJoke = (keywords = [], scenarios = []) => {
return request.post('/generate', {
keywords,
scenarios
})
}
```
- [ ] **Step 2: 验证语法**
Run: `cd web && node -c src/api/generate.js`
Expected: 无语法错误(注意:ES module 语法可能有警告,可跳过)
- [ ] **Step 3: 提交**
```bash
git add web/src/api/generate.js
git commit -m "feat(web): add generateJoke API function"
```
---
## Task 4: 前端路由配置
**Files:**
- Modify: `web/src/router/index.js:1-17`
- [ ] **Step 1: 添加 /generate 路由**
`web/src/router/index.js` 第 7 行后添加:
```javascript
{
path: '/generate',
name: 'Generate',
component: () => import('@/views/generate/index.vue')
},
```
- [ ] **Step 2: 提交**
```bash
git add web/src/router/index.js
git commit -m "feat(web): add /generate route"
```
---
## Task 5: 头部导航 AI 入口按钮
**Files:**
- Modify: `web/src/components/AppHeader.vue`
- [ ] **Step 1: 在导航栏添加 AI 生成按钮**
`web/src/components/AppHeader.vue``header-nav` 区域,找到 `random-btn` 后添加:
```vue
<router-link
to="/generate"
class="nav-item generate-btn"
:class="{ active: $route.path === '/generate' }"
title="AI 生成笑话"
>
<span>&#x2728;</span>
</router-link>
```
并在样式区域添加(在 `.random-btn:hover` 后):
```css
.generate-btn {
font-size: 16px;
padding: 8px 12px;
}
.generate-btn:hover {
background: rgba(147, 51, 234, 0.1);
}
.generate-btn.active {
background: rgba(147, 51, 234, 0.15);
color: #9333ea;
}
```
- [ ] **Step 2: 提交**
```bash
git add web/src/components/AppHeader.vue
git commit -m "feat(web): add AI generate button to header nav"
```
---
## Task 6: 生成器页面
**Files:**
- Create: `web/src/views/generate/index.vue`
- [ ] **Step 1: 创建生成器页面**
创建 `web/src/views/generate/index.vue`:
```vue
<template>
<div class="generate-page">
<h1 class="page-title">
<span class="emoji">&#x2728;</span>
智能笑话生成器
</h1>
<!-- 场景选择 -->
<div class="section">
<div class="section-label">选择场景可多选</div>
<div class="scenario-chips">
<el-check-tag
v-for="s in predefinedScenarios"
:key="s"
:checked="selectedScenarios.includes(s)"
@change="toggleScenario(s)"
class="scenario-chip"
>
{{ s }}
</el-check-tag>
</div>
</div>
<!-- 关键词输入 -->
<div class="section">
<div class="section-label">添加关键词可选</div>
<el-input
v-model="keywordInput"
placeholder="输入关键词,如:加班、相亲、熊孩子..."
@keyup.enter="addKeyword"
clearable
>
<template #append>
<el-button @click="addKeyword">添加</el-button>
</template>
</el-input>
<div v-if="keywords.length" class="keyword-tags">
<el-tag
v-for="(kw, i) in keywords"
:key="i"
closable
@close="removeKeyword(i)"
class="keyword-tag"
>
{{ kw }}
</el-tag>
</div>
</div>
<!-- 生成按钮 -->
<div class="generate-actions">
<el-button
type="primary"
size="large"
:loading="loading"
:disabled="loading"
@click="handleGenerate"
class="generate-btn"
>
<span v-if="!loading">&#x1F3B2; 开始生成</span>
<span v-else>生成中...</span>
</el-button>
<el-button v-if="generatedJoke" size="large" @click="resetForm">
清空
</el-button>
</div>
<!-- 生成结果 -->
<div v-if="generatedJoke" class="result-card">
<h2 class="result-title">{{ generatedJoke.title }}</h2>
<pre class="result-content">{{ generatedJoke.content }}</pre>
<div class="result-actions">
<el-button type="primary" @click="handleGenerate">
&#x1F504; 重新生成
</el-button>
<el-button @click="handleFavorite" :type="isFavorited ? 'danger' : 'default'">
<span v-if="!isFavorited">&#x2764; 收藏</span>
<span v-else>&#x2714; 已收藏</span>
</el-button>
</div>
</div>
<!-- 收藏列表 -->
<div v-if="favorites.length" class="favorites-section">
<h3>&#x1F4EC; 我的收藏 ({{ favorites.length }})</h3>
<div class="favorite-list">
<div
v-for="(fav, i) in favorites"
:key="fav.id"
class="favorite-item"
>
<div class="fav-title">{{ fav.title }}</div>
<div class="fav-content">{{ fav.content.substring(0, 100) }}...</div>
<div class="fav-meta">
<span class="fav-date">{{ formatDate(fav.created_at) }}</span>
<el-button type="danger" size="small" text @click="removeFavorite(i)">
删除
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { generateJoke } from '@/api/generate'
const predefinedScenarios = ['职场', '校园', '社交', '家庭', '情感', '搞笑日常']
const selectedScenarios = ref([])
const keywordInput = ref('')
const keywords = ref([])
const loading = ref(false)
const generatedJoke = ref(null)
const isFavorited = ref(false)
// 收藏
const favorites = ref(JSON.parse(localStorage.getItem('joke_favorites') || '[]'))
const STORAGE_KEY = 'joke_favorites'
function toggleScenario(s) {
const idx = selectedScenarios.value.indexOf(s)
if (idx >= 0) {
selectedScenarios.value.splice(idx, 1)
} else {
selectedScenarios.value.push(s)
}
}
function addKeyword() {
const kw = keywordInput.value.trim()
if (kw && !keywords.value.includes(kw)) {
keywords.value.push(kw)
keywordInput.value = ''
}
}
function removeKeyword(i) {
keywords.value.splice(i, 1)
}
async function handleGenerate() {
loading.value = true
isFavorited.value = false
try {
const res = await generateJoke(keywords.value, selectedScenarios.value)
generatedJoke.value = res
// 检查是否已收藏
const exists = favorites.value.find(f => f.content === res.content)
isFavorited.value = !!exists
} catch (e) {
ElMessage.error(e.detail || '生成失败,请重试')
generatedJoke.value = null
} finally {
loading.value = false
}
}
function handleFavorite() {
if (!generatedJoke.value) return
// 检查是否已存在
const exists = favorites.value.findIndex(f => f.content === generatedJoke.value.content)
if (exists >= 0) {
ElMessage.info('已经收藏过了')
return
}
favorites.value.unshift({
id: Date.now().toString(),
title: generatedJoke.value.title,
content: generatedJoke.value.content,
created_at: new Date().toISOString()
})
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
isFavorited.value = true
ElMessage.success('收藏成功')
}
function removeFavorite(i) {
favorites.value.splice(i, 1)
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
// 检查当前生成的是否也从收藏中被删除了
if (generatedJoke.value) {
const stillExists = favorites.value.find(f => f.content === generatedJoke.value.content)
isFavorited.value = !!stillExists
}
}
function resetForm() {
generatedJoke.value = null
isFavorited.value = false
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('zh-CN')
}
</script>
<style scoped>
.generate-page {
max-width: 720px;
margin: 0 auto;
padding: 40px 24px;
}
.page-title {
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
text-align: center;
margin-bottom: 40px;
}
.emoji {
display: inline-block;
margin-right: 10px;
}
.section {
margin-bottom: 28px;
}
.section-label {
font-size: 15px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
}
.scenario-chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
:deep(.scenario-chip) {
padding: 8px 18px;
border-radius: 20px;
background: var(--bg-card);
border: 1.5px solid var(--border);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
:deep(.scenario-chip:hover) {
border-color: #9333ea;
}
:deep(.scenario-chip.is-checked) {
background: #9333ea;
border-color: #9333ea;
color: white;
}
.keyword-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.keyword-tag {
background: #f3e8ff;
border-color: #9333ea;
color: #9333ea;
}
.generate-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-bottom: 32px;
}
:deep(.generate-btn) {
padding: 12px 40px;
font-size: 16px;
background: linear-gradient(135deg, #9333ea, #7c3aed);
border: none;
border-radius: 24px;
}
:deep(.generate-btn:hover) {
box-shadow: 0 4px 20px rgba(147, 51, 234, 0.4);
}
.result-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 32px;
margin-bottom: 32px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.result-title {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 16px;
text-align: center;
}
.result-content {
font-size: 16px;
line-height: 1.9;
color: var(--text-secondary);
white-space: pre-wrap;
margin-bottom: 24px;
}
.result-actions {
display: flex;
justify-content: center;
gap: 12px;
}
.favorites-section {
margin-top: 40px;
}
.favorites-section h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.favorite-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.favorite-item {
background: var(--bg-card);
border-radius: var(--radius);
padding: 16px 20px;
border: 1px solid var(--border);
}
.fav-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.fav-content {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 8px;
line-height: 1.5;
}
.fav-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.fav-date {
font-size: 12px;
color: var(--text-muted);
}
</style>
```
- [ ] **Step 2: 验证页面加载**
启动服务后访问 `http://localhost:3000/generate`,验证:
- 页面正常显示
- 场景选择显示正常
- 关键词输入正常
- [ ] **Step 3: 提交**
```bash
git add web/src/views/generate/index.vue
git commit -m "feat(web): add AI joke generator page with favorites"
```
---
## Task 7: 更新 CLAUDE.md API 文档
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: 添加新 API 文档**
在 API 接口一览部分添加:
```
- `POST /api/generate` — 公开:AI 生成笑话(keywords + scenarios
```
- [ ] **Step 2: 提交**
```bash
git add CLAUDE.md
git commit -m "docs: add /api/generate to API docs"
```
---
## 验证清单
完成所有任务后,请验证:
- [ ] API 文档 `http://localhost:8001/docs` 显示 `/api/generate` 接口
- [ ] 头部导航显示 AI 生成按钮
- [ ] 访问 `/generate` 页面正常
- [ ] 选择场景 + 输入关键词可正常调用 AI 生成
- [ ] 生成结果正确显示
- [ ] 收藏按钮可用,收藏数据正确存储到 localStorage
- [ ] 收藏列表正确显示,可删除收藏
- [ ] 页面样式正常(浅色/深色主题)