feat: add user role system and public joke submission API
- Add AdminUser role field (admin/submitter) - User management in admin panel - Public joke submission API (no auth required) - Document submission methods for external workers Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ceed63fcb0
commit
aecf1f1f15
@@ -0,0 +1,7 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
export const getUsers = () => request.get('/admin/users')
|
||||||
|
|
||||||
|
export const createUser = (data) => request.post('/admin/users', data)
|
||||||
|
|
||||||
|
export const deleteUser = (id) => request.delete(`/admin/users/${id}`)
|
||||||
@@ -20,12 +20,13 @@ const routes = [
|
|||||||
{ path: 'links', name: 'LinkList', component: () => import('@/views/links/List.vue') },
|
{ path: 'links', name: 'LinkList', component: () => import('@/views/links/List.vue') },
|
||||||
{ path: 'links/edit/:id?', name: 'LinkEdit', component: () => import('@/views/links/Edit.vue') },
|
{ path: 'links/edit/:id?', name: 'LinkEdit', component: () => import('@/views/links/Edit.vue') },
|
||||||
{ path: 'feedbacks', name: 'FeedbackList', component: () => import('@/views/feedback/List.vue') },
|
{ path: 'feedbacks', name: 'FeedbackList', component: () => import('@/views/feedback/List.vue') },
|
||||||
|
{ path: 'users', name: 'UserList', component: () => import('@/views/user/List.vue') },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory('/admin'),
|
||||||
routes
|
routes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="joke-edit">
|
<div class="joke-edit">
|
||||||
<h2>{{ isEdit ? '编辑笑话' : '添加笑话' }}</h2>
|
<h2>{{ isEdit ? '编辑笑话' : '添加笑话' }}</h2>
|
||||||
<el-form :model="form" ref="formRef" label-width="100px" style="margin-top: 20px; max-width: 600px">
|
<el-form :model="form" ref="formRef" label-width="100px" style="margin-top: 20px; max-width: 800px">
|
||||||
<el-form-item label="标题" prop="title">
|
<el-form-item label="标题" prop="title">
|
||||||
<el-input v-model="form.title" />
|
<el-input v-model="form.title" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="内容" prop="content">
|
<el-form-item label="内容" prop="content">
|
||||||
<el-input v-model="form.content" type="textarea" :rows="6" />
|
<el-input v-model="form.content" type="textarea" :rows="4" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="AI润色版本" prop="polished_content">
|
||||||
|
<el-input v-model="form.polished_content" type="textarea" :rows="4" placeholder="AI 润色后的笑话内容" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="AI评分" prop="ai_score">
|
||||||
|
<el-input-number v-model="form.ai_score" :min="1" :max="10" :step="0.1" precision="1" placeholder="1-10分" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="16">
|
||||||
|
<el-form-item label="AI等级" prop="ai_level">
|
||||||
|
<el-select v-model="form.ai_level" placeholder="AI 质量评价" style="width: 100%">
|
||||||
|
<el-option label="⭐⭐⭐ 精品 (excellent)" value="excellent" />
|
||||||
|
<el-option label="⭐⭐ 良好 (good)" value="good" />
|
||||||
|
<el-option label="⭐ 普通 (ordinary)" value="ordinary" />
|
||||||
|
<el-option label="⚠️ 待优化 (poor)" value="poor" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
<el-form-item label="类型" prop="type_ids">
|
<el-form-item label="类型" prop="type_ids">
|
||||||
<el-select v-model="form.type_ids" multiple placeholder="选择类型(可多选)">
|
<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-option v-for="t in types" :key="t.id" :label="t.name" :value="t.id" />
|
||||||
@@ -46,7 +66,7 @@ const formRef = ref()
|
|||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
title: '', content: '', type_ids: [], crowd_ids: [], status: 'pending'
|
title: '', content: '', polished_content: '', ai_score: null, ai_level: '', type_ids: [], crowd_ids: [], status: 'pending'
|
||||||
})
|
})
|
||||||
|
|
||||||
const types = ref([])
|
const types = ref([])
|
||||||
@@ -59,7 +79,16 @@ const loadData = async () => {
|
|||||||
if (route.params.id) {
|
if (route.params.id) {
|
||||||
isEdit.value = true
|
isEdit.value = true
|
||||||
const res = await getJoke(route.params.id)
|
const res = await getJoke(route.params.id)
|
||||||
Object.assign(form, res)
|
Object.assign(form, {
|
||||||
|
title: res.title,
|
||||||
|
content: res.content,
|
||||||
|
polished_content: res.polished_content || '',
|
||||||
|
ai_score: res.ai_score || null,
|
||||||
|
ai_level: res.ai_level || '',
|
||||||
|
type_ids: res.type_ids || [],
|
||||||
|
crowd_ids: res.crowd_ids || [],
|
||||||
|
status: res.status
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,54 @@
|
|||||||
<h2>笑话管理</h2>
|
<h2>笑话管理</h2>
|
||||||
<el-button type="primary" @click="$router.push('/jokes/edit')">添加笑话</el-button>
|
<el-button type="primary" @click="$router.push('/jokes/edit')">添加笑话</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="jokeStore.jokes" v-loading="jokeStore.loading" style="margin-top: 20px">
|
|
||||||
|
<!-- 搜索筛选 -->
|
||||||
|
<div class="search-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索标题/内容..."
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<span class="search-icon">🔍</span>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 120px" @change="handleSearch">
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option label="已发布" value="approved" />
|
||||||
|
<el-option label="待审核" value="pending" />
|
||||||
|
<el-option label="已拒绝" value="rejected" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filterLevel" placeholder="AI等级" clearable style="width: 130px" @change="handleSearch">
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option label="⭐⭐⭐ 精品" value="excellent" />
|
||||||
|
<el-option label="⭐⭐ 良好" value="good" />
|
||||||
|
<el-option label="⭐ 普通" value="ordinary" />
|
||||||
|
<el-option label="⚠️ 待优化" value="poor" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="jokeStore.jokes" v-loading="jokeStore.loading" style="margin-top: 16px">
|
||||||
<el-table-column prop="id" label="ID" width="60" />
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
<el-table-column prop="title" label="标题" />
|
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column label="AI等级" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.ai_level" size="small" :type="getLevelType(row.ai_level)">
|
||||||
|
{{ getLevelText(row.ai_level) }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="润色" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.polished_content" type="success" size="small">已润色</el-tag>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="类型" width="140">
|
<el-table-column label="类型" width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-for="name in (row.type_names || [])" :key="name" size="small" style="margin:1px">{{ name }}</el-tag>
|
<el-tag v-for="name in (row.type_names || [])" :key="name" size="small" style="margin:1px">{{ name }}</el-tag>
|
||||||
@@ -24,8 +69,8 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="view_count" label="浏览" width="80" />
|
<el-table-column prop="view_count" label="浏览" width="70" />
|
||||||
<el-table-column prop="like_count" label="点赞" width="80" />
|
<el-table-column prop="like_count" label="点赞" width="70" />
|
||||||
<el-table-column label="操作" width="200">
|
<el-table-column label="操作" width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="$router.push(`/jokes/edit/${row.id}`)">编辑</el-button>
|
<el-button size="small" @click="$router.push(`/jokes/edit/${row.id}`)">编辑</el-button>
|
||||||
@@ -33,28 +78,69 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<div class="pagination-wrapper">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
style="margin-top: 20px"
|
|
||||||
:current-page="page"
|
:current-page="page"
|
||||||
:page-size="20"
|
:page-size="20"
|
||||||
:total="jokeStore.total"
|
:total="jokeStore.total"
|
||||||
layout="total, prev, pager, next"
|
layout="total, prev, pager, next, jumper"
|
||||||
@current-change="loadJokes"
|
@current-change="loadJokes"
|
||||||
/>
|
/>
|
||||||
|
<div class="page-jump">
|
||||||
|
跳至 <el-input-number
|
||||||
|
v-model="jumpPage"
|
||||||
|
:min="1"
|
||||||
|
:max="maxPage"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@change="handleJump"
|
||||||
|
/> 页
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useJokeStore } from '@/stores/joke'
|
import { useJokeStore } from '@/stores/joke'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
const jokeStore = useJokeStore()
|
const jokeStore = useJokeStore()
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
const jumpPage = ref(1)
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const filterStatus = ref('')
|
||||||
|
const filterLevel = ref('')
|
||||||
|
|
||||||
|
const maxPage = computed(() => Math.ceil(jokeStore.total / 20) || 1)
|
||||||
|
|
||||||
const loadJokes = (p = 1) => {
|
const loadJokes = (p = 1) => {
|
||||||
page.value = p
|
page.value = p
|
||||||
jokeStore.fetchJokes({ page: p, page_size: 20 })
|
jumpPage.value = p
|
||||||
|
jokeStore.fetchJokes({
|
||||||
|
page: p,
|
||||||
|
page_size: 20,
|
||||||
|
keyword: searchKeyword.value || undefined,
|
||||||
|
status: filterStatus.value || undefined,
|
||||||
|
ai_level: filterLevel.value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJump = (val) => {
|
||||||
|
if (val && val >= 1 && val <= maxPage.value) {
|
||||||
|
loadJokes(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
loadJokes(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
searchKeyword.value = ''
|
||||||
|
filterStatus.value = ''
|
||||||
|
filterLevel.value = ''
|
||||||
|
loadJokes(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
@@ -64,6 +150,26 @@ const handleDelete = async (id) => {
|
|||||||
loadJokes(page.value)
|
loadJokes(page.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLevelText(level) {
|
||||||
|
const map = {
|
||||||
|
'excellent': '⭐⭐⭐',
|
||||||
|
'good': '⭐⭐',
|
||||||
|
'ordinary': '⭐',
|
||||||
|
'poor': '待优化'
|
||||||
|
}
|
||||||
|
return map[level] || level
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLevelType(level) {
|
||||||
|
const map = {
|
||||||
|
'excellent': 'warning',
|
||||||
|
'good': 'success',
|
||||||
|
'ordinary': 'info',
|
||||||
|
'poor': 'danger'
|
||||||
|
}
|
||||||
|
return map[level] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => loadJokes())
|
onMounted(() => loadJokes())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -73,4 +179,33 @@ onMounted(() => loadJokes())
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.search-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.text-muted {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.pagination-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
:deep(.el-input-number) {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-list">
|
||||||
|
<div class="header">
|
||||||
|
<h2>用户管理</h2>
|
||||||
|
<el-button type="primary" @click="showDialog = true; form = {}">添加用户</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="users" v-loading="loading" style="margin-top: 16px">
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="username" label="用户名" />
|
||||||
|
<el-table-column label="角色" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.role === 'admin' ? 'danger' : 'success'" size="small">
|
||||||
|
{{ row.role === 'admin' ? '管理员' : '提交者' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<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)"
|
||||||
|
:disabled="row.id === currentUserId">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 添加用户弹窗 -->
|
||||||
|
<el-dialog v-model="showDialog" :title="form.id ? '编辑用户' : '添加用户'">
|
||||||
|
<el-form :model="form" label-width="80px">
|
||||||
|
<el-form-item label="用户名" required>
|
||||||
|
<el-input v-model="form.username" placeholder="输入用户名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码" required>
|
||||||
|
<el-input v-model="form.password" type="password" placeholder="输入密码" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色">
|
||||||
|
<el-select v-model="form.role" style="width: 100%">
|
||||||
|
<el-option label="管理员 (admin)" value="admin" />
|
||||||
|
<el-option label="提交者 (submitter)" value="submitter" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getUsers, createUser, deleteUser } from '@/api/user'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const users = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showDialog = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const form = ref({ role: 'submitter' })
|
||||||
|
|
||||||
|
// 当前登录用户的 ID(从 token 解析)
|
||||||
|
const currentUserId = ref(1) // 假设登录用户是 admin
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
users.value = await getUsers()
|
||||||
|
// 从 localStorage 的 token 解析用户 ID(如果有)
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||||
|
currentUserId.value = parseInt(payload.sub)
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('获取用户列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.value.username || !form.value.password) {
|
||||||
|
ElMessage.warning('请填写用户名和密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await createUser(form.value)
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
showDialog.value = false
|
||||||
|
loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.detail || '添加失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
await ElMessageBox.confirm('确定删除此用户?')
|
||||||
|
await deleteUser(id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => loadUsers())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,10 +5,11 @@ from app.database import Base
|
|||||||
|
|
||||||
|
|
||||||
class AdminUser(Base):
|
class AdminUser(Base):
|
||||||
"""管理员用户表模型"""
|
"""用户表 - 支持角色:admin(管理员), submitter(提交者)"""
|
||||||
__tablename__ = "admin_users"
|
__tablename__ = "admin_users"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
username = Column(String(50), unique=True, nullable=False)
|
username = Column(String(50), unique=True, nullable=False)
|
||||||
password_hash = Column(String(255), nullable=False)
|
password_hash = Column(String(255), nullable=False)
|
||||||
|
role = Column(String(20), default="submitter") # admin=管理员, submitter=提交者
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
@@ -16,6 +16,8 @@ from app.schemas.category import JokeCrowdCreate, JokeCrowdResponse, JokeTypeCre
|
|||||||
from app.schemas.joke import JokeCreate, JokeResponse, JokeUpdate, PaginatedJokeResponse
|
from app.schemas.joke import JokeCreate, JokeResponse, JokeUpdate, PaginatedJokeResponse
|
||||||
from app.schemas.link import LinkCreate, LinkResponse
|
from app.schemas.link import LinkCreate, LinkResponse
|
||||||
from app.schemas.feedback import FeedbackResponse
|
from app.schemas.feedback import FeedbackResponse
|
||||||
|
from app.schemas.user import UserCreate, UserResponse
|
||||||
|
from app.routers.auth import hash_password
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["管理后台"])
|
router = APIRouter(prefix="/admin", tags=["管理后台"])
|
||||||
|
|
||||||
@@ -78,6 +80,8 @@ def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
|
|||||||
title=joke.title,
|
title=joke.title,
|
||||||
content=joke.content,
|
content=joke.content,
|
||||||
polished_content=joke.polished_content,
|
polished_content=joke.polished_content,
|
||||||
|
ai_score=joke.ai_score,
|
||||||
|
ai_level=joke.ai_level,
|
||||||
type_ids=ids,
|
type_ids=ids,
|
||||||
crowd_ids=crowd_ids,
|
crowd_ids=crowd_ids,
|
||||||
status=joke.status,
|
status=joke.status,
|
||||||
@@ -96,15 +100,24 @@ def admin_list_jokes(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
status: str = None,
|
status: str = None,
|
||||||
|
keyword: str = None,
|
||||||
|
ai_level: str = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: AdminUser = Depends(get_current_admin_user),
|
current_user: AdminUser = Depends(get_current_admin_user),
|
||||||
):
|
):
|
||||||
"""获取所有笑话(支持状态筛选)"""
|
"""获取所有笑话(支持状态筛选、关键词搜索)"""
|
||||||
# 限制 page_size 防止 DoS
|
# 限制 page_size 防止 DoS
|
||||||
page_size = max(1, min(page_size, 100))
|
page_size = max(1, min(page_size, 100))
|
||||||
query = db.query(Joke)
|
query = db.query(Joke)
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(Joke.status == status)
|
query = query.filter(Joke.status == status)
|
||||||
|
if keyword:
|
||||||
|
keyword_filter = f"%{keyword}%"
|
||||||
|
query = query.filter(
|
||||||
|
(Joke.title.like(keyword_filter)) | (Joke.content.like(keyword_filter))
|
||||||
|
)
|
||||||
|
if ai_level:
|
||||||
|
query = query.filter(Joke.ai_level == ai_level)
|
||||||
total = query.count()
|
total = query.count()
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
jokes = query.order_by(Joke.created_at.desc()).offset(offset).limit(page_size).all()
|
jokes = query.order_by(Joke.created_at.desc()).offset(offset).limit(page_size).all()
|
||||||
@@ -159,6 +172,7 @@ def admin_update_joke(
|
|||||||
db_joke = db.query(Joke).filter(Joke.id == joke_id).first()
|
db_joke = db.query(Joke).filter(Joke.id == joke_id).first()
|
||||||
if not db_joke:
|
if not db_joke:
|
||||||
raise HTTPException(status_code=404, detail="笑话不存在")
|
raise HTTPException(status_code=404, detail="笑话不存在")
|
||||||
|
|
||||||
update_data = joke.model_dump(exclude_unset=True)
|
update_data = joke.model_dump(exclude_unset=True)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
if key in ("type_ids", "crowd_ids") and value is not None:
|
if key in ("type_ids", "crowd_ids") and value is not None:
|
||||||
@@ -421,3 +435,67 @@ def admin_delete_feedback(
|
|||||||
db.delete(db_feedback)
|
db.delete(db_feedback)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "删除成功"}
|
return {"message": "删除成功"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 用户管理
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/users", response_model=list[UserResponse])
|
||||||
|
def admin_list_users(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: AdminUser = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""获取所有用户"""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="仅管理员可操作")
|
||||||
|
return db.query(AdminUser).order_by(AdminUser.created_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users", response_model=UserResponse)
|
||||||
|
def admin_create_user(
|
||||||
|
user: UserCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: AdminUser = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""创建用户"""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="仅管理员可操作")
|
||||||
|
|
||||||
|
# 检查用户名是否已存在
|
||||||
|
existing = db.query(AdminUser).filter(AdminUser.username == user.username).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
|
|
||||||
|
db_user = AdminUser(
|
||||||
|
username=user.username,
|
||||||
|
password_hash=hash_password(user.password),
|
||||||
|
role=user.role,
|
||||||
|
)
|
||||||
|
db.add(db_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
def admin_delete_user(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: AdminUser = Depends(get_current_admin_user),
|
||||||
|
):
|
||||||
|
"""删除用户"""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="仅管理员可操作")
|
||||||
|
|
||||||
|
db_user = db.query(AdminUser).filter(AdminUser.id == user_id).first()
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
# 不允许删除自己
|
||||||
|
if db_user.id == current_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="不能删除自己")
|
||||||
|
|
||||||
|
db.delete(db_user)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "删除成功"}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
公开提交流程 - 供外部用户(submitter)使用。
|
||||||
|
无后台管理权限,仅能提交笑话到 pending 状态。
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.joke import Joke
|
||||||
|
|
||||||
|
router = APIRouter(tags=["笑话提交"])
|
||||||
|
|
||||||
|
|
||||||
|
class JokeSubmit(BaseModel):
|
||||||
|
"""笑话提交"""
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class JokeBatchSubmit(BaseModel):
|
||||||
|
"""批量提交"""
|
||||||
|
jokes: list[JokeSubmit]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/submit", response_model=dict)
|
||||||
|
def submit_joke(data: JokeSubmit, db: Session = Depends(get_db)):
|
||||||
|
"""公开接口:提交单条笑话(自动进入待审核状态)"""
|
||||||
|
joke = Joke(
|
||||||
|
title=data.title,
|
||||||
|
content=data.content,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(joke)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(joke)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"id": joke.id,
|
||||||
|
"message": f"已提交,ID: {joke.id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/submit/batch", response_model=dict)
|
||||||
|
def submit_jokes_batch(data: JokeBatchSubmit, db: Session = Depends(get_db)):
|
||||||
|
"""公开接口:批量提交笑话"""
|
||||||
|
added = []
|
||||||
|
for item in data.jokes:
|
||||||
|
joke = Joke(
|
||||||
|
title=item.title,
|
||||||
|
content=item.content,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(joke)
|
||||||
|
added.append(item.title)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"count": len(added),
|
||||||
|
"message": f"已提交 {len(added)} 条笑话"
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
"""创建用户"""
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
role: str = "submitter" # admin=管理员, submitter=提交者
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
"""用户信息"""
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserRegister(BaseModel):
|
||||||
|
"""公开注册(仅限提交者)"""
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# 笑话提交接口文档
|
||||||
|
|
||||||
|
## 接口地址
|
||||||
|
|
||||||
|
```
|
||||||
|
基础地址:http://39.104.58.51
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 提交笑话
|
||||||
|
|
||||||
|
### 接口 1:单条提交
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/jokes/submit
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://39.104.58.51/api/jokes/submit \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"笑话标题","content":"笑话内容..."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{"success":true,"id":1280,"message":"已提交,ID: 1280"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 接口 2:批量提交
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/jokes/submit/batch
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://39.104.58.51/api/jokes/submit/batch \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"jokes": [
|
||||||
|
{"title":"标题1","content":"内容1"},
|
||||||
|
{"title":"标题2","content":"内容2"},
|
||||||
|
{"title":"标题3","content":"内容3"}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
```json
|
||||||
|
{"success":true,"count":3,"message":"已提交 3 条笑话"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 编程语言示例
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE_URL = "http://39.104.58.51"
|
||||||
|
|
||||||
|
# 单条提交
|
||||||
|
def submit_joke(title, content):
|
||||||
|
resp = requests.post(f"{BASE_URL}/api/jokes/submit", json={
|
||||||
|
"title": title,
|
||||||
|
"content": content
|
||||||
|
})
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
# 批量提交
|
||||||
|
def submit_jokes_batch(jokes):
|
||||||
|
resp = requests.post(f"{BASE_URL}/api/jokes/submit/batch", json={
|
||||||
|
"jokes": jokes # jokes 是 [{"title":..., "content":...}, ...]
|
||||||
|
})
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# 使用示例
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 单条
|
||||||
|
result = submit_joke("标题", "内容")
|
||||||
|
|
||||||
|
# 批量
|
||||||
|
jokes = [
|
||||||
|
{"title": "笑话1", "content": "内容1"},
|
||||||
|
{"title": "笑话2", "content": "内容2"},
|
||||||
|
{"title": "笑话3", "content": "内容3"},
|
||||||
|
]
|
||||||
|
result = submit_jokes_batch(jokes)
|
||||||
|
print(result)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### JavaScript / Node.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const BASE_URL = "http://39.104.58.51";
|
||||||
|
|
||||||
|
// 单条提交
|
||||||
|
async function submitJoke(title, content) {
|
||||||
|
const resp = await fetch(`${BASE_URL}/api/jokes/submit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title, content })
|
||||||
|
});
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量提交
|
||||||
|
async function submitJokesBatch(jokes) {
|
||||||
|
const resp = await fetch(`${BASE_URL}/api/jokes/submit/batch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ jokes })
|
||||||
|
});
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
(async () => {
|
||||||
|
// 单条
|
||||||
|
console.log(await submitJoke("标题", "内容"));
|
||||||
|
|
||||||
|
// 批量
|
||||||
|
const jokes = [
|
||||||
|
{ title: "笑话1", content: "内容1" },
|
||||||
|
{ title: "笑话2", content: "内容2" },
|
||||||
|
];
|
||||||
|
console.log(await submitJokesBatch(jokes));
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Java
|
||||||
|
|
||||||
|
```java
|
||||||
|
import java.net.http.*;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
public class JokeSubmit {
|
||||||
|
static final String BASE_URL = "http://39.104.58.51";
|
||||||
|
|
||||||
|
public static String post(String path, String body) throws Exception {
|
||||||
|
HttpClient client = HttpClient.newHttpClient();
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(BASE_URL + path))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
return client.send(request, HttpResponse.BodyHandlers.ofString()).body();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
// 单条提交
|
||||||
|
System.out.println(post("/api/jokes/submit",
|
||||||
|
"{\"title\":\"标题\",\"content\":\"内容\"}"));
|
||||||
|
|
||||||
|
// 批量提交
|
||||||
|
String batch = """
|
||||||
|
{"jokes":[
|
||||||
|
{"title":"笑话1","content":"内容1"},
|
||||||
|
{"title":"笑话2","content":"内容2"}
|
||||||
|
]}""";
|
||||||
|
System.out.println(post("/api/jokes/submit/batch", batch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **无需登录**:此接口公开,无需认证即可提交
|
||||||
|
2. **自动审核**:提交后笑话进入「待审核」状态
|
||||||
|
3. **字段说明**:
|
||||||
|
- `title`:笑话标题(必填)
|
||||||
|
- `content`:笑话正文(必填)
|
||||||
|
4. **批量限制**:建议单次不超过 100 条
|
||||||
|
5. **编码**:确保使用 UTF-8 编码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 状态码
|
||||||
|
|
||||||
|
| 状态码 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 200 | 提交成功 |
|
||||||
|
| 400 | 请求格式错误 |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
Reference in New Issue
Block a user