fix: resolve 10 code review issues

High priority:
- Fix concurrent race condition for view_count/like_count (atomic update)
- Add route request ID tracking to prevent race conditions
- Filter get_joke by status=approved (no pending content leak)
- Add error feedback for like button

Performance:
- Optimize random joke query (avoid full table sort)
- Limit page_size max to 100 (DoS prevention)

Medium:
- Add localStorage quota error handling
- Handle empty AI response gracefully
- Fix generate content title extraction

Low:
- Add rejected_jokes to stats API
- Update dashboard to show rejected count
This commit is contained in:
bwstudio
2026-06-02 20:35:08 +08:00
parent 0b43973236
commit ceed63fcb0
144 changed files with 191660 additions and 270 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
import request from './request'
export const getTypes = () => request.get('/types')
export const getCrowds = () => request.get('/crowds')
export const getTypes = () => request.get('/categories/types')
export const getCrowds = () => request.get('/categories/crowds')
export const createType = data => request.post('/admin/types', data)
export const updateType = (id, data) => request.put(`/admin/types/${id}`, data)
export const deleteType = id => request.delete(`/admin/types/${id}`)
+4
View File
@@ -0,0 +1,4 @@
import request from './request'
export const getFeedbacks = () => request.get('/admin/feedbacks')
export const deleteFeedback = id => request.delete('/admin/feedbacks/' + id)
+7
View File
@@ -0,0 +1,7 @@
import request from './request'
export const getLinks = () => request.get('/admin/links')
export const getLink = id => request.get('/admin/links/' + id)
export const createLink = data => request.post('/admin/links', data)
export const updateLink = (id, data) => request.put('/admin/links/' + id, data)
export const deleteLink = id => request.delete('/admin/links/' + id)
+4 -4
View File
@@ -13,15 +13,15 @@ request.interceptors.request.use(config => {
return config
})
request.interceptors.response.use({
successHandler: res => res.data,
errorHandler: err => {
request.interceptors.response.use(
res => res.data,
err => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(err)
}
})
)
export default request
+8
View File
@@ -0,0 +1,8 @@
import request from './request'
export const getSettings = () => request.get('/admin/settings')
export const getActiveSetting = () => request.get('/admin/settings/active')
export const createSetting = data => request.post('/admin/settings', data)
export const updateSetting = (id, data) => request.put(`/admin/settings/${id}`, data)
export const toggleSetting = id => request.put(`/admin/settings/${id}/toggle`)
export const deleteSetting = id => request.delete(`/admin/settings/${id}`)
+4
View File
@@ -16,6 +16,10 @@ const routes = [
{ path: 'jokes/edit/:id?', name: 'JokeEdit', component: () => import('@/views/joke/Edit.vue') },
{ path: 'category/type', name: 'CategoryType', component: () => import('@/views/category/Type.vue') },
{ path: 'category/crowd', name: 'CategoryCrowd', component: () => import('@/views/category/Crowd.vue') },
{ path: 'settings', name: 'Settings', component: () => import('@/views/setting/Index.vue') },
{ path: 'links', name: 'LinkList', component: () => import('@/views/links/List.vue') },
{ path: 'links/edit/:id?', name: 'LinkEdit', component: () => import('@/views/links/Edit.vue') },
{ path: 'feedbacks', name: 'FeedbackList', component: () => import('@/views/feedback/List.vue') },
]
}
]
+4 -1
View File
@@ -11,7 +11,10 @@ export const useJokeStore = defineStore('joke', {
async fetchJokes(params) {
this.loading = true
try {
const res = await getJokes(params)
const cleanParams = Object.fromEntries(
Object.entries(params).filter(([_, v]) => v != null && v !== '')
)
const res = await getJokes(cleanParams)
this.jokes = res.items
this.total = res.total
} finally {
+40 -11
View File
@@ -10,14 +10,6 @@
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
<div class="stat-value">{{ stats.pending }}</div>
<div class="stat-label">待审核</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
@@ -26,6 +18,24 @@
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
<div class="stat-value">{{ stats.pending }}</div>
<div class="stat-label">待审核</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
<div class="stat-value">{{ stats.rejected }}</div>
<div class="stat-label">已拒绝</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="6">
<el-card>
<div class="stat-card">
@@ -34,6 +44,14 @@
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
<div class="stat-value">{{ stats.likes }}</div>
<div class="stat-label">总点赞</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
@@ -42,11 +60,22 @@
import { ref, onMounted } from 'vue'
import { getStats } from '@/api/joke'
const stats = ref({ total: 0, pending: 0, approved: 0, views: 0 })
const stats = ref({ total: 0, pending: 0, approved: 0, rejected: 0, views: 0, likes: 0 })
onMounted(async () => {
const res = await getStats()
stats.value = res
try {
const res = await getStats()
stats.value = {
total: res.total_jokes || 0,
pending: res.pending_jokes || 0,
approved: res.approved_jokes || 0,
rejected: res.rejected_jokes || 0,
views: res.total_views || 0,
likes: res.total_likes || 0,
}
} catch (e) {
console.error('获取统计数据失败', e)
}
})
</script>
+54
View File
@@ -0,0 +1,54 @@
<template>
<div class="feedback-list">
<div class="header">
<h2>反馈管理</h2>
</div>
<el-table :data="feedbacks" v-loading="loading" style="margin-top: 20px">
<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" />
<el-table-column prop="content" label="内容" min-width="300" show-overflow-tooltip />
<el-table-column prop="created_at" label="提交时间" width="180" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getFeedbacks, deleteFeedback } from '@/api/feedback'
import { ElMessage, ElMessageBox } from 'element-plus'
const feedbacks = ref([])
const loading = ref(false)
const loadFeedbacks = async () => {
loading.value = true
try {
feedbacks.value = await getFeedbacks()
} finally {
loading.value = false
}
}
const handleDelete = async (id) => {
await ElMessageBox.confirm('确定删除?')
await deleteFeedback(id)
ElMessage.success('删除成功')
loadFeedbacks()
}
onMounted(() => loadFeedbacks())
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+5 -5
View File
@@ -8,13 +8,13 @@
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="6" />
</el-form-item>
<el-form-item label="类型" prop="type_id">
<el-select v-model="form.type_id" placeholder="选择类型">
<el-form-item label="类型" prop="type_ids">
<el-select v-model="form.type_ids" multiple placeholder="选择类型(可多选)">
<el-option v-for="t in types" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="人群" prop="crowd_id">
<el-select v-model="form.crowd_id" placeholder="选择人群">
<el-form-item label="人群" prop="crowd_ids">
<el-select v-model="form.crowd_ids" multiple placeholder="选择人群(可多选)">
<el-option v-for="c in crowds" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
@@ -46,7 +46,7 @@ const formRef = ref()
const isEdit = ref(false)
const form = reactive({
title: '', content: '', type_id: null, crowd_id: null, status: 'pending'
title: '', content: '', type_ids: [], crowd_ids: [], status: 'pending'
})
const types = ref([])
+10 -2
View File
@@ -7,8 +7,16 @@
<el-table :data="jokeStore.jokes" v-loading="jokeStore.loading" style="margin-top: 20px">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="title" label="标题" />
<el-table-column prop="type_name" label="类型" width="100" />
<el-table-column prop="crowd_name" label="人群" width="100" />
<el-table-column label="类型" width="140">
<template #default="{ row }">
<el-tag v-for="name in (row.type_names || [])" :key="name" size="small" style="margin:1px">{{ name }}</el-tag>
</template>
</el-table-column>
<el-table-column label="人群" width="140">
<template #default="{ row }">
<el-tag v-for="name in (row.crowd_names || [])" :key="name" size="small" type="success" style="margin:1px">{{ name }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'approved' ? 'success' : row.status === 'pending' ? 'warning' : 'danger'">
+60
View File
@@ -0,0 +1,60 @@
<template>
<div class="link-edit">
<h2>{{ isEdit ? '编辑友链' : '添加友链' }}</h2>
<el-form :model="form" ref="formRef" label-width="120px" style="margin-top: 20px; max-width: 600px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="URL" prop="url">
<el-input v-model="form.url" placeholder="https://" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number v-model="form.sort_order" :min="0" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button @click="$router.back()">取消</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getLink, createLink, updateLink } from '@/api/link'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const formRef = ref()
const isEdit = ref(false)
const form = reactive({
name: '', url: '', description: '', sort_order: 0
})
const loadData = async () => {
if (route.params.id) {
isEdit.value = true
const res = await getLink(route.params.id)
Object.assign(form, res)
}
}
const handleSave = async () => {
if (isEdit.value) {
await updateLink(route.params.id, form)
ElMessage.success('更新成功')
} else {
await createLink(form)
ElMessage.success('创建成功')
}
router.push('/links')
}
onMounted(() => loadData())
</script>
+61
View File
@@ -0,0 +1,61 @@
<template>
<div class="link-list">
<div class="header">
<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">
<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">
<template #default="{ row }">
<a :href="row.url" target="_blank">{{ row.url }}</a>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" />
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="$router.push(`/links/edit/${row.id}`)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getLinks, deleteLink } from '@/api/link'
import { ElMessage, ElMessageBox } from 'element-plus'
const links = ref([])
const loading = ref(false)
const loadLinks = async () => {
loading.value = true
try {
links.value = await getLinks()
} finally {
loading.value = false
}
}
const handleDelete = async (id) => {
await ElMessageBox.confirm('确定删除?')
await deleteLink(id)
ElMessage.success('删除成功')
loadLinks()
}
onMounted(() => loadLinks())
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+198
View File
@@ -0,0 +1,198 @@
<template>
<div class="setting-page">
<h2>AI 配置</h2>
<!-- AI 配置表单 -->
<el-card style="margin-top: 20px">
<template #header>
<span>AI 配置</span>
</template>
<el-form :model="form" label-width="120px">
<el-form-item label="提供商">
<el-select v-model="form.provider" style="width: 200px">
<el-option label="NVIDIA NIM" value="nvidia" />
<el-option label="OpenAI" value="openai" />
<el-option label="Anthropic" value="anthropic" />
</el-select>
</el-form-item>
<el-form-item label="API 地址">
<el-input v-model="form.api_base" placeholder="https://integrate.api.nvidia.com/v1" style="width: 400px" />
</el-form-item>
<el-form-item label="API 密钥">
<el-input v-model="form.api_key" type="password" show-password style="width: 400px" placeholder="nvapi-..." />
</el-form-item>
<el-form-item label="模型名称">
<el-input v-model="form.model_name" style="width: 300px" placeholder="nvidia/llama-3.1-nemotron-70b-instruct" />
</el-form-item>
<el-form-item label="温度">
<el-input-number v-model="form.temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="最大 Token">
<el-input-number v-model="form.max_tokens" :min="1" :max="8192" />
</el-form-item>
</el-form>
</el-card>
<!-- 爬虫配置 -->
<el-card style="margin-top: 20px">
<template #header>
<span>爬虫配置</span>
</template>
<el-form :model="form" label-width="120px">
<el-form-item label="启用爬虫">
<el-switch v-model="form.crawl_enabled" />
</el-form-item>
<el-form-item label="搜索关键词">
<el-input
v-model="form.crawl_keywords"
type="textarea"
:rows="2"
style="width: 400px"
placeholder="多个关键词用逗号分隔,如:冷笑话,段子,谐音梗"
/>
</el-form-item>
<el-form-item label="每轮页数">
<el-input-number v-model="form.max_pages_per_run" :min="1" :max="20" />
</el-form-item>
</el-form>
</el-card>
<div style="margin-top: 20px">
<el-button type="primary" @click="handleSave" :loading="saving">保存配置</el-button>
</div>
<!-- 历史配置 -->
<el-card style="margin-top: 20px">
<template #header>
<span>历史配置</span>
</template>
<el-table :data="allSettings" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="provider" label="提供商" width="100" />
<el-table-column prop="model_name" label="模型" />
<el-table-column prop="api_base" label="API 地址" show-overflow-tooltip />
<el-table-column prop="is_active" label="激活" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="warning" @click="handleToggle(row)" v-if="!row.is_active">激活</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSettings, getActiveSetting, createSetting, updateSetting, toggleSetting, deleteSetting } from '@/api/setting'
const form = ref({
provider: 'nvidia',
api_base: 'https://integrate.api.nvidia.com/v1',
api_key: '',
model_name: 'nvidia/llama-3.1-nemotron-70b-instruct',
temperature: 0.7,
max_tokens: 2048,
crawl_enabled: false,
crawl_keywords: '冷笑话,段子,谐音梗',
max_pages_per_run: 3,
})
const allSettings = ref([])
const saving = ref(false)
const editingId = ref(null)
const loadData = async () => {
try {
const active = await getActiveSetting()
editingId.value = active.id
form.value = {
provider: active.provider,
api_base: active.api_base,
api_key: active.api_key,
model_name: active.model_name,
temperature: active.temperature,
max_tokens: active.max_tokens,
crawl_enabled: active.crawl_enabled,
crawl_keywords: active.crawl_keywords || '',
max_pages_per_run: active.max_pages_per_run,
}
} catch (e) {
// 没有激活配置,使用默认值
}
try {
allSettings.value = await getSettings()
} catch (e) {
console.error(e)
}
}
const handleSave = async () => {
saving.value = true
try {
if (editingId.value) {
await updateSetting(editingId.value, form.value)
ElMessage.success('保存成功')
} else {
const created = await createSetting(form.value)
editingId.value = created.id
await toggleSetting(created.id) // 设为激活
ElMessage.success('创建并激活成功')
}
await loadData()
} catch (e) {
ElMessage.error('保存失败: ' + (e.message || ''))
} finally {
saving.value = false
}
}
const handleEdit = (row) => {
editingId.value = row.id
form.value = {
provider: row.provider,
api_base: row.api_base,
api_key: row.api_key,
model_name: row.model_name,
temperature: row.temperature,
max_tokens: row.max_tokens,
crawl_enabled: row.crawl_enabled,
crawl_keywords: row.crawl_keywords || '',
max_pages_per_run: row.max_pages_per_run,
}
}
const handleToggle = async (row) => {
await ElMessageBox.confirm('确定激活此配置?')
await toggleSetting(row.id)
ElMessage.success('激活成功')
await loadData()
}
const handleDelete = async (id) => {
await ElMessageBox.confirm('确定删除此配置?')
await deleteSetting(id)
ElMessage.success('删除成功')
if (editingId.value === id) {
editingId.value = null
}
await loadData()
}
onMounted(() => loadData())
</script>
<style scoped>
.setting-page { max-width: 900px }
</style>