feat: AI 生成增强 - 风格/长度/评分/历史/复制
- 后端 prompt 支持风格选择(6种)和字数范围 - 后端 AI 输出改为 JSON 格式,含评分和推荐理由 - GenerateRequest/Response schema 新增 style/length/score/reason 字段 - 前端新增风格选择、字数范围控件 - 前端新增随机选题按钮 - 前端新增复制内容功能 - 前端新增生成历史记录(会话级 20 条) - 结果卡片展示 AI 评分和推荐理由
This commit is contained in:
+85
-15
@@ -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 输入
|
||||
|
||||
@@ -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
@@ -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
@@ -2,100 +2,130 @@
|
||||
<div class="generate-page">
|
||||
<h1 class="page-title">
|
||||
<span class="emoji">✨</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)">×</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">🎲 开始生成</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">🔀 随机选题</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">💡</span> {{ generatedJoke.reason }}
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<el-button type="primary" @click="handleGenerate">
|
||||
🔄 重新生成
|
||||
</el-button>
|
||||
<el-button @click="handleFavorite" :type="isFavorited ? 'danger' : 'default'">
|
||||
<button class="btn-primary" title="复制到剪贴板" @click="copyContent">📋 复制</button>
|
||||
<button class="btn-primary" @click="handleGenerate">🔄 重新生成</button>
|
||||
<button class="btn-fav" :class="{ favorited: isFavorited }" @click="handleFavorite">
|
||||
<span v-if="!isFavorited">❤ 收藏</span>
|
||||
<span v-else>✔ 已收藏</span>
|
||||
</el-button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史记录 -->
|
||||
<div v-if="history.length" class="history-section">
|
||||
<h3>📄 生成历史 ({{ 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)">×</span>
|
||||
</div>
|
||||
</div>
|
||||
</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-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>
|
||||
Reference in New Issue
Block a user