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""" """智能笑话生成器 API"""
import json
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from openai import OpenAI from openai import OpenAI
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -21,17 +22,43 @@ GENERATION_PROMPT = """你是一位幽默大师,专门创作轻松搞笑的短
要求: 要求:
1. 根据场景和关键词创作一条原创笑话 1. 根据场景和关键词创作一条原创笑话
2. 笑话要有反转或意外结局 2. 笑话要有反转或意外结局
3. 语言简洁,30-150字 3. 语言风格:{style}
4. 直接输出笑话内容,不需要解释 4. 字数要求:{length_requirement}
5. 直接输出笑话内容,不需要解释
格式 请严格按照以下 JSON 格式输出,不要加任何额外说明
标题:xxx {{
内容:xxx "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: def _build_prompt(
"""构建 AI prompt""" scenarios: list[str],
keywords: list[str],
style: str = "twist",
length: str = "medium",
) -> str:
"""构建 AI prompt,支持风格和长度控制"""
parts = [] parts = []
if scenarios: if scenarios:
parts.append(f"场景:{', '.join(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)}") parts.append(f"关键词:{', '.join(keywords)}")
if not parts: if not parts:
parts.append("场景:日常生活的各种趣事(不指定具体场景)") 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) @router.post("", response_model=GenerateResponse)
@@ -47,11 +78,10 @@ def generate_joke(
req: GenerateRequest, req: GenerateRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""调用 AI 生成笑话""" """调用 AI 生成笑话,支持风格和长度控制"""
# 获取激活的 AI 配置 # 获取激活的 AI 配置
setting = db.query(AiSetting).filter(AiSetting.is_active == True).first() setting = db.query(AiSetting).filter(AiSetting.is_active == True).first()
if not setting: if not setting:
# 如果没有配置,尝试返回默认配置
setting = db.query(AiSetting).first() setting = db.query(AiSetting).first()
if not setting or not setting.api_key: if not setting or not setting.api_key:
@@ -60,22 +90,62 @@ def generate_joke(
# 调用 AI # 调用 AI
try: try:
client = OpenAI(base_url=setting.api_base, api_key=setting.api_key) 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( response = client.chat.completions.create(
model=setting.model_name, model=setting.model_name,
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
temperature=setting.temperature, temperature=req.temperature,
max_tokens=setting.max_tokens, max_tokens=setting.max_tokens,
) )
raw = response.choices[0].message.content 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: except Exception as e:
raise HTTPException(status_code=500, detail=f"生成失败: {str(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: def _parse_response(raw: str | None) -> GenerateResponse:
"""解析 AI 返回内容,提取标题和内容""" """解析 AI 返回内容,提取标题和内容"""
# 防御:处理空或 None 输入 # 防御:处理空或 None 输入
+9 -1
View File
@@ -74,12 +74,18 @@ class GenerateRequest(BaseModel):
"""笑话生成请求""" """笑话生成请求"""
keywords: list[str] = [] keywords: list[str] = []
scenarios: list[str] = [] scenarios: list[str] = []
style: str = "twist"
length: str = "medium"
temperature: float = 0.8
class Config: class Config:
json_schema_extra = { json_schema_extra = {
"example": { "example": {
"keywords": ["加班"], "keywords": ["加班"],
"scenarios": ["职场"] "scenarios": ["职场"],
"style": "twist",
"length": "medium",
"temperature": 0.8,
} }
} }
@@ -88,6 +94,8 @@ class GenerateResponse(BaseModel):
"""笑话生成响应""" """笑话生成响应"""
title: str title: str
content: str content: str
score: int = 0
reason: str = ""
created_at: datetime | None = None created_at: datetime | None = None
class Config: class Config:
+10 -3
View File
@@ -4,11 +4,18 @@ import request from './request'
* 调用 AI 生成笑话 * 调用 AI 生成笑话
* @param {string[]} keywords - 关键词列表 * @param {string[]} keywords - 关键词列表
* @param {string[]} scenarios - 场景列表 * @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', { return request.post('/generate', {
keywords, 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"> <div class="generate-page">
<h1 class="page-title"> <h1 class="page-title">
<span class="emoji">&#x2728;</span> <span class="emoji">&#x2728;</span>
智能笑话生成 AI 笑话生成
</h1> </h1>
<!-- 场景选择 --> <!-- 场景选择 -->
<div class="section"> <div class="section">
<div class="section-label">选择场景可多选</div> <div class="section-label">选择场景可多选</div>
<div class="scenario-chips"> <div class="chips-group">
<el-check-tag <span
v-for="s in predefinedScenarios" v-for="s in predefinedScenarios"
:key="s" :key="s"
:checked="selectedScenarios.includes(s)" class="chip"
@change="toggleScenario(s)" :class="{ checked: selectedScenarios.includes(s) }"
class="scenario-chip" @click="toggleScenario(s)"
> >{{ s }}</span>
{{ s }}
</el-check-tag>
</div> </div>
</div> </div>
<!-- 关键词输入 --> <!-- 关键词输入 -->
<div class="section"> <div class="section">
<div class="section-label">添加关键词可选</div> <div class="section-label">添加关键词可选</div>
<el-input <div class="input-row">
v-model="keywordInput" <input
placeholder="输入关键词,如:加班、相亲、熊孩子..." v-model="keywordInput"
@keyup.enter="addKeyword" type="text"
clearable placeholder="输入关键词,如:加班、相亲、熊孩子..."
> @keyup.enter="addKeyword"
<template #append> />
<el-button @click="addKeyword">添加</el-button> <button class="btn-add" @click="addKeyword">添加</button>
</template> </div>
</el-input> <div v-if="keywords.length" class="tag-list">
<div v-if="keywords.length" class="keyword-tags"> <span v-for="(kw, i) in keywords" :key="i" class="tag-item">
<el-tag
v-for="(kw, i) in keywords"
:key="i"
closable
@close="removeKeyword(i)"
class="keyword-tag"
>
{{ kw }} {{ 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> </div>
<!-- 生成按钮 --> <!-- 生成按钮 -->
<div class="generate-actions"> <div class="generate-actions">
<el-button <button class="btn-generate" :disabled="loading" @click="handleGenerate">
type="primary"
size="large"
:loading="loading"
:disabled="loading"
@click="handleGenerate"
class="generate-btn"
>
<span v-if="!loading">&#x1F3B2; 开始生成</span> <span v-if="!loading">&#x1F3B2; 开始生成</span>
<span v-else>生成中...</span> <span v-else>生成中...</span>
</el-button> </button>
<el-button v-if="generatedJoke" size="large" @click="resetForm"> <button class="btn-random" @click="randomPick">&#x1F500; 随机选题</button>
清空 <button v-if="generatedJoke" class="btn-clear" @click="resetForm">清空</button>
</el-button>
</div> </div>
<!-- 生成结果 --> <!-- 生成结果 -->
<div v-if="generatedJoke" class="result-card"> <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> <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"> <div class="result-actions">
<el-button type="primary" @click="handleGenerate"> <button class="btn-primary" title="复制到剪贴板" @click="copyContent">&#x1F4CB; 复制</button>
&#x1F504; 重新生成 <button class="btn-primary" @click="handleGenerate">&#x1F504; 重新生成</button>
</el-button> <button class="btn-fav" :class="{ favorited: isFavorited }" @click="handleFavorite">
<el-button @click="handleFavorite" :type="isFavorited ? 'danger' : 'default'">
<span v-if="!isFavorited">&#x2764; 收藏</span> <span v-if="!isFavorited">&#x2764; 收藏</span>
<span v-else>&#x2714; 已收藏</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> </div>
<!-- 收藏列表 --> <!-- 收藏列表 -->
<div v-if="favorites.length" class="favorites-section"> <div v-if="favorites.length" class="favorites-section">
<h3>&#x1F4EC; 我的收藏 ({{ favorites.length }})</h3> <h3>&#x1F4EC; 我的收藏 ({{ favorites.length }})</h3>
<div class="favorite-list"> <div class="fav-list">
<div <div v-for="(fav, i) in favorites" :key="fav.id" class="fav-item">
v-for="(fav, i) in favorites"
:key="fav.id"
class="favorite-item"
>
<div class="fav-title">{{ fav.title }}</div> <div class="fav-title">{{ fav.title }}</div>
<div class="fav-content">{{ fav.content.substring(0, 100) }}...</div> <div class="fav-content">{{ fav.content.substring(0, 100) }}...</div>
<div class="fav-meta"> <div class="fav-meta">
<span class="fav-date">{{ formatDate(fav.created_at) }}</span> <span class="fav-date">{{ formatDate(fav.created_at) }}</span>
<el-button type="danger" size="small" text @click="removeFavorite(i)"> <button class="btn-del" @click="removeFavorite(i)">删除</button>
删除
</el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -104,31 +134,48 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { generateJoke } from '@/api/generate' import { generateJoke } from '@/api/generate'
const predefinedScenarios = ['职场', '校园', '社交', '家庭', '情感', '搞笑日常'] 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 selectedScenarios = ref([])
const keywordInput = ref('') const keywordInput = ref('')
const keywords = ref([]) const keywords = ref([])
const selectedStyle = ref('twist')
const selectedLength = ref('medium')
// 输出状态
const loading = ref(false) const loading = ref(false)
const generatedJoke = ref(null) const generatedJoke = ref(null)
const isFavorited = ref(false) const isFavorited = ref(false)
// 历史记录(会话级)
const history = ref([])
// 收藏 // 收藏
const favorites = ref(JSON.parse(localStorage.getItem('joke_favorites') || '[]')) const favorites = ref(JSON.parse(localStorage.getItem('joke_favorites') || '[]'))
const STORAGE_KEY = 'joke_favorites' const STORAGE_KEY = 'joke_favorites'
function toggleScenario(s) { function toggleScenario(s) {
const idx = selectedScenarios.value.indexOf(s) const idx = selectedScenarios.value.indexOf(s)
if (idx >= 0) { if (idx >= 0) selectedScenarios.value.splice(idx, 1)
selectedScenarios.value.splice(idx, 1) else selectedScenarios.value.push(s)
} else {
selectedScenarios.value.push(s)
}
} }
function addKeyword() { function addKeyword() {
@@ -143,61 +190,101 @@ function removeKeyword(i) {
keywords.value.splice(i, 1) 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() { async function handleGenerate() {
loading.value = true loading.value = true
isFavorited.value = false isFavorited.value = false
try { 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 generatedJoke.value = res
// 检查是否已收藏 // 加入历史
const exists = favorites.value.find(f => f.content === res.content) history.value.unshift({ ...res })
isFavorited.value = !!exists if (history.value.length > 20) history.value.pop()
// 检查收藏状态
isFavorited.value = !!favorites.value.find(f => f.content === res.content)
} catch (e) { } catch (e) {
const msg = e?.detail || e?.message || '生成失败,请重试' const msg = e?.detail || e?.message || '生成失败,请重试'
ElMessage.error(msg) alert(msg)
generatedJoke.value = null generatedJoke.value = null
} finally { } finally {
loading.value = false 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() { function handleFavorite() {
if (!generatedJoke.value) return if (!generatedJoke.value) return
// 检查是否已存在 if (favorites.value.find(f => f.content === generatedJoke.value.content)) {
const exists = favorites.value.findIndex(f => f.content === generatedJoke.value.content) alert('已经收藏过了')
if (exists >= 0) {
ElMessage.info('已经收藏过了')
return return
} }
const newFavorite = { const newFav = {
id: Date.now().toString(), id: Date.now().toString(),
title: generatedJoke.value.title, title: generatedJoke.value.title,
content: generatedJoke.value.content, content: generatedJoke.value.content,
score: generatedJoke.value.score,
reason: generatedJoke.value.reason,
created_at: new Date().toISOString() created_at: new Date().toISOString()
} }
favorites.value.unshift(newFavorite) favorites.value.unshift(newFav)
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value)) localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
isFavorited.value = true isFavorited.value = true
ElMessage.success('收藏成功') alert('收藏成功')
} catch (e) { } catch (e) {
// 处理 localStorage 满的情况 favorites.value.shift()
favorites.value.shift() // 移除刚添加的 alert('收藏失败,存储空间可能已满')
ElMessage.error('收藏失败,存储空间可能已满,请清理部分收藏')
} }
} }
function removeFavorite(i) { function removeFavorite(i) {
favorites.value.splice(i, 1) favorites.value.splice(i, 1)
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
} catch (e) {
ElMessage.error('保存失败')
}
// 检查当前生成的是否也从收藏中被删除了
if (generatedJoke.value) { if (generatedJoke.value) {
const stillExists = favorites.value.find(f => f.content === generatedJoke.value.content) isFavorited.value = !!favorites.value.find(f => f.content === generatedJoke.value.content)
isFavorited.value = !!stillExists
} }
} }
@@ -216,152 +303,312 @@ function formatDate(dateStr) {
.generate-page { .generate-page {
max-width: 720px; max-width: 720px;
margin: 0 auto; margin: 0 auto;
padding: 40px 24px;
} }
.page-title { .page-title {
font-size: 28px; font-size: 26px;
font-weight: 800; font-weight: 800;
color: var(--text-primary); color: var(--text-primary);
text-align: center; text-align: center;
margin-bottom: 40px; margin-bottom: 32px;
}
.emoji {
display: inline-block;
margin-right: 10px;
} }
.emoji { margin-right: 8px; }
.section { .section { margin-bottom: 22px; }
margin-bottom: 28px;
}
.section-label { .section-label {
font-size: 15px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 12px; margin-bottom: 10px;
} }
.scenario-chips { /* 标签组(场景 + 风格 + 长度 共用) */
.chips-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 8px;
} }
:deep(.scenario-chip) { .chip {
padding: 8px 18px; padding: 7px 16px;
border-radius: 20px; border-radius: 20px;
background: var(--bg-card); background: var(--bg-card);
border: 1.5px solid var(--border); border: 1.5px solid var(--border);
cursor: pointer;
font-size: 14px; font-size: 14px;
cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
color: var(--text-secondary);
} }
:deep(.scenario-chip:hover) { .chip:hover { border-color: #9333ea; }
border-color: #9333ea; .chip.checked {
}
:deep(.scenario-chip.is-checked) {
background: #9333ea; background: #9333ea;
border-color: #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; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-top: 12px; margin-top: 12px;
} }
.keyword-tag { .tag-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #f3e8ff; background: #f3e8ff;
border-color: #9333ea; border: 1px solid #9333ea;
border-radius: 14px;
color: #9333ea; 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 { .generate-actions {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 16px; gap: 10px;
margin-bottom: 32px; margin-bottom: 28px;
flex-wrap: wrap;
} }
:deep(.generate-btn) { .btn-generate {
padding: 12px 40px; padding: 12px 40px;
font-size: 16px; font-size: 16px;
background: linear-gradient(135deg, #9333ea, #7c3aed); background: linear-gradient(135deg, #9333ea, #7c3aed);
border: none; border: none;
border-radius: 24px; border-radius: 24px;
color: #fff;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
} }
:deep(.generate-btn:hover) { .btn-generate:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(147,51,234,0.4); }
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 { .result-card {
background: var(--bg-card); background: var(--bg-card);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 32px; padding: 28px;
margin-bottom: 32px; margin-bottom: 24px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.result-title { .result-title {
font-size: 22px; font-size: 20px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 16px; margin: 0;
text-align: center; }
.result-score {
font-size: 13px;
font-weight: 700;
color: #9333ea;
background: #f3e8ff;
padding: 2px 10px;
border-radius: 10px;
flex-shrink: 0;
} }
.result-content { .result-content {
font-size: 16px; font-size: 15px;
line-height: 1.9; line-height: 1.9;
color: var(--text-secondary); color: var(--text-secondary);
white-space: pre-wrap; 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 { .result-actions {
display: flex; display: flex;
justify-content: center; 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; .history-section { margin-top: 32px; }
} .history-section h3 {
.favorites-section h3 { font-size: 17px;
font-size: 18px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 16px; margin-bottom: 12px;
padding-bottom: 12px; padding-bottom: 10px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.history-list { display: flex; flex-direction: column; gap: 8px; }
.favorite-list { .history-item {
display: flex;
flex-direction: column;
gap: 12px;
}
.favorite-item {
background: var(--bg-card); background: var(--bg-card);
border-radius: var(--radius); border-radius: var(--radius);
padding: 16px 20px; padding: 12px 16px;
border: 1px solid var(--border); border: 1px solid var(--border);
cursor: pointer;
transition: border-color 0.2s;
} }
.fav-title { .history-item:hover { border-color: var(--primary); }
font-weight: 600; .history-title { font-weight: 600; font-size: 14px; color: var(--text-primary); margin-bottom: 2px; }
color: var(--text-primary); .history-meta {
margin-bottom: 6px;
}
.fav-content {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 8px;
line-height: 1.5;
}
.fav-meta {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
}
.fav-date {
font-size: 12px; font-size: 12px;
color: var(--text-muted); 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> </style>