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/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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user