feat: AI 生成增强 - 风格/长度/评分/历史/复制

- 后端 prompt 支持风格选择(6种)和字数范围
- 后端 AI 输出改为 JSON 格式,含评分和推荐理由
- GenerateRequest/Response schema 新增 style/length/score/reason 字段
- 前端新增风格选择、字数范围控件
- 前端新增随机选题按钮
- 前端新增复制内容功能
- 前端新增生成历史记录(会话级 20 条)
- 结果卡片展示 AI 评分和推荐理由
This commit is contained in:
bwstudio
2026-06-13 16:04:45 +08:00
parent ab54b0137d
commit 6d015bb56d
4 changed files with 504 additions and 172 deletions
+85 -15
View File
@@ -1,7 +1,8 @@
"""智能笑话生成器 API"""
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from openai import OpenAI
from sqlalchemy.orm import Session
@@ -21,17 +22,43 @@ GENERATION_PROMPT = """你是一位幽默大师,专门创作轻松搞笑的短
要求:
1. 根据场景和关键词创作一条原创笑话
2. 笑话要有反转或意外结局
3. 语言简洁,30-150字
4. 直接输出笑话内容,不需要解释
3. 语言风格:{style}
4. 字数要求:{length_requirement}
5. 直接输出笑话内容,不需要解释
格式
标题:xxx
内容:xxx
"""
请严格按照以下 JSON 格式输出,不要加任何额外说明
{{
"title": "笑话标题",
"content": "笑话正文",
"score": 8,
"reason": "这个笑话巧妙结合了场景和关键词,结尾有反转"
}}
score 是 1-10 的整数评分,reason 是用一句话说明亮点。"""
STYLE_MAP = {
"cold": "冷幽默 / 无厘头",
"warm": "温馨幽默 / 暖心搞笑",
"twist": "反转 / 神转折",
"pun": "谐音梗 / 文字游戏",
"sketch": "段子 / 吐槽调侃",
"irony": "讽刺幽默 / 黑色幽默",
}
LENGTH_MAP = {
"short": "30-80字,非常简短",
"medium": "80-150字,正常长度",
"long": "150-300字,可以描述一个小场景",
}
def _build_prompt(scenarios: list[str], keywords: list[str]) -> str:
"""构建 AI prompt"""
def _build_prompt(
scenarios: list[str],
keywords: list[str],
style: str = "twist",
length: str = "medium",
) -> str:
"""构建 AI prompt,支持风格和长度控制"""
parts = []
if scenarios:
parts.append(f"场景:{', '.join(scenarios)}")
@@ -39,7 +66,11 @@ def _build_prompt(scenarios: list[str], keywords: list[str]) -> str:
parts.append(f"关键词:{', '.join(keywords)}")
if not parts:
parts.append("场景:日常生活的各种趣事(不指定具体场景)")
return GENERATION_PROMPT.format(context="\n".join(parts))
return GENERATION_PROMPT.format(
context="\n".join(parts),
style=STYLE_MAP.get(style, "反转 / 神转折"),
length_requirement=LENGTH_MAP.get(length, "80-150字,正常长度"),
)
@router.post("", response_model=GenerateResponse)
@@ -47,11 +78,10 @@ def generate_joke(
req: GenerateRequest,
db: Session = Depends(get_db),
):
"""调用 AI 生成笑话"""
"""调用 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:
@@ -60,22 +90,62 @@ def generate_joke(
# 调用 AI
try:
client = OpenAI(base_url=setting.api_base, api_key=setting.api_key)
prompt = _build_prompt(req.scenarios, req.keywords)
prompt = _build_prompt(req.scenarios, req.keywords, req.style, req.length)
response = client.chat.completions.create(
model=setting.model_name,
messages=[{"role": "user", "content": prompt}],
temperature=setting.temperature,
temperature=req.temperature,
max_tokens=setting.max_tokens,
)
raw = response.choices[0].message.content
return _parse_response(raw)
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)
# 尝试从 markdown 代码块中提取 JSON
raw = raw.strip()
if raw.startswith("```"):
lines = raw.split("\n")
# 去掉第一行 ```json 和最后一行 ```
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:
# 如果 JSON 解析失败,尝试查找花括号内的内容
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 返回内容,提取标题和内容"""
# 防御:处理空或 None 输入
+9 -1
View File
@@ -74,12 +74,18 @@ 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": ["职场"]
"scenarios": ["职场"],
"style": "twist",
"length": "medium",
"temperature": 0.8,
}
}
@@ -88,6 +94,8 @@ class GenerateResponse(BaseModel):
"""笑话生成响应"""
title: str
content: str
score: int = 0
reason: str = ""
created_at: datetime | None = None
class Config:
+10 -3
View File
@@ -4,11 +4,18 @@ import request from './request'
* 调用 AI 生成笑话
* @param {string[]} keywords - 关键词列表
* @param {string[]} scenarios - 场景列表
* @returns {Promise<{title: string, content: string, created_at: string}>}
* @param {object} opts - 可选参数
* @param {string} opts.style - 风格: cold/warm/twist/pun/sketch/irony
* @param {string} opts.length - 长度: short/medium/long
* @param {number} opts.temperature - 创造力 0-1
* @returns {Promise<{title: string, content: string, score: number, reason: string}>}
*/
export const generateJoke = (keywords = [], scenarios = []) => {
export const generateJoke = (keywords = [], scenarios = [], opts = {}) => {
return request.post('/generate', {
keywords,
scenarios
scenarios,
style: opts.style || 'twist',
length: opts.length || 'medium',
temperature: opts.temperature ?? 0.8
})
}
+400 -153
View File
@@ -2,100 +2,130 @@
<div class="generate-page">
<h1 class="page-title">
<span class="emoji">&#x2728;</span>
智能笑话生成
AI 笑话生成
</h1>
<!-- 场景选择 -->
<div class="section">
<div class="section-label">选择场景可多选</div>
<div class="scenario-chips">
<el-check-tag
<div class="chips-group">
<span
v-for="s in predefinedScenarios"
:key="s"
:checked="selectedScenarios.includes(s)"
@change="toggleScenario(s)"
class="scenario-chip"
>
{{ s }}
</el-check-tag>
class="chip"
:class="{ checked: selectedScenarios.includes(s) }"
@click="toggleScenario(s)"
>{{ s }}</span>
</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"
>
<div class="input-row">
<input
v-model="keywordInput"
type="text"
placeholder="输入关键词,如:加班、相亲、熊孩子..."
@keyup.enter="addKeyword"
/>
<button class="btn-add" @click="addKeyword">添加</button>
</div>
<div v-if="keywords.length" class="tag-list">
<span v-for="(kw, i) in keywords" :key="i" class="tag-item">
{{ kw }}
</el-tag>
<span class="tag-close" @click="removeKeyword(i)">&times;</span>
</span>
</div>
</div>
<!-- 风格选择 -->
<div class="section">
<div class="section-label">笑话风格</div>
<div class="chips-group">
<span
v-for="s in styles"
:key="s.value"
class="chip style"
:class="{ checked: selectedStyle === s.value }"
@click="selectedStyle = s.value"
>{{ s.label }}</span>
</div>
</div>
<!-- 字数范围 -->
<div class="section">
<div class="section-label">字数范围</div>
<div class="chips-group">
<span
v-for="l in lengths"
:key="l.value"
class="chip length"
:class="{ checked: selectedLength === l.value }"
@click="selectedLength = l.value"
>{{ l.label }}</span>
</div>
</div>
<!-- 生成按钮 -->
<div class="generate-actions">
<el-button
type="primary"
size="large"
:loading="loading"
:disabled="loading"
@click="handleGenerate"
class="generate-btn"
>
<button class="btn-generate" :disabled="loading" @click="handleGenerate">
<span v-if="!loading">&#x1F3B2; 开始生成</span>
<span v-else>生成中...</span>
</el-button>
<el-button v-if="generatedJoke" size="large" @click="resetForm">
清空
</el-button>
</button>
<button class="btn-random" @click="randomPick">&#x1F500; 随机选题</button>
<button v-if="generatedJoke" class="btn-clear" @click="resetForm">清空</button>
</div>
<!-- 生成结果 -->
<div v-if="generatedJoke" class="result-card">
<h2 class="result-title">{{ generatedJoke.title }}</h2>
<div class="result-header">
<h2 class="result-title">{{ generatedJoke.title }}</h2>
<span v-if="generatedJoke.score" class="result-score">{{ generatedJoke.score }}/10</span>
</div>
<pre class="result-content">{{ generatedJoke.content }}</pre>
<div v-if="generatedJoke.reason" class="result-reason">
<span class="reason-icon">&#x1F4A1;</span> {{ generatedJoke.reason }}
</div>
<div class="result-actions">
<el-button type="primary" @click="handleGenerate">
&#x1F504; 重新生成
</el-button>
<el-button @click="handleFavorite" :type="isFavorited ? 'danger' : 'default'">
<button class="btn-primary" title="复制到剪贴板" @click="copyContent">&#x1F4CB; 复制</button>
<button class="btn-primary" @click="handleGenerate">&#x1F504; 重新生成</button>
<button class="btn-fav" :class="{ favorited: isFavorited }" @click="handleFavorite">
<span v-if="!isFavorited">&#x2764; 收藏</span>
<span v-else>&#x2714; 已收藏</span>
</el-button>
</button>
</div>
</div>
<!-- 历史记录 -->
<div v-if="history.length" class="history-section">
<h3>&#x1F4C4; 生成历史 ({{ history.length }})</h3>
<div class="history-list">
<div
v-for="(item, i) in history"
:key="i"
class="history-item"
@click="selectHistory(i)"
>
<div class="history-title">{{ item.title }}</div>
<div class="history-meta">
<span>{{ item.content.substring(0, 40) }}...</span>
<span class="history-del" @click.stop="removeHistory(i)">&times;</span>
</div>
</div>
</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-list">
<div v-for="(fav, i) in favorites" :key="fav.id" class="fav-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>
<button class="btn-del" @click="removeFavorite(i)">删除</button>
</div>
</div>
</div>
@@ -104,31 +134,48 @@
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ref, onMounted } from 'vue'
import { generateJoke } from '@/api/generate'
const predefinedScenarios = ['职场', '校园', '社交', '家庭', '情感', '搞笑日常']
const styles = [
{ value: 'twist', label: '神反转' },
{ value: 'cold', label: '冷幽默' },
{ value: 'warm', label: '暖心' },
{ value: 'pun', label: '谐音梗' },
{ value: 'sketch', label: '吐槽段子' },
{ value: 'irony', label: '黑色幽默' },
]
const lengths = [
{ value: 'short', label: '短(30-50字)' },
{ value: 'medium', label: '中(50-150字)' },
{ value: 'long', label: '长(150-300字)' },
]
// 输入状态
const selectedScenarios = ref([])
const keywordInput = ref('')
const keywords = ref([])
const selectedStyle = ref('twist')
const selectedLength = ref('medium')
// 输出状态
const loading = ref(false)
const generatedJoke = ref(null)
const isFavorited = ref(false)
// 历史记录(会话级)
const history = ref([])
// 收藏
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)
}
if (idx >= 0) selectedScenarios.value.splice(idx, 1)
else selectedScenarios.value.push(s)
}
function addKeyword() {
@@ -143,61 +190,101 @@ function removeKeyword(i) {
keywords.value.splice(i, 1)
}
// 随机选题
function randomPick() {
const allScenarios = ['职场', '校园', '社交', '家庭', '情感', '搞笑日常', '医院', '地铁', '餐厅', '健身房']
const allStyles = styles.map(s => s.value)
selectedScenarios.value = [allScenarios[Math.floor(Math.random() * allScenarios.length)]]
selectedStyle.value = allStyles[Math.floor(Math.random() * allStyles.length)]
keywords.value = []
}
// 生成
async function handleGenerate() {
loading.value = true
isFavorited.value = false
try {
const res = await generateJoke(keywords.value, selectedScenarios.value)
const res = await generateJoke(keywords.value, selectedScenarios.value, {
style: selectedStyle.value,
length: selectedLength.value,
})
generatedJoke.value = res
// 检查是否已收藏
const exists = favorites.value.find(f => f.content === res.content)
isFavorited.value = !!exists
// 加入历史
history.value.unshift({ ...res })
if (history.value.length > 20) history.value.pop()
// 检查收藏状态
isFavorited.value = !!favorites.value.find(f => f.content === res.content)
} catch (e) {
const msg = e?.detail || e?.message || '生成失败,请重试'
ElMessage.error(msg)
alert(msg)
generatedJoke.value = null
} finally {
loading.value = false
}
}
// 从历史选择
function selectHistory(i) {
generatedJoke.value = history.value[i]
isFavorited.value = !!favorites.value.find(f => f.content === history.value[i].content)
// 移到最前
const item = history.value.splice(i, 1)[0]
history.value.unshift(item)
}
// 复制
function copyContent() {
if (!generatedJoke.value) return
const text = `${generatedJoke.value.title}\n\n${generatedJoke.value.content}`
navigator.clipboard.writeText(text).then(() => {
alert('已复制到剪贴板')
}).catch(() => {
// fallback
const ta = document.createElement('textarea')
ta.value = text
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
alert('已复制到剪贴板')
})
}
function removeHistory(i) {
history.value.splice(i, 1)
}
// 收藏
function handleFavorite() {
if (!generatedJoke.value) return
// 检查是否已存在
const exists = favorites.value.findIndex(f => f.content === generatedJoke.value.content)
if (exists >= 0) {
ElMessage.info('已经收藏过了')
if (favorites.value.find(f => f.content === generatedJoke.value.content)) {
alert('已经收藏过了')
return
}
const newFavorite = {
const newFav = {
id: Date.now().toString(),
title: generatedJoke.value.title,
content: generatedJoke.value.content,
score: generatedJoke.value.score,
reason: generatedJoke.value.reason,
created_at: new Date().toISOString()
}
favorites.value.unshift(newFavorite)
favorites.value.unshift(newFav)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
isFavorited.value = true
ElMessage.success('收藏成功')
alert('收藏成功')
} catch (e) {
// 处理 localStorage 满的情况
favorites.value.shift() // 移除刚添加的
ElMessage.error('收藏失败,存储空间可能已满,请清理部分收藏')
favorites.value.shift()
alert('收藏失败,存储空间可能已满')
}
}
function removeFavorite(i) {
favorites.value.splice(i, 1)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
} catch (e) {
ElMessage.error('保存失败')
}
// 检查当前生成的是否也从收藏中被删除了
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
isFavorited.value = !!favorites.value.find(f => f.content === generatedJoke.value.content)
}
}
@@ -216,152 +303,312 @@ function formatDate(dateStr) {
.generate-page {
max-width: 720px;
margin: 0 auto;
padding: 40px 24px;
}
.page-title {
font-size: 28px;
font-size: 26px;
font-weight: 800;
color: var(--text-primary);
text-align: center;
margin-bottom: 40px;
}
.emoji {
display: inline-block;
margin-right: 10px;
margin-bottom: 32px;
}
.emoji { margin-right: 8px; }
.section {
margin-bottom: 28px;
}
.section { margin-bottom: 22px; }
.section-label {
font-size: 15px;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
margin-bottom: 10px;
}
.scenario-chips {
/* 标签组(场景 + 风格 + 长度 共用) */
.chips-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 8px;
}
:deep(.scenario-chip) {
padding: 8px 18px;
.chip {
padding: 7px 16px;
border-radius: 20px;
background: var(--bg-card);
border: 1.5px solid var(--border);
cursor: pointer;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
color: var(--text-secondary);
}
:deep(.scenario-chip:hover) {
border-color: #9333ea;
}
:deep(.scenario-chip.is-checked) {
.chip:hover { border-color: #9333ea; }
.chip.checked {
background: #9333ea;
border-color: #9333ea;
color: white;
color: #fff;
}
/* 风格标签紫色,长度标签用橙色 */
.chip.style.checked { background: #7c3aed; border-color: #7c3aed; }
.chip.length.checked { background: var(--primary); border-color: var(--primary); }
.keyword-tags {
/* 输入行 */
.input-row {
display: flex;
gap: 8px;
}
.input-row input {
flex: 1;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
transition: border-color 0.2s;
font-family: inherit;
}
.input-row input:focus { outline: none; border-color: var(--primary); }
.input-row input::placeholder { color: var(--text-muted); }
.btn-add {
padding: 9px 18px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.btn-add:hover { border-color: var(--primary); color: var(--primary); }
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.keyword-tag {
.tag-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #f3e8ff;
border-color: #9333ea;
border: 1px solid #9333ea;
border-radius: 14px;
color: #9333ea;
font-size: 12px;
}
.tag-close { cursor: pointer; font-size: 16px; line-height: 1; opacity: 0.7; }
.tag-close:hover { opacity: 1; }
/* 按钮区 */
.generate-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-bottom: 32px;
gap: 10px;
margin-bottom: 28px;
flex-wrap: wrap;
}
:deep(.generate-btn) {
.btn-generate {
padding: 12px 40px;
font-size: 16px;
background: linear-gradient(135deg, #9333ea, #7c3aed);
border: none;
border-radius: 24px;
color: #fff;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
:deep(.generate-btn:hover) {
box-shadow: 0 4px 20px rgba(147, 51, 234, 0.4);
.btn-generate:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(147,51,234,0.4); }
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-random {
padding: 12px 22px;
border: 1.5px solid var(--border);
border-radius: 24px;
background: var(--bg-card);
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-random:hover { border-color: #9333ea; color: #9333ea; }
.btn-clear {
padding: 12px 22px;
border: 1.5px solid var(--border);
border-radius: 24px;
background: var(--bg-card);
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-clear:hover { border-color: var(--primary); color: var(--primary); }
/* 结果卡片 */
.result-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 32px;
margin-bottom: 32px;
padding: 28px;
margin-bottom: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.result-title {
font-size: 22px;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 16px;
text-align: center;
margin: 0;
}
.result-score {
font-size: 13px;
font-weight: 700;
color: #9333ea;
background: #f3e8ff;
padding: 2px 10px;
border-radius: 10px;
flex-shrink: 0;
}
.result-content {
font-size: 16px;
font-size: 15px;
line-height: 1.9;
color: var(--text-secondary);
white-space: pre-wrap;
margin-bottom: 24px;
margin-bottom: 14px;
font-family: inherit;
}
.result-reason {
font-size: 13px;
color: var(--text-muted);
background: var(--bg-page);
padding: 8px 12px;
border-radius: 8px;
margin-bottom: 18px;
line-height: 1.5;
}
.reason-icon { margin-right: 4px; }
.result-actions {
display: flex;
justify-content: center;
gap: 12px;
gap: 10px;
flex-wrap: wrap;
}
.btn-primary {
padding: 9px 20px;
border-radius: 20px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
background: var(--primary);
border: none;
color: #fff;
font-weight: 600;
}
.btn-primary:hover { opacity: 0.9; }
.btn-fav {
padding: 9px 20px;
border-radius: 20px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
background: var(--bg-card);
border: 1.5px solid var(--border);
color: var(--text-secondary);
}
.btn-fav:hover { border-color: #e53935; color: #e53935; }
.btn-fav.favorited { border-color: #e53935; color: #e53935; background: #fff0f0; }
.favorites-section {
margin-top: 40px;
}
.favorites-section h3 {
font-size: 18px;
/* 历史记录 */
.history-section { margin-top: 32px; }
.history-section h3 {
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
padding-bottom: 12px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.favorite-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.favorite-item {
.history-list { display: flex; flex-direction: column; gap: 8px; }
.history-item {
background: var(--bg-card);
border-radius: var(--radius);
padding: 16px 20px;
padding: 12px 16px;
border: 1px solid var(--border);
cursor: pointer;
transition: border-color 0.2s;
}
.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 {
.history-item:hover { border-color: var(--primary); }
.history-title { font-weight: 600; font-size: 14px; color: var(--text-primary); margin-bottom: 2px; }
.history-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.fav-date {
font-size: 12px;
color: var(--text-muted);
}
.history-del {
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 0 4px;
opacity: 0.5;
}
.history-del:hover { opacity: 1; color: #e53935; }
/* 收藏 */
.favorites-section { margin-top: 32px; }
.favorites-section h3 {
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.fav-list { display: flex; flex-direction: column; gap: 10px; }
.fav-item {
background: var(--bg-card);
border-radius: var(--radius);
padding: 14px 18px;
border: 1px solid var(--border);
}
.fav-title { font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.fav-content { font-size: 13px; color: var(--text-muted); margin-bottom: 6px; line-height: 1.5; }
.fav-meta { display: flex; justify-content: space-between; align-items: center; }
.fav-date { font-size: 12px; color: var(--text-muted); }
.btn-del {
background: none;
border: none;
color: #e53935;
font-size: 12px;
cursor: pointer;
padding: 2px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.btn-del:hover { background: #fff0f0; }
/* ============================================================
移动端适配
============================================================ */
@media (max-width: 767px) {
.generate-page { padding: 0; }
.page-title { font-size: 22px; margin-bottom: 24px; }
.result-card { padding: 20px; }
.result-title { font-size: 18px; }
.result-content { font-size: 14px; }
.btn-generate { width: 100%; padding: 12px 20px; }
.btn-random { flex: 1; text-align: center; }
.input-row { flex-direction: column; }
.chips-group { gap: 6px; }
.chip { font-size: 13px; padding: 6px 14px; }
.generate-actions { flex-direction: column; }
.btn-clear { width: 100%; text-align: center; }
.result-header { flex-direction: column; align-items: flex-start; gap: 6px; }
}
</style>