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:
bwstudio
2026-06-07 12:48:35 +08:00
co-authored by Claude Opus 4.8
parent ceed63fcb0
commit aecf1f1f15
10 changed files with 681 additions and 21 deletions
+7
View File
@@ -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}`)
+2 -1
View File
@@ -20,12 +20,13 @@ const routes = [
{ 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') },
{ path: 'users', name: 'UserList', component: () => import('@/views/user/List.vue') },
]
}
]
const router = createRouter({
history: createWebHistory(),
history: createWebHistory('/admin'),
routes
})
+33 -4
View File
@@ -1,13 +1,33 @@
<template>
<div class="joke-edit">
<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-input v-model="form.title" />
</el-form-item>
<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 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-select v-model="form.type_ids" multiple placeholder="选择类型(可多选)">
<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 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([])
@@ -59,7 +79,16 @@ const loadData = async () => {
if (route.params.id) {
isEdit.value = true
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
})
}
}
+149 -14
View File
@@ -4,9 +4,54 @@
<h2>笑话管理</h2>
<el-button type="primary" @click="$router.push('/jokes/edit')">添加笑话</el-button>
</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="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">
<template #default="{ row }">
<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>
</template>
</el-table-column>
<el-table-column prop="view_count" label="浏览" width="80" />
<el-table-column prop="like_count" label="点赞" width="80" />
<el-table-column prop="view_count" label="浏览" width="70" />
<el-table-column prop="like_count" label="点赞" width="70" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="$router.push(`/jokes/edit/${row.id}`)">编辑</el-button>
@@ -33,28 +78,69 @@
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 20px"
:current-page="page"
:page-size="20"
:total="jokeStore.total"
layout="total, prev, pager, next"
@current-change="loadJokes"
/>
<div class="pagination-wrapper">
<el-pagination
:current-page="page"
:page-size="20"
:total="jokeStore.total"
layout="total, prev, pager, next, jumper"
@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>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useJokeStore } from '@/stores/joke'
import { ElMessage, ElMessageBox } from 'element-plus'
const jokeStore = useJokeStore()
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) => {
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) => {
@@ -64,6 +150,26 @@ const handleDelete = async (id) => {
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())
</script>
@@ -73,4 +179,33 @@ onMounted(() => loadJokes())
justify-content: space-between;
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>
+118
View File
@@ -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>