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

18 KiB
Raw Blame History

智能笑话生成器实现计划

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 文件末尾添加:

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: 提交
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:

"""智能笑话生成器 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 行添加导入:

from app.routers import jokes_router, categories_router, auth_router, admin_router, settings_router, links_router, feedback_router, generate_router

api/main.py 第 32 行后添加:

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: 提交
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:

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: 提交
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 行后添加:

{
  path: '/generate',
  name: 'Generate',
  component: () => import('@/views/generate/index.vue')
},
  • Step 2: 提交
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.vueheader-nav 区域,找到 random-btn 后添加:

<router-link
  to="/generate"
  class="nav-item generate-btn"
  :class="{ active: $route.path === '/generate' }"
  title="AI 生成笑话"
>
  <span>&#x2728;</span>
</router-link>

并在样式区域添加(在 .random-btn:hover 后):

.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: 提交
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:

<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: 提交

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: 提交
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
  • 收藏列表正确显示,可删除收藏
  • 页面样式正常(浅色/深色主题)