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
This commit is contained in:
@@ -0,0 +1,754 @@
|
||||
# 智能笑话生成器实现计划
|
||||
|
||||
> **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>✨</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">✨</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">🎲 开始生成</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">
|
||||
🔄 重新生成
|
||||
</el-button>
|
||||
<el-button @click="handleFavorite" :type="isFavorited ? 'danger' : 'default'">
|
||||
<span v-if="!isFavorited">❤ 收藏</span>
|
||||
<span v-else>✔ 已收藏</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收藏列表 -->
|
||||
<div v-if="favorites.length" class="favorites-section">
|
||||
<h3>📬 我的收藏 ({{ 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
|
||||
- [ ] 收藏列表正确显示,可删除收藏
|
||||
- [ ] 页面样式正常(浅色/深色主题)
|
||||
Reference in New Issue
Block a user