diff --git a/admin/src/api/user.js b/admin/src/api/user.js new file mode 100644 index 0000000..b0bef4f --- /dev/null +++ b/admin/src/api/user.js @@ -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}`) \ No newline at end of file diff --git a/admin/src/router/index.js b/admin/src/router/index.js index b058897..b8dff2a 100644 --- a/admin/src/router/index.js +++ b/admin/src/router/index.js @@ -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 }) diff --git a/admin/src/views/joke/Edit.vue b/admin/src/views/joke/Edit.vue index 2c5cf73..53ad232 100644 --- a/admin/src/views/joke/Edit.vue +++ b/admin/src/views/joke/Edit.vue @@ -1,13 +1,33 @@ @@ -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; +} diff --git a/admin/src/views/user/List.vue b/admin/src/views/user/List.vue new file mode 100644 index 0000000..595ed5a --- /dev/null +++ b/admin/src/views/user/List.vue @@ -0,0 +1,118 @@ + + + + + \ No newline at end of file diff --git a/api/app/models/user.py b/api/app/models/user.py index 7e34312..cf61d52 100644 --- a/api/app/models/user.py +++ b/api/app/models/user.py @@ -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()) \ No newline at end of file diff --git a/api/app/routers/admin.py b/api/app/routers/admin.py index 4cbedf4..7c0354c 100644 --- a/api/app/routers/admin.py +++ b/api/app/routers/admin.py @@ -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: @@ -420,4 +434,68 @@ def admin_delete_feedback( raise HTTPException(status_code=404, detail="反馈不存在") 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": "删除成功"} \ No newline at end of file diff --git a/api/app/routers/submit.py b/api/app/routers/submit.py new file mode 100644 index 0000000..204f1b2 --- /dev/null +++ b/api/app/routers/submit.py @@ -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)} 条笑话" + } \ No newline at end of file diff --git a/api/app/schemas/user.py b/api/app/schemas/user.py new file mode 100644 index 0000000..4d456c4 --- /dev/null +++ b/api/app/schemas/user.py @@ -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 \ No newline at end of file diff --git a/docs/数据提交接口文档.md b/docs/数据提交接口文档.md new file mode 100644 index 0000000..d34a24f --- /dev/null +++ b/docs/数据提交接口文档.md @@ -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 | 服务器内部错误 | \ No newline at end of file