feat: 前后台静态资源路径冲突修复 + 用户管理功能

- 修改 admin/vite.config.js 添加 base: '/admin/' 解决静态资源路径问题
- 修复后台 index.html 资源引用从 /assets/ → /admin/assets/
- 更新优化器默认 API 地址为服务器地址
- 添加友链管理相关代码
- 修复多处分类管理页面
This commit is contained in:
bwstudio
2026-06-10 07:30:49 +08:00
parent aecf1f1f15
commit 595394fcb7
22 changed files with 740 additions and 100 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ request.interceptors.response.use(
err => { err => {
if (err.response?.status === 401) { if (err.response?.status === 401) {
localStorage.removeItem('token') localStorage.removeItem('token')
window.location.href = '/login' window.location.href = '/admin/login'
} }
return Promise.reject(err) return Promise.reject(err)
} }
+1
View File
@@ -33,6 +33,7 @@
<el-menu-item index="/settings">AI 配置</el-menu-item> <el-menu-item index="/settings">AI 配置</el-menu-item>
<el-menu-item index="/links">友链管理</el-menu-item> <el-menu-item index="/links">友链管理</el-menu-item>
<el-menu-item index="/feedbacks">反馈管理</el-menu-item> <el-menu-item index="/feedbacks">反馈管理</el-menu-item>
<el-menu-item index="/users">用户管理</el-menu-item>
</el-sub-menu> </el-sub-menu>
</el-menu> </el-menu>
</el-aside> </el-aside>
+36 -2
View File
@@ -4,7 +4,23 @@
<h2>人群管理</h2> <h2>人群管理</h2>
<el-button type="primary" @click="dialogVisible = true; form = {}">添加</el-button> <el-button type="primary" @click="dialogVisible = true; form = {}">添加</el-button>
</div> </div>
<el-table :data="crowds" style="margin-top: 20px">
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索名称..."
clearable
style="width: 200px"
@input="handleSearch"
>
<template #prefix>
<span>🔍</span>
</template>
</el-input>
<span class="result-count"> {{ filteredCrowds.length }} </span>
</div>
<el-table :data="filteredCrowds" style="margin-top: 16px">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" /> <el-table-column prop="name" label="名称" />
<el-table-column prop="sort_order" label="排序" width="100" /> <el-table-column prop="sort_order" label="排序" width="100" />
@@ -34,15 +50,23 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { getCrowds, createCrowd, updateCrowd, deleteCrowd } from '@/api/category' import { getCrowds, createCrowd, updateCrowd, deleteCrowd } from '@/api/category'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
const crowds = ref([]) const crowds = ref([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const form = ref({}) const form = ref({})
const searchKeyword = ref('')
const filteredCrowds = computed(() => {
if (!searchKeyword.value) return crowds.value
const kw = searchKeyword.value.toLowerCase()
return crowds.value.filter(c => c.name.toLowerCase().includes(kw))
})
const loadData = async () => { crowds.value = await getCrowds() } const loadData = async () => { crowds.value = await getCrowds() }
const handleSearch = () => { /* computed property handles filtering */ }
const handleEdit = (row) => { form.value = { ...row }; dialogVisible.value = true } const handleEdit = (row) => { form.value = { ...row }; dialogVisible.value = true }
const handleSave = async () => { const handleSave = async () => {
form.value.id ? await updateCrowd(form.value.id, form.value) : await createCrowd(form.value) form.value.id ? await updateCrowd(form.value.id, form.value) : await createCrowd(form.value)
@@ -60,4 +84,14 @@ onMounted(() => loadData())
<style scoped> <style scoped>
.header { display: flex; justify-content: space-between; align-items: center; } .header { display: flex; justify-content: space-between; align-items: center; }
.search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.result-count {
color: #909399;
font-size: 13px;
}
</style> </style>
+39 -2
View File
@@ -4,7 +4,23 @@
<h2>类型管理</h2> <h2>类型管理</h2>
<el-button type="primary" @click="dialogVisible = true; form = {}">添加</el-button> <el-button type="primary" @click="dialogVisible = true; form = {}">添加</el-button>
</div> </div>
<el-table :data="types" style="margin-top: 20px">
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索名称..."
clearable
style="width: 200px"
@input="handleSearch"
>
<template #prefix>
<span>🔍</span>
</template>
</el-input>
<span class="result-count"> {{ filteredTypes.length }} </span>
</div>
<el-table :data="paginatedTypes" style="margin-top: 16px">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" /> <el-table-column prop="name" label="名称" />
<el-table-column prop="sort_order" label="排序" width="100" /> <el-table-column prop="sort_order" label="排序" width="100" />
@@ -34,15 +50,26 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { getTypes, createType, updateType, deleteType } from '@/api/category' import { getTypes, createType, updateType, deleteType } from '@/api/category'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
const types = ref([]) const types = ref([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const form = ref({}) const form = ref({})
const searchKeyword = ref('')
const searchResults = ref([])
const filteredTypes = computed(() => {
if (!searchKeyword.value) return types.value
const kw = searchKeyword.value.toLowerCase()
return types.value.filter(t => t.name.toLowerCase().includes(kw))
})
const paginatedTypes = computed(() => filteredTypes.value)
const loadData = async () => { types.value = await getTypes() } const loadData = async () => { types.value = await getTypes() }
const handleSearch = () => { /* computed property handles filtering */ }
const handleEdit = (row) => { form.value = { ...row }; dialogVisible.value = true } const handleEdit = (row) => { form.value = { ...row }; dialogVisible.value = true }
const handleSave = async () => { const handleSave = async () => {
form.value.id ? await updateType(form.value.id, form.value) : await createType(form.value) form.value.id ? await updateType(form.value.id, form.value) : await createType(form.value)
@@ -60,4 +87,14 @@ onMounted(() => loadData())
<style scoped> <style scoped>
.header { display: flex; justify-content: space-between; align-items: center; } .header { display: flex; justify-content: space-between; align-items: center; }
.search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.result-count {
color: #909399;
font-size: 13px;
}
</style> </style>
+35 -2
View File
@@ -3,7 +3,20 @@
<div class="header"> <div class="header">
<h2>反馈管理</h2> <h2>反馈管理</h2>
</div> </div>
<el-table :data="feedbacks" v-loading="loading" style="margin-top: 20px">
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索名称/内容..."
clearable
style="width: 240px"
>
<template #prefix><span>🔍</span></template>
</el-input>
<span class="result-count"> {{ filteredFeedbacks.length }} </span>
</div>
<el-table :data="filteredFeedbacks" v-loading="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="name" label="名称" width="120" /> <el-table-column prop="name" label="名称" width="120" />
<el-table-column prop="email" label="邮箱" width="200" /> <el-table-column prop="email" label="邮箱" width="200" />
@@ -19,12 +32,22 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { getFeedbacks, deleteFeedback } from '@/api/feedback' import { getFeedbacks, deleteFeedback } from '@/api/feedback'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
const feedbacks = ref([]) const feedbacks = ref([])
const loading = ref(false) const loading = ref(false)
const searchKeyword = ref('')
const filteredFeedbacks = computed(() => {
if (!searchKeyword.value) return feedbacks.value
const kw = searchKeyword.value.toLowerCase()
return feedbacks.value.filter(f =>
(f.name && f.name.toLowerCase().includes(kw)) ||
(f.content && f.content.toLowerCase().includes(kw))
)
})
const loadFeedbacks = async () => { const loadFeedbacks = async () => {
loading.value = true loading.value = true
@@ -51,4 +74,14 @@ onMounted(() => loadFeedbacks())
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.result-count {
color: #909399;
font-size: 13px;
}
</style> </style>
+34 -2
View File
@@ -4,7 +4,20 @@
<h2>友链管理</h2> <h2>友链管理</h2>
<el-button type="primary" @click="$router.push('/links/edit')">添加友链</el-button> <el-button type="primary" @click="$router.push('/links/edit')">添加友链</el-button>
</div> </div>
<el-table :data="links" v-loading="loading" style="margin-top: 20px">
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索名称/URL..."
clearable
style="width: 240px"
>
<template #prefix><span>🔍</span></template>
</el-input>
<span class="result-count"> {{ filteredLinks.length }} </span>
</div>
<el-table :data="filteredLinks" v-loading="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="name" label="名称" /> <el-table-column prop="name" label="名称" />
<el-table-column prop="url" label="URL" min-width="200"> <el-table-column prop="url" label="URL" min-width="200">
@@ -26,12 +39,21 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { getLinks, deleteLink } from '@/api/link' import { getLinks, deleteLink } from '@/api/link'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
const links = ref([]) const links = ref([])
const loading = ref(false) const loading = ref(false)
const searchKeyword = ref('')
const filteredLinks = computed(() => {
if (!searchKeyword.value) return links.value
const kw = searchKeyword.value.toLowerCase()
return links.value.filter(l =>
l.name.toLowerCase().includes(kw) || l.url.toLowerCase().includes(kw)
)
})
const loadLinks = async () => { const loadLinks = async () => {
loading.value = true loading.value = true
@@ -58,4 +80,14 @@ onMounted(() => loadLinks())
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.result-count {
color: #909399;
font-size: 13px;
}
</style> </style>
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
'@': path.resolve(__dirname, './src') '@': path.resolve(__dirname, './src')
} }
}, },
base: '/admin/',
server: { server: {
port: 3001, port: 3001,
proxy: { proxy: {
+6 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey from sqlalchemy import Column, Integer, String, Text, DateTime, Float, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -12,7 +12,12 @@ class Joke(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(200), nullable=False) title = Column(String(200), nullable=False)
content = Column(Text, nullable=False) content = Column(Text, nullable=False)
# AI 润色后的内容
polished_content = Column(Text, nullable=True) polished_content = Column(Text, nullable=True)
# AI 评分 (1-10)
ai_score = Column(Float, nullable=True)
# AI 质量等级: excellent/good/ordinary/poor
ai_level = Column(String(20), nullable=True)
type_ids = Column(Text, nullable=True) type_ids = Column(Text, nullable=True)
crowd_ids = Column(Text, nullable=True) crowd_ids = Column(Text, nullable=True)
type_id = Column(Integer, ForeignKey("joke_types.id"), nullable=True) type_id = Column(Integer, ForeignKey("joke_types.id"), nullable=True)
+2
View File
@@ -11,4 +11,6 @@ class Link(Base):
url = Column(String(500), nullable=False) url = Column(String(500), nullable=False)
description = Column(String(200), nullable=True) description = Column(String(200), nullable=True)
sort_order = Column(Integer, default=0) sort_order = Column(Integer, default=0)
status = Column(String(20), default="approved") # approved=已通过, pending=待审核
contact = Column(String(100), nullable=True) # 申请联系方式
created_at = Column(DateTime, default=func.now()) created_at = Column(DateTime, default=func.now())
+14 -3
View File
@@ -9,10 +9,11 @@ from app.config import JWT_ALGORITHM, JWT_EXPIRATION_HOURS, JWT_SECRET_KEY
from app.database import get_db from app.database import get_db
from app.models.user import AdminUser from app.models.user import AdminUser
from app.schemas.auth import LoginRequest, TokenResponse from app.schemas.auth import LoginRequest, TokenResponse
from app.schemas.user import UserCreate, UserResponse, UserRegister
router = APIRouter(prefix="/auth", tags=["认证"]) router = APIRouter(prefix="/auth", tags=["认证"])
# 已存在的函数保持不变...
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码""" """验证密码"""
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode()) return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
@@ -27,11 +28,21 @@ def create_access_token(data: dict) -> str:
return encoded_jwt return encoded_jwt
def hash_password(password: str) -> str:
"""哈希密码"""
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def get_current_user():
"""获取当前用户(保留用于后续权限控制)"""
pass
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
def login(req: LoginRequest, db: Session = Depends(get_db)): def login(req: LoginRequest, db: Session = Depends(get_db)):
"""管理员登录""" """登录"""
user = db.query(AdminUser).filter(AdminUser.username == req.username).first() user = db.query(AdminUser).filter(AdminUser.username == req.username).first()
if not user or not verify_password(req.password, user.password_hash): if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
access_token = create_access_token(data={"sub": str(user.id), "username": user.username}) access_token = create_access_token(data={"sub": str(user.id), "username": user.username, "role": user.role})
return TokenResponse(access_token=access_token) return TokenResponse(access_token=access_token)
+90 -36
View File
@@ -2,7 +2,7 @@ import json
import random import random
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func from sqlalchemy import func, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
@@ -24,26 +24,49 @@ def _parse_ids(raw) -> list[int]:
return [] return []
def _get_type_names(db: Session, ids: list[int]) -> list[str]: def _batch_load_categories(db: Session, jokes: list[Joke]) -> tuple[dict, dict]:
if not ids: """批量加载类型和人群名称,避免 N+1 查询"""
return [] # 收集所有需要的 id
rows = db.query(JokeType).filter(JokeType.id.in_(ids)).all() all_type_ids = set()
return [r.name for r in rows] all_crowd_ids = set()
for joke in jokes:
all_type_ids.update(_parse_ids(joke.type_ids))
all_crowd_ids.update(_parse_ids(joke.crowd_ids))
# 批量查询
type_map = {}
if all_type_ids:
types = db.query(JokeType).filter(JokeType.id.in_(all_type_ids)).all()
type_map = {t.id: t.name for t in types}
crowd_map = {}
if all_crowd_ids:
crowds = db.query(JokeCrowd).filter(JokeCrowd.id.in_(all_crowd_ids)).all()
crowd_map = {c.id: c.name for c in crowds}
return type_map, crowd_map
def _get_crowd_names(db: Session, ids: list[int]) -> list[str]: def joke_to_response(joke: Joke, type_map: dict = None, crowd_map: dict = None) -> JokeResponse:
if not ids: """Convert Joke model to JokeResponse schema.
return []
rows = db.query(JokeCrowd).filter(JokeCrowd.id.in_(ids)).all()
return [r.name for r in rows]
Args:
def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse: type_map: 预加载的类型 id→name 映射
"""Convert Joke model to JokeResponse schema.""" crowd_map: 预加载的人群 id→name 映射
"""
ids = _parse_ids(joke.type_ids) ids = _parse_ids(joke.type_ids)
crowd_ids = _parse_ids(joke.crowd_ids) crowd_ids = _parse_ids(joke.crowd_ids)
type_names = _get_type_names(db, ids) if db else []
crowd_names = _get_crowd_names(db, crowd_ids) if db else [] if type_map is not None:
type_names = [type_map[i] for i in ids if i in type_map]
else:
type_names = []
if crowd_map is not None:
crowd_names = [crowd_map[i] for i in crowd_ids if i in crowd_map]
else:
crowd_names = []
return JokeResponse( return JokeResponse(
id=joke.id, id=joke.id,
title=joke.title, title=joke.title,
@@ -57,9 +80,25 @@ def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
updated_at=joke.updated_at, updated_at=joke.updated_at,
type_names=type_names, type_names=type_names,
crowd_names=crowd_names, crowd_names=crowd_names,
polished_content=joke.polished_content,
ai_score=joke.ai_score,
ai_level=joke.ai_level,
) )
def _build_json_filter(column, target_ids: set[int]) -> list:
"""构建精确的 JSON 数组匹配过滤器(SQLite"""
filters = []
for tid in target_ids:
# 匹配 [n,...] 或 [...,n,...] 或 [...,n]
# 使用 JSON_EACH 函数检查
json_str = json.dumps(tid)
filters.append(
func.json_extract(column, '$').cast(type(target_ids)).contains(tid)
)
return filters
@router.get("/", response_model=PaginatedJokeResponse) @router.get("/", response_model=PaginatedJokeResponse)
def list_jokes( def list_jokes(
page: int = 1, page: int = 1,
@@ -72,38 +111,49 @@ def list_jokes(
query = db.query(Joke).filter(Joke.status == "approved") query = db.query(Joke).filter(Joke.status == "approved")
# 支持多选过滤:逗号分隔的 ID # 支持多选过滤:逗号分隔的 ID
# 使用自定义 JSON 匹配函数避免 LIKE %N% 的错误匹配
if type_ids: if type_ids:
filter_set = {int(x.strip()) for x in type_ids.split(",") if x.strip().isdigit()} filter_set = {int(x.strip()) for x in type_ids.split(",") if x.strip().isdigit()}
if filter_set: if filter_set:
# 兼容旧单值字段 type_id # 使用文本匹配,确保精确匹配 JSON 数组中的 id
# 匹配模式:[1,2,3] 或 [1] 或带尾部逗号的
type_filters = []
for tid in filter_set:
# 构建精确匹配的正则:在逗号、引号、方括号包围中的数字
import re
pattern = rf'["\s\[,]{tid}[",\s\]]'
type_filters.append(Joke.type_ids.regexp_match(pattern))
# 只要匹配任一类型即可
type_filter = type_filters[0]
for f in type_filters[1:]:
type_filter = type_filter | f
# 还需要兼容旧的单值 type_id 字段
old_filter = Joke.type_id.in_(filter_set) old_filter = Joke.type_id.in_(filter_set)
# 兼容新数组字段 type_ids(JSON 包含任一) query = query.filter(old_filter | type_filter)
new_filter = [
Joke.type_ids.contains(str(tid)) for tid in filter_set
]
combined = old_filter
for nf in new_filter:
combined = combined | nf
query = query.filter(combined)
if crowd_ids: if crowd_ids:
filter_set = {int(x.strip()) for x in crowd_ids.split(",") if x.strip().isdigit()} filter_set = {int(x.strip()) for x in crowd_ids.split(",") if x.strip().isdigit()}
if filter_set: if filter_set:
import re
crowd_filters = []
for cid in filter_set:
pattern = rf'["\s\[,]{cid}[",\s\]]'
crowd_filters.append(Joke.crowd_ids.regexp_match(pattern))
crowd_filter = crowd_filters[0]
for f in crowd_filters[1:]:
crowd_filter = crowd_filter | f
old_filter = Joke.crowd_id.in_(filter_set) old_filter = Joke.crowd_id.in_(filter_set)
new_filter = [ query = query.filter(old_filter | crowd_filter)
Joke.crowd_ids.contains(str(cid)) for cid in filter_set
]
combined = old_filter
for nf in new_filter:
combined = combined | nf
query = query.filter(combined)
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()
# 批量预加载类型和人群名称
type_map, crowd_map = _batch_load_categories(db, jokes)
return PaginatedJokeResponse( return PaginatedJokeResponse(
items=[joke_to_response(j, db) for j in jokes], items=[joke_to_response(j, type_map, crowd_map) for j in jokes],
total=total, total=total,
page=page, page=page,
page_size=page_size, page_size=page_size,
@@ -125,7 +175,9 @@ def get_joke(joke_id: int, db: Session = Depends(get_db)):
db.commit() db.commit()
# 重新查询获取更新后的数据 # 重新查询获取更新后的数据
db.refresh(joke) db.refresh(joke)
return joke_to_response(joke, db) # 批量加载类型和人群名称
type_map, crowd_map = _batch_load_categories(db, [joke])
return joke_to_response(joke, type_map, crowd_map)
@router.get("/random", response_model=JokeResponse) @router.get("/random", response_model=JokeResponse)
@@ -144,13 +196,15 @@ def get_random_joke(db: Session = Depends(get_db)):
Joke.status == "approved" Joke.status == "approved"
).first() ).first()
if joke: if joke:
return joke_to_response(joke, db) type_map, crowd_map = _batch_load_categories(db, [joke])
return joke_to_response(joke, type_map, crowd_map)
# 兜底:全表随机 # 兜底:全表随机
joke = db.query(Joke).filter(Joke.status == "approved").order_by(func.random()).first() joke = db.query(Joke).filter(Joke.status == "approved").order_by(func.random()).first()
if not joke: if not joke:
raise HTTPException(status_code=404, detail="暂无笑话") raise HTTPException(status_code=404, detail="暂无笑话")
return joke_to_response(joke, db) type_map, crowd_map = _batch_load_categories(db, [joke])
return joke_to_response(joke, type_map, crowd_map)
@router.post("/{joke_id}/like") @router.post("/{joke_id}/like")
+27 -3
View File
@@ -3,12 +3,36 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models.link import Link from app.models.link import Link
from app.schemas.link import LinkCreate, LinkResponse from app.schemas.link import LinkCreate, LinkResponse, LinkApply
router = APIRouter(tags=["友情链接"]) router = APIRouter(tags=["友情链接"])
@router.get("/links", response_model=list[LinkResponse]) @router.get("/links", response_model=list[LinkResponse])
def list_links(db: Session = Depends(get_db)): def list_links(db: Session = Depends(get_db)):
"""公开:获取所有友情链接""" """公开:获取已通过审核的友情链接"""
return db.query(Link).order_by(Link.sort_order, Link.id).all() return db.query(Link).filter(Link.status == "approved").order_by(Link.sort_order, Link.id).all()
@router.post("/links/apply", response_model=LinkResponse)
def apply_link(link_data: LinkApply, db: Session = Depends(get_db)):
"""公开:申请友链"""
# 检查是否已存在同名或同URL的申请/已通过友链
existing = db.query(Link).filter(
(Link.name == link_data.name) | (Link.url == link_data.url)
).first()
if existing:
return existing # 已存在则返回已有记录
link = Link(
name=link_data.name,
url=link_data.url,
description=link_data.description,
contact=link_data.contact,
status="pending",
sort_order=999, # 新申请放在最后
)
db.add(link)
db.commit()
db.refresh(link)
return link
+20
View File
@@ -18,6 +18,8 @@ class JokeUpdate(BaseModel):
title: str | None = None title: str | None = None
content: str | None = None content: str | None = None
polished_content: str | None = None polished_content: str | None = None
ai_score: float | None = None
ai_level: str | None = None
type_ids: list[int] | None = None type_ids: list[int] | None = None
crowd_ids: list[int] | None = None crowd_ids: list[int] | None = None
status: str | None = None status: str | None = None
@@ -27,6 +29,9 @@ class JokeResponse(JokeBase):
id: int id: int
status: str status: str
polished_content: str | None = None polished_content: str | None = None
# AI 评价字段
ai_score: float | None = None
ai_level: str | None = None
view_count: int view_count: int
like_count: int like_count: int
created_at: datetime created_at: datetime
@@ -37,6 +42,21 @@ class JokeResponse(JokeBase):
class Config: class Config:
from_attributes = True from_attributes = True
@property
def display_content(self) -> str:
"""优先显示润色后的内容"""
return self.polished_content if self.polished_content else self.content
def get_level_display(self) -> str:
"""获取 AI 评级的中文显示"""
level_map = {
'excellent': '⭐⭐⭐ 精品',
'good': '⭐⭐ 良好',
'ordinary': '⭐ 普通',
'poor': '⚠️ 待优化'
}
return level_map.get(self.ai_level, '') if self.ai_level else ''
class PaginatedJokeResponse(BaseModel): class PaginatedJokeResponse(BaseModel):
items: list[JokeResponse] items: list[JokeResponse]
+11 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel, HttpUrl
class LinkCreate(BaseModel): class LinkCreate(BaseModel):
@@ -10,8 +10,18 @@ class LinkCreate(BaseModel):
sort_order: int = 0 sort_order: int = 0
class LinkApply(BaseModel):
"""友链申请"""
name: str
url: str
description: str | None = None
contact: str | None = None # 联系方式
class LinkResponse(LinkCreate): class LinkResponse(LinkCreate):
id: int id: int
status: str
contact: str | None = None
created_at: datetime created_at: datetime
class Config: class Config:
+2
View File
@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.config import API_TITLE, API_VERSION from app.config import API_TITLE, API_VERSION
from app.routers import jokes_router, categories_router, auth_router, admin_router, settings_router, links_router, feedback_router, generate_router from app.routers import jokes_router, categories_router, auth_router, admin_router, settings_router, links_router, feedback_router, generate_router
from app.routers.submit import router as submit_router
from app.database import Base, engine from app.database import Base, engine
from app.models.setting import AiSetting from app.models.setting import AiSetting
from app.models.link import Link from app.models.link import Link
@@ -32,6 +33,7 @@ app.include_router(settings_router, prefix="/api")
app.include_router(links_router, prefix="/api") app.include_router(links_router, prefix="/api")
app.include_router(feedback_router, prefix="/api") app.include_router(feedback_router, prefix="/api")
app.include_router(generate_router, prefix="/api") app.include_router(generate_router, prefix="/api")
app.include_router(submit_router, prefix="/api/jokes") # 公开提交接口
@app.get("/") @app.get("/")
+1
View File
@@ -1,5 +1,6 @@
fastapi==0.109.0 fastapi==0.109.0
uvicorn[standard]==0.27.0 uvicorn[standard]==0.27.0
gunicorn==21.2.0
sqlalchemy==2.0.25 sqlalchemy==2.0.25
pydantic==2.5.3 pydantic==2.5.3
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
+2 -2
View File
@@ -25,8 +25,8 @@ def parse_args():
parser.add_argument( parser.add_argument(
"--api-url", "--api-url",
type=str, type=str,
default=os.getenv("API_URL", "http://localhost:8001"), default=os.getenv("API_URL", "http://39.104.58.51"),
help="API 地址(默认: http://localhost:8001", help="API 地址(默认: http://39.104.58.51",
) )
parser.add_argument( parser.add_argument(
"--username", "--username",
+49 -5
View File
@@ -120,7 +120,8 @@ class Optimizer:
# === Stage 1: 质量检测 === # === Stage 1: 质量检测 ===
def quality_check(self, content: str) -> dict: def quality_check(self, content: str) -> dict:
"""判断笑话是否有笑点,返回 {"has_punchline": bool, "reason": str}""" """判断笑话是否有笑点,返回 {"has_punchline": bool, "reason": str}"""
resp = self.ai_client.chat.completions.create( def _call():
return self.ai_client.chat.completions.create(
model=self.model_name, model=self.model_name,
messages=[ messages=[
{"role": "system", "content": QUALITY_CHECK_SYSTEM_PROMPT}, {"role": "system", "content": QUALITY_CHECK_SYSTEM_PROMPT},
@@ -129,13 +130,15 @@ class Optimizer:
temperature=0.3, temperature=0.3,
max_tokens=200, max_tokens=200,
) )
resp = self._safe_api_call(_call)
raw = resp.choices[0].message.content.strip() raw = resp.choices[0].message.content.strip()
return self._parse_json(raw, {"has_punchline": True, "reason": ""}) return self._parse_json(raw, {"has_punchline": True, "reason": ""})
# === Stage 2: AI 润色 === # === Stage 2: AI 润色 ===
def polish(self, content: str) -> str: def polish(self, content: str) -> str:
"""润色笑话内容""" """润色笑话内容"""
resp = self.ai_client.chat.completions.create( def _call():
return self.ai_client.chat.completions.create(
model=self.model_name, model=self.model_name,
messages=[ messages=[
{"role": "system", "content": POLISH_SYSTEM_PROMPT}, {"role": "system", "content": POLISH_SYSTEM_PROMPT},
@@ -144,6 +147,7 @@ class Optimizer:
temperature=0.8, temperature=0.8,
max_tokens=1024, max_tokens=1024,
) )
resp = self._safe_api_call(_call)
return resp.choices[0].message.content.strip() return resp.choices[0].message.content.strip()
# === Stage 3: 评价分类 === # === Stage 3: 评价分类 ===
@@ -152,7 +156,8 @@ class Optimizer:
type_names = [t.get("name", "") for t in self.types] type_names = [t.get("name", "") for t in self.types]
crowd_names = [c.get("name", "") for c in self.crowds] crowd_names = [c.get("name", "") for c in self.crowds]
resp = self.ai_client.chat.completions.create( def _call():
return self.ai_client.chat.completions.create(
model=self.model_name, model=self.model_name,
messages=[ messages=[
{"role": "system", "content": EVALUATE_SYSTEM_PROMPT}, {"role": "system", "content": EVALUATE_SYSTEM_PROMPT},
@@ -165,6 +170,7 @@ class Optimizer:
temperature=0.3, temperature=0.3,
max_tokens=300, max_tokens=300,
) )
resp = self._safe_api_call(_call)
raw = resp.choices[0].message.content.strip() raw = resp.choices[0].message.content.strip()
result = self._parse_json(raw, {"types": [], "crowds": [], "score": 5, "comment": ""}) result = self._parse_json(raw, {"types": [], "crowds": [], "score": 5, "comment": ""})
# Backward compatibility: if LLM returns old single format, convert to array # Backward compatibility: if LLM returns old single format, convert to array
@@ -175,6 +181,27 @@ class Optimizer:
return result return result
# === 辅助方法 === # === 辅助方法 ===
def _safe_api_call(self, func, *args, max_retries: int = 3, **kwargs):
"""带重试的 API 调用,自动处理限流"""
import time as time_module
last_error = None
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
last_error = e
error_str = str(e)
# 检查是否是限流错误
if "429" in error_str or "rate" in error_str.lower():
wait_time = (attempt + 1) * 30 # 30, 60, 90 秒
print(f" [!] API 限流,等待 {wait_time} 秒后重试 ({attempt+1}/{max_retries})...")
time_module.sleep(wait_time)
else:
# 其他错误,直接重试一次
if attempt < max_retries - 1:
time_module.sleep(5)
raise last_error
def _parse_json(self, raw: str, default: dict) -> dict: def _parse_json(self, raw: str, default: dict) -> dict:
"""安全解析 LLM 返回的 JSON""" """安全解析 LLM 返回的 JSON"""
try: try:
@@ -266,12 +293,28 @@ class Optimizer:
crowd_names = [] crowd_names = []
score = None score = None
# 根据评分计算 AI 等级
ai_level = None
if score is not None:
if score >= 8:
ai_level = "excellent"
elif score >= 6:
ai_level = "good"
elif score >= 4:
ai_level = "ordinary"
else:
ai_level = "poor"
# 保存更新 # 保存更新
try: try:
update = { update = {
"polished_content": polished, "polished_content": polished,
"status": "approved" if (score or 5) >= 4 else "pending", "status": "approved" if (score or 5) >= 4 else "pending",
} }
if score is not None:
update["ai_score"] = float(score)
if ai_level:
update["ai_level"] = ai_level
if type_names: if type_names:
update["type_ids"] = [] update["type_ids"] = []
for n in type_names: for n in type_names:
@@ -325,9 +368,10 @@ class Optimizer:
print(f" [!] 处理异常: {e}") print(f" [!] 处理异常: {e}")
self.stats["skipped"] += 1 self.stats["skipped"] += 1
# 每条间稍等,避免 API 限流 # 每条间稍等,避免 API 限流(每次请求间隔 2-3 秒)
if idx < len(jokes) - 1: if idx < len(jokes) - 1:
time.sleep(1) import time
time.sleep(2)
# 输出统计 # 输出统计
print(f"\n{'='*40}") print(f"\n{'='*40}")
+2
View File
@@ -1,3 +1,5 @@
import request from '@/api/request' import request from '@/api/request'
export const getLinks = () => request.get('/links') export const getLinks = () => request.get('/links')
export const applyLink = (data) => request.post('/links/apply', data)
+88 -4
View File
@@ -7,11 +7,28 @@
<span class="tag tag-crowd" v-for="(name, i) in (joke.crowd_names||[])" :key="'c'+i">{{ name }}</span> <span class="tag tag-crowd" v-for="(name, i) in (joke.crowd_names||[])" :key="'c'+i">{{ name }}</span>
</div> </div>
<!-- 标题 --> <!-- 标题 + AI评价 -->
<div class="title-row">
<h1 class="detail-title">{{ joke.title }}</h1> <h1 class="detail-title">{{ joke.title }}</h1>
<span v-if="joke.ai_level" class="ai-badge" :class="'ai-' + joke.ai_level">
{{ getLevelText(joke.ai_level) }}
</span>
</div>
<!-- 内容 --> <!-- 内容优先显示润色内容 -->
<div class="detail-content">{{ joke.content }}</div> <div class="detail-content">
<template v-if="joke.polished_content">
<div class="polished-label"> AI 润色版</div>
{{ joke.polished_content }}
</template>
<template v-else>{{ joke.content }}</template>
</div>
<!-- 原始内容如果有润色 -->
<details v-if="joke.polished_content" class="original-content">
<summary>查看原文</summary>
{{ joke.content }}
</details>
<!-- 操作栏 --> <!-- 操作栏 -->
<div class="detail-actions"> <div class="detail-actions">
@@ -33,6 +50,8 @@
</button> </button>
<span class="sep">|</span> <span class="sep">|</span>
<span>{{ formatDate(joke.created_at) }}</span> <span>{{ formatDate(joke.created_at) }}</span>
<span v-if="joke.ai_score" class="sep">|</span>
<span v-if="joke.ai_score" class="ai-score">评分: {{ joke.ai_score.toFixed(1) }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -106,6 +125,16 @@ const formatDate = (dateStr) => {
return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
} }
function getLevelText(level) {
const map = {
'excellent': '⭐⭐⭐ 精品',
'good': '⭐⭐ 良好',
'ordinary': '⭐ 普通',
'poor': '⚠️ 待优化'
}
return map[level] || ''
}
onMounted(() => loadJoke()) onMounted(() => loadJoke())
watch(() => route.params.id, () => loadJoke()) watch(() => route.params.id, () => loadJoke())
@@ -131,7 +160,60 @@ watch(() => route.params.id, () => loadJoke())
transition: background 0.3s, box-shadow 0.3s; transition: background 0.3s, box-shadow 0.3s;
} }
.detail-tags { display: flex; gap: 10px; } .detail-tags { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
/* 标题行:标题 + AI评价右对齐 */
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
/* AI 评价徽章 */
.ai-badge {
font-size: 13px;
padding: 4px 14px;
border-radius: 14px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.ai-excellent { background: linear-gradient(135deg, #fef3c7, #fde68a); color: #92400e; }
.ai-good { background: linear-gradient(135deg, #d1fae5, #a7f3d0); color: #065f46; }
.ai-ordinary { background: #f3f4f6; color: #4b5563; }
.ai-poor { background: #fee2e2; color: #991b1b; }
/* AI 润色标签 */
.polished-label {
font-size: 12px;
color: #9333ea;
font-weight: 600;
margin-bottom: 8px;
display: inline-block;
}
/* 原文折叠 */
.original-content {
margin-top: 16px;
padding: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
color: var(--text-muted);
}
.original-content summary {
cursor: pointer;
font-weight: 600;
color: var(--text-secondary);
}
/* AI 评分 */
.ai-score {
color: #9333ea;
font-weight: 600;
}
.detail-title { .detail-title {
font-size: 26px; font-size: 26px;
@@ -140,6 +222,8 @@ watch(() => route.params.id, () => loadJoke())
line-height: 1.4; line-height: 1.4;
letter-spacing: 0.5px; letter-spacing: 0.5px;
transition: color 0.3s; transition: color 0.3s;
margin: 0;
flex: 1;
} }
.detail-content { .detail-content {
+43 -4
View File
@@ -6,11 +6,16 @@
<span class="tag tag-crowd" v-for="(name, i) in (joke.crowd_names||[])" :key="'c'+i">{{ name }}</span> <span class="tag tag-crowd" v-for="(name, i) in (joke.crowd_names||[])" :key="'c'+i">{{ name }}</span>
</div> </div>
<!-- 标题 --> <!-- 标题 + AI评价等级 -->
<div class="title-row">
<h3 class="card-title">{{ joke.title }}</h3> <h3 class="card-title">{{ joke.title }}</h3>
<span v-if="joke.ai_level" class="ai-badge" :class="'ai-' + joke.ai_level">
{{ getLevelText(joke.ai_level) }}
</span>
</div>
<!-- 内容 --> <!-- 内容优先显示润色内容 -->
<p class="card-content">{{ joke.content }}</p> <p class="card-content">{{ joke.polished_content || joke.content }}</p>
<!-- 底部信息 --> <!-- 底部信息 -->
<div class="card-footer"> <div class="card-footer">
@@ -33,6 +38,16 @@
defineProps({ defineProps({
joke: { type: Object, required: true } joke: { type: Object, required: true }
}) })
function getLevelText(level) {
const map = {
'excellent': '⭐⭐⭐ 精品',
'good': '⭐⭐ 良好',
'ordinary': '⭐ 普通',
'poor': '⚠️ 待优化'
}
return map[level] || ''
}
</script> </script>
<style scoped> <style scoped>
@@ -68,7 +83,15 @@ defineProps({
.joke-card:hover::before { transform: scaleY(1); } .joke-card:hover::before { transform: scaleY(1); }
/* 标签 */ /* 标签 */
.card-header { display: flex; gap: 8px; flex-wrap: wrap; } .card-header { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* 标题行:标题 + AI评价右对齐 */
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
/* 标题 */ /* 标题 */
.card-title { .card-title {
@@ -80,8 +103,24 @@ defineProps({
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
margin: 0;
flex: 1;
} }
/* AI 评价徽章 */
.ai-badge {
font-size: 11px;
padding: 3px 10px;
border-radius: 12px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.ai-excellent { background: linear-gradient(135deg, #fef3c7, #fde68a); color: #92400e; }
.ai-good { background: linear-gradient(135deg, #d1fae5, #a7f3d0); color: #065f46; }
.ai-ordinary { background: #f3f4f6; color: #4b5563; }
.ai-poor { background: #fee2e2; color: #991b1b; }
/* 内容 */ /* 内容 */
.card-content { .card-content {
font-size: 14px; font-size: 14px;
+205 -1
View File
@@ -26,15 +26,62 @@
<div class="link-desc" v-if="link.description">{{ link.description }}</div> <div class="link-desc" v-if="link.description">{{ link.description }}</div>
</a> </a>
</div> </div>
<!-- 自助申请 -->
<div class="apply-section">
<button class="apply-btn" @click="showDialog = true">
<span class="apply-icon">+</span>
自助申请友链
</button>
</div>
<!-- 申请弹窗 -->
<div v-if="showDialog" class="dialog-overlay" @click.self="showDialog = false">
<div class="dialog">
<h2 class="dialog-title">申请友链</h2>
<form @submit.prevent="handleApply" class="apply-form">
<div class="form-item">
<label>网站名称 <span class="required">*</span></label>
<input v-model="form.name" type="text" placeholder="请输入网站名称" required />
</div>
<div class="form-item">
<label>网站地址 <span class="required">*</span></label>
<input v-model="form.url" type="url" placeholder="https://example.com" required />
</div>
<div class="form-item">
<label>网站描述</label>
<input v-model="form.description" type="text" placeholder="简短描述您的网站" />
</div>
<div class="form-item">
<label>联系方式</label>
<input v-model="form.contact" type="text" placeholder="邮箱或社交账号(选填)" />
</div>
<div class="dialog-footer">
<button type="button" class="btn-cancel" @click="showDialog = false">取消</button>
<button type="submit" class="btn-submit" :disabled="submitting">
{{ submitting ? '提交中...' : '提交申请' }}
</button>
</div>
</form>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { getLinks } from '@/api/link' import { getLinks, applyLink } from '@/api/link'
const links = ref([]) const links = ref([])
const loading = ref(true) const loading = ref(true)
const showDialog = ref(false)
const submitting = ref(false)
const form = ref({
name: '',
url: '',
description: '',
contact: '',
})
onMounted(async () => { onMounted(async () => {
try { try {
@@ -45,6 +92,21 @@ onMounted(async () => {
loading.value = false loading.value = false
} }
}) })
const handleApply = async () => {
submitting.value = true
try {
await applyLink(form.value)
alert('申请已提交,等待审核!')
showDialog.value = false
form.value = { name: '', url: '', description: '', contact: '' }
} catch (e) {
alert('提交失败,请重试')
console.error(e)
} finally {
submitting.value = false
}
}
</script> </script>
<style scoped> <style scoped>
@@ -101,4 +163,146 @@ onMounted(async () => {
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.5; line-height: 1.5;
} }
/* 自助申请 */
.apply-section {
margin-top: 32px;
text-align: center;
}
.apply-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: var(--bg-card);
border: 2px dashed var(--border);
border-radius: var(--radius);
color: var(--text-secondary);
font-size: 15px;
cursor: pointer;
transition: all 0.2s;
}
.apply-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.apply-icon {
font-size: 18px;
font-weight: 700;
}
/* 弹窗 */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: var(--bg-card);
border-radius: var(--radius);
padding: 24px;
width: 90%;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.dialog-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 20px;
color: var(--text-primary);
}
.apply-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-item label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.required {
color: #e53935;
}
.form-item input {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
transition: border-color 0.2s;
}
.form-item input:focus {
outline: none;
border-color: var(--primary);
}
.form-item input::placeholder {
color: var(--text-muted);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.btn-cancel,
.btn-submit {
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-cancel {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.btn-cancel:hover {
background: var(--bg-hover);
}
.btn-submit {
background: var(--primary);
border: none;
color: #fff;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style> </style>