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>
+2 -1
View File
@@ -5,10 +5,11 @@ from app.database import Base
class AdminUser(Base):
"""管理员用户表模型"""
"""用户表 - 支持角色:admin(管理员), submitter(提交者)"""
__tablename__ = "admin_users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
role = Column(String(20), default="submitter") # admin=管理员, submitter=提交者
created_at = Column(DateTime, default=func.now())
+79 -1
View File
@@ -16,6 +16,8 @@ from app.schemas.category import JokeCrowdCreate, JokeCrowdResponse, JokeTypeCre
from app.schemas.joke import JokeCreate, JokeResponse, JokeUpdate, PaginatedJokeResponse
from app.schemas.link import LinkCreate, LinkResponse
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=["管理后台"])
@@ -78,6 +80,8 @@ def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
title=joke.title,
content=joke.content,
polished_content=joke.polished_content,
ai_score=joke.ai_score,
ai_level=joke.ai_level,
type_ids=ids,
crowd_ids=crowd_ids,
status=joke.status,
@@ -96,15 +100,24 @@ def admin_list_jokes(
page: int = 1,
page_size: int = 20,
status: str = None,
keyword: str = None,
ai_level: str = None,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""获取所有笑话(支持状态筛选)"""
"""获取所有笑话(支持状态筛选、关键词搜索"""
# 限制 page_size 防止 DoS
page_size = max(1, min(page_size, 100))
query = db.query(Joke)
if 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()
offset = (page - 1) * page_size
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()
if not db_joke:
raise HTTPException(status_code=404, detail="笑话不存在")
update_data = joke.model_dump(exclude_unset=True)
for key, value in update_data.items():
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.commit()
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": "删除成功"}
+62
View File
@@ -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)} 条笑话"
}
+26
View File
@@ -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
+203
View File
@@ -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 | 服务器内部错误 |