Compare commits

...
17 Commits
Author SHA1 Message Date
bwstudio db8cea02f8 feat: 提示词和 AI 参数移至数据库管理
- AiSetting 模型新增 6 个提示词字段 + 3 个阶段温度字段
- main.py 迁移逻辑改为检测 ai_settings 表的新字段并填充默认值
- generate.py 从数据库读取生成提示词,去掉硬编码
- optimizer 从 API 读取各阶段提示词和温度,删除 prompts.py
- crawler 从 API 读取提取/改写提示词和温度,删除 prompts.py
- settings/active 端点去掉 token 认证(供爬虫/优化器使用)
- 后台设置页新增提示词编辑区和温度调节控件
- 新增 _ensure_default_settings 自动创建默认配置
2026-06-13 17:27:09 +08:00
bwstudio 6d015bb56d feat: AI 生成增强 - 风格/长度/评分/历史/复制
- 后端 prompt 支持风格选择(6种)和字数范围
- 后端 AI 输出改为 JSON 格式,含评分和推荐理由
- GenerateRequest/Response schema 新增 style/length/score/reason 字段
- 前端新增风格选择、字数范围控件
- 前端新增随机选题按钮
- 前端新增复制内容功能
- 前端新增生成历史记录(会话级 20 条)
- 结果卡片展示 AI 评分和推荐理由
2026-06-13 16:04:45 +08:00
bwstudio ab54b0137d style: Sidebar 类型列表改为胶囊标签样式,新增人群分类 2026-06-13 14:43:11 +08:00
bwstudio cab4d6a9bd refactor(web): 前台代码全面改进
- 搜索逻辑改为后端关键词搜索(修复前端100条限制)
- 合并重复的 handleSelectType/handleDrawerSelectType
- 提取 getLevelText 到公共工具 utils/joke.js
- 删除废弃组件 Header.vue
- 删除重复 API searchJokes
- 修复 AppHeader 滚动事件监听未清理
- 统一 Rightbar URL 参数为 type_ids/crowd_ids
- 清除调试 console.log 代码
- 清理未使用导入 watchEffect
- onMounted 请求并行化(Promise.allSettled)
2026-06-12 08:58:23 +08:00
bwstudio 076812f1aa feat: 当月热门笑话 + 统计面板,删除首页清除筛选按钮
- 后端新增 GET /api/jokes/hot-monthly(当月浏览量 top 10)
- 后端新增 GET /api/jokes/stats(公开统计:总数/审核/待审/浏览等)
- 右侧栏热门改为当月热门笑话
- 右侧栏新增网站统计区(笑话总数/已审核/待审核/总浏览)
- 首页删除清除筛选按钮
2026-06-12 05:48:32 +08:00
bwstudio 93b1d725e3 fix: 路由切换时自动滚动到页面顶部 2026-06-11 21:17:32 +08:00
bwstudio 45137c2f4f fix: 详情页「返回首页」改为「返回」,保留前页筛选/分页状态
- 使用 router.back() 返回上一页,保留 URL 参数(分类筛选、页码等)
- 无历史记录时兜底跳转首页
- 图标改为 🔙
2026-06-11 20:53:55 +08:00
bwstudio 8571066931 fix: 翻页后自动滚动到顶部,确保用户看到筛选信息栏 2026-06-11 20:41:25 +08:00
bwstudio 0a85f99d3a fix: 修复左侧大屏 AppSidebar 点击分类无响应
- 左侧大屏侧边栏 @selectType 处理器从空函数改为 handleSelectType
- 之前点击左侧分类列表无任何反应,现已修复
2026-06-11 20:22:58 +08:00
bwstudio fde86b90d2 feat: 新增点踩功能,列表卡片和详情页支持 👍/👎 投票
- 后端: 新增 dislike_count 字段(模型/Schema/数据库迁移)
- 后端: 新增 POST /api/jokes/{id}/dislike 端点
- 后端: 管理后台统计新增 total_dislikes
- 前端: 新增 useVote 组合函数(localStorage 持久化防重复)
- 前端: JokeCard 列表卡片新增 👍/👎 可点击投票按钮
- 前端: 详情页 ❤️ 改为 👍 赞一下 / 👎 踩一脚 双按钮
2026-06-11 19:17:12 +08:00
bwstudio b5194c4965 fix: 修复 AppSidebar 组件未导入导致 prop 序列化错误,大屏幕隐藏副导航避免内容重复
- 添加缺失的 AppSidebar import(修复 types prop 显示为 [object Object] 的问题)
- 大屏幕(≥1200px)隐藏 AppSubNav,避免与 AppSidebar 分类列表重复
- 移除布局调试代码(调试条、updateLayoutInfo、调试 CSS)
2026-06-11 08:12:21 +08:00
bwstudio 1eeadb815b fix: 1024px+ 屏幕填满,去除居中空白 2026-06-10 22:06:08 +08:00
bwstudio 0009aa7e88 fix: 超宽屏填满屏幕,去除左侧空白 2026-06-10 21:41:25 +08:00
bwstudio f0b07bd854 fix: 分类筛选改为单选(手机端操作优化) 2026-06-10 19:46:53 +08:00
bwstudio e203305bac fix: 修复移动端副导航栏未隐藏的问题 2026-06-10 08:24:47 +08:00
bwstudio c55d883524 feat(web): 优化前台移动端适配
- 添加响应式 CSS 变量断点系统
- 移动端(<768px):单列布局,汉堡菜单
- 平板(768-1200px):显示副导航
- 桌面(1200px+):完整三栏布局
- 主页网格使用 auto-fill 自适应列数
- 详情页按钮垂直堆叠显示
- 添加移动端抽屉式分类导航
2026-06-10 07:43:59 +08:00
bwstudio 595394fcb7 feat: 前后台静态资源路径冲突修复 + 用户管理功能
- 修改 admin/vite.config.js 添加 base: '/admin/' 解决静态资源路径问题
- 修复后台 index.html 资源引用从 /assets/ → /admin/assets/
- 更新优化器默认 API 地址为服务器地址
- 添加友链管理相关代码
- 修复多处分类管理页面
2026-06-10 07:30:49 +08:00
45 changed files with 2542 additions and 732 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ request.interceptors.response.use(
err => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
window.location.href = '/admin/login'
}
return Promise.reject(err)
}
+1
View File
@@ -33,6 +33,7 @@
<el-menu-item index="/settings">AI 配置</el-menu-item>
<el-menu-item index="/links">友链管理</el-menu-item>
<el-menu-item index="/feedbacks">反馈管理</el-menu-item>
<el-menu-item index="/users">用户管理</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
+36 -2
View File
@@ -4,7 +4,23 @@
<h2>人群管理</h2>
<el-button type="primary" @click="dialogVisible = true; form = {}">添加</el-button>
</div>
<el-table :data="crowds" style="margin-top: 20px">
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索名称..."
clearable
style="width: 200px"
@input="handleSearch"
>
<template #prefix>
<span>🔍</span>
</template>
</el-input>
<span class="result-count"> {{ filteredCrowds.length }} </span>
</div>
<el-table :data="filteredCrowds" style="margin-top: 16px">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="sort_order" label="排序" width="100" />
@@ -34,15 +50,23 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { getCrowds, createCrowd, updateCrowd, deleteCrowd } from '@/api/category'
import { ElMessage, ElMessageBox } from 'element-plus'
const crowds = ref([])
const dialogVisible = ref(false)
const form = ref({})
const searchKeyword = ref('')
const filteredCrowds = computed(() => {
if (!searchKeyword.value) return crowds.value
const kw = searchKeyword.value.toLowerCase()
return crowds.value.filter(c => c.name.toLowerCase().includes(kw))
})
const loadData = async () => { crowds.value = await getCrowds() }
const handleSearch = () => { /* computed property handles filtering */ }
const handleEdit = (row) => { form.value = { ...row }; dialogVisible.value = true }
const handleSave = async () => {
form.value.id ? await updateCrowd(form.value.id, form.value) : await createCrowd(form.value)
@@ -60,4 +84,14 @@ onMounted(() => loadData())
<style scoped>
.header { display: flex; justify-content: space-between; align-items: center; }
.search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.result-count {
color: #909399;
font-size: 13px;
}
</style>
+39 -2
View File
@@ -4,7 +4,23 @@
<h2>类型管理</h2>
<el-button type="primary" @click="dialogVisible = true; form = {}">添加</el-button>
</div>
<el-table :data="types" style="margin-top: 20px">
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索名称..."
clearable
style="width: 200px"
@input="handleSearch"
>
<template #prefix>
<span>🔍</span>
</template>
</el-input>
<span class="result-count"> {{ filteredTypes.length }} </span>
</div>
<el-table :data="paginatedTypes" style="margin-top: 16px">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="sort_order" label="排序" width="100" />
@@ -34,15 +50,26 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { getTypes, createType, updateType, deleteType } from '@/api/category'
import { ElMessage, ElMessageBox } from 'element-plus'
const types = ref([])
const dialogVisible = ref(false)
const form = ref({})
const searchKeyword = ref('')
const searchResults = ref([])
const filteredTypes = computed(() => {
if (!searchKeyword.value) return types.value
const kw = searchKeyword.value.toLowerCase()
return types.value.filter(t => t.name.toLowerCase().includes(kw))
})
const paginatedTypes = computed(() => filteredTypes.value)
const loadData = async () => { types.value = await getTypes() }
const handleSearch = () => { /* computed property handles filtering */ }
const handleEdit = (row) => { form.value = { ...row }; dialogVisible.value = true }
const handleSave = async () => {
form.value.id ? await updateType(form.value.id, form.value) : await createType(form.value)
@@ -60,4 +87,14 @@ onMounted(() => loadData())
<style scoped>
.header { display: flex; justify-content: space-between; align-items: center; }
.search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.result-count {
color: #909399;
font-size: 13px;
}
</style>
+35 -2
View File
@@ -3,7 +3,20 @@
<div class="header">
<h2>反馈管理</h2>
</div>
<el-table :data="feedbacks" v-loading="loading" style="margin-top: 20px">
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索名称/内容..."
clearable
style="width: 240px"
>
<template #prefix><span>🔍</span></template>
</el-input>
<span class="result-count"> {{ filteredFeedbacks.length }} </span>
</div>
<el-table :data="filteredFeedbacks" v-loading="loading" style="margin-top: 16px">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" width="120" />
<el-table-column prop="email" label="邮箱" width="200" />
@@ -19,12 +32,22 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { getFeedbacks, deleteFeedback } from '@/api/feedback'
import { ElMessage, ElMessageBox } from 'element-plus'
const feedbacks = ref([])
const loading = ref(false)
const searchKeyword = ref('')
const filteredFeedbacks = computed(() => {
if (!searchKeyword.value) return feedbacks.value
const kw = searchKeyword.value.toLowerCase()
return feedbacks.value.filter(f =>
(f.name && f.name.toLowerCase().includes(kw)) ||
(f.content && f.content.toLowerCase().includes(kw))
)
})
const loadFeedbacks = async () => {
loading.value = true
@@ -51,4 +74,14 @@ onMounted(() => loadFeedbacks())
justify-content: space-between;
align-items: center;
}
.search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.result-count {
color: #909399;
font-size: 13px;
}
</style>
+34 -2
View File
@@ -4,7 +4,20 @@
<h2>友链管理</h2>
<el-button type="primary" @click="$router.push('/links/edit')">添加友链</el-button>
</div>
<el-table :data="links" v-loading="loading" style="margin-top: 20px">
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索名称/URL..."
clearable
style="width: 240px"
>
<template #prefix><span>🔍</span></template>
</el-input>
<span class="result-count"> {{ filteredLinks.length }} </span>
</div>
<el-table :data="filteredLinks" v-loading="loading" style="margin-top: 16px">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="url" label="URL" min-width="200">
@@ -26,12 +39,21 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { getLinks, deleteLink } from '@/api/link'
import { ElMessage, ElMessageBox } from 'element-plus'
const links = ref([])
const loading = ref(false)
const searchKeyword = ref('')
const filteredLinks = computed(() => {
if (!searchKeyword.value) return links.value
const kw = searchKeyword.value.toLowerCase()
return links.value.filter(l =>
l.name.toLowerCase().includes(kw) || l.url.toLowerCase().includes(kw)
)
})
const loadLinks = async () => {
loading.value = true
@@ -58,4 +80,14 @@ onMounted(() => loadLinks())
justify-content: space-between;
align-items: center;
}
.search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.result-count {
color: #909399;
font-size: 13px;
}
</style>
+105 -3
View File
@@ -2,10 +2,10 @@
<div class="setting-page">
<h2>AI 配置</h2>
<!-- AI 配置表单 -->
<!-- AI 基础配置 -->
<el-card style="margin-top: 20px">
<template #header>
<span>AI 配置</span>
<span>API 基础配置</span>
</template>
<el-form :model="form" label-width="120px">
<el-form-item label="提供商">
@@ -57,6 +57,81 @@
</el-form>
</el-card>
<!-- 提示词配置 -->
<el-card style="margin-top: 20px">
<template #header>
<span>提示词模板</span>
</template>
<el-form :model="form" label-width="140px">
<el-form-item label="AI 生成笑话">
<el-input
v-model="form.generate_prompt"
type="textarea"
:rows="6"
placeholder="使用 {context}/{style}/{length_requirement} 作为占位符"
style="width: 100%"
/>
</el-form-item>
<el-divider />
<el-form-item label="优化器-质量检测">
<el-input
v-model="form.optimizer_quality_prompt"
type="textarea"
:rows="4"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="质量检测温度">
<el-input-number v-model="form.optimizer_quality_temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-divider />
<el-form-item label="优化器-润色">
<el-input
v-model="form.optimizer_polish_prompt"
type="textarea"
:rows="4"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="润色温度">
<el-input-number v-model="form.optimizer_polish_temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-divider />
<el-form-item label="优化器-评价分类">
<el-input
v-model="form.optimizer_evaluate_prompt"
type="textarea"
:rows="4"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="评价温度">
<el-input-number v-model="form.optimizer_evaluate_temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-divider />
<el-form-item label="爬虫-提取笑话">
<el-input
v-model="form.crawler_extract_prompt"
type="textarea"
:rows="4"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="爬虫-改写">
<el-input
v-model="form.crawler_rewrite_prompt"
type="textarea"
:rows="4"
style="width: 100%"
/>
</el-form-item>
</el-form>
</el-card>
<div style="margin-top: 20px">
<el-button type="primary" @click="handleSave" :loading="saving">保存配置</el-button>
</div>
@@ -106,6 +181,15 @@ const form = ref({
crawl_enabled: false,
crawl_keywords: '冷笑话,段子,谐音梗',
max_pages_per_run: 3,
generate_prompt: '',
optimizer_quality_prompt: '',
optimizer_polish_prompt: '',
optimizer_evaluate_prompt: '',
optimizer_quality_temperature: 0.3,
optimizer_polish_temperature: 0.8,
optimizer_evaluate_temperature: 0.3,
crawler_extract_prompt: '',
crawler_rewrite_prompt: '',
})
const allSettings = ref([])
@@ -126,6 +210,15 @@ const loadData = async () => {
crawl_enabled: active.crawl_enabled,
crawl_keywords: active.crawl_keywords || '',
max_pages_per_run: active.max_pages_per_run,
generate_prompt: active.generate_prompt || '',
optimizer_quality_prompt: active.optimizer_quality_prompt || '',
optimizer_polish_prompt: active.optimizer_polish_prompt || '',
optimizer_evaluate_prompt: active.optimizer_evaluate_prompt || '',
optimizer_quality_temperature: active.optimizer_quality_temperature ?? 0.3,
optimizer_polish_temperature: active.optimizer_polish_temperature ?? 0.8,
optimizer_evaluate_temperature: active.optimizer_evaluate_temperature ?? 0.3,
crawler_extract_prompt: active.crawler_extract_prompt || '',
crawler_rewrite_prompt: active.crawler_rewrite_prompt || '',
}
} catch (e) {
// 没有激活配置,使用默认值
@@ -147,7 +240,7 @@ const handleSave = async () => {
} else {
const created = await createSetting(form.value)
editingId.value = created.id
await toggleSetting(created.id) // 设为激活
await toggleSetting(created.id)
ElMessage.success('创建并激活成功')
}
await loadData()
@@ -170,6 +263,15 @@ const handleEdit = (row) => {
crawl_enabled: row.crawl_enabled,
crawl_keywords: row.crawl_keywords || '',
max_pages_per_run: row.max_pages_per_run,
generate_prompt: row.generate_prompt || '',
optimizer_quality_prompt: row.optimizer_quality_prompt || '',
optimizer_polish_prompt: row.optimizer_polish_prompt || '',
optimizer_evaluate_prompt: row.optimizer_evaluate_prompt || '',
optimizer_quality_temperature: row.optimizer_quality_temperature ?? 0.3,
optimizer_polish_temperature: row.optimizer_polish_temperature ?? 0.8,
optimizer_evaluate_temperature: row.optimizer_evaluate_temperature ?? 0.3,
crawler_extract_prompt: row.crawler_extract_prompt || '',
crawler_rewrite_prompt: row.crawler_rewrite_prompt || '',
}
}
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
'@': path.resolve(__dirname, './src')
}
},
base: '/admin/',
server: {
port: 3001,
proxy: {
+7 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
from sqlalchemy import Column, Integer, String, Text, DateTime, Float, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -12,7 +12,12 @@ class Joke(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(200), nullable=False)
content = Column(Text, nullable=False)
# AI 润色后的内容
polished_content = Column(Text, nullable=True)
# AI 评分 (1-10)
ai_score = Column(Float, nullable=True)
# AI 质量等级: excellent/good/ordinary/poor
ai_level = Column(String(20), nullable=True)
type_ids = Column(Text, nullable=True)
crowd_ids = Column(Text, nullable=True)
type_id = Column(Integer, ForeignKey("joke_types.id"), nullable=True)
@@ -20,6 +25,7 @@ class Joke(Base):
status = Column(String(20), default="pending")
view_count = Column(Integer, default=0)
like_count = Column(Integer, default=0)
dislike_count = Column(Integer, default=0)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
+2
View File
@@ -11,4 +11,6 @@ class Link(Base):
url = Column(String(500), nullable=False)
description = Column(String(200), nullable=True)
sort_order = Column(Integer, default=0)
status = Column(String(20), default="approved") # approved=已通过, pending=待审核
contact = Column(String(100), nullable=True) # 申请联系方式
created_at = Column(DateTime, default=func.now())
+13
View File
@@ -14,6 +14,19 @@ class AiSetting(Base):
temperature = Column(Float, nullable=False, default=0.7)
max_tokens = Column(Integer, nullable=False, default=2048)
# 提示词模板
generate_prompt = Column(Text, nullable=True, comment="AI 笑话生成提示词")
optimizer_quality_prompt = Column(Text, nullable=True, comment="优化器-质量检测提示词")
optimizer_polish_prompt = Column(Text, nullable=True, comment="优化器-润色提示词")
optimizer_evaluate_prompt = Column(Text, nullable=True, comment="优化器-评价分类提示词")
crawler_extract_prompt = Column(Text, nullable=True, comment="爬虫-笑话提取提示词")
crawler_rewrite_prompt = Column(Text, nullable=True, comment="爬虫-改写提示词")
# 优化器各阶段温度
optimizer_quality_temperature = Column(Float, nullable=False, default=0.3)
optimizer_polish_temperature = Column(Float, nullable=False, default=0.8)
optimizer_evaluate_temperature = Column(Float, nullable=False, default=0.3)
crawl_enabled = Column(Boolean, nullable=False, default=False)
crawl_keywords = Column(Text, nullable=True, default="")
max_pages_per_run = Column(Integer, nullable=False, default=3)
+3
View File
@@ -87,6 +87,7 @@ def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
status=joke.status,
view_count=joke.view_count,
like_count=joke.like_count,
dislike_count=joke.dislike_count,
created_at=joke.created_at,
updated_at=joke.updated_at,
type_names=type_names,
@@ -224,6 +225,7 @@ def admin_stats(
rejected_jokes = db.query(Joke).filter(Joke.status == "rejected").count()
total_views = db.query(Joke).with_entities(func.sum(Joke.view_count)).scalar() or 0
total_likes = db.query(Joke).with_entities(func.sum(Joke.like_count)).scalar() or 0
total_dislikes = db.query(Joke).with_entities(func.sum(Joke.dislike_count)).scalar() or 0
return {
"total_jokes": total_jokes,
"approved_jokes": approved_jokes,
@@ -231,6 +233,7 @@ def admin_stats(
"rejected_jokes": rejected_jokes,
"total_views": total_views,
"total_likes": total_likes,
"total_dislikes": total_dislikes,
}
+14 -3
View File
@@ -9,10 +9,11 @@ from app.config import JWT_ALGORITHM, JWT_EXPIRATION_HOURS, JWT_SECRET_KEY
from app.database import get_db
from app.models.user import AdminUser
from app.schemas.auth import LoginRequest, TokenResponse
from app.schemas.user import UserCreate, UserResponse, UserRegister
router = APIRouter(prefix="/auth", tags=["认证"])
# 已存在的函数保持不变...
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
@@ -27,11 +28,21 @@ def create_access_token(data: dict) -> str:
return encoded_jwt
def hash_password(password: str) -> str:
"""哈希密码"""
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def get_current_user():
"""获取当前用户(保留用于后续权限控制)"""
pass
@router.post("/login", response_model=TokenResponse)
def login(req: LoginRequest, db: Session = Depends(get_db)):
"""管理员登录"""
"""登录"""
user = db.query(AdminUser).filter(AdminUser.username == req.username).first()
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
access_token = create_access_token(data={"sub": str(user.id), "username": user.username, "role": user.role})
return TokenResponse(access_token=access_token)
+94 -24
View File
@@ -1,5 +1,6 @@
"""智能笑话生成器 API"""
"""AI 笑话生成器 API — 提示词从数据库读取"""
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from openai import OpenAI
@@ -12,26 +13,60 @@ from app.schemas.joke import GenerateRequest, GenerateResponse
router = APIRouter(prefix="/generate", tags=["生成器"])
STYLE_MAP = {
"cold": "冷幽默 / 无厘头",
"warm": "温馨幽默 / 暖心搞笑",
"twist": "反转 / 神转折",
"pun": "谐音梗 / 文字游戏",
"sketch": "段子 / 吐槽调侃",
"irony": "讽刺幽默 / 黑色幽默",
}
# AI Prompt
GENERATION_PROMPT = """你是一位幽默大师,专门创作轻松搞笑的短笑话。
LENGTH_MAP = {
"short": "30-80字,非常简短",
"medium": "80-150字,正常长度",
"long": "150-300字,可以描述一个小场景",
}
def _load_prompt(setting: AiSetting) -> str:
"""从数据库读取生成提示词,没有则返回默认值"""
prompt = setting.generate_prompt
if not prompt or not prompt.strip():
prompt = GENERATE_PROMPT_DEFAULT
return prompt
GENERATE_PROMPT_DEFAULT = """你是一位幽默大师,专门创作轻松搞笑的短笑话。
{context}
要求:
1. 根据场景和关键词创作一条原创笑话
2. 笑话要有反转或意外结局
3. 语言简洁,30-150字
4. 直接输出笑话内容,不需要解释
3. 语言风格:{style}
4. 字数要求:{length_requirement}
5. 直接输出笑话内容,不需要解释
格式
标题:xxx
内容:xxx
"""
请严格按照以下 JSON 格式输出,不要加任何额外说明
{{
"title": "笑话标题",
"content": "笑话正文",
"score": 8,
"reason": "这个笑话巧妙结合了场景和关键词,结尾有反转"
}}
score 是 1-10 的整数评分,reason 是用一句话说明亮点。"""
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",
setting: AiSetting | None = None,
) -> str:
"""构建 AI prompt,支持风格和长度控制"""
parts = []
if scenarios:
parts.append(f"场景:{', '.join(scenarios)}")
@@ -39,7 +74,13 @@ 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))
prompt_template = _load_prompt(setting) if setting else GENERATE_PROMPT_DEFAULT
return prompt_template.format(
context="\n".join(parts),
style=STYLE_MAP.get(style, "反转 / 神转折"),
length_requirement=LENGTH_MAP.get(length, "80-150字,正常长度"),
)
@router.post("", response_model=GenerateResponse)
@@ -47,50 +88,81 @@ 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:
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)
prompt = _build_prompt(req.scenarios, req.keywords, req.style, req.length, setting)
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)
raw = raw.strip()
if raw.startswith("```"):
lines = raw.split("\n")
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:
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 输入
"""解析 AI 返回内容(非 JSON 回退)"""
if not raw or not raw.strip():
raise ValueError("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()
# 只替换这一行,不要 replace 全局
lines = content.split("\n")
for i, l in enumerate(lines):
if l.strip() == line:
@@ -99,14 +171,12 @@ def _parse_response(raw: str | None) -> GenerateResponse:
content = "\n".join(lines).strip()
break
# 如果没有提取到标题,取第一行
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
# 防御:content 不能为空
if not content.strip():
content = "(内容生成失败,请重新生成)"
+174 -60
View File
@@ -1,8 +1,9 @@
import json
import random
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func
from sqlalchemy import func, text
from sqlalchemy.orm import Session
from app.database import get_db
@@ -24,26 +25,49 @@ def _parse_ids(raw) -> list[int]:
return []
def _get_type_names(db: Session, ids: list[int]) -> list[str]:
if not ids:
return []
rows = db.query(JokeType).filter(JokeType.id.in_(ids)).all()
return [r.name for r in rows]
def _batch_load_categories(db: Session, jokes: list[Joke]) -> tuple[dict, dict]:
"""批量加载类型和人群名称,避免 N+1 查询"""
# 收集所有需要的 id
all_type_ids = set()
all_crowd_ids = set()
for joke in jokes:
all_type_ids.update(_parse_ids(joke.type_ids))
all_crowd_ids.update(_parse_ids(joke.crowd_ids))
# 批量查询
type_map = {}
if all_type_ids:
types = db.query(JokeType).filter(JokeType.id.in_(all_type_ids)).all()
type_map = {t.id: t.name for t in types}
crowd_map = {}
if all_crowd_ids:
crowds = db.query(JokeCrowd).filter(JokeCrowd.id.in_(all_crowd_ids)).all()
crowd_map = {c.id: c.name for c in crowds}
return type_map, crowd_map
def _get_crowd_names(db: Session, ids: list[int]) -> list[str]:
if not ids:
return []
rows = db.query(JokeCrowd).filter(JokeCrowd.id.in_(ids)).all()
return [r.name for r in rows]
def joke_to_response(joke: Joke, type_map: dict = None, crowd_map: dict = None) -> JokeResponse:
"""Convert Joke model to JokeResponse schema.
def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
"""Convert Joke model to JokeResponse schema."""
Args:
type_map: 预加载的类型 id→name 映射
crowd_map: 预加载的人群 id→name 映射
"""
ids = _parse_ids(joke.type_ids)
crowd_ids = _parse_ids(joke.crowd_ids)
type_names = _get_type_names(db, ids) if db else []
crowd_names = _get_crowd_names(db, crowd_ids) if db else []
if type_map is not None:
type_names = [type_map[i] for i in ids if i in type_map]
else:
type_names = []
if crowd_map is not None:
crowd_names = [crowd_map[i] for i in crowd_ids if i in crowd_map]
else:
crowd_names = []
return JokeResponse(
id=joke.id,
title=joke.title,
@@ -53,81 +77,99 @@ def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
status=joke.status,
view_count=joke.view_count,
like_count=joke.like_count,
dislike_count=joke.dislike_count,
created_at=joke.created_at,
updated_at=joke.updated_at,
type_names=type_names,
crowd_names=crowd_names,
polished_content=joke.polished_content,
ai_score=joke.ai_score,
ai_level=joke.ai_level,
)
def _build_json_filter(column, target_ids: set[int]) -> list:
"""构建精确的 JSON 数组匹配过滤器(SQLite"""
filters = []
for tid in target_ids:
# 匹配 [n,...] 或 [...,n,...] 或 [...,n]
# 使用 JSON_EACH 函数检查
json_str = json.dumps(tid)
filters.append(
func.json_extract(column, '$').cast(type(target_ids)).contains(tid)
)
return filters
@router.get("/", response_model=PaginatedJokeResponse)
def list_jokes(
page: int = 1,
page_size: int = 20,
type_ids: str | None = Query(None, description="逗号分隔的类型 ID,如 1,3,5"),
crowd_ids: str | None = Query(None, description="逗号分隔的人群 ID,如 2,4"),
keyword: str | None = Query(None, description="关键词搜索(标题+内容)"),
db: Session = Depends(get_db),
):
"""获取笑话列表(仅返回已审核通过的笑话)"""
query = db.query(Joke).filter(Joke.status == "approved")
# 关键词搜索:标题和内容模糊匹配
if keyword:
kw = f"%{keyword}%"
query = query.filter(
(Joke.title.like(kw)) | (Joke.content.like(kw))
)
# 支持多选过滤:逗号分隔的 ID
# 使用自定义 JSON 匹配函数避免 LIKE %N% 的错误匹配
if type_ids:
filter_set = {int(x.strip()) for x in type_ids.split(",") if x.strip().isdigit()}
if filter_set:
# 兼容旧单值字段 type_id
# 使用文本匹配,确保精确匹配 JSON 数组中的 id
# 匹配模式:[1,2,3] 或 [1] 或带尾部逗号的
type_filters = []
for tid in filter_set:
# 构建精确匹配的正则:在逗号、引号、方括号包围中的数字
import re
pattern = rf'["\s\[,]{tid}[",\s\]]'
type_filters.append(Joke.type_ids.regexp_match(pattern))
# 只要匹配任一类型即可
type_filter = type_filters[0]
for f in type_filters[1:]:
type_filter = type_filter | f
# 还需要兼容旧的单值 type_id 字段
old_filter = Joke.type_id.in_(filter_set)
# 兼容新数组字段 type_ids(JSON 包含任一)
new_filter = [
Joke.type_ids.contains(str(tid)) for tid in filter_set
]
combined = old_filter
for nf in new_filter:
combined = combined | nf
query = query.filter(combined)
query = query.filter(old_filter | type_filter)
if crowd_ids:
filter_set = {int(x.strip()) for x in crowd_ids.split(",") if x.strip().isdigit()}
if filter_set:
import re
crowd_filters = []
for cid in filter_set:
pattern = rf'["\s\[,]{cid}[",\s\]]'
crowd_filters.append(Joke.crowd_ids.regexp_match(pattern))
crowd_filter = crowd_filters[0]
for f in crowd_filters[1:]:
crowd_filter = crowd_filter | f
old_filter = Joke.crowd_id.in_(filter_set)
new_filter = [
Joke.crowd_ids.contains(str(cid)) for cid in filter_set
]
combined = old_filter
for nf in new_filter:
combined = combined | nf
query = query.filter(combined)
query = query.filter(old_filter | crowd_filter)
total = query.count()
offset = (page - 1) * page_size
jokes = query.order_by(Joke.created_at.desc()).offset(offset).limit(page_size).all()
# 批量预加载类型和人群名称
type_map, crowd_map = _batch_load_categories(db, jokes)
return PaginatedJokeResponse(
items=[joke_to_response(j, db) for j in jokes],
items=[joke_to_response(j, type_map, crowd_map) for j in jokes],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{joke_id}", response_model=JokeResponse)
def get_joke(joke_id: int, db: Session = Depends(get_db)):
"""获取单条笑话详情"""
# 只返回已审核通过的笑话
joke = db.query(Joke).filter(
Joke.id == joke_id,
Joke.status == "approved"
).first()
if not joke:
raise HTTPException(status_code=404, detail="笑话不存在")
# 使用原子更新避免并发竞态
db.query(Joke).filter(Joke.id == joke_id).update({Joke.view_count: Joke.view_count + 1})
db.commit()
# 重新查询获取更新后的数据
db.refresh(joke)
return joke_to_response(joke, db)
@router.get("/random", response_model=JokeResponse)
def get_random_joke(db: Session = Depends(get_db)):
"""随机获取一条已审核通过的笑话"""
@@ -144,19 +186,61 @@ def get_random_joke(db: Session = Depends(get_db)):
Joke.status == "approved"
).first()
if joke:
return joke_to_response(joke, db)
type_map, crowd_map = _batch_load_categories(db, [joke])
return joke_to_response(joke, type_map, crowd_map)
# 兜底:全表随机
joke = db.query(Joke).filter(Joke.status == "approved").order_by(func.random()).first()
if not joke:
raise HTTPException(status_code=404, detail="暂无笑话")
return joke_to_response(joke, db)
type_map, crowd_map = _batch_load_categories(db, [joke])
return joke_to_response(joke, type_map, crowd_map)
@router.post("/{joke_id}/like")
def like_joke(joke_id: int, db: Session = Depends(get_db)):
"""为笑话点赞(仅允许已审核通过的笑话)"""
# 先检查笑话是否存在且已审核
@router.get("/hot-monthly")
def hot_monthly_jokes(db: Session = Depends(get_db)):
"""当月热门笑话:本月浏览量最高的前 10 条"""
now = datetime.now()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
jokes = (
db.query(Joke)
.filter(
Joke.status == "approved",
Joke.created_at >= start_of_month,
)
.order_by(Joke.view_count.desc())
.limit(10)
.all()
)
type_map, crowd_map = _batch_load_categories(db, jokes)
return [joke_to_response(j, type_map, crowd_map) for j in jokes]
@router.get("/stats")
def public_stats(db: Session = Depends(get_db)):
"""公开统计数据:笑话总数、审核/待审/拒绝数量、总浏览/赞/踩"""
total = db.query(Joke).count()
approved = db.query(Joke).filter(Joke.status == "approved").count()
pending = db.query(Joke).filter(Joke.status == "pending").count()
rejected = db.query(Joke).filter(Joke.status == "rejected").count()
total_views = db.query(Joke).with_entities(func.sum(Joke.view_count)).scalar() or 0
total_likes = db.query(Joke).with_entities(func.sum(Joke.like_count)).scalar() or 0
total_dislikes = db.query(Joke).with_entities(func.sum(Joke.dislike_count)).scalar() or 0
return {
"total_jokes": total,
"approved_jokes": approved,
"pending_jokes": pending,
"rejected_jokes": rejected,
"total_views": total_views,
"total_likes": total_likes,
"total_dislikes": total_dislikes,
}
@router.get("/{joke_id}", response_model=JokeResponse)
def get_joke(joke_id: int, db: Session = Depends(get_db)):
"""获取单条笑话详情"""
# 只返回已审核通过的笑话
joke = db.query(Joke).filter(
Joke.id == joke_id,
Joke.status == "approved"
@@ -164,8 +248,38 @@ def like_joke(joke_id: int, db: Session = Depends(get_db)):
if not joke:
raise HTTPException(status_code=404, detail="笑话不存在")
# 使用原子更新避免并发竞态
db.query(Joke).filter(Joke.id == joke_id).update({Joke.like_count: Joke.like_count + 1})
db.query(Joke).filter(Joke.id == joke_id).update({Joke.view_count: Joke.view_count + 1})
db.commit()
# 获取更新后的
# 重新查询获取更新后的数据
db.refresh(joke)
return {"message": "点赞成功", "like_count": joke.like_count}
# 批量加载类型和人群名称
type_map, crowd_map = _batch_load_categories(db, [joke])
return joke_to_response(joke, type_map, crowd_map)
def _vote_joke(joke_id: int, field: str, db: Session):
"""通用投票逻辑:点赞或点踩(仅允许已审核通过的笑话)"""
joke = db.query(Joke).filter(
Joke.id == joke_id,
Joke.status == "approved"
).first()
if not joke:
raise HTTPException(status_code=404, detail="笑话不存在")
# 使用原子更新避免并发竞态
update_data = {getattr(Joke, field): getattr(Joke, field) + 1}
db.query(Joke).filter(Joke.id == joke_id).update(update_data)
db.commit()
db.refresh(joke)
return {"message": "操作成功", field: getattr(joke, field)}
@router.post("/{joke_id}/like")
def like_joke(joke_id: int, db: Session = Depends(get_db)):
"""为笑话点赞(仅允许已审核通过的笑话)"""
return _vote_joke(joke_id, "like_count", db)
@router.post("/{joke_id}/dislike")
def dislike_joke(joke_id: int, db: Session = Depends(get_db)):
"""为笑话点踩(仅允许已审核通过的笑话)"""
return _vote_joke(joke_id, "dislike_count", db)
+27 -3
View File
@@ -3,12 +3,36 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models.link import Link
from app.schemas.link import LinkCreate, LinkResponse
from app.schemas.link import LinkCreate, LinkResponse, LinkApply
router = APIRouter(tags=["友情链接"])
@router.get("/links", response_model=list[LinkResponse])
def list_links(db: Session = Depends(get_db)):
"""公开:获取所有友情链接"""
return db.query(Link).order_by(Link.sort_order, Link.id).all()
"""公开:获取已通过审核的友情链接"""
return db.query(Link).filter(Link.status == "approved").order_by(Link.sort_order, Link.id).all()
@router.post("/links/apply", response_model=LinkResponse)
def apply_link(link_data: LinkApply, db: Session = Depends(get_db)):
"""公开:申请友链"""
# 检查是否已存在同名或同URL的申请/已通过友链
existing = db.query(Link).filter(
(Link.name == link_data.name) | (Link.url == link_data.url)
).first()
if existing:
return existing # 已存在则返回已有记录
link = Link(
name=link_data.name,
url=link_data.url,
description=link_data.description,
contact=link_data.contact,
status="pending",
sort_order=999, # 新申请放在最后
)
db.add(link)
db.commit()
db.refresh(link)
return link
+22 -4
View File
@@ -22,12 +22,12 @@ def list_settings(
@router.get("/active")
def get_active_setting(
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""获取当前激活的 AI 配置(爬虫调用,无需用户认证,token 校验仍保留"""
"""获取当前激活的 AI 配置(无需认证,供爬虫/优化器使用"""
setting = db.query(AiSetting).filter(AiSetting.is_active == True).first()
if not setting:
raise HTTPException(status_code=404, detail="未找到激活的 AI 配置")
from app.routers.settings import _ensure_default_settings
setting = _ensure_default_settings(db)
return setting
@@ -93,4 +93,22 @@ def delete_setting(
raise HTTPException(status_code=404, detail="配置不存在")
db.delete(db_setting)
db.commit()
return {"message": "删除成功"}
return {"message": "删除成功"}
def _ensure_default_settings(db: Session) -> AiSetting:
"""当没有激活配置时,创建一条默认配置"""
existing = db.query(AiSetting).first()
if existing:
existing.is_active = True
db.commit()
return existing
defaults = AiSetting(
is_active=True,
# 默认提示词会在 main.py 迁移时填充
)
db.add(defaults)
db.commit()
db.refresh(defaults)
return defaults
+30 -1
View File
@@ -18,6 +18,8 @@ class JokeUpdate(BaseModel):
title: str | None = None
content: str | None = None
polished_content: str | None = None
ai_score: float | None = None
ai_level: str | None = None
type_ids: list[int] | None = None
crowd_ids: list[int] | None = None
status: str | None = None
@@ -27,8 +29,12 @@ class JokeResponse(JokeBase):
id: int
status: str
polished_content: str | None = None
# AI 评价字段
ai_score: float | None = None
ai_level: str | None = None
view_count: int
like_count: int
dislike_count: int
created_at: datetime
updated_at: datetime
type_names: list[str] | None = None
@@ -37,6 +43,21 @@ class JokeResponse(JokeBase):
class Config:
from_attributes = True
@property
def display_content(self) -> str:
"""优先显示润色后的内容"""
return self.polished_content if self.polished_content else self.content
def get_level_display(self) -> str:
"""获取 AI 评级的中文显示"""
level_map = {
'excellent': '⭐⭐⭐ 精品',
'good': '⭐⭐ 良好',
'ordinary': '⭐ 普通',
'poor': '⚠️ 待优化'
}
return level_map.get(self.ai_level, '') if self.ai_level else ''
class PaginatedJokeResponse(BaseModel):
items: list[JokeResponse]
@@ -53,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,
}
}
@@ -67,6 +94,8 @@ class GenerateResponse(BaseModel):
"""笑话生成响应"""
title: str
content: str
score: int = 0
reason: str = ""
created_at: datetime | None = None
class Config:
+11 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, HttpUrl
class LinkCreate(BaseModel):
@@ -10,8 +10,18 @@ class LinkCreate(BaseModel):
sort_order: int = 0
class LinkApply(BaseModel):
"""友链申请"""
name: str
url: str
description: str | None = None
contact: str | None = None # 联系方式
class LinkResponse(LinkCreate):
id: int
status: str
contact: str | None = None
created_at: datetime
class Config:
+15 -1
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, Field
class AiSettingBase(BaseModel):
@@ -8,6 +8,20 @@ class AiSettingBase(BaseModel):
model_name: str = Field(default="nvidia/llama-3.1-nemotron-70b-instruct")
temperature: float = Field(default=0.7, ge=0, le=2)
max_tokens: int = Field(default=2048, ge=1)
# 提示词
generate_prompt: str = Field(default="")
optimizer_quality_prompt: str = Field(default="")
optimizer_polish_prompt: str = Field(default="")
optimizer_evaluate_prompt: str = Field(default="")
crawler_extract_prompt: str = Field(default="")
crawler_rewrite_prompt: str = Field(default="")
# 优化器各阶段温度
optimizer_quality_temperature: float = Field(default=0.3, ge=0, le=2)
optimizer_polish_temperature: float = Field(default=0.8, ge=0, le=2)
optimizer_evaluate_temperature: float = Field(default=0.3, ge=0, le=2)
crawl_enabled: bool = Field(default=False)
crawl_keywords: str = Field(default="")
max_pages_per_run: int = Field(default=3, ge=1)
+117
View File
@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.config import API_TITLE, API_VERSION
from app.routers import jokes_router, categories_router, auth_router, admin_router, settings_router, links_router, feedback_router, generate_router
from app.routers.submit import router as submit_router
from app.database import Base, engine
from app.models.setting import AiSetting
from app.models.link import Link
@@ -11,6 +12,121 @@ from app.models.feedback import Feedback # 注册模型,确保 create_all 能
# 创建新表(如果不存在)
Base.metadata.create_all(bind=engine)
# 对已有表新增字段的兼容迁移(SQLite 不支持 ALTER TABLE ADD COLUMN IF NOT EXISTS
# 默认提示词
_GENERATE_PROMPT_DEFAULT = """你是一位幽默大师,专门创作轻松搞笑的短笑话。
{context}
要求:
1. 根据场景和关键词创作一条原创笑话
2. 笑话要有反转或意外结局
3. 语言风格:{style}
4. 字数要求:{length_requirement}
5. 直接输出笑话内容,不需要解释
请严格按照以下 JSON 格式输出,不要加任何额外说明:
{{
"title": "笑话标题",
"content": "笑话正文",
"score": 8,
"reason": "这个笑话巧妙结合了场景和关键词,结尾有反转"
}}
score 是 1-10 的整数评分,reason 是用一句话说明亮点。"""
_OPTIMIZER_QUALITY_DEFAULT = """你是一个幽默内容审核专家。判断以下内容是否是一个合格的笑话/段子。
合格标准(满足任一即可):
1. 有明确的笑点或反转(punchline)
2. 有幽默的语言表达或双关
3. 有意外结局或情理之中意料之外
不合格标准(符合任一即判定不合格):
1. 纯粹的事实陈述,没有任何幽默元素
2. 只是对话片段,没有笑点
3. 普通故事或叙事,没有幽默设计
4. 说教或道理阐述
5. 内容不完整或难以理解
始终返回 JSON 格式:{"has_punchline": true/false, "reason": "简要说明判断理由"}"""
_OPTIMIZER_POLISH_DEFAULT = """你是一个专业的幽默文案编辑。请润色以下笑话,要求:
1. 保持核心笑点不变
2. 优化语言表达,使其更通顺、更精炼
3. 增强节奏感和幽默效果,但不改变原意
4. 字数控制在原内容的 80%-120%
5. 不要添加额外解释或评论
6. 直接输出润色后的内容,不要加任何前缀"""
_OPTIMIZER_EVALUATE_DEFAULT = """你是一个笑话分类和评价专家。对给定的笑话进行分析,返回 JSON 格式的分类和评分结果。
要求:
1. types: 从提供的类型列表中选择所有匹配的类型名称(数组,可以选多个)
2. crowds: 从提供的人群列表中选择所有匹配的人群名称(数组,可以选多个)
3. score: 1-10 分,基于幽默程度、创意和表达效果
4. comment: 简短评语(10字以内)
始终返回 JSON 格式。"""
_CRAWLER_EXTRACT_DEFAULT = """你是一个笑话提取专家。从给定的网页文本中识别并提取所有笑话、幽默段子或有趣内容。
要求:
1. 只返回真正的笑话内容,不要提取普通文章或新闻
2. 每条笑话需要包含:title(简短标题)、content(完整笑话内容)、type(类型)、crowd(人群)
3. 如果网页中没有笑话,返回空数组 []
4. 永远返回合法的 JSON 格式,根节点为数组或包含 jokes 键的对象"""
_CRAWLER_REWRITE_DEFAULT = """你是一个幽默作家,负责润色和改写笑话。
要求:
1. 保持笑话的核心笑点不变
2. 语言更通顺、更幽默
3. 字数控制在原内容的 80%-120% 之间
4. 不要添加任何解释说明"""
def _migrate_db():
from sqlalchemy import inspect, text
inspector = inspect(engine)
columns = [c["name"] for c in inspector.get_columns("ai_settings")]
# 新增提示词字段
prompt_fields = {
"generate_prompt": _GENERATE_PROMPT_DEFAULT,
"optimizer_quality_prompt": _OPTIMIZER_QUALITY_DEFAULT,
"optimizer_polish_prompt": _OPTIMIZER_POLISH_DEFAULT,
"optimizer_evaluate_prompt": _OPTIMIZER_EVALUATE_DEFAULT,
"crawler_extract_prompt": _CRAWLER_EXTRACT_DEFAULT,
"crawler_rewrite_prompt": _CRAWLER_REWRITE_DEFAULT,
}
for field, default_val in prompt_fields.items():
if field not in columns:
from sqlalchemy import Text
col_type = "TEXT"
with engine.connect() as conn:
conn.execute(text(f"ALTER TABLE ai_settings ADD COLUMN {field} {col_type}"))
conn.commit()
# 新增 temperature 字段
temp_fields = [
("optimizer_quality_temperature", "FLOAT", "0.3"),
("optimizer_polish_temperature", "FLOAT", "0.8"),
("optimizer_evaluate_temperature", "FLOAT", "0.3"),
]
for field, col_type, default_val in temp_fields:
if field not in columns:
with engine.connect() as conn:
conn.execute(text(f"ALTER TABLE ai_settings ADD COLUMN {field} {col_type} DEFAULT {default_val}"))
conn.commit()
# 给新增字段填充默认值(已有记录)
with engine.connect() as conn:
for field, default_val in prompt_fields.items():
conn.execute(text(f"UPDATE ai_settings SET {field} = :val WHERE {field} IS NULL"),
{"val": default_val})
conn.commit()
_migrate_db()
# Create FastAPI application
app = FastAPI(title=API_TITLE, version=API_VERSION)
@@ -32,6 +148,7 @@ app.include_router(settings_router, prefix="/api")
app.include_router(links_router, prefix="/api")
app.include_router(feedback_router, prefix="/api")
app.include_router(generate_router, prefix="/api")
app.include_router(submit_router, prefix="/api/jokes") # 公开提交接口
@app.get("/")
+1
View File
@@ -1,5 +1,6 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
gunicorn==21.2.0
sqlalchemy==2.0.25
pydantic==2.5.3
python-jose[cryptography]==3.3.0
+80 -20
View File
@@ -1,35 +1,90 @@
"""LLM 处理:调用 NVIDIA NIMOpenAI 兼容 API)进行笑话提取、改写和分类"""
"""LLM 处理:调用 NVIDIA NIMOpenAI 兼容 API)进行笑话提取、改写。"""
import json
import os
import httpx
from openai import OpenAI
class AiService:
def __init__(self, api_base: str, api_key: str, model_name: str, temperature: float = 0.7, max_tokens: int = 2048):
self.client = OpenAI(base_url=api_base, api_key=api_key)
self.model = model_name
self.temperature = temperature
self.max_tokens = max_tokens
def __init__(self, api_base: str, username: str, password: str):
self.api_base = api_base.rstrip("/")
self.username = username
self.password = password
self.token = None
self.client = None
self.model = ""
self.ai_config = None
def _login(self) -> str:
resp = httpx.post(
f"{self.api_base}/api/auth/login",
json={"username": self.username, "password": self.password},
timeout=30,
)
resp.raise_for_status()
return resp.json()["access_token"]
def _get(self, path: str) -> dict | list:
resp = httpx.get(
f"{self.api_base}{path}",
headers={"Authorization": f"Bearer {self.token}"},
timeout=30,
)
resp.raise_for_status()
return resp.json()
def setup(self):
"""登录并从 API 获取 AI 配置(含提示词和温度)"""
self.token = self._login()
self.ai_config = self._get("/api/admin/settings/active")
self.client = OpenAI(
base_url=self.ai_config["api_base"],
api_key=self.ai_config["api_key"],
)
self.model = self.ai_config["model_name"]
def _get_prompt(self, key: str, default: str) -> str:
if self.ai_config:
val = self.ai_config.get(key)
if val and val.strip():
return val
return default
def extract_jokes(self, page_content: str, known_types: list[str], known_crowds: list[str]) -> list[dict]:
"""从页面内容中提取笑话,返回结构化数据。"""
from crawler.prompts import EXTRACTION_SYSTEM_PROMPT, EXTRACTION_USER_PROMPT
system_prompt = self._get_prompt("crawler_extract_prompt",
"""你是一个笑话提取专家。从给定的网页文本中识别并提取所有笑话、幽默段子或有趣内容。
要求:
1. 只返回真正的笑话内容,不要提取普通文章或新闻
2. 每条笑话需要包含:title(简短标题)、content(完整笑话内容)、type(类型)、crowd(人群)
3. 如果网页中没有笑话,返回空数组 []
4. 永远返回合法的 JSON 格式,根节点为数组或包含 jokes 键的对象""")
user_prompt = EXTRACTION_USER_PROMPT.format(
page_content=page_content[:8000],
known_types=", ".join(known_types),
known_crowds=", ".join(known_crowds),
)
user_prompt = f"""网页内容:
---
{page_content[:8000]}
---
已知笑话类型:{', '.join(known_types)}
已知人群分类:{', '.join(known_crowds)}
请提取所有笑话,以 JSON 格式返回,示例:
[
{{"title": "程序员的幽默", "content": "程序员去相亲...", "types": ["谐音梗", "段子"], "crowds": ["职场", "大学生"]}},
{{"title": "...", "content": "...", "types": ["..."], "crowds": ["..."]}}
]
注意:types 和 crowds 是数组,可以填多个。
只返回 JSON,不要其他文字。"""
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": EXTRACTION_SYSTEM_PROMPT},
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=self.temperature,
max_tokens=self.max_tokens,
temperature=self.ai_config.get("temperature", 0.7) if self.ai_config else 0.7,
max_tokens=self.ai_config.get("max_tokens", 2048) if self.ai_config else 2048,
)
raw = response.choices[0].message.content
@@ -37,13 +92,19 @@ class AiService:
def rewrite_joke(self, content: str) -> str:
"""润色单条笑话内容。"""
from crawler.prompts import REWRITE_SYSTEM_PROMPT, REWRITE_USER_PROMPT
system_prompt = self._get_prompt("crawler_rewrite_prompt",
"""你是一个幽默作家,负责润色和改写笑话。
要求:
1. 保持笑话的核心笑点不变
2. 语言更通顺、更幽默
3. 字数控制在原内容的 80%-120% 之间
4. 不要添加任何解释说明""")
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": REWRITE_SYSTEM_PROMPT},
{"role": "user", "content": REWRITE_USER_PROMPT.format(content=content)},
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"请润色以下笑话:\n\n{content}"},
],
temperature=0.8,
max_tokens=500,
@@ -60,7 +121,6 @@ class AiService:
jokes = data
return [j for j in jokes if isinstance(j, dict) and j.get("content")]
except json.JSONDecodeError:
# 尝试提取 markdown 代码块
if "```json" in raw:
raw = raw.split("```json")[1].split("```")[0]
elif "```" in raw:
+5 -7
View File
@@ -57,15 +57,13 @@ class Processor:
self.token = self._login()
print("[*] 登录成功")
ai_config = self._get("/api/admin/settings/active")
self.ai = AiService(
api_base=ai_config["api_base"],
api_key=ai_config["api_key"],
model_name=ai_config["model_name"],
temperature=ai_config.get("temperature", 0.7),
max_tokens=ai_config.get("max_tokens", 2048),
api_base=self.api_base,
username=self.username,
password=self.password,
)
print(f"[*] AI 配置: {ai_config['model_name']}")
self.ai.setup()
print(f"[*] AI 配置: {self.ai.model}")
self.types = self._get("/api/categories/types")
self.crowds = self._get("/api/categories/crowds")
-41
View File
@@ -1,41 +0,0 @@
"""AI 提示词模板。"""
# ===== 笑话提取 =====
EXTRACTION_SYSTEM_PROMPT = """你是一个笑话提取专家。从给定的网页文本中识别并提取所有笑话、幽默段子或有趣内容。
要求:
1. 只返回真正的笑话内容,不要提取普通文章或新闻
2. 每条笑话需要包含:title(简短标题)、content(完整笑话内容)、type(类型)、crowd(人群)
3. 如果网页中没有笑话,返回空数组 []
4. 永远返回合法的 JSON 格式,根节点为数组或包含 jokes 键的对象"""
EXTRACTION_USER_PROMPT = """网页内容:
---
{page_content}
---
已知笑话类型:{known_types}
已知人群分类:{known_crowds}
请提取所有笑话,以 JSON 格式返回,示例:
[
{{"title": "程序员的幽默", "content": "程序员去相亲...", "types": ["谐音梗", "段子"], "crowds": ["职场", "大学生"]}},
{{"title": "...", "content": "...", "types": ["..."], "crowds": ["..."]}}
]
注意:types 和 crowds 是数组,可以填多个。
只返回 JSON,不要其他文字。"""
# ===== 笑话改写 =====
REWRITE_SYSTEM_PROMPT = """你是一个幽默作家,负责润色和改写笑话。
要求:
1. 保持笑话的核心笑点不变
2. 语言更通顺、更幽默
3. 字数控制在原内容的 80%-120% 之间
4. 不要添加任何解释说明"""
REWRITE_USER_PROMPT = """请润色以下笑话:
{content}
只返回润色后的笑话文字,不要其他内容。"""
+2 -2
View File
@@ -25,8 +25,8 @@ def parse_args():
parser.add_argument(
"--api-url",
type=str,
default=os.getenv("API_URL", "http://localhost:8001"),
help="API 地址(默认: http://localhost:8001",
default=os.getenv("API_URL", "http://39.104.58.51"),
help="API 地址(默认: http://39.104.58.51",
)
parser.add_argument(
"--username",
+140 -43
View File
@@ -8,15 +8,6 @@ import time
import httpx
from openai import OpenAI
from optimizer.prompts import (
QUALITY_CHECK_SYSTEM_PROMPT,
QUALITY_CHECK_USER_PROMPT,
POLISH_SYSTEM_PROMPT,
POLISH_USER_PROMPT,
EVALUATE_SYSTEM_PROMPT,
EVALUATE_USER_PROMPT,
)
class Optimizer:
def __init__(self, api_base: str, username: str, password: str):
@@ -26,6 +17,7 @@ class Optimizer:
self.token = None
self.ai_client = None
self.model_name = ""
self.ai_config = None # 完整 AI 配置(含提示词和温度)
self.types = []
self.crowds = []
# 统计
@@ -67,6 +59,7 @@ class Optimizer:
print("[*] 登录成功")
ai_config = self._get("/api/admin/settings/active")
self.ai_config = ai_config
self.ai_client = OpenAI(
base_url=ai_config["api_base"],
api_key=ai_config["api_key"],
@@ -78,6 +71,22 @@ class Optimizer:
self.crowds = self._get("/api/categories/crowds")
print(f"[*] 分类: {len(self.types)} 种类型, {len(self.crowds)} 种人群")
def _get_prompt(self, key: str, default: str) -> str:
"""从数据库配置中读取提示词,没有则返回默认"""
if self.ai_config:
val = self.ai_config.get(key)
if val and val.strip():
return val
return default
def _get_temp(self, key: str, default: float) -> float:
"""从数据库配置中读取温度"""
if self.ai_config:
val = self.ai_config.get(key)
if val is not None:
return float(val)
return default
# === 读取笑话 ===
def get_jokes(self, status: str | None = None, limit: int | None = None,
ids: list[int] | None = None) -> list[dict]:
@@ -120,30 +129,62 @@ class Optimizer:
# === Stage 1: 质量检测 ===
def quality_check(self, content: str) -> dict:
"""判断笑话是否有笑点,返回 {"has_punchline": bool, "reason": str}"""
resp = self.ai_client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": QUALITY_CHECK_SYSTEM_PROMPT},
{"role": "user", "content": QUALITY_CHECK_USER_PROMPT.format(content=content[:2000])},
],
temperature=0.3,
max_tokens=200,
)
system_prompt = self._get_prompt("optimizer_quality_prompt",
"""你是一个幽默内容审核专家。判断以下内容是否是一个合格的笑话/段子。
合格标准(满足任一即可):
1. 有明确的笑点或反转(punchline)
2. 有幽默的语言表达或双关
3. 有意外结局或情理之中意料之外
不合格标准(符合任一即判定不合格):
1. 纯粹的事实陈述,没有任何幽默元素
2. 只是对话片段,没有笑点
3. 普通故事或叙事,没有幽默设计
4. 说教或道理阐述
5. 内容不完整或难以理解
始终返回 JSON 格式:{"has_punchline": true/false, "reason": "简要说明判断理由"}""")
temperature = self._get_temp("optimizer_quality_temperature", 0.3)
def _call():
return self.ai_client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"请判断以下内容是否为合格笑话:\n\n{content[:2000]}\n\n返回 JSON 格式。"},
],
temperature=temperature,
max_tokens=200,
)
resp = self._safe_api_call(_call)
raw = resp.choices[0].message.content.strip()
return self._parse_json(raw, {"has_punchline": True, "reason": ""})
# === Stage 2: AI 润色 ===
def polish(self, content: str) -> str:
"""润色笑话内容"""
resp = self.ai_client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": POLISH_SYSTEM_PROMPT},
{"role": "user", "content": POLISH_USER_PROMPT.format(content=content)},
],
temperature=0.8,
max_tokens=1024,
)
system_prompt = self._get_prompt("optimizer_polish_prompt",
"""你是一个专业的幽默文案编辑。请润色以下笑话,要求:
1. 保持核心笑点不变
2. 优化语言表达,使其更通顺、更精炼
3. 增强节奏感和幽默效果,但不改变原意
4. 字数控制在原内容的 80%-120%
5. 不要添加额外解释或评论
6. 直接输出润色后的内容,不要加任何前缀""")
temperature = self._get_temp("optimizer_polish_temperature", 0.8)
def _call():
return self.ai_client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"请润色以下笑话:\n\n{content}"},
],
temperature=temperature,
max_tokens=1024,
)
resp = self._safe_api_call(_call)
return resp.choices[0].message.content.strip()
# === Stage 3: 评价分类 ===
@@ -152,22 +193,40 @@ class Optimizer:
type_names = [t.get("name", "") for t in self.types]
crowd_names = [c.get("name", "") for c in self.crowds]
resp = self.ai_client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": EVALUATE_SYSTEM_PROMPT},
{"role": "user", "content": EVALUATE_USER_PROMPT.format(
content=content[:2000],
known_types=", ".join(type_names),
known_crowds=", ".join(crowd_names),
)},
],
temperature=0.3,
max_tokens=300,
)
system_prompt = self._get_prompt("optimizer_evaluate_prompt",
"""你是一个笑话分类和评价专家。对给定的笑话进行分析,返回 JSON 格式的分类和评分结果。
要求:
1. types: 从提供的类型列表中选择所有匹配的类型名称(数组,可以选多个)
2. crowds: 从提供的人群列表中选择所有匹配的人群名称(数组,可以选多个)
3. score: 1-10 分,基于幽默程度、创意和表达效果
4. comment: 简短评语(10字以内)
始终返回 JSON 格式。""")
temperature = self._get_temp("optimizer_evaluate_temperature", 0.3)
user_content = f"""笑话内容:
{content[:2000]}
可选类型:{', '.join(type_names)}
可选人群:{', '.join(crowd_names)}
返回 JSON 格式:{{"types": ["类型1", "类型2"], "crowds": ["人群1", "人群2"], "score": 8, "comment": "简短评语"}}"""
def _call():
return self.ai_client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
temperature=temperature,
max_tokens=300,
)
resp = self._safe_api_call(_call)
raw = resp.choices[0].message.content.strip()
result = self._parse_json(raw, {"types": [], "crowds": [], "score": 5, "comment": ""})
# Backward compatibility: if LLM returns old single format, convert to array
# Backward compatibility
if isinstance(result.get("types"), str):
result["types"] = [result["types"]] if result["types"] else []
if isinstance(result.get("crowds"), str):
@@ -175,6 +234,27 @@ class Optimizer:
return result
# === 辅助方法 ===
def _safe_api_call(self, func, *args, max_retries: int = 3, **kwargs):
"""带重试的 API 调用,自动处理限流"""
import time as time_module
last_error = None
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
last_error = e
error_str = str(e)
# 检查是否是限流错误
if "429" in error_str or "rate" in error_str.lower():
wait_time = (attempt + 1) * 30 # 30, 60, 90 秒
print(f" [!] API 限流,等待 {wait_time} 秒后重试 ({attempt+1}/{max_retries})...")
time_module.sleep(wait_time)
else:
# 其他错误,直接重试一次
if attempt < max_retries - 1:
time_module.sleep(5)
raise last_error
def _parse_json(self, raw: str, default: dict) -> dict:
"""安全解析 LLM 返回的 JSON"""
try:
@@ -266,12 +346,28 @@ class Optimizer:
crowd_names = []
score = None
# 根据评分计算 AI 等级
ai_level = None
if score is not None:
if score >= 8:
ai_level = "excellent"
elif score >= 6:
ai_level = "good"
elif score >= 4:
ai_level = "ordinary"
else:
ai_level = "poor"
# 保存更新
try:
update = {
"polished_content": polished,
"status": "approved" if (score or 5) >= 4 else "pending",
}
if score is not None:
update["ai_score"] = float(score)
if ai_level:
update["ai_level"] = ai_level
if type_names:
update["type_ids"] = []
for n in type_names:
@@ -325,9 +421,10 @@ class Optimizer:
print(f" [!] 处理异常: {e}")
self.stats["skipped"] += 1
# 每条间稍等,避免 API 限流
# 每条间稍等,避免 API 限流(每次请求间隔 2-3 秒)
if idx < len(jokes) - 1:
time.sleep(1)
import time
time.sleep(2)
# 输出统计
print(f"\n{'='*40}")
-58
View File
@@ -1,58 +0,0 @@
"""AI 提示词模板 — 笑话质量检测、润色、评价分类。"""
# ===== Stage 1: 质量检测 =====
QUALITY_CHECK_SYSTEM_PROMPT = """你是一个幽默内容审核专家。判断以下内容是否是一个合格的笑话/段子。
合格标准(满足任一即可):
1. 有明确的笑点或反转(punchline)
2. 有幽默的语言表达或双关
3. 有意外结局或情理之中意料之外
不合格标准(符合任一即判定不合格):
1. 纯粹的事实陈述,没有任何幽默元素
2. 只是对话片段,没有笑点
3. 普通故事或叙事,没有幽默设计
4. 说教或道理阐述
5. 内容不完整或难以理解
始终返回 JSON 格式:{"has_punchline": true/false, "reason": "简要说明判断理由"}"""
QUALITY_CHECK_USER_PROMPT = """请判断以下内容是否为合格笑话:
{content}
返回 JSON 格式。"""
# ===== Stage 2: AI 润色 =====
POLISH_SYSTEM_PROMPT = """你是一个专业的幽默文案编辑。请润色以下笑话,要求:
1. 保持核心笑点不变
2. 优化语言表达,使其更通顺、更精炼
3. 增强节奏感和幽默效果,但不改变原意
4. 字数控制在原内容的 80%-120%
5. 不要添加额外解释或评论
6. 直接输出润色后的内容,不要加任何前缀"""
POLISH_USER_PROMPT = """请润色以下笑话:
{content}
只输出润色后的笑话内容。"""
# ===== Stage 3: 评价分类 =====
EVALUATE_SYSTEM_PROMPT = """你是一个笑话分类和评价专家。对给定的笑话进行分析,返回 JSON 格式的分类和评分结果。
要求:
1. types: 从提供的类型列表中选择所有匹配的类型名称(数组,可以选多个)
2. crowds: 从提供的人群列表中选择所有匹配的人群名称(数组,可以选多个)
3. score: 1-10 分,基于幽默程度、创意和表达效果
4. comment: 简短评语(10字以内)
始终返回 JSON 格式。"""
EVALUATE_USER_PROMPT = """笑话内容:
{content}
可选类型:{known_types}
可选人群:{known_crowds}
返回 JSON 格式:{{"types": ["类型1", "类型2"], "crowds": ["人群1", "人群2"], "score": 8, "comment": "简短评语"}}"""
+236 -30
View File
@@ -3,14 +3,42 @@
<!-- 顶部主导航 -->
<AppHeader
:theme="themeStore.theme"
:menu-open="menuOpen"
@toggleTheme="themeStore.toggleTheme()"
@random="handleRandom"
@toggleMenu="menuOpen = !menuOpen"
/>
<!-- 移动端抽屉菜单 -->
<div class="mobile-drawer" :class="{ open: menuOpen }" @click.self="menuOpen = false">
<div class="drawer-content">
<AppSidebar
:types="allTypes"
:current-type="null"
@selectType="(id) => handleSelectType(id, true)"
@random="handleRandom"
/>
</div>
</div>
<!-- 内容区域: 左副导航 + 主内容 + 右信息栏 -->
<div class="content-wrapper">
<!-- 左侧副导航内容区内 -->
<!-- 左侧边栏仅大屏幕显示 -->
<div class="left-sidebar">
<AppSidebar
:types="allTypes"
:crowds="allCrowds"
:current-type-ids="filterTypeIds"
:current-crowd-ids="filterCrowdIds"
@selectType="(id) => handleSelectType(id)"
@selectCrowd="(id) => handleSelectCrowd(id)"
@random="handleRandom"
/>
</div>
<!-- 左侧副导航平板及以上显示 -->
<AppSubNav
class="left-subnav"
:types="allTypes"
:crowds="allCrowds"
:current-type-ids="filterTypeIds"
@@ -26,12 +54,14 @@
<router-view />
</main>
<!-- 右侧信息栏首页 -->
<!-- 右侧信息栏大屏幕 -->
<AppRightbar
class="rightbar"
v-if="showRightBar"
:hot-jokes="hotJokes"
:types="allTypes"
:crowds="allCrowds"
:stats="stats"
/>
</div>
@@ -44,17 +74,21 @@
import { ref, provide, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import AppSubNav from '@/components/AppSubNav.vue'
import AppRightbar from '@/components/AppRightbar.vue'
import Footer from '@/components/Footer.vue'
import { getTypes, getCrowds } from '@/api/category'
import { getJokes } from '@/api/joke'
import { getJokes, getHotMonthly, getStats } from '@/api/joke'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const route = useRoute()
const router = useRouter()
// 移动端菜单状态
const menuOpen = ref(false)
// 初始化主题
themeStore.initTheme()
@@ -68,6 +102,9 @@ const filterCrowdIds = ref([])
// 热门笑话(右侧栏)
const hotJokes = ref([])
// 网站统计
const stats = ref(null)
// 右侧栏:仅首页显示
const showRightBar = computed(() => route.path === '/')
@@ -85,24 +122,30 @@ const handleRandom = async () => {
} catch (e) { /* ignore */ }
}
const handleSelectType = (typeId) => {
const ids = filterTypeIds.value
const idx = ids.indexOf(typeId)
if (idx >= 0) ids.splice(idx, 1)
else ids.push(typeId)
const handleSelectType = (typeId, closeMenu = false) => {
// 单选:点同一个取消,点别的切换
if (filterTypeIds.value.length === 1 && filterTypeIds.value[0] === typeId) {
filterTypeIds.value = []
} else {
filterTypeIds.value = [typeId]
}
filterCrowdIds.value = []
const query = ids.length ? { type_ids: ids.join(',') } : {}
const query = filterTypeIds.value.length ? { type_ids: filterTypeIds.value.join(',') } : {}
router.push({ path: '/', query })
if (closeMenu) menuOpen.value = false
}
const handleSelectCrowd = (crowdId) => {
const ids = filterCrowdIds.value
const idx = ids.indexOf(crowdId)
if (idx >= 0) ids.splice(idx, 1)
else ids.push(crowdId)
// 单选:点同一个取消,点别的切换
if (filterCrowdIds.value.length === 1 && filterCrowdIds.value[0] === crowdId) {
filterCrowdIds.value = []
} else {
filterCrowdIds.value = [crowdId]
}
filterTypeIds.value = []
const query = ids.length ? { crowd_ids: ids.join(',') } : {}
const query = filterCrowdIds.value.length ? { crowd_ids: filterCrowdIds.value.join(',') } : {}
router.push({ path: '/', query })
menuOpen.value = false
}
// provide 必须在函数定义之后
@@ -119,24 +162,37 @@ watch(() => route.query, (q) => {
}, { immediate: true })
onMounted(async () => {
console.log('[APP] mounting, route:', route.path)
try {
allTypes.value = await getTypes()
allCrowds.value = await getCrowds()
console.log('[APP] categories loaded:', allTypes.value.length, allCrowds.value.length)
} catch (e) {
console.warn('[APP] Failed to load categories:', e)
// 并行加载:分类、热门、统计互不依赖
const results = await Promise.allSettled([
Promise.all([getTypes(), getCrowds()]),
getHotMonthly(),
getStats(),
])
// 分类数据
const [catResult, hotResult, statsResult] = results
if (catResult.status === 'fulfilled') {
const [types, crowds] = catResult.value
allTypes.value = types
allCrowds.value = crowds
} else {
console.warn('[APP] Failed to load categories:', catResult.reason)
}
try {
const res = await getJokes({ page: 1, page_size: 30 })
console.log('[APP] jokes loaded:', res.items?.length, 'total:', res.total)
const sorted = ((res.items || []).sort((a, b) => (b.view_count || 0) - (a.view_count || 0)))
hotJokes.value = sorted.slice(0, 5)
} catch (e) {
console.warn('[APP] Failed to load hot jokes:', e)
// 当月热门
if (hotResult.status === 'fulfilled') {
hotJokes.value = hotResult.value
} else {
console.warn('[APP] Failed to load hot jokes:', hotResult.reason)
hotJokes.value = []
}
// 统计数据
if (statsResult.status === 'fulfilled') {
stats.value = statsResult.value
} else {
console.warn('[APP] Failed to load stats:', statsResult.reason)
}
})
</script>
@@ -192,6 +248,67 @@ onMounted(async () => {
--tag-crowd-bg: #0a2040;
}
/* ============================================================
响应式断点变量
============================================================ */
:root {
/* 左侧边栏 */
--sidebar-width: 0px;
/* 副导航栏 */
--subnav-width: 220px;
/* 右侧边栏 */
--rightbar-width: 260px;
/* 内容区域最大宽度 */
--content-max-width: 1440px;
/* 顶部导航高度 */
--header-height: 64px;
/* 两侧内边距 */
--content-padding: 24px;
}
/* 平板(768px+)显示副导航 */
@media (min-width: 768px) {
:root {
--subnav-width: 220px;
}
}
/* 大屏幕(1200px+)显示完整布局 */
@media (min-width: 1200px) {
:root {
--sidebar-width: 220px;
--rightbar-width: 260px;
}
}
/* 1024px+ 强制填满屏幕 */
@media (min-width: 1024px) {
.content-wrapper {
max-width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
padding-left: var(--content-padding) !important;
padding-right: 0 !important;
}
.app-header .header-inner {
max-width: 100% !important;
padding-left: var(--content-padding) !important;
padding-right: var(--content-padding) !important;
}
}
/* 大屏增加内边距 */
@media (min-width: 1400px) {
:root {
--content-padding: 32px;
}
}
@media (min-width: 1600px) {
:root {
--content-padding: 48px;
}
}
/* ============================================================
全局基础样式
============================================================ */
@@ -220,13 +337,45 @@ body {
.content-wrapper {
display: flex;
flex: 1;
max-width: 1440px;
max-width: var(--content-max-width);
margin: 0 auto;
width: 100%;
padding-top: 64px;
padding-top: var(--header-height);
min-height: 100vh;
}
/* 默认隐藏侧边栏,小屏幕适配 */
.left-sidebar { display: none; }
.left-subnav { display: none; } /* 默认隐藏平板导航 */
/* 平板及以上显示副导航 */
@media (min-width: 768px) {
.left-subnav {
display: flex;
width: var(--subnav-width);
min-width: var(--subnav-width);
flex-shrink: 0;
}
}
/* 大屏幕显示 AppSidebar + 右侧栏,隐藏副导航(避免与 AppSidebar 内容重复) */
@media (min-width: 1200px) {
.left-sidebar {
display: block;
width: var(--sidebar-width);
min-width: var(--sidebar-width);
flex-shrink: 0;
}
.left-subnav {
display: none !important;
}
.rightbar {
width: var(--rightbar-width);
min-width: var(--rightbar-width);
flex-shrink: 0;
}
}
.main-content {
flex: 1;
min-width: 0;
@@ -254,6 +403,63 @@ body {
.el-button { border-radius: 8px !important; }
.el-pagination.is-background .el-pager li.is-active { background: var(--primary) !important; }
/* ============================================================
移动端抽屉菜单
============================================================ */
.mobile-drawer {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.mobile-drawer.open {
display: block;
opacity: 1;
pointer-events: auto;
}
.drawer-content {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 280px;
max-width: 80vw;
transform: translateX(-100%);
transition: transform 0.3s;
overflow: hidden;
}
.mobile-drawer.open .drawer-content {
transform: translateX(0);
}
/* 确保抽屉内的 AppSidebar 有正确样式 */
.drawer-content .sidebar {
width: 100%;
position: static;
height: 100%;
}
/* 移动端:小屏幕隐藏副导航,显示汉堡菜单按钮 */
@media (max-width: 767px) {
.content-wrapper {
padding-top: var(--header-height);
}
.main-content {
padding: 16px 12px 40px;
}
}
/* ============================================================
深色主题 Element Plus 额外覆盖
============================================================ */
+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
})
}
+3 -1
View File
@@ -4,4 +4,6 @@ export const getJokes = params => request.get('/jokes/', { params })
export const getJoke = id => request.get(`/jokes/${id}`)
export const getRandomJoke = () => request.get('/jokes/random')
export const likeJoke = id => request.post(`/jokes/${id}/like`)
export const searchJokes = params => request.get('/jokes/', { params })
export const dislikeJoke = id => request.post(`/jokes/${id}/dislike`)
export const getHotMonthly = () => request.get('/jokes/hot-monthly')
export const getStats = () => request.get('/jokes/stats')
+3 -1
View File
@@ -1,3 +1,5 @@
import request from '@/api/request'
export const getLinks = () => request.get('/links')
export const getLinks = () => request.get('/links')
export const applyLink = (data) => request.post('/links/apply', data)
+119 -50
View File
@@ -1,13 +1,20 @@
<template>
<header class="app-header" :class="{ 'scrolled': isScrolled }">
<div class="header-inner">
<!-- 汉堡菜单按钮移动端 -->
<button class="menu-btn" @click="$emit('toggleMenu')" :class="{ open: menuOpen }">
<span class="menu-line"></span>
<span class="menu-line"></span>
<span class="menu-line"></span>
</button>
<!-- Logo 区域 -->
<div class="header-brand" @click="$router.push('/')">
<span class="brand-icon">&#x1F60A;</span>
<span class="brand-text">笑话大全</span>
</div>
<!-- 主导航 -->
<!-- 主导航桌面端 -->
<nav class="header-nav">
<router-link
to="/"
@@ -29,16 +36,6 @@
class="nav-item"
:class="{ active: $route.path === '/links' }"
>友链</router-link>
<router-link
to="/feedback"
class="nav-item"
:class="{ active: $route.path === '/feedback' }"
>反馈</router-link>
<router-link
to="/contact"
class="nav-item"
:class="{ active: $route.path === '/contact' }"
>联系</router-link>
<router-link
to="/generate"
class="nav-item generate-btn"
@@ -54,7 +51,7 @@
<!-- 右侧操作 -->
<div class="header-actions">
<!-- 搜索框 -->
<!-- 搜索框桌面端 -->
<div class="search-box" :class="{ expanded: searchExpanded }">
<el-input
v-model="searchKw"
@@ -82,11 +79,14 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
defineProps({ theme: { type: String, default: 'light' } })
defineEmits(['toggleTheme', 'random'])
const props = defineProps({
theme: { type: String, default: 'light' },
menuOpen: { type: Boolean, default: false }
})
defineEmits(['toggleTheme', 'random', 'toggleMenu'])
const router = useRouter()
const searchKw = ref('')
@@ -99,12 +99,15 @@ const doSearch = () => {
searchKw.value = ''
}
// 监听滚动
if (typeof window !== 'undefined') {
window.addEventListener('scroll', () => {
isScrolled.value = window.scrollY > 10
}, { passive: true })
}
// 监听滚动(含清理)
const onScroll = () => { isScrolled.value = window.scrollY > 10 }
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
</script>
<style scoped>
@@ -114,7 +117,7 @@ if (typeof window !== 'undefined') {
left: 0;
right: 0;
width: 100%;
height: 64px;
height: var(--header-height);
background: var(--bg-header);
border-bottom: 1px solid var(--border);
z-index: 999;
@@ -125,13 +128,49 @@ if (typeof window !== 'undefined') {
}
.header-inner {
max-width: 1440px;
max-width: var(--content-max-width);
margin: 0 auto;
height: 100%;
padding: 0 24px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 24px;
gap: 12px;
}
/* 汉堡菜单按钮 */
.menu-btn {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 36px;
height: 36px;
background: transparent;
border: none;
cursor: pointer;
padding: 6px;
border-radius: 6px;
transition: background 0.2s;
}
.menu-btn:hover {
background: var(--bg-page);
}
.menu-line {
display: block;
width: 100%;
height: 2px;
background: var(--text-primary);
border-radius: 2px;
transition: all 0.3s;
}
.menu-btn.open .menu-line:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.menu-btn.open .menu-line:nth-child(2) {
opacity: 0;
}
.menu-btn.open .menu-line:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
/* Brand */
@@ -142,9 +181,9 @@ if (typeof window !== 'undefined') {
cursor: pointer;
flex-shrink: 0;
}
.brand-icon { font-size: 26px; }
.brand-icon { font-size: 24px; }
.brand-text {
font-size: 20px;
font-size: 18px;
font-weight: 800;
color: var(--text-primary);
letter-spacing: 1px;
@@ -162,11 +201,11 @@ if (typeof window !== 'undefined') {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
border-radius: 10px;
padding: 6px 12px;
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
font-size: 15px;
font-size: 14px;
font-weight: 500;
background: none;
border: none;
@@ -186,25 +225,16 @@ if (typeof window !== 'undefined') {
color: var(--primary-light);
}
.random-btn {
font-size: 16px;
padding: 8px 12px;
margin-left: 8px;
.random-btn, .generate-btn {
font-size: 14px;
padding: 6px 10px;
}
.random-btn:hover {
.random-btn:hover, .generate-btn:hover {
background: rgba(255,107,0,0.1);
}
.generate-btn {
font-size: 16px;
padding: 8px 12px;
text-decoration: none;
}
.generate-btn:hover {
background: rgba(147, 51, 234, 0.1);
}
[data-theme="dark"] .generate-btn:hover {
background: rgba(147, 51, 234, 0.2);
}
.generate-btn.active {
background: rgba(147, 51, 234, 0.15);
color: #9333ea;
@@ -217,18 +247,18 @@ if (typeof window !== 'undefined') {
.header-actions {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex-shrink: 0;
}
/* Search */
.search-box {
width: 40px;
width: 36px;
overflow: hidden;
transition: width 0.3s;
}
.search-box.expanded {
width: 220px;
width: 180px;
}
:deep(.el-input__wrapper) {
background: var(--bg-page) !important;
@@ -244,17 +274,17 @@ if (typeof window !== 'undefined') {
/* Theme toggle */
.theme-btn {
width: 38px;
height: 38px;
width: 34px;
height: 34px;
border: 1.5px solid var(--border);
border-radius: 10px;
border-radius: 8px;
background: var(--bg-card);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
font-size: 18px;
font-size: 16px;
}
.theme-btn:hover {
border-color: var(--primary);
@@ -265,4 +295,43 @@ if (typeof window !== 'undefined') {
}
.theme-icon { transition: transform 0.3s; display: inline-block; }
.theme-btn:hover .theme-icon { transform: rotate(20deg); }
/* ============================================================
移动端适配
============================================================ */
@media (max-width: 767px) {
.header-inner {
padding: 0 12px;
gap: 8px;
}
.menu-btn {
display: flex;
}
.brand-text {
font-size: 16px;
}
.header-nav {
display: none;
}
.search-box {
width: 36px;
}
}
@media (min-width: 768px) and (max-width: 1199px) {
.header-nav {
gap: 0;
}
.nav-item {
padding: 6px 10px;
font-size: 13px;
}
.brand-text {
display: none;
}
}
</style>
+65 -5
View File
@@ -1,10 +1,10 @@
<template>
<aside class="rightbar">
<!-- 热门笑话 -->
<!-- 当月热门笑话 -->
<div class="rightbar-section">
<div class="section-title">
<span class="title-icon">&#x1F525;</span>
热门笑话
当月热门笑话
</div>
<div class="hot-list" v-if="hotJokes.length">
<div
@@ -23,6 +23,32 @@
<div v-else class="empty-hint">暂无数据</div>
</div>
<!-- 网站统计 -->
<div class="rightbar-section stats-section" v-if="stats">
<div class="section-title">
<span class="title-icon">&#x1F4CA;</span>
网站统计
</div>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-num">{{ stats.total_jokes || 0 }}</span>
<span class="stat-label">笑话总数</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ stats.approved_jokes || 0 }}</span>
<span class="stat-label">已审核</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ stats.pending_jokes || 0 }}</span>
<span class="stat-label">待审核</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ stats.total_views || 0 }}</span>
<span class="stat-label">总浏览</span>
</div>
</div>
</div>
<!-- 类型快捷 -->
<div class="rightbar-section">
<div class="section-title">
@@ -34,7 +60,7 @@
v-for="t in (types || []).slice(0, 6)"
:key="t.id"
class="quick-tag type"
@click="$router.push({ path: '/', query: { type_id: t.id } })"
@click="$router.push({ path: '/', query: { type_ids: t.id } })"
>{{ t.name }}</span>
</div>
</div>
@@ -50,7 +76,7 @@
v-for="c in (crowds || []).slice(0, 6)"
:key="c.id"
class="quick-tag crowd"
@click="$router.push({ path: '/', query: { crowd_id: c.id } })"
@click="$router.push({ path: '/', query: { crowd_ids: c.id } })"
>{{ c.name }}</span>
</div>
</div>
@@ -74,7 +100,8 @@
defineProps({
hotJokes: { type: Array, default: () => [] },
types: { type: Array, default: () => [] },
crowds: { type: Array, default: () => [] }
crowds: { type: Array, default: () => [] },
stats: { type: Object, default: null }
})
</script>
@@ -157,6 +184,32 @@ defineProps({
}
.quick-tag.crowd:hover { background: #2196F3; color: white; }
/* 统计区域 */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.stat-item {
text-align: center;
padding: 10px 6px;
background: var(--bg-page);
border-radius: 8px;
transition: background 0.3s;
}
.stat-num {
display: block;
font-size: 20px;
font-weight: 800;
color: var(--primary);
line-height: 1.2;
}
.stat-label {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* 每日一笑 */
.daily-section { background: var(--tag-type-bg); }
.daily-text {
@@ -166,4 +219,11 @@ defineProps({
font-style: italic;
transition: color 0.3s;
}
/* 移动端隐藏右侧栏 */
@media (max-width: 1199px) {
.rightbar {
display: none;
}
}
</style>
+67 -29
View File
@@ -23,17 +23,31 @@
</router-link>
</nav>
<!-- 分类列表 -->
<!-- 类型标签 -->
<div class="sidebar-categories">
<div class="nav-label">分类</div>
<div
v-for="type in types"
:key="type.id"
class="cat-item"
:class="{ active: currentType === type.id }"
@click="$emit('selectType', type.id)"
>
{{ type.name }}
<div class="nav-label">类型分类</div>
<div class="tag-group">
<span
v-for="t in types"
:key="t.id"
class="tag tag-type"
:class="{ active: currentTypeIds.includes(t.id) }"
@click="$emit('selectType', t.id)"
>{{ t.name }}</span>
</div>
</div>
<!-- 人群标签 -->
<div class="sidebar-categories">
<div class="nav-label">人群分类</div>
<div class="tag-group">
<span
v-for="c in crowds"
:key="c.id"
class="tag tag-crowd"
:class="{ active: currentCrowdIds.includes(c.id) }"
@click="$emit('selectCrowd', c.id)"
>{{ c.name }}</span>
</div>
</div>
@@ -49,10 +63,12 @@
<script setup>
defineProps({
types: { type: Array, default: () => [] },
currentType: { type: Number, default: null }
crowds: { type: Array, default: () => [] },
currentTypeIds: { type: Array, default: () => [] },
currentCrowdIds: { type: Array, default: () => [] }
})
defineEmits(['selectType', 'random'])
defineEmits(['selectType', 'selectCrowd', 'random'])
</script>
<style scoped>
@@ -60,12 +76,12 @@ defineEmits(['selectType', 'random'])
width: var(--sidebar-width);
min-width: var(--sidebar-width);
min-height: 100vh;
height: 100%;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
flex-shrink: 0;
}
@@ -123,33 +139,55 @@ defineEmits(['selectType', 'random'])
}
.nav-icon { font-size: 16px; }
/* 分类 */
/* 分类区域 */
.sidebar-categories {
padding: 8px 12px;
flex: 1;
padding: 8px 12px 4px;
}
.cat-item {
padding: 8px 12px;
border-radius: 8px;
color: rgba(255,255,255,0.6);
font-size: 13px;
/* 标签组(胶囊样式,仿 Rightbar) */
.tag-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 8px 4px;
}
.tag {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 2px;
font-weight: 500;
}
.cat-item:hover {
background: rgba(255,255,255,0.06);
color: white;
.tag-type {
background: rgba(255,107,0,0.15);
color: #FF8C30;
}
.cat-item.active {
background: rgba(255,107,0,0.2);
color: var(--primary-light, #FF8C30);
.tag-type:hover {
background: rgba(255,107,0,0.3);
color: #FFA050;
}
.tag-type.active {
background: var(--primary, #FF6B00);
color: #fff;
}
.tag-crowd {
background: rgba(33,150,243,0.15);
color: #64B5F6;
}
.tag-crowd:hover {
background: rgba(33,150,243,0.3);
color: #90CAF9;
}
.tag-crowd.active {
background: #2196F3;
color: #fff;
}
/* 底部随机 */
.sidebar-footer {
padding: 16px;
border-top: 1px solid rgba(255,255,255,0.06);
margin-top: auto;
}
.random-btn {
width: 100%;
-71
View File
@@ -1,71 +0,0 @@
<template>
<header class="header">
<div class="header-content">
<router-link to="/" class="logo">笑话大全</router-link>
<nav class="nav">
<router-link to="/">首页</router-link>
<router-link to="/category">分类</router-link>
<router-link to="/search">搜索</router-link>
</nav>
<button class="random-btn" @click="handleRandom">随机笑话</button>
</div>
</header>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useJokeStore } from '@/stores/joke'
const router = useRouter()
const jokeStore = useJokeStore()
const handleRandom = async () => {
const res = await jokeStore.fetchRandom()
router.push(`/detail/${res.id}`)
}
</script>
<style scoped>
.header {
background: linear-gradient(135deg, #FF9500 0%, #FFB800 100%);
color: white;
padding: 0 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: white;
text-decoration: none;
}
.nav {
display: flex;
gap: 30px;
}
.nav a {
color: white;
text-decoration: none;
opacity: 0.9;
transition: opacity 0.2s;
}
.nav a:hover, .nav a.router-link-active { opacity: 1; font-weight: 500; }
.random-btn {
background: white;
color: #FF9500;
border: none;
padding: 8px 20px;
border-radius: 20px;
cursor: pointer;
font-weight: 500;
transition: transform 0.2s;
}
.random-btn:hover { transform: scale(1.05); }
</style>
+67
View File
@@ -0,0 +1,67 @@
/**
* 点赞/点踩 投票状态管理 (localStorage 持久化)
* 键名格式: joke_vote_{id} → { liked: bool, disliked: bool }
*
* @param {import('vue').Ref<number|null>} jokeIdRef — 响应式的笑话 ID
* @returns {{ state: { liked: boolean, disliked: boolean }, handleLike: () => Promise<number>, handleDislike: () => Promise<number> }}
*/
import { reactive, watchEffect, isRef, ref, toRef } from 'vue'
import { likeJoke, dislikeJoke } from '@/api/joke'
const STORAGE_PREFIX = 'joke_vote_'
function loadState(jokeId) {
if (!jokeId) return { liked: false, disliked: false }
try {
return JSON.parse(localStorage.getItem(STORAGE_PREFIX + jokeId)) || {}
} catch {
return {}
}
}
function persistState(jokeId, key) {
if (!jokeId) return
const prev = loadState(jokeId)
prev[key] = true
localStorage.setItem(STORAGE_PREFIX + jokeId, JSON.stringify(prev))
}
export function useVote(jokeIdRef) {
const state = reactive({ liked: false, disliked: false })
// 当 jokeId 变化时重新从 localStorage 读取
const _id = isRef(jokeIdRef) ? jokeIdRef : ref(jokeIdRef)
watchEffect(() => {
const saved = loadState(_id.value)
state.liked = !!saved.liked
state.disliked = !!saved.disliked
})
const handleLike = async () => {
const id = _id.value
if (!id || state.liked) return
try {
const res = await likeJoke(id)
state.liked = true
persistState(id, 'liked')
return res.like_count
} catch {
// 静默失败
}
}
const handleDislike = async () => {
const id = _id.value
if (!id || state.disliked) return
try {
const res = await dislikeJoke(id)
state.disliked = true
persistState(id, 'disliked')
return res.dislike_count
} catch {
// 静默失败
}
}
return { state, handleLike, handleDislike }
}
+5 -1
View File
@@ -13,5 +13,9 @@ const routes = [
export default createRouter({
history: createWebHistory(),
routes
routes,
scrollBehavior() {
// 每次路由切换滚动到顶部
return { top: 0 }
}
})
+10
View File
@@ -0,0 +1,10 @@
/** AI 质量等级文本映射 */
export function getLevelText(level) {
const map = {
'excellent': '⭐⭐⭐ 精品',
'good': '⭐⭐ 良好',
'ordinary': '⭐ 普通',
'poor': '⚠️ 待优化'
}
return map[level] || ''
}
+194 -55
View File
@@ -7,19 +7,36 @@
<span class="tag tag-crowd" v-for="(name, i) in (joke.crowd_names||[])" :key="'c'+i">{{ name }}</span>
</div>
<!-- 标题 -->
<h1 class="detail-title">{{ joke.title }}</h1>
<!-- 标题 + AI评价 -->
<div class="title-row">
<h1 class="detail-title">{{ joke.title }}</h1>
<span v-if="joke.ai_level" class="ai-badge" :class="'ai-' + joke.ai_level">
{{ getLevelText(joke.ai_level) }}
</span>
</div>
<!-- 内容 -->
<div class="detail-content">{{ joke.content }}</div>
<!-- 内容优先显示润色内容 -->
<div class="detail-content">
<template v-if="joke.polished_content">
<div class="polished-label"> AI 润色版</div>
{{ joke.polished_content }}
</template>
<template v-else>{{ joke.content }}</template>
</div>
<!-- 原始内容如果有润色 -->
<details v-if="joke.polished_content" class="original-content">
<summary>查看原文</summary>
{{ joke.content }}
</details>
<!-- 操作栏 -->
<div class="detail-actions">
<button class="action-primary" @click="handleNext">
<span>&#x1F3B2;</span> 再看一条
</button>
<button class="action-secondary" @click="$router.push('/')">
<span>&#x1F3E0;</span> 返回首页
<button class="action-secondary" @click="goBack">
<span>&#x1F519;</span> 返回
</button>
</div>
@@ -27,12 +44,19 @@
<div class="detail-meta">
<span>浏览 {{ joke.view_count || 0 }}</span>
<span class="sep">|</span>
<button class="like-btn" :class="{ 'liked': liked }" @click.stop="handleLike">
<span class="heart">&#x2764;</span>
<span> {{ joke.like_count || 0 }}</span>
<button class="vote-btn" :class="{ voted: voteState.liked }" @click.stop="doLike">
<span class="vote-icon">&#x1F44D;</span>
<span>一下 {{ joke.like_count || 0 }}</span>
</button>
<span class="sep">|</span>
<button class="vote-btn" :class="{ voted: voteState.disliked }" @click.stop="doDislike">
<span class="vote-icon">&#x1F44E;</span>
<span>踩一脚 {{ joke.dislike_count || 0 }}</span>
</button>
<span class="sep">|</span>
<span>{{ formatDate(joke.created_at) }}</span>
<span v-if="joke.ai_score" class="sep">|</span>
<span v-if="joke.ai_score" class="ai-score">评分: {{ joke.ai_score.toFixed(1) }}</span>
</div>
</div>
</div>
@@ -44,50 +68,62 @@
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getJoke, getJokes, likeJoke } from '@/api/joke'
import { ElMessage } from 'element-plus'
import { getJoke, getJokes } from '@/api/joke'
import { useVote } from '@/composables/useVote'
import { getLevelText } from '@/utils/joke'
const route = useRoute()
const router = useRouter()
const joke = ref(null)
const liked = ref(false)
const loading = ref(false)
const currentJokeId = ref(null)
// 请求 ID 追踪,防止竞态
let currentRequestId = 0
const { state: voteState, handleLike, handleDislike } = useVote(currentJokeId)
const loadJoke = async () => {
const requestId = ++currentRequestId
loading.value = true
try {
const res = await getJoke(route.params.id)
// 只接受最新请求的响应
if (requestId !== currentRequestId) return
joke.value = res
liked.value = false
currentJokeId.value = res.id
loading.value = false
} catch (e) {
if (requestId !== currentRequestId) return
loading.value = false
// 笑话不存在时返回首页
if (e?.detail === '笑话不存在' || e?.response?.status === 404) {
router.push('/')
}
}
}
const handleLike = async () => {
if (liked.value || !joke.value) return
try {
const res = await likeJoke(route.params.id)
if (joke.value && res?.like_count !== undefined) {
joke.value.like_count = res.like_count
}
liked.value = true
ElMessage.success('点赞成功')
} catch (e) {
ElMessage.error('点赞失败,请重试')
const doLike = async () => {
if (!joke.value) return
const newCount = await handleLike()
if (newCount !== undefined && joke.value) {
joke.value.like_count = newCount
}
}
const doDislike = async () => {
if (!joke.value) return
const newCount = await handleDislike()
if (newCount !== undefined && joke.value) {
joke.value.dislike_count = newCount
}
}
const goBack = () => {
// 优先返回上一页(保留筛选/分页状态),无历史则回首页
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
@@ -123,43 +159,99 @@ watch(() => route.params.id, () => loadJoke())
max-width: 780px;
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 40px 48px;
padding: 32px 40px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 20px;
gap: 18px;
transition: background 0.3s, box-shadow 0.3s;
}
.detail-tags { display: flex; gap: 10px; }
.detail-tags { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* 标题行:标题 + AI评价右对齐 */
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
/* AI 评价徽章 */
.ai-badge {
font-size: 12px;
padding: 3px 12px;
border-radius: 12px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.ai-excellent { background: linear-gradient(135deg, #fef3c7, #fde68a); color: #92400e; }
.ai-good { background: linear-gradient(135deg, #d1fae5, #a7f3d0); color: #065f46; }
.ai-ordinary { background: #f3f4f6; color: #4b5563; }
.ai-poor { background: #fee2e2; color: #991b1b; }
/* AI 润色标签 */
.polished-label {
font-size: 11px;
color: #9333ea;
font-weight: 600;
margin-bottom: 6px;
display: inline-block;
}
/* 原文折叠 */
.original-content {
margin-top: 12px;
padding: 10px;
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 12px;
color: var(--text-muted);
}
.original-content summary {
cursor: pointer;
font-weight: 600;
color: var(--text-secondary);
}
/* AI 评分 */
.ai-score {
color: #9333ea;
font-weight: 600;
}
.detail-title {
font-size: 26px;
font-size: 22px;
font-weight: 800;
color: var(--text-primary);
line-height: 1.4;
letter-spacing: 0.5px;
transition: color 0.3s;
margin: 0;
flex: 1;
}
.detail-content {
font-size: 17px;
line-height: 2;
font-size: 15px;
line-height: 1.9;
color: var(--text-secondary);
white-space: pre-wrap;
letter-spacing: 0.3px;
transition: color 0.3s;
}
.detail-actions { display: flex; gap: 14px; padding-top: 8px; }
.detail-actions { display: flex; gap: 12px; padding-top: 8px; flex-wrap: wrap; }
.action-primary {
padding: 12px 28px;
padding: 10px 24px;
background: var(--primary);
color: white;
border: none;
border-radius: 25px;
font-size: 15px;
border-radius: 22px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
@@ -174,12 +266,12 @@ watch(() => route.params.id, () => loadJoke())
}
.action-secondary {
padding: 12px 28px;
padding: 10px 24px;
background: var(--bg-card);
color: var(--text-secondary);
border: 1.5px solid var(--border);
border-radius: 25px;
font-size: 15px;
border-radius: 22px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
@@ -194,18 +286,20 @@ watch(() => route.params.id, () => loadJoke())
.detail-meta {
text-align: center;
color: var(--text-muted);
font-size: 13px;
font-size: 12px;
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
transition: color 0.3s, border-color 0.3s;
flex-wrap: wrap;
}
.sep { color: #ddd; }
.like-btn {
/* 投票按钮(详情页) */
.vote-btn {
background: none;
border: none;
color: var(--text-muted);
@@ -213,20 +307,28 @@ watch(() => route.params.id, () => loadJoke())
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
padding: 0;
transition: color 0.2s;
font-size: 12px;
padding: 2px 8px;
border-radius: 8px;
transition: all 0.2s;
font-family: inherit;
}
.like-btn:hover { color: #e74c3c; }
.like-btn.liked { color: #e74c3c; }
.like-btn .heart {
.vote-btn:hover {
background: var(--bg-page);
color: var(--text-primary);
}
.vote-btn.voted {
color: var(--primary);
font-weight: 700;
}
.vote-icon {
font-size: 14px;
color: #ccc;
transition: color 0.2s, transform 0.2s;
transition: transform 0.2s;
}
.like-btn:hover .heart { color: #e74c3c; }
.like-btn.liked .heart {
color: #e74c3c;
.vote-btn:hover .vote-icon {
transform: scale(1.2);
}
.vote-btn.voted .vote-icon {
animation: pop 0.3s ease;
}
@keyframes pop {
@@ -245,11 +347,48 @@ watch(() => route.params.id, () => loadJoke())
color: var(--text-muted);
}
.spinner {
width: 28px; height: 28px;
width: 24px; height: 24px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ============================================================
移动端适配
============================================================ */
@media (max-width: 767px) {
.joke-detail-card {
padding: 24px 20px;
}
.detail-title {
font-size: 18px;
}
.detail-content {
font-size: 14px;
line-height: 1.8;
}
.detail-actions {
flex-direction: column;
}
.action-primary,
.action-secondary {
width: 100%;
justify-content: center;
}
.detail-meta {
flex-wrap: wrap;
gap: 6px;
}
.sep {
display: none;
}
}
</style>
+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>
+104 -25
View File
@@ -6,11 +6,16 @@
<span class="tag tag-crowd" v-for="(name, i) in (joke.crowd_names||[])" :key="'c'+i">{{ name }}</span>
</div>
<!-- 标题 -->
<h3 class="card-title">{{ joke.title }}</h3>
<!-- 标题 + AI评价等级 -->
<div class="title-row">
<h3 class="card-title">{{ joke.title }}</h3>
<span v-if="joke.ai_level" class="ai-badge" :class="'ai-' + joke.ai_level">
{{ getLevelText(joke.ai_level) }}
</span>
</div>
<!-- 内容 -->
<p class="card-content">{{ joke.content }}</p>
<!-- 内容优先显示润色内容 -->
<p class="card-content">{{ joke.polished_content || joke.content }}</p>
<!-- 底部信息 -->
<div class="card-footer">
@@ -19,10 +24,12 @@
<span class="stat-icon">&#x1F441;</span>
{{ joke.view_count || 0 }}
</span>
<span class="stat">
<span class="stat-icon">&#x2764;</span>
{{ joke.like_count || 0 }}
</span>
<button class="stat vote-btn" :class="{ voted: voteState.liked }" @click.stop="handleLike">
&#x1F44D; {{ joke.like_count || 0 }}
</button>
<button class="stat vote-btn" :class="{ voted: voteState.disliked }" @click.stop="handleDislike">
&#x1F44E; {{ joke.dislike_count || 0 }}
</button>
</div>
<span class="read-more">阅读 &#x2192;</span>
</div>
@@ -30,16 +37,23 @@
</template>
<script setup>
defineProps({
import { computed } from 'vue'
import { useVote } from '@/composables/useVote'
import { getLevelText } from '@/utils/joke'
const props = defineProps({
joke: { type: Object, required: true }
})
const jokeId = computed(() => props.joke.id)
const { state: voteState, handleLike, handleDislike } = useVote(jokeId)
</script>
<style scoped>
.joke-card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 22px 24px;
padding: 18px 20px;
cursor: pointer;
transition: all 0.25s;
box-shadow: var(--shadow);
@@ -61,18 +75,26 @@ defineProps({
border-radius: 0 0 0 var(--radius);
}
.joke-card:hover {
transform: translateY(-3px);
transform: translateY(-2px);
box-shadow: var(--shadow-hover);
border-color: #FFF0E6;
}
.joke-card:hover::before { transform: scaleY(1); }
/* 标签 */
.card-header { display: flex; gap: 8px; flex-wrap: wrap; }
.card-header { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
/* 标题行:标题 + AI评价右对齐 */
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
/* 标题 */
.card-title {
font-size: 17px;
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.4;
@@ -80,15 +102,31 @@ defineProps({
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin: 0;
flex: 1;
}
/* AI 评价徽章 */
.ai-badge {
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.ai-excellent { background: linear-gradient(135deg, #fef3c7, #fde68a); color: #92400e; }
.ai-good { background: linear-gradient(135deg, #d1fae5, #a7f3d0); color: #065f46; }
.ai-ordinary { background: #f3f4f6; color: #4b5563; }
.ai-poor { background: #fee2e2; color: #991b1b; }
/* 内容 */
.card-content {
font-size: 14px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.7;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
@@ -99,25 +137,66 @@ defineProps({
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--border);
margin-top: auto;
}
.card-stats { display: flex; gap: 14px; }
.card-stats { display: flex; gap: 12px; }
.stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
gap: 3px;
font-size: 11px;
color: var(--text-muted);
}
.stat-icon { font-size: 13px; }
.read-more {
.stat-icon { font-size: 12px; }
/* 投票按钮 */
.vote-btn {
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 2px;
font-size: 12px;
color: var(--text-muted);
font-family: inherit;
}
.vote-btn:hover {
background: var(--bg-page);
transform: scale(1.08);
}
.vote-btn.voted {
color: var(--primary);
font-weight: 700;
}
.vote-btn .stat-icon { font-size: 14px; }
.read-more {
font-size: 11px;
color: var(--primary);
font-weight: 600;
opacity: 0;
transition: opacity 0.2s;
}
.joke-card:hover .read-more { opacity: 1; }
/* ============================================================
移动端适配
============================================================ */
@media (max-width: 767px) {
.joke-card {
padding: 16px;
}
.card-title {
font-size: 15px;
}
.card-content {
font-size: 13px;
-webkit-line-clamp: 4;
}
}
</style>
+27 -18
View File
@@ -4,7 +4,6 @@
<div class="filter-bar" v-if="filterText">
<span class="filter-text">{{ filterText }}</span>
<span class="filter-count"> {{ jokeStore.total }} </span>
<el-button link type="primary" @click="clearFilter" v-if="jokeStore.total > 0">清除筛选</el-button>
</div>
<!-- 笑话网格 -->
@@ -80,19 +79,14 @@ const loadJokes = async (p = 1) => {
if (filterTypeIds.value.length) params.type_ids = filterTypeIds.value.join(',')
if (filterCrowdIds.value.length) params.crowd_ids = filterCrowdIds.value.join(',')
await jokeStore.fetchJokes(params)
}
const clearFilter = () => {
window.history.pushState({}, '', '/')
loadJokes(1)
// 翻页后滚动到顶部,确保用户能看到筛选信息栏
window.scrollTo({ top: 0, behavior: 'smooth' })
}
onMounted(async () => {
types.value = allTypes.value?.length ? allTypes.value : await getTypes()
crowds.value = allCrowds.value?.length ? allCrowds.value : await getCrowds()
console.log('[DEBUG] types:', types.value.length, 'crowds:', crowds.value.length)
await loadJokes()
console.log('[DEBUG] jokes:', jokeStore.jokes.length, 'total:', jokeStore.total)
})
watch(() => route.query, () => {
@@ -108,36 +102,37 @@ watch(() => route.query, () => {
.filter-bar {
background: var(--bg-card);
border-radius: var(--radius);
padding: 14px 20px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
box-shadow: var(--shadow);
border-left: 3px solid var(--primary);
transition: background 0.3s;
flex-wrap: wrap;
}
.filter-text { font-size: 15px; font-weight: 700; color: var(--primary); }
.filter-count { font-size: 13px; color: var(--text-muted); margin-left: auto; }
.filter-text { font-size: 14px; font-weight: 700; color: var(--primary); }
.filter-count { font-size: 12px; color: var(--text-muted); margin-left: auto; }
/* 网格:双列自适应 */
.joke-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 20px;
padding: 48px 20px;
background: var(--bg-card);
border-radius: var(--radius);
box-shadow: var(--shadow);
transition: background 0.3s;
}
.empty-icon { font-size: 64px; margin-bottom: 16px; }
.empty-text { font-size: 18px; color: var(--text-primary); font-weight: 600; margin-bottom: 8px; transition: color 0.3s; }
.empty-sub { font-size: 14px; color: var(--text-muted); }
.empty-icon { font-size: 56px; margin-bottom: 14px; }
.empty-text { font-size: 16px; color: var(--text-primary); font-weight: 600; margin-bottom: 8px; transition: color 0.3s; }
.empty-sub { font-size: 13px; color: var(--text-muted); }
/* 加载 */
.loading-state {
@@ -149,7 +144,7 @@ watch(() => route.query, () => {
color: var(--text-muted);
}
.spinner {
width: 20px; height: 20px;
width: 18px; height: 18px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
@@ -160,4 +155,18 @@ watch(() => route.query, () => {
/* 分页 */
.pagination-wrap { display: flex; justify-content: center; padding: 8px 0; }
:deep(.el-pagination.is-background .el-pager li.is-active) { background: var(--primary) !important; }
/* ============================================================
移动端适配
============================================================ */
@media (max-width: 767px) {
.joke-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.filter-bar {
padding: 10px 12px;
}
}
</style>
+205 -1
View File
@@ -26,15 +26,62 @@
<div class="link-desc" v-if="link.description">{{ link.description }}</div>
</a>
</div>
<!-- 自助申请 -->
<div class="apply-section">
<button class="apply-btn" @click="showDialog = true">
<span class="apply-icon">+</span>
自助申请友链
</button>
</div>
<!-- 申请弹窗 -->
<div v-if="showDialog" class="dialog-overlay" @click.self="showDialog = false">
<div class="dialog">
<h2 class="dialog-title">申请友链</h2>
<form @submit.prevent="handleApply" class="apply-form">
<div class="form-item">
<label>网站名称 <span class="required">*</span></label>
<input v-model="form.name" type="text" placeholder="请输入网站名称" required />
</div>
<div class="form-item">
<label>网站地址 <span class="required">*</span></label>
<input v-model="form.url" type="url" placeholder="https://example.com" required />
</div>
<div class="form-item">
<label>网站描述</label>
<input v-model="form.description" type="text" placeholder="简短描述您的网站" />
</div>
<div class="form-item">
<label>联系方式</label>
<input v-model="form.contact" type="text" placeholder="邮箱或社交账号(选填)" />
</div>
<div class="dialog-footer">
<button type="button" class="btn-cancel" @click="showDialog = false">取消</button>
<button type="submit" class="btn-submit" :disabled="submitting">
{{ submitting ? '提交中...' : '提交申请' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getLinks } from '@/api/link'
import { getLinks, applyLink } from '@/api/link'
const links = ref([])
const loading = ref(true)
const showDialog = ref(false)
const submitting = ref(false)
const form = ref({
name: '',
url: '',
description: '',
contact: '',
})
onMounted(async () => {
try {
@@ -45,6 +92,21 @@ onMounted(async () => {
loading.value = false
}
})
const handleApply = async () => {
submitting.value = true
try {
await applyLink(form.value)
alert('申请已提交,等待审核!')
showDialog.value = false
form.value = { name: '', url: '', description: '', contact: '' }
} catch (e) {
alert('提交失败,请重试')
console.error(e)
} finally {
submitting.value = false
}
}
</script>
<style scoped>
@@ -101,4 +163,146 @@ onMounted(async () => {
color: var(--text-secondary);
line-height: 1.5;
}
/* 自助申请 */
.apply-section {
margin-top: 32px;
text-align: center;
}
.apply-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: var(--bg-card);
border: 2px dashed var(--border);
border-radius: var(--radius);
color: var(--text-secondary);
font-size: 15px;
cursor: pointer;
transition: all 0.2s;
}
.apply-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.apply-icon {
font-size: 18px;
font-weight: 700;
}
/* 弹窗 */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: var(--bg-card);
border-radius: var(--radius);
padding: 24px;
width: 90%;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.dialog-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 20px;
color: var(--text-primary);
}
.apply-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-item label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.required {
color: #e53935;
}
.form-item input {
padding: 10px 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;
}
.form-item input:focus {
outline: none;
border-color: var(--primary);
}
.form-item input::placeholder {
color: var(--text-muted);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.btn-cancel,
.btn-submit {
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-cancel {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.btn-cancel:hover {
background: var(--bg-hover);
}
.btn-submit {
background: var(--primary);
border: none;
color: #fff;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
+18 -8
View File
@@ -22,7 +22,7 @@
<!-- 结果 -->
<div class="results-area" v-if="searched">
<div class="results-count" v-if="results.length > 0">
找到 <strong>{{ results.length }}</strong> 条相关笑话
找到 <strong>{{ total }}</strong> 条相关笑话
</div>
<div class="joke-grid" v-if="results.length">
@@ -52,6 +52,7 @@ import JokeCard from '../home/components/JokeCard.vue'
const route = useRoute()
const keyword = ref('')
const results = ref([])
const total = ref(0)
const searched = ref(false)
const hotWords = ['冷笑话', '段子', '谐音梗', '职场', '校园']
@@ -60,14 +61,12 @@ const handleSearch = async () => {
if (!keyword.value.trim()) return
searched.value = true
try {
const res = await getJokes({ page: 1, page_size: 100 })
const kw = keyword.value.toLowerCase()
results.value = (res.items || []).filter(j =>
(j.title && j.title.toLowerCase().includes(kw)) ||
(j.content && j.content.toLowerCase().includes(kw))
)
const res = await getJokes({ page: 1, page_size: 50, keyword: keyword.value.trim() })
results.value = res.items || []
total.value = res.total || 0
} catch (e) {
results.value = []
total.value = 0
}
}
@@ -140,10 +139,21 @@ watch(() => route.query.q, (q) => {
.joke-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
/* 移动端适配 */
@media (max-width: 767px) {
.search-box {
padding: 16px;
}
.joke-grid {
grid-template-columns: 1fr;
gap: 12px;
}
}
/* 空/默认 */
.empty-state, .search-hint {
text-align: center;