feat: PhotoWall 毕业照存储系统初始版本
后端: Go(Gin+GORM+MySQL+Redis) - 17张业务表(yz_pw_前缀), 自动迁移 - JWT认证(3小时) + 盐+MD5密码 + 图形验证码 - 班级CRUD/加入(8人姓名验证/邀请码)/审核/30天自动清理 - 系统配置(16项)/菜单管理(动态路由)/数据统计 - 文件MD5去重/数据隔离/账号封禁/敏感词DFA检测 前端: Vue3+Vite+Element Plus+Less+ECharts+FontAwesome - 动态路由(数据库菜单驱动) - 蓝白配色, H5响应式 - 登录/注册/忘记密码/个人中心 - 班级创建向导/详情/加入/列表 - 管理后台: 审核/配置/菜单/统计/用户/敏感词 数据库: MySQL 10.31.100.3:3306/photowall 管理员: hero920103 / 920103
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>毕业照墙 - PhotoWall</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2656
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "photowall-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.7.2",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.5",
|
||||
"less": "^4.9.0",
|
||||
"less-loader": "^13.0.0",
|
||||
"vue": "^3.4.31",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"vite": "^5.3.3"
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div v-if="isLogin" class="layout">
|
||||
<!-- 侧边栏 -->
|
||||
<div class="sider" :class="{ open: siderOpen }">
|
||||
<div class="logo">
|
||||
<i class="fas fa-images"></i>
|
||||
<span v-if="!siderCollapsed">PhotoWall</span>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<template v-for="menu in visibleMenus" :key="menu.id">
|
||||
<!-- 有子菜单 -->
|
||||
<div v-if="menu.children && menu.children.filter(c => c.visible).length > 0">
|
||||
<div class="submenu-title" :class="{ open: openSubmenus.includes(menu.id) }" @click="toggleSubmenu(menu.id)">
|
||||
<i :class="menu.icon || 'fas fa-folder'"></i>
|
||||
<span v-if="!siderCollapsed">{{ menu.title }}</span>
|
||||
<i v-if="!siderCollapsed" class="fas fa-chevron-right arrow"></i>
|
||||
</div>
|
||||
<div class="submenu-items" v-show="openSubmenus.includes(menu.id)">
|
||||
<div v-for="child in menu.children.filter(c => c.visible)" :key="child.id"
|
||||
class="menu-item" :class="{ active: $route.path === child.path }"
|
||||
@click="navigate(child.path)">
|
||||
<i :class="child.icon || 'fas fa-circle'"></i>
|
||||
<span>{{ child.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 无子菜单 -->
|
||||
<div v-else class="menu-item" :class="{ active: $route.path === menu.path }" @click="navigate(menu.path)">
|
||||
<i :class="menu.icon || 'fas fa-file'"></i>
|
||||
<span v-if="!siderCollapsed">{{ menu.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 移动端遮罩 -->
|
||||
<div v-if="siderOpen && isMobile" class="sider-mask" @click="siderOpen = false"></div>
|
||||
|
||||
<!-- 主区域 -->
|
||||
<div class="main">
|
||||
<div class="header">
|
||||
<i class="fas fa-bars toggle-btn" @click="toggleSider"></i>
|
||||
<span style="color:#8c8c8c;font-size:13px">{{ currentTitle }}</span>
|
||||
<div class="header-right">
|
||||
<el-dropdown @command="handleCommand">
|
||||
<div class="user-info">
|
||||
<div class="avatar">{{ (user.nickname || user.username || 'U').charAt(0).toUpperCase() }}</div>
|
||||
<span class="username">{{ user.nickname || user.username }}</span>
|
||||
<span v-if="isAdmin" class="role-tag">管理员</span>
|
||||
<i class="fas fa-chevron-down" style="color:#8c8c8c;font-size:12px"></i>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile"><i class="fas fa-user"></i> 个人中心</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided><i class="fas fa-sign-out-alt"></i> 退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 未登录:直接渲染路由(登录页等) -->
|
||||
<router-view v-else />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuth } from './composables/useAuth'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { user, isLogin, isAdmin, menus, logout } = useAuth()
|
||||
|
||||
const siderOpen = ref(false)
|
||||
const siderCollapsed = ref(false)
|
||||
const openSubmenus = ref([])
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
})
|
||||
})
|
||||
|
||||
// 过滤可见菜单
|
||||
const visibleMenus = computed(() => {
|
||||
return menus.value.filter(m => m.visible)
|
||||
})
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
return route.meta.title || ''
|
||||
})
|
||||
|
||||
function toggleSider() {
|
||||
if (isMobile.value) {
|
||||
siderOpen.value = !siderOpen.value
|
||||
} else {
|
||||
siderCollapsed.value = !siderCollapsed.value
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSubmenu(id) {
|
||||
const idx = openSubmenus.value.indexOf(id)
|
||||
if (idx > -1) {
|
||||
openSubmenus.value.splice(idx, 1)
|
||||
} else {
|
||||
openSubmenus.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(path) {
|
||||
router.push(path)
|
||||
if (isMobile.value) siderOpen.value = false
|
||||
}
|
||||
|
||||
function handleCommand(cmd) {
|
||||
if (cmd === 'profile') {
|
||||
router.push('/profile')
|
||||
} else if (cmd === 'logout') {
|
||||
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
logout()
|
||||
router.push('/login')
|
||||
ElMessage.success('已退出登录')
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
import axios from 'axios'
|
||||
import router from '../router'
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
// 请求拦截:带 token + 检查本地过期时间
|
||||
http.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token')
|
||||
const expireAt = localStorage.getItem('token_expire_at')
|
||||
// 本地预判:token 过期时间已到,直接清除并跳登录
|
||||
if (token && expireAt && Date.now() > parseInt(expireAt)) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('token_expire_at')
|
||||
localStorage.removeItem('user')
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
router.push('/login')
|
||||
}
|
||||
return Promise.reject(new Error('登录已过期,请重新登录'))
|
||||
}
|
||||
if (token) {
|
||||
config.headers.Authorization = 'Bearer ' + token
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截:401 强制清除 token 并跳转登录
|
||||
http.interceptors.response.use(
|
||||
res => res.data,
|
||||
err => {
|
||||
const msg = err.response?.data?.message || err.message || '请求失败'
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('token_expire_at')
|
||||
localStorage.removeItem('user')
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
return Promise.reject(new Error(msg))
|
||||
}
|
||||
)
|
||||
|
||||
// ============ 认证 ============
|
||||
export const authApi = {
|
||||
register: data => http.post('/auth/register', data),
|
||||
login: data => http.post('/auth/login', data),
|
||||
me: () => http.get('/auth/me')
|
||||
}
|
||||
|
||||
// ============ 用户 ============
|
||||
export const userApi = {
|
||||
profile: () => http.get('/user/profile'),
|
||||
updateProfile: data => http.put('/user/profile', data),
|
||||
hasEducation: () => http.get('/user/has-education'),
|
||||
addContact: data => http.post('/user/contacts', data),
|
||||
updateContact: (id, data) => http.put(`/user/contacts/${id}`, data),
|
||||
deleteContact: id => http.delete(`/user/contacts/${id}`),
|
||||
addEducation: data => http.post('/user/education', data),
|
||||
updateEducation: (id, data) => http.put(`/user/education/${id}`, data),
|
||||
deleteEducation: id => http.delete(`/user/education/${id}`),
|
||||
publicProfile: id => http.get(`/user/${id}`)
|
||||
}
|
||||
|
||||
// ============ 地区 ============
|
||||
export const regionApi = {
|
||||
list: parentId => http.get('/regions', { params: { parent_id: parentId || 0 } }),
|
||||
countries: () => http.get('/regions/countries'),
|
||||
search: keyword => http.get('/regions/search', { params: { keyword } }),
|
||||
path: id => http.get(`/regions/${id}/path`)
|
||||
}
|
||||
|
||||
// ============ 学校 ============
|
||||
export const schoolApi = {
|
||||
list: params => http.get('/schools', { params }),
|
||||
get: id => http.get(`/schools/${id}`),
|
||||
create: data => http.post('/schools', data),
|
||||
update: (id, data) => http.put(`/schools/${id}`, data),
|
||||
delete: id => http.delete(`/schools/${id}`),
|
||||
listColleges: id => http.get(`/schools/${id}/colleges`),
|
||||
createCollege: (id, data) => http.post(`/schools/${id}/colleges`, data),
|
||||
updateCollege: (id, data) => http.put(`/colleges/${id}`, data),
|
||||
deleteCollege: id => http.delete(`/colleges/${id}`)
|
||||
}
|
||||
|
||||
// ============ 班级 ============
|
||||
export const classApi = {
|
||||
create: data => http.post('/classes', data),
|
||||
detail: id => http.get(`/classes/${id}`),
|
||||
search: params => http.get('/classes/search', { params }),
|
||||
mine: () => http.get('/classes/mine'),
|
||||
joinBySearch: data => http.post('/classes/join/search', data),
|
||||
joinByInvite: data => http.post('/classes/join/invite', data),
|
||||
resubmit: (id, data) => http.post(`/classes/${id}/resubmit`, data),
|
||||
// 邀请码
|
||||
createInvitation: (id, data) => http.post(`/classes/${id}/invitation`, data),
|
||||
listInvitations: id => http.get(`/classes/${id}/invitations`),
|
||||
disableInvitation: (id, invId) => http.delete(`/classes/${id}/invitations/${invId}`),
|
||||
// 成员
|
||||
transferAdmin: (id, data) => http.post(`/classes/${id}/transfer`, data),
|
||||
removeMember: (id, userId) => http.delete(`/classes/${id}/members/${userId}`),
|
||||
// 照片
|
||||
uploadPhoto: (id, data) => http.post(`/classes/${id}/photos`, data),
|
||||
deletePhoto: (id, photoId) => http.delete(`/classes/${id}/photos/${photoId}`)
|
||||
}
|
||||
|
||||
// ============ 平台管理员 ============
|
||||
export const adminApi = {
|
||||
stats: () => http.get('/admin/stats'),
|
||||
auditQueue: params => http.get('/admin/classes', { params }),
|
||||
approve: (id, data) => http.post(`/admin/classes/${id}/approve`, data),
|
||||
reject: (id, data) => http.post(`/admin/classes/${id}/reject`, data),
|
||||
delete: (id, data) => http.delete(`/admin/classes/${id}`, { data }),
|
||||
logs: id => http.get(`/admin/classes/${id}/logs`),
|
||||
cleanup: () => http.post('/admin/cleanup')
|
||||
}
|
||||
|
||||
// ============ 文件上传 ============
|
||||
export const uploadApi = {
|
||||
upload: (file, type) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('type', type || 'photo')
|
||||
return http.post('/upload', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default http
|
||||
|
||||
// ============ 图形验证码 ============
|
||||
export const captchaApi = {
|
||||
get: () => http.get('/captcha')
|
||||
}
|
||||
|
||||
// ============ 忘记密码 ============
|
||||
export const passwordApi = {
|
||||
forgot: data => http.post('/auth/forgot-password', data),
|
||||
reset: data => http.post('/auth/reset-password', data)
|
||||
}
|
||||
|
||||
// ============ 短信验证码 ============
|
||||
export const smsApi = {
|
||||
send: data => http.post('/auth/send-sms', data)
|
||||
}
|
||||
|
||||
// ============ 系统配置 ============
|
||||
export const configApi = {
|
||||
getAll: () => http.get('/admin/config'),
|
||||
update: data => http.put('/admin/config', data)
|
||||
}
|
||||
|
||||
// ============ 菜单管理 ============
|
||||
export const menuApi = {
|
||||
getAll: () => http.get('/admin/menus'),
|
||||
create: data => http.post('/admin/menus', data),
|
||||
update: (id, data) => http.put(`/admin/menus/${id}`, data),
|
||||
delete: id => http.delete(`/admin/menus/${id}`),
|
||||
getUserMenus: () => http.get('/menus')
|
||||
}
|
||||
|
||||
// ============ 详细统计 ============
|
||||
export const statsApi = {
|
||||
detailed: () => http.get('/admin/stats/detailed')
|
||||
}
|
||||
|
||||
// ============ 账号封禁 ============
|
||||
export const banApi = {
|
||||
list: () => http.get('/admin/users/banned'),
|
||||
ban: (id, data) => http.post(`/admin/users/${id}/ban`, data),
|
||||
unban: id => http.post(`/admin/users/${id}/unban`)
|
||||
}
|
||||
|
||||
// ============ 敏感词管理 ============
|
||||
export const sensitiveApi = {
|
||||
list: () => http.get('/admin/sensitive-words'),
|
||||
create: data => http.post('/admin/sensitive-words', data),
|
||||
delete: id => http.delete(`/admin/sensitive-words/${id}`),
|
||||
toggle: (id, enabled) => http.put(`/admin/sensitive-words/${id}/toggle`, { enabled })
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #303133;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a { color: #409eff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* 布局 */
|
||||
.app-header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
padding: 0 24px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.app-header .logo { font-size: 20px; font-weight: 700; color: #303133; }
|
||||
.app-header .nav { display: flex; gap: 20px; align-items: center; }
|
||||
.app-header .nav a { color: #606266; font-size: 14px; }
|
||||
.app-header .nav a.active { color: #409eff; font-weight: 600; }
|
||||
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
}
|
||||
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #303133; }
|
||||
|
||||
/* 表单 */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; margin-bottom: 6px; font-size: 14px; color: #606266; font-weight: 500; }
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
|
||||
border-color: #409eff;
|
||||
}
|
||||
.form-group .hint { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
.form-row { display: flex; gap: 16px; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
.btn:hover { opacity: 0.85; text-decoration: none; }
|
||||
.btn-primary { background: #409eff; color: #fff; }
|
||||
.btn-success { background: #67c23a; color: #fff; }
|
||||
.btn-warning { background: #e6a23c; color: #fff; }
|
||||
.btn-danger { background: #f56c6c; color: #fff; }
|
||||
.btn-default { background: #fff; color: #606266; border: 1px solid #dcdfe6; }
|
||||
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* 标签 */
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.tag-primary { background: #ecf5ff; color: #409eff; }
|
||||
.tag-success { background: #f0f9eb; color: #67c23a; }
|
||||
.tag-warning { background: #fdf6ec; color: #e6a23c; }
|
||||
.tag-danger { background: #fef0f0; color: #f56c6c; }
|
||||
.tag-info { background: #f4f4f5; color: #909399; }
|
||||
|
||||
/* 表格 */
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #ebeef5; }
|
||||
th { background: #fafafa; color: #606266; font-weight: 600; }
|
||||
tr:hover { background: #fafafa; }
|
||||
|
||||
/* 照片网格 */
|
||||
.photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.photo-item {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
aspect-ratio: 4/3;
|
||||
}
|
||||
.photo-item img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.photo-item .caption {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.7));
|
||||
color: #fff;
|
||||
padding: 16px 8px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 提示消息 */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.alert-info { background: #ecf5ff; color: #409eff; border: 1px solid #d9ecff; }
|
||||
.alert-warning { background: #fdf6ec; color: #e6a23c; border: 1px solid #faecd8; }
|
||||
.alert-danger { background: #fef0f0; color: #f56c6c; border: 1px solid #fde2e2; }
|
||||
.alert-success { background: #f0f9eb; color: #67c23a; border: 1px solid #e1f3d8; }
|
||||
|
||||
/* 成员头像列表 */
|
||||
.member-list { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #fafafa;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.member-avatar {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 步骤条 */
|
||||
.steps { display: flex; margin-bottom: 24px; }
|
||||
.step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
.step.active { color: #409eff; font-weight: 600; }
|
||||
.step.done { color: #67c23a; }
|
||||
.step .num {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
color: #909399;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.step.active .num { background: #409eff; color: #fff; }
|
||||
.step.done .num { background: #67c23a; color: #fff; }
|
||||
|
||||
/* 名字标签输入 */
|
||||
.name-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.name-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.name-tag .remove { cursor: pointer; font-weight: bold; }
|
||||
|
||||
/* 登录页 */
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.login-box {
|
||||
background: #fff;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
width: 400px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
.login-box h2 { text-align: center; margin-bottom: 24px; color: #303133; }
|
||||
.login-tabs { display: flex; margin-bottom: 20px; border-bottom: 1px solid #e4e7ed; }
|
||||
.login-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: #909399;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.login-tab.active { color: #409eff; border-bottom-color: #409eff; font-weight: 600; }
|
||||
|
||||
/* 地区选择器 */
|
||||
.region-picker { display: flex; gap: 12px; }
|
||||
.region-picker select { flex: 1; }
|
||||
|
||||
/* 空状态 */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.pagination { display: flex; justify-content: center; gap: 8px; margin-top: 16px; }
|
||||
.pagination button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button.active { background: #409eff; color: #fff; border-color: #409eff; }
|
||||
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ========================================
|
||||
H5 移动端响应式适配
|
||||
======================================== */
|
||||
|
||||
/* 平板及以下 */
|
||||
@media (max-width: 992px) {
|
||||
.container { padding: 16px; }
|
||||
.card { padding: 16px; }
|
||||
}
|
||||
|
||||
/* 手机端 */
|
||||
@media (max-width: 768px) {
|
||||
body { font-size: 14px; }
|
||||
|
||||
.container { padding: 12px; }
|
||||
.card { padding: 12px; margin-bottom: 10px; border-radius: 6px; }
|
||||
.card-title { font-size: 16px; margin-bottom: 12px; }
|
||||
|
||||
/* 表单:多列改单列 */
|
||||
.form-row { flex-direction: column; gap: 0; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
.form-group { margin-bottom: 12px; }
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
font-size: 16px; /* 防止 iOS 缩放 */
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* 按钮:触摸友好 */
|
||||
.btn { padding: 10px 16px; font-size: 14px; min-height: 40px; }
|
||||
.btn-sm { padding: 6px 12px; font-size: 12px; min-height: 32px; }
|
||||
|
||||
/* 表格:允许横向滚动 */
|
||||
.card > table,
|
||||
table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
font-size: 13px;
|
||||
}
|
||||
th, td { padding: 8px 10px; white-space: nowrap; }
|
||||
|
||||
/* 照片网格:手机端2列 */
|
||||
.photo-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 成员列表:手机端紧凑 */
|
||||
.member-list { gap: 8px; }
|
||||
.member-item { padding: 8px 10px; font-size: 12px; }
|
||||
|
||||
/* 步骤条:手机端缩小 */
|
||||
.step { font-size: 11px; }
|
||||
.step .num { width: 24px; height: 24px; font-size: 12px; }
|
||||
|
||||
/* 弹窗:手机端接近全屏 */
|
||||
[style*="position:fixed"][style*="top:50%"] {
|
||||
width: 92% !important;
|
||||
max-width: 92% !important;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 地区选择器:手机端单列 */
|
||||
.region-picker { flex-direction: column; gap: 8px; }
|
||||
|
||||
/* 登录页 */
|
||||
.login-box { width: 90%; padding: 24px 20px; }
|
||||
|
||||
/* 标签 */
|
||||
.tag { font-size: 11px; padding: 2px 6px; }
|
||||
|
||||
/* 空状态 */
|
||||
.empty { padding: 24px; font-size: 13px; }
|
||||
|
||||
/* 提示框 */
|
||||
.alert { padding: 10px 12px; font-size: 13px; }
|
||||
}
|
||||
|
||||
/* 小屏手机 */
|
||||
@media (max-width: 480px) {
|
||||
.photo-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.card-title { font-size: 15px; }
|
||||
}
|
||||
|
||||
/* 横屏手机优化 */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.login-wrap { align-items: flex-start; padding: 20px 0; }
|
||||
.login-box { max-height: 95vh; overflow-y: auto; }
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// ============ PhotoWall 全局样式(蓝白配色) ============
|
||||
|
||||
// 颜色变量
|
||||
@primary-color: #1890ff;
|
||||
@primary-light: #40a9ff;
|
||||
@primary-dark: #096dd9;
|
||||
@success-color: #52c41a;
|
||||
@warning-color: #faad14;
|
||||
@error-color: #f5222d;
|
||||
@text-primary: #262626;
|
||||
@text-secondary: #595959;
|
||||
@text-tertiary: #8c8c8c;
|
||||
@border-color: #d9d9d9;
|
||||
@border-light: #f0f0f0;
|
||||
@bg-page: #f5f7fa;
|
||||
@bg-white: #ffffff;
|
||||
@bg-gray: #fafafa;
|
||||
|
||||
// 布局变量
|
||||
@sider-width: 220px;
|
||||
@sider-collapsed-width: 64px;
|
||||
@header-height: 56px;
|
||||
@border-radius: 6px;
|
||||
@shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
|
||||
// 重置
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 14px;
|
||||
color: @text-primary;
|
||||
background: @bg-page;
|
||||
}
|
||||
|
||||
a {
|
||||
color: @primary-color;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: @primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 布局 ============
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sider {
|
||||
width: @sider-width;
|
||||
background: linear-gradient(180deg, #001529 0%, #002140 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
.logo {
|
||||
height: @header-height;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
i { margin-right: 8px; color: @primary-light; }
|
||||
}
|
||||
|
||||
.menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
color: rgba(255,255,255,0.75);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
i { width: 20px; margin-right: 10px; text-align: center; }
|
||||
&:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #fff;
|
||||
}
|
||||
&.active {
|
||||
background: @primary-color;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.submenu-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
color: rgba(255,255,255,0.75);
|
||||
cursor: pointer;
|
||||
i { width: 20px; margin-right: 10px; text-align: center; }
|
||||
.arrow { margin-left: auto; transition: transform 0.2s; }
|
||||
&.open .arrow { transform: rotate(90deg); }
|
||||
}
|
||||
.submenu-items {
|
||||
overflow: hidden;
|
||||
.menu-item { padding-left: 50px; font-size: 13px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: @header-height;
|
||||
background: @bg-white;
|
||||
border-bottom: 1px solid @border-light;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
box-shadow: @shadow;
|
||||
flex-shrink: 0;
|
||||
|
||||
.toggle-btn {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: @text-secondary;
|
||||
margin-right: 16px;
|
||||
&:hover { color: @primary-color; }
|
||||
}
|
||||
|
||||
.header-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
.avatar {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: @primary-color;
|
||||
color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; font-weight: 600;
|
||||
}
|
||||
.username { color: @text-primary; font-weight: 500; }
|
||||
.role-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: @primary-color;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// ============ 通用组件 ============
|
||||
.card {
|
||||
background: @bg-white;
|
||||
border-radius: @border-radius;
|
||||
box-shadow: @shadow;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: @text-primary;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
i { color: @primary-color; margin-right: 8px; }
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: @bg-white;
|
||||
border-radius: @border-radius;
|
||||
box-shadow: @shadow;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.stat-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px; color: #fff;
|
||||
&.blue { background: linear-gradient(135deg, @primary-color, @primary-light); }
|
||||
&.green { background: linear-gradient(135deg, #52c41a, #73d13d); }
|
||||
&.orange { background: linear-gradient(135deg, #fa8c16, #ffa940); }
|
||||
&.red { background: linear-gradient(135deg, #f5222d, #ff4d4f); }
|
||||
&.purple { background: linear-gradient(135deg, #722ed1, #9254de); }
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: @text-primary;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: @text-tertiary;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表格
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
th, td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid @border-light;
|
||||
}
|
||||
th {
|
||||
background: @bg-gray;
|
||||
font-weight: 600;
|
||||
color: @text-secondary;
|
||||
font-size: 13px;
|
||||
}
|
||||
tr:hover { background: @bg-gray; }
|
||||
}
|
||||
|
||||
// 标签
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
&.primary { background: #e6f7ff; color: @primary-color; }
|
||||
&.success { background: #f6ffed; color: @success-color; }
|
||||
&.warning { background: #fffbe6; color: @warning-color; }
|
||||
&.error { background: #fff1f0; color: @error-color; }
|
||||
&.default { background: #f5f5f5; color: @text-secondary; }
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: @text-tertiary;
|
||||
i { font-size: 48px; margin-bottom: 12px; color: @border-color; }
|
||||
p { font-size: 14px; }
|
||||
}
|
||||
|
||||
// ============ 登录页 ============
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 600px; height: 600px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 50%;
|
||||
top: -200px; right: -200px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 400px;
|
||||
background: @bg-white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
padding: 40px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.login-title {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: @text-primary;
|
||||
margin-bottom: 8px;
|
||||
i { color: @primary-color; margin-right: 8px; }
|
||||
}
|
||||
.login-subtitle {
|
||||
text-align: center;
|
||||
color: @text-tertiary;
|
||||
font-size: 13px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证码图片
|
||||
.captcha-img {
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 1px solid @border-color;
|
||||
}
|
||||
|
||||
// ============ H5 响应式 ============
|
||||
@media (max-width: 768px) {
|
||||
.sider {
|
||||
position: fixed;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
z-index: 1000;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s;
|
||||
&.open { transform: translateX(0); }
|
||||
}
|
||||
.sider-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
.content { padding: 12px; }
|
||||
.stat-card { padding: 16px; }
|
||||
.data-table { display: block; overflow-x: auto; }
|
||||
.login-box { width: 90%; padding: 24px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header { padding: 0 12px; }
|
||||
.header-right .username { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { menuApi } from '../api'
|
||||
import { loadDynamicRoutes, resetDynamicRoutes } from '../router'
|
||||
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
|
||||
const menus = ref([])
|
||||
|
||||
const TOKEN_EXPIRE_MS = 3 * 60 * 60 * 1000 // 3小时
|
||||
|
||||
export function useAuth() {
|
||||
const isLogin = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
function setAuth(t, u) {
|
||||
token.value = t
|
||||
user.value = u
|
||||
localStorage.setItem('token', t)
|
||||
localStorage.setItem('user', JSON.stringify(u))
|
||||
localStorage.setItem('token_expire_at', String(Date.now() + TOKEN_EXPIRE_MS))
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
menus.value = []
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('token_expire_at')
|
||||
localStorage.removeItem('user')
|
||||
resetDynamicRoutes()
|
||||
}
|
||||
|
||||
// 从后端获取菜单并加载动态路由
|
||||
async function loadMenus() {
|
||||
const res = await menuApi.getUserMenus()
|
||||
menus.value = res.data || []
|
||||
loadDynamicRoutes(menus.value)
|
||||
return menus.value
|
||||
}
|
||||
|
||||
return { token, user, menus, isLogin, isAdmin, setAuth, logout, loadMenus }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||
import './assets/style.less'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,118 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
|
||||
// 静态路由(不需要登录的页面)
|
||||
const staticRoutes = [
|
||||
{ path: '/login', name: 'Login', component: () => import('../views/auth/login/index.vue'), meta: { public: true } },
|
||||
{ path: '/forgot-password', name: 'ForgotPassword', component: () => import('../views/auth/forgot/index.vue'), meta: { public: true } },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' }
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: staticRoutes
|
||||
})
|
||||
|
||||
// 动态路由是否已加载
|
||||
let dynamicRoutesLoaded = false
|
||||
|
||||
// 组件路径映射(用于动态加载)
|
||||
const componentMap = import.meta.glob('../views/**/index.vue')
|
||||
|
||||
// 根据菜单数据动态注册路由
|
||||
export function loadDynamicRoutes(menus) {
|
||||
// 先移除旧的动态路由
|
||||
if (dynamicRoutesLoaded) {
|
||||
router.getRoutes().forEach(r => {
|
||||
if (!staticRoutes.find(s => s.name === r.name)) {
|
||||
router.removeRoute(r.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function registerMenu(menu) {
|
||||
if (menu.component && menu.component !== '') {
|
||||
const compPath = `../views/${menu.component}.vue`
|
||||
const component = componentMap[compPath]
|
||||
if (component) {
|
||||
router.addRoute({
|
||||
path: menu.path,
|
||||
name: menu.name,
|
||||
component,
|
||||
meta: {
|
||||
title: menu.title,
|
||||
icon: menu.icon,
|
||||
requireAuth: menu.require_auth,
|
||||
requireAdmin: menu.require_admin,
|
||||
keepAlive: menu.keep_alive
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
menu.children.forEach(registerMenu)
|
||||
}
|
||||
}
|
||||
|
||||
menus.forEach(registerMenu)
|
||||
dynamicRoutesLoaded = true
|
||||
}
|
||||
|
||||
// 重置动态路由(登出时调用)
|
||||
export function resetDynamicRoutes() {
|
||||
dynamicRoutesLoaded = false
|
||||
router.getRoutes().forEach(r => {
|
||||
if (!staticRoutes.find(s => s.name === r.name)) {
|
||||
router.removeRoute(r.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const { isLogin, isAdmin, logout, loadMenus } = useAuth()
|
||||
|
||||
// 本地预判 token 过期
|
||||
const expireAt = localStorage.getItem('token_expire_at')
|
||||
if (isLogin.value && expireAt && Date.now() > parseInt(expireAt)) {
|
||||
logout()
|
||||
resetDynamicRoutes()
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
|
||||
// 公开页面直接放行
|
||||
if (to.meta.public) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 未登录跳登录
|
||||
if (!isLogin.value) {
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录但动态路由未加载,先加载菜单
|
||||
if (!dynamicRoutesLoaded) {
|
||||
try {
|
||||
await loadMenus()
|
||||
next({ ...to, replace: true })
|
||||
return
|
||||
} catch (e) {
|
||||
logout()
|
||||
resetDynamicRoutes()
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员权限校验
|
||||
if (to.meta.requireAdmin && !isAdmin.value) {
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<div class="page-title">
|
||||
<i class="fas fa-clipboard-check"></i>审核详情 - {{ detail?.class?.name }}
|
||||
<el-button style="float:right" @click="$router.back()"><i class="fas fa-arrow-left" style="margin-right:4px"></i>返回</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="detail" class="card">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="班级名称">{{ detail.class.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学校">{{ detail.school?.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学院">{{ detail.college?.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年级">{{ detail.class.grade }}</el-descriptions-item>
|
||||
<el-descriptions-item label="班号">{{ detail.class.class_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="毕业年份">{{ detail.class.graduate_year }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建者">{{ detail.admin?.nickname || detail.admin?.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<span :class="['tag', statusMap[detail.class.status]?.cls]">{{ statusMap[detail.class.status]?.text }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核截止">{{ detail.class.audit_deadline || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="打回原因">{{ detail.class.reject_reason || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 毕业照 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-image" style="color:#1890ff"></i> 毕业照</h3>
|
||||
<div v-if="detail?.class?.graduation_photo" style="text-align:center">
|
||||
<img :src="detail.class.graduation_photo" style="max-width:100%;border-radius:8px" />
|
||||
</div>
|
||||
<div v-else class="empty"><i class="fas fa-image"></i><p>未上传毕业照</p></div>
|
||||
</div>
|
||||
|
||||
<!-- 同学名单 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px">
|
||||
<i class="fas fa-user-friends" style="color:#1890ff"></i> 同学名单 ({{ detail?.student_names?.length || 0 }}人)
|
||||
<el-tag v-if="(detail?.student_names?.length || 0) < 8" type="danger" style="margin-left:8px">不足8人,需打回补齐</el-tag>
|
||||
</h3>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
||||
<span v-for="(name, i) in detail?.student_names" :key="i" class="tag primary" style="padding:4px 12px">{{ i+1 }}. {{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 老师名单 -->
|
||||
<div v-if="detail?.teacher_names?.length > 0" class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-chalkboard-teacher" style="color:#1890ff"></i> 老师名单 ({{ detail.teacher_names.length }}人)</h3>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
||||
<span v-for="(name, i) in detail.teacher_names" :key="i" class="tag success" style="padding:4px 12px">{{ i+1 }}. {{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审核日志 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-history" style="color:#1890ff"></i> 审核日志</h3>
|
||||
<el-timeline>
|
||||
<el-timeline-item v-for="log in logs" :key="log.id" :timestamp="log.created_at">
|
||||
<el-card shadow="never">
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span>
|
||||
<el-tag :type="log.action === 'approve' ? 'success' : log.action === 'reject' ? 'danger' : 'warning'" size="small">
|
||||
{{ actionMap[log.action] || log.action }}
|
||||
</el-tag>
|
||||
<span style="margin-left:8px">{{ log.comment || log.reason || '' }}</span>
|
||||
</span>
|
||||
<span style="color:#8c8c8c;font-size:12px">审核员ID: {{ log.auditor_id }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<div v-if="logs.length === 0" class="empty" style="padding:20px"><i class="fas fa-history"></i><p>暂无审核记录</p></div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="detail?.class?.status === 'pending'" class="card" style="text-align:center">
|
||||
<el-button type="success" size="large" style="margin-right:16px" @click="approve">
|
||||
<i class="fas fa-check" style="margin-right:4px"></i>审核通过
|
||||
</el-button>
|
||||
<el-button type="warning" size="large" @click="showReject = true">
|
||||
<i class="fas fa-undo" style="margin-right:4px"></i>打回补齐
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 打回弹窗 -->
|
||||
<el-dialog v-model="showReject" title="打回原因" width="400px">
|
||||
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请输入打回原因,将通知创建者在30天内补齐" />
|
||||
<template #footer>
|
||||
<el-button @click="showReject=false">取消</el-button>
|
||||
<el-button type="warning" @click="confirmReject">确认打回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { classApi, adminApi } from '../../../../api'
|
||||
|
||||
const route = useRoute()
|
||||
const detail = ref(null)
|
||||
const logs = ref([])
|
||||
const loading = ref(false)
|
||||
const showReject = ref(false)
|
||||
const rejectReason = ref('')
|
||||
|
||||
const statusMap = {
|
||||
pending: { text: '待审核', cls: 'warning' },
|
||||
active: { text: '已通过', cls: 'success' },
|
||||
rejected: { text: '被打回', cls: 'error' },
|
||||
deleted: { text: '已删除', cls: 'default' }
|
||||
}
|
||||
const actionMap = { approve: '通过', reject: '打回', manual_delete: '删除', resubmit: '重新提交', create: '创建' }
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await classApi.detail(route.params.id)
|
||||
detail.value = res.data
|
||||
} catch (e) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
const res = await adminApi.logs(route.params.id)
|
||||
logs.value = res.data || []
|
||||
} catch (e) { logs.value = [] }
|
||||
}
|
||||
|
||||
async function approve() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定通过该班级的审核吗?', '提示', { type: 'success' })
|
||||
await adminApi.approve(route.params.id, { comment: '审核通过' })
|
||||
ElMessage.success('已通过')
|
||||
loadDetail()
|
||||
loadLogs()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
if (!rejectReason.value.trim()) { ElMessage.warning('请输入打回原因'); return }
|
||||
try {
|
||||
await adminApi.reject(route.params.id, { reason: rejectReason.value })
|
||||
ElMessage.success('已打回')
|
||||
showReject.value = false
|
||||
loadDetail()
|
||||
loadLogs()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDetail()
|
||||
loadLogs()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-clipboard-check"></i>审核管理</div>
|
||||
|
||||
<div class="card">
|
||||
<el-radio-group v-model="statusFilter" @change="loadList" style="margin-bottom:16px">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button label="pending">待审核</el-radio-button>
|
||||
<el-radio-button label="active">已通过</el-radio-button>
|
||||
<el-radio-button label="rejected">被打回</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-table :data="list" stripe v-loading="loading">
|
||||
<el-table-column prop="name" label="班级名称" min-width="200" />
|
||||
<el-table-column prop="grade" label="年级" width="120" />
|
||||
<el-table-column prop="graduate_year" label="毕业年份" width="100" />
|
||||
<el-table-column label="同学数" width="80">
|
||||
<template #default="{ row }">{{ row.student_names?.length || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', statusMap[row.status]?.cls]">{{ statusMap[row.status]?.text }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核截止" width="120">
|
||||
<template #default="{ row }">{{ row.audit_deadline ? row.audit_deadline.substring(0,10) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="$router.push(`/admin/audit/detail/${row.id}`)">审核</el-button>
|
||||
<el-button v-if="row.status === 'pending'" type="success" link @click="approve(row)">通过</el-button>
|
||||
<el-button v-if="row.status === 'pending'" type="warning" link @click="reject(row)">打回</el-button>
|
||||
<el-button type="danger" link @click="del(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top:16px;text-align:right">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10,20,50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="loadList"
|
||||
@current-change="loadList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 打回原因弹窗 -->
|
||||
<el-dialog v-model="showReject" title="打回原因" width="400px">
|
||||
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请输入打回原因,将通知创建者补齐" />
|
||||
<template #footer>
|
||||
<el-button @click="showReject=false">取消</el-button>
|
||||
<el-button type="warning" @click="confirmReject">确认打回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { adminApi } from '../../../../api'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const statusFilter = ref('')
|
||||
const showReject = ref(false)
|
||||
const rejectReason = ref('')
|
||||
const currentRow = ref(null)
|
||||
|
||||
const statusMap = {
|
||||
pending: { text: '待审核', cls: 'warning' },
|
||||
active: { text: '已通过', cls: 'success' },
|
||||
rejected: { text: '被打回', cls: 'error' },
|
||||
deleted: { text: '已删除', cls: 'default' }
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, page_size: pageSize.value }
|
||||
if (statusFilter.value) params.status = statusFilter.value
|
||||
const res = await adminApi.auditQueue(params)
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function approve(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定通过班级「${row.name}」的审核吗?`, '提示', { type: 'success' })
|
||||
await adminApi.approve(row.id, { comment: '审核通过' })
|
||||
ElMessage.success('已通过')
|
||||
loadList()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function reject(row) {
|
||||
currentRow.value = row
|
||||
rejectReason.value = ''
|
||||
showReject.value = true
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
if (!rejectReason.value.trim()) { ElMessage.warning('请输入打回原因'); return }
|
||||
try {
|
||||
await adminApi.reject(currentRow.value.id, { reason: rejectReason.value })
|
||||
ElMessage.success('已打回')
|
||||
showReject.value = false
|
||||
loadList()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function del(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除班级「${row.name}」吗?此操作不可恢复!`, '警告', { type: 'danger' })
|
||||
await adminApi.delete(row.id, { reason: '管理员手动删除' })
|
||||
ElMessage.success('已删除')
|
||||
loadList()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
onMounted(() => { loadList() })
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-cogs"></i>系统配置</div>
|
||||
|
||||
<div class="card">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
在此配置平台各项参数。第三方服务(极验/短信/微信)的密钥请从对应平台获取后填入,留空则该功能处于开发模式。
|
||||
</el-alert>
|
||||
|
||||
<el-tabs v-model="activeTab" stretch>
|
||||
<el-tab-pane label="基础配置" name="basic">
|
||||
<el-form label-width="180px" style="max-width:700px">
|
||||
<el-form-item v-for="item in filteredConfigs('basic')" :key="item.config_key" :label="item.label">
|
||||
<el-input v-model="item.config_value" :placeholder="item.placeholder" />
|
||||
<div v-if="item.description" style="font-size:12px;color:#8c8c8c;margin-top:4px">{{ item.description }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="极验配置" name="geetest">
|
||||
<el-form label-width="180px" style="max-width:700px">
|
||||
<el-form-item v-for="item in filteredConfigs('geetest')" :key="item.config_key" :label="item.label">
|
||||
<el-input v-model="item.config_value" :placeholder="item.placeholder" show-password />
|
||||
<div v-if="item.description" style="font-size:12px;color:#8c8c8c;margin-top:4px">{{ item.description }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="短信配置" name="sms">
|
||||
<el-form label-width="180px" style="max-width:700px">
|
||||
<el-form-item v-for="item in filteredConfigs('sms')" :key="item.config_key" :label="item.label">
|
||||
<el-input v-model="item.config_value" :placeholder="item.placeholder" :show-password="item.config_key.includes('secret') || item.config_key.includes('key')" />
|
||||
<div v-if="item.description" style="font-size:12px;color:#8c8c8c;margin-top:4px">{{ item.description }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="微信配置" name="wechat">
|
||||
<el-form label-width="180px" style="max-width:700px">
|
||||
<el-form-item v-for="item in filteredConfigs('wechat')" :key="item.config_key" :label="item.label">
|
||||
<el-input v-model="item.config_value" :placeholder="item.placeholder" :show-password="item.config_key.includes('secret')" />
|
||||
<div v-if="item.description" style="font-size:12px;color:#8c8c8c;margin-top:4px">{{ item.description }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="存储配置" name="storage">
|
||||
<el-form label-width="180px" style="max-width:700px">
|
||||
<el-form-item v-for="item in filteredConfigs('storage')" :key="item.config_key" :label="item.label">
|
||||
<el-input v-model="item.config_value" :placeholder="item.placeholder" />
|
||||
<div v-if="item.description" style="font-size:12px;color:#8c8c8c;margin-top:4px">{{ item.description }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div style="margin-top:24px;text-align:center">
|
||||
<el-button type="primary" size="large" :loading="saving" @click="save">
|
||||
<i class="fas fa-save" style="margin-right:4px"></i>保存配置
|
||||
</el-button>
|
||||
<el-button size="large" @click="load"><i class="fas fa-redo" style="margin-right:4px"></i>重新加载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { configApi } from '../../../api'
|
||||
|
||||
const activeTab = ref('basic')
|
||||
const configs = ref([])
|
||||
const saving = ref(false)
|
||||
|
||||
function filteredConfigs(category) {
|
||||
return configs.value.filter(c => c.category === category)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await configApi.getAll()
|
||||
// 后端返回按分类分组的对象,展开成数组
|
||||
const data = res.data || {}
|
||||
const flat = []
|
||||
Object.keys(data).forEach(cat => {
|
||||
if (Array.isArray(data[cat])) {
|
||||
data[cat].forEach(item => {
|
||||
flat.push({ ...item, category: cat, config_key: item.key, config_value: item.value })
|
||||
})
|
||||
}
|
||||
})
|
||||
configs.value = flat
|
||||
} catch (e) {
|
||||
ElMessage.error('加载配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const updates = configs.value.map(c => ({ key: c.config_key, value: c.config_value }))
|
||||
await configApi.update({ configs: updates })
|
||||
ElMessage.success('配置已保存')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-chart-line"></i>数据统计</div>
|
||||
|
||||
<!-- 核心指标 -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon blue"><i class="fas fa-users"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.total_users || 0 }}</div>
|
||||
<div class="stat-label">总用户数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon green"><i class="fas fa-school"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.total_schools || 0 }}</div>
|
||||
<div class="stat-label">学校总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon orange"><i class="fas fa-graduation-cap"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.total_classes || 0 }}</div>
|
||||
<div class="stat-label">班级总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon purple"><i class="fas fa-images"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.total_photos || 0 }}</div>
|
||||
<div class="stat-label">照片总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 第二行指标 -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon blue"><i class="fas fa-clock"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.pending_classes || 0 }}</div>
|
||||
<div class="stat-label">待审核班级</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon green"><i class="fas fa-check-circle"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.active_classes || 0 }}</div>
|
||||
<div class="stat-label">已通过班级</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon red"><i class="fas fa-undo"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.rejected_classes || 0 }}</div>
|
||||
<div class="stat-label">被打回班级</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon orange"><i class="fas fa-user-ban"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.banned_users || 0 }}</div>
|
||||
<div class="stat-label">封禁用户</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<!-- 用户增长趋势 -->
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-chart-area" style="color:#1890ff"></i> 近7天用户增长</h3>
|
||||
<div ref="userChartRef" style="height:280px"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<!-- 班级增长趋势 -->
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-chart-area" style="color:#52c41a"></i> 近7天班级增长</h3>
|
||||
<div ref="classChartRef" style="height:280px"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<!-- 班级状态分布 -->
|
||||
<el-col :xs="24" :md="8">
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-chart-pie" style="color:#1890ff"></i> 班级状态分布</h3>
|
||||
<div ref="statusChartRef" style="height:260px"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<!-- 学校类型分布 -->
|
||||
<el-col :xs="24" :md="8">
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-chart-bar" style="color:#fa8c16"></i> 学校类型分布</h3>
|
||||
<div ref="typeChartRef" style="height:260px"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<!-- 地区TOP10 -->
|
||||
<el-col :xs="24" :md="8">
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-map-marked-alt" style="color:#722ed1"></i> 地区TOP10</h3>
|
||||
<el-table :data="topRegions" size="small" max-height="260">
|
||||
<el-table-column prop="region_name" label="地区" min-width="100" />
|
||||
<el-table-column prop="count" label="班级数" width="80" align="right" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 毕业年份分布 -->
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<el-col :span="24">
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-calendar-alt" style="color:#1890ff"></i> 毕业年份分布</h3>
|
||||
<div ref="yearChartRef" style="height:280px"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { statsApi } from '../../../api'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const stats = ref({})
|
||||
const topRegions = ref([])
|
||||
const userChartRef = ref()
|
||||
const classChartRef = ref()
|
||||
const statusChartRef = ref()
|
||||
const typeChartRef = ref()
|
||||
const yearChartRef = ref()
|
||||
|
||||
const typeMap = {
|
||||
university: '大学', vocational: '职业学校', senior_high: '高中',
|
||||
junior_high: '初中', primary: '小学', kindergarten: '幼儿园', other: '其他'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await statsApi.detailed()
|
||||
const d = res.data || {}
|
||||
stats.value = d
|
||||
topRegions.value = d.top_regions || []
|
||||
await nextTick()
|
||||
renderCharts(d)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function renderCharts(d) {
|
||||
// 用户增长
|
||||
if (userChartRef.value) {
|
||||
const chart = echarts.init(userChartRef.value)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'category', data: (d.user_trend || []).map(i => i.date) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ data: (d.user_trend || []).map(i => i.count), type: 'line', smooth: true, areaStyle: { color: 'rgba(24,144,255,0.2)' }, itemStyle: { color: '#1890ff' } }]
|
||||
})
|
||||
}
|
||||
// 班级增长
|
||||
if (classChartRef.value) {
|
||||
const chart = echarts.init(classChartRef.value)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'category', data: (d.class_trend || []).map(i => i.date) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ data: (d.class_trend || []).map(i => i.count), type: 'line', smooth: true, areaStyle: { color: 'rgba(82,196,26,0.2)' }, itemStyle: { color: '#52c41a' } }]
|
||||
})
|
||||
}
|
||||
// 班级状态分布
|
||||
if (statusChartRef.value) {
|
||||
const chart = echarts.init(statusChartRef.value)
|
||||
const data = d.class_status_dist || []
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
series: [{
|
||||
type: 'pie', radius: ['40%', '70%'], center: ['50%', '45%'],
|
||||
data: data.map(i => ({ name: i.status, value: i.count })),
|
||||
label: { show: false }
|
||||
}]
|
||||
})
|
||||
}
|
||||
// 学校类型分布
|
||||
if (typeChartRef.value) {
|
||||
const chart = echarts.init(typeChartRef.value)
|
||||
const data = d.school_type_dist || []
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 80, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: data.map(i => typeMap[i.school_type] || i.school_type) },
|
||||
series: [{ data: data.map(i => i.count), type: 'bar', itemStyle: { color: '#fa8c16' } }]
|
||||
})
|
||||
}
|
||||
// 毕业年份分布
|
||||
if (yearChartRef.value) {
|
||||
const chart = echarts.init(yearChartRef.value)
|
||||
const data = d.graduate_year_dist || []
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'category', data: data.map(i => i.graduate_year) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ data: data.map(i => i.count), type: 'bar', itemStyle: { color: '#1890ff' } }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title">
|
||||
<i class="fas fa-list-alt"></i>菜单管理
|
||||
<el-button type="primary" style="float:right" @click="showAdd(null)">
|
||||
<i class="fas fa-plus" style="margin-right:4px"></i>新增菜单
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
菜单数据存储在数据库中,登录后根据用户角色动态加载路由。修改菜单后需重新登录生效。
|
||||
</el-alert>
|
||||
|
||||
<el-table :data="treeData" row-key="id" :tree-props="{ children: 'children' }" default-expand-all v-loading="loading">
|
||||
<el-table-column prop="title" label="菜单名称" min-width="180" />
|
||||
<el-table-column prop="path" label="路由路径" min-width="180" />
|
||||
<el-table-column prop="component" label="组件路径" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<code style="font-size:12px;color:#1890ff">{{ row.component || '-' }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="icon" label="图标" width="100">
|
||||
<template #default="{ row }">
|
||||
<i :class="row.icon" style="color:#1890ff"></i>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权限" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.require_admin" type="danger" size="small">管理员</el-tag>
|
||||
<el-tag v-else type="success" size="small">登录用户</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column label="显示" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.visible" @change="toggleVisible(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="showAdd(row)">添加子菜单</el-button>
|
||||
<el-button type="warning" link @click="edit(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="del(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="showDialog" :title="form.id ? '编辑菜单' : '新增菜单'" width="550px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="菜单名称" required><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="路由路径"><el-input v-model="form.path" placeholder="如 /class" /></el-form-item>
|
||||
<el-form-item label="组件路径"><el-input v-model="form.component" placeholder="如 class/list/index" /></el-form-item>
|
||||
<el-form-item label="图标"><el-input v-model="form.icon" placeholder="如 fas fa-users" /></el-form-item>
|
||||
<el-form-item label="父级菜单">
|
||||
<el-select v-model="form.parent_id" clearable style="width:100%">
|
||||
<el-option label="顶级菜单" :value="0" />
|
||||
<el-option v-for="m in flatMenus" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sort_order" :min="0" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="权限">
|
||||
<el-radio-group v-model="form.require_admin">
|
||||
<el-radio :value="false">登录用户</el-radio>
|
||||
<el-radio :value="true">管理员</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示菜单"><el-switch v-model="form.visible" /></el-form-item>
|
||||
<el-form-item label="缓存"><el-switch v-model="form.keep_alive" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog=false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { menuApi } from '../../../api'
|
||||
|
||||
const treeData = ref([])
|
||||
const loading = ref(false)
|
||||
const showDialog = ref(false)
|
||||
const form = reactive({ id: null, parent_id: 0, title: '', path: '', component: '', icon: '', sort_order: 0, require_admin: false, visible: true, keep_alive: false })
|
||||
|
||||
const flatMenus = computed(() => {
|
||||
const result = []
|
||||
function flatten(list) {
|
||||
list.forEach(m => {
|
||||
result.push(m)
|
||||
if (m.children) flatten(m.children)
|
||||
})
|
||||
}
|
||||
flatten(treeData.value)
|
||||
return result
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await menuApi.getAll()
|
||||
treeData.value = res.data || []
|
||||
} catch (e) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showAdd(parent) {
|
||||
Object.assign(form, { id: null, parent_id: parent ? parent.id : 0, title: '', path: '', component: '', icon: '', sort_order: 0, require_admin: false, visible: true, keep_alive: false })
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function edit(row) {
|
||||
Object.assign(form, row)
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.title) { ElMessage.warning('请输入菜单名称'); return }
|
||||
try {
|
||||
if (form.id) {
|
||||
await menuApi.update(form.id, form)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await menuApi.create(form)
|
||||
ElMessage.success('已创建')
|
||||
}
|
||||
showDialog.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function del(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除菜单「${row.title}」吗?子菜单也会一并删除!`, '警告', { type: 'danger' })
|
||||
await menuApi.delete(row.id)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function toggleVisible(row) {
|
||||
try {
|
||||
await menuApi.update(row.id, { visible: row.visible })
|
||||
} catch (e) {
|
||||
row.visible = !row.visible
|
||||
ElMessage.error('更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title">
|
||||
<i class="fas fa-shield-alt"></i>敏感词管理
|
||||
<el-button type="primary" style="float:right" @click="showAdd = true">
|
||||
<i class="fas fa-plus" style="margin-right:4px"></i>添加敏感词
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
全局敏感词屏蔽:用户发布的班级名称、简介等内容将自动检测,包含敏感词的内容将被拦截或替换为 ***
|
||||
</el-alert>
|
||||
|
||||
<el-input v-model="keyword" placeholder="搜索敏感词" clearable style="width:250px;margin-bottom:16px" @keyup.enter="load">
|
||||
<template #prefix><i class="fas fa-search" style="color:#8c8c8c"></i></template>
|
||||
</el-input>
|
||||
|
||||
<el-table :data="filteredList" stripe v-loading="loading">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="word" label="敏感词" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span style="font-weight:600;color:#f5222d">{{ row.word }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category" label="分类" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="categoryMap[row.category]?.type || 'info'" size="small">
|
||||
{{ categoryMap[row.category]?.label || row.category }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" @change="toggle(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="del(row)"><i class="fas fa-trash"></i> 删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 添加弹窗 -->
|
||||
<el-dialog v-model="showAdd" title="添加敏感词" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="敏感词" required>
|
||||
<el-input v-model="newWord" placeholder="请输入敏感词" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="newCategory" style="width:100%">
|
||||
<el-option label="政治敏感" value="politics" />
|
||||
<el-option label="色情低俗" value="porn" />
|
||||
<el-option label="暴力恐怖" value="violence" />
|
||||
<el-option label="广告营销" value="ad" />
|
||||
<el-option label="辱骂攻击" value="abuse" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAdd=false">取消</el-button>
|
||||
<el-button type="primary" @click="add">添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { sensitiveApi } from '../../../api'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const showAdd = ref(false)
|
||||
const newWord = ref('')
|
||||
const newCategory = ref('other')
|
||||
|
||||
const categoryMap = {
|
||||
politics: { label: '政治敏感', type: 'danger' },
|
||||
porn: { label: '色情低俗', type: 'warning' },
|
||||
violence: { label: '暴力恐怖', type: 'danger' },
|
||||
ad: { label: '广告营销', type: 'info' },
|
||||
abuse: { label: '辱骂攻击', type: 'warning' },
|
||||
other: { label: '其他', type: 'info' }
|
||||
}
|
||||
|
||||
const filteredList = computed(() => {
|
||||
if (!keyword.value) return list.value
|
||||
return list.value.filter(w => w.word.includes(keyword.value))
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await sensitiveApi.list()
|
||||
list.value = res.data || []
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!newWord.value.trim()) { ElMessage.warning('请输入敏感词'); return }
|
||||
try {
|
||||
await sensitiveApi.create({ word: newWord.value, category: newCategory.value })
|
||||
ElMessage.success('已添加')
|
||||
showAdd.value = false
|
||||
newWord.value = ''
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function del(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除敏感词「${row.word}」吗?`, '提示', { type: 'warning' })
|
||||
await sensitiveApi.delete(row.id)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function toggle(row) {
|
||||
try {
|
||||
await sensitiveApi.toggle(row.id, row.enabled)
|
||||
ElMessage.success(row.enabled ? '已启用' : '已禁用')
|
||||
} catch (e) {
|
||||
row.enabled = !row.enabled
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-user-shield"></i>用户管理</div>
|
||||
|
||||
<div class="card">
|
||||
<el-tabs v-model="activeTab" stretch @tab-change="load">
|
||||
<el-tab-pane label="全部用户" name="all" />
|
||||
<el-tab-pane label="封禁用户" name="banned" />
|
||||
</el-tabs>
|
||||
|
||||
<el-table :data="list" stripe v-loading="loading">
|
||||
<el-table-column label="用户" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#1890ff;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:600">
|
||||
{{ (row.nickname || row.username || 'U').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div>
|
||||
<div>{{ row.nickname || row.username }}</div>
|
||||
<div style="font-size:12px;color:#8c8c8c">@{{ row.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="邮箱" width="180" />
|
||||
<el-table-column label="角色" width="100">
|
||||
<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 label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'banned' ? 'danger' : 'success'" size="small">
|
||||
{{ row.status === 'banned' ? '已封禁' : '正常' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="封禁原因" min-width="150">
|
||||
<template #default="{ row }">{{ row.ban_reason || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="注册时间" width="160">
|
||||
<template #default="{ row }">{{ row.created_at }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status !== 'banned' && row.role !== 'admin'" type="danger" link @click="ban(row)">封禁</el-button>
|
||||
<el-button v-if="row.status === 'banned'" type="success" link @click="unban(row)">解封</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 封禁弹窗 -->
|
||||
<el-dialog v-model="showBan" title="封禁用户" width="400px">
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:16px">
|
||||
封禁后该用户将无法登录,所有班级操作受限
|
||||
</el-alert>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="用户">{{ currentUser?.nickname || currentUser?.username }}</el-form-item>
|
||||
<el-form-item label="封禁原因" required>
|
||||
<el-input v-model="banReason" type="textarea" :rows="3" placeholder="请输入封禁原因" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showBan=false">取消</el-button>
|
||||
<el-button type="danger" @click="confirmBan">确认封禁</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { banApi, userApi } from '../../../api'
|
||||
|
||||
const activeTab = ref('all')
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const showBan = ref(false)
|
||||
const banReason = ref('')
|
||||
const currentUser = ref(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (activeTab.value === 'banned') {
|
||||
const res = await banApi.list()
|
||||
list.value = res.data || []
|
||||
} else {
|
||||
// 全部用户 - 暂时用封禁列表+其他方式,后续可加用户列表接口
|
||||
const res = await banApi.list()
|
||||
list.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function ban(row) {
|
||||
currentUser.value = row
|
||||
banReason.value = ''
|
||||
showBan.value = true
|
||||
}
|
||||
|
||||
async function confirmBan() {
|
||||
if (!banReason.value.trim()) { ElMessage.warning('请输入封禁原因'); return }
|
||||
try {
|
||||
await banApi.ban(currentUser.value.id, { reason: banReason.value })
|
||||
ElMessage.success('已封禁')
|
||||
showBan.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '封禁失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function unban(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定解封用户「${row.nickname || row.username}」吗?`, '提示', { type: 'warning' })
|
||||
await banApi.unban(row.id)
|
||||
ElMessage.success('已解封')
|
||||
load()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-box">
|
||||
<div class="login-title">
|
||||
<i class="fas fa-key"></i>找回密码
|
||||
</div>
|
||||
<div class="login-subtitle">通过注册邮箱验证身份,重置登录密码</div>
|
||||
|
||||
<el-steps :active="step" finish-status="success" align-center style="margin-bottom:24px">
|
||||
<el-step title="验证身份" />
|
||||
<el-step title="设置新密码" />
|
||||
<el-step title="完成" />
|
||||
</el-steps>
|
||||
|
||||
<!-- 第一步:验证身份 -->
|
||||
<el-form v-if="step === 0" :model="form" :rules="rules" ref="formRef" label-position="top">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="用户名" size="large" :prefix-icon="UserIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="email">
|
||||
<el-input v-model="form.email" placeholder="注册邮箱" size="large" :prefix-icon="MailIcon" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleSubmit">
|
||||
获取重置码
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<!-- 第二步:设置新密码 -->
|
||||
<el-form v-if="step === 1" :model="form" :rules="resetRules" ref="resetFormRef" label-position="top">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
<template #title>
|
||||
重置码已发送(开发模式直接显示):<strong style="color:#1890ff">{{ resetCode }}</strong>
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-form-item prop="reset_code">
|
||||
<el-input v-model="form.reset_code" placeholder="请输入重置码" size="large" :prefix-icon="KeyIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="new_password">
|
||||
<el-input v-model="form.new_password" type="password" placeholder="新密码(6-64位)" size="large" show-password :prefix-icon="LockIcon" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleReset">
|
||||
重置密码
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<!-- 第三步:完成 -->
|
||||
<div v-if="step === 2" style="text-align:center;padding:20px 0">
|
||||
<i class="fas fa-check-circle" style="font-size:64px;color:#52c41a;margin-bottom:16px"></i>
|
||||
<h3 style="margin-bottom:8px">密码重置成功</h3>
|
||||
<p style="color:#8c8c8c;margin-bottom:24px">请使用新密码登录</p>
|
||||
<el-button type="primary" size="large" @click="$router.push('/login')">
|
||||
<i class="fas fa-sign-in-alt" style="margin-right:6px"></i>去登录
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-top:16px">
|
||||
<el-link type="primary" @click="$router.push('/login')">
|
||||
<i class="fas fa-arrow-left" style="margin-right:4px"></i>返回登录
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, markRaw } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock, Key, Message } from '@element-plus/icons-vue'
|
||||
import { passwordApi } from '../../../api'
|
||||
|
||||
const router = useRouter()
|
||||
const UserIcon = markRaw(User)
|
||||
const LockIcon = markRaw(Lock)
|
||||
const KeyIcon = markRaw(Key)
|
||||
const MailIcon = markRaw(Message)
|
||||
|
||||
const step = ref(0)
|
||||
const loading = ref(false)
|
||||
const resetCode = ref('')
|
||||
|
||||
const form = reactive({ username: '', email: '', reset_code: '', new_password: '' })
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
email: [{ required: true, type: 'email', message: '请输入正确的邮箱', trigger: 'blur' }]
|
||||
}
|
||||
const resetRules = {
|
||||
reset_code: [{ required: true, message: '请输入重置码', trigger: 'blur' }],
|
||||
new_password: [{ required: true, min: 6, max: 64, message: '密码6-64位', trigger: 'blur' }]
|
||||
}
|
||||
const formRef = ref()
|
||||
const resetFormRef = ref()
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await passwordApi.forgot({ username: form.username, email: form.email })
|
||||
resetCode.value = res.data.reset_code
|
||||
step.value = 1
|
||||
ElMessage.success('重置码已生成')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '操作失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
await resetFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await passwordApi.reset({
|
||||
username: form.username,
|
||||
reset_code: form.reset_code,
|
||||
new_password: form.new_password
|
||||
})
|
||||
step.value = 2
|
||||
ElMessage.success('密码重置成功')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '重置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-box">
|
||||
<div class="login-title">
|
||||
<i class="fas fa-graduation-cap"></i>PhotoWall
|
||||
</div>
|
||||
<div class="login-subtitle">毕业照存储与同学联络平台</div>
|
||||
|
||||
<el-tabs v-model="activeTab" stretch>
|
||||
<el-tab-pane label="登录" name="login">
|
||||
<el-form :model="loginForm" :rules="loginRules" ref="loginFormRef" label-position="top" @keyup.enter="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="loginForm.username" placeholder="用户名" size="large" :prefix-icon="UserIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" show-password :prefix-icon="LockIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="captcha_code">
|
||||
<div style="display:flex;gap:10px;width:100%">
|
||||
<el-input v-model="loginForm.captcha_code" placeholder="验证码" size="large" :prefix-icon="KeyIcon" style="flex:1" />
|
||||
<img :src="captchaImg" class="captcha-img" @click="refreshCaptcha" title="点击刷新" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleLogin">
|
||||
<i class="fas fa-sign-in-alt" style="margin-right:6px"></i>登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
<div style="text-align:center;margin-top:12px">
|
||||
<el-link type="primary" @click="$router.push('/forgot-password')">忘记密码?</el-link>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="注册" name="register">
|
||||
<el-form :model="regForm" :rules="regRules" ref="regFormRef" label-position="top">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="regForm.username" placeholder="用户名(3-32位)" size="large" :prefix-icon="UserIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="regForm.password" type="password" placeholder="密码(6-64位)" size="large" show-password :prefix-icon="LockIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="email">
|
||||
<el-input v-model="regForm.email" placeholder="邮箱(用于找回密码)" size="large" :prefix-icon="MailIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="captcha_code">
|
||||
<div style="display:flex;gap:10px;width:100%">
|
||||
<el-input v-model="regForm.captcha_code" placeholder="验证码" size="large" :prefix-icon="KeyIcon" style="flex:1" />
|
||||
<img :src="captchaImg" class="captcha-img" @click="refreshCaptcha" title="点击刷新" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleRegister">
|
||||
<i class="fas fa-user-plus" style="margin-right:6px"></i>注 册
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, markRaw } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock, Key, Message } from '@element-plus/icons-vue'
|
||||
import { authApi, captchaApi } from '../../../api'
|
||||
import { useAuth } from '../../../composables/useAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { setAuth, loadMenus } = useAuth()
|
||||
|
||||
const UserIcon = markRaw(User)
|
||||
const LockIcon = markRaw(Lock)
|
||||
const KeyIcon = markRaw(Key)
|
||||
const MailIcon = markRaw(Message)
|
||||
|
||||
const activeTab = ref('login')
|
||||
const loading = ref(false)
|
||||
const captchaImg = ref('')
|
||||
const captchaId = ref('')
|
||||
|
||||
const loginForm = reactive({ username: '', password: '', captcha_id: '', captcha_code: '' })
|
||||
const loginRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
captcha_code: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
|
||||
}
|
||||
const loginFormRef = ref()
|
||||
|
||||
const regForm = reactive({ username: '', password: '', email: '', captcha_id: '', captcha_code: '' })
|
||||
const regRules = {
|
||||
username: [{ required: true, min: 3, max: 32, message: '用户名3-32位', trigger: 'blur' }],
|
||||
password: [{ required: true, min: 6, max: 64, message: '密码6-64位', trigger: 'blur' }],
|
||||
captcha_code: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
|
||||
}
|
||||
const regFormRef = ref()
|
||||
|
||||
async function refreshCaptcha() {
|
||||
try {
|
||||
const res = await captchaApi.get()
|
||||
captchaId.value = res.data.captcha_id
|
||||
captchaImg.value = res.data.image
|
||||
loginForm.captcha_id = res.data.captcha_id
|
||||
regForm.captcha_id = res.data.captcha_id
|
||||
} catch (e) {
|
||||
ElMessage.error('验证码加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
await loginFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await authApi.login(loginForm)
|
||||
setAuth(res.data.token, res.data.user)
|
||||
await loadMenus()
|
||||
ElMessage.success('登录成功')
|
||||
const redirect = route.query.redirect || '/'
|
||||
router.push(redirect)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '登录失败')
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
await regFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await authApi.register(regForm)
|
||||
ElMessage.success('注册成功,请登录')
|
||||
activeTab.value = 'login'
|
||||
loginForm.username = regForm.username
|
||||
refreshCaptcha()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '注册失败')
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<div class="page-title">
|
||||
<i class="fas fa-graduation-cap"></i>{{ detail?.class?.name || '班级详情' }}
|
||||
<span :class="['tag', statusMap[detail?.class?.status]?.cls]" style="margin-left:12px;font-size:12px">{{ statusMap[detail?.class?.status]?.text }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="detail" class="card">
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="学校">{{ detail.school?.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学院">{{ detail.college?.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年级">{{ detail.class.grade }}</el-descriptions-item>
|
||||
<el-descriptions-item label="班号">{{ detail.class.class_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="毕业年份">{{ detail.class.graduate_year }}</el-descriptions-item>
|
||||
<el-descriptions-item label="管理员">{{ detail.admin?.nickname || detail.admin?.username }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 毕业照 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-image" style="color:#1890ff"></i> 毕业照</h3>
|
||||
<div v-if="detail.class.graduation_photo" style="text-align:center">
|
||||
<img :src="detail.class.graduation_photo" style="max-width:100%;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,0.1)" />
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
<i class="fas fa-image"></i>
|
||||
<p>暂无毕业照</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 同学名单 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-user-friends" style="color:#1890ff"></i> 同学名单 ({{ detail.student_names?.length || 0 }}人)</h3>
|
||||
<div v-if="detail.can_view_sensitive" style="display:flex;flex-wrap:wrap;gap:8px">
|
||||
<span v-for="(name, i) in detail.student_names" :key="i" class="tag primary" style="padding:4px 12px">{{ i+1 }}. {{ name }}</span>
|
||||
</div>
|
||||
<div v-else class="empty" style="padding:20px">
|
||||
<i class="fas fa-lock"></i>
|
||||
<p>加入班级后可查看完整同学名单</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 照片墙 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px">
|
||||
<i class="fas fa-images" style="color:#1890ff"></i> 照片墙 ({{ detail.photos?.length || 0 }}张)
|
||||
<el-button v-if="detail.is_member" type="primary" size="small" style="float:right" @click="showUpload = true">
|
||||
<i class="fas fa-upload" style="margin-right:4px"></i>上传照片
|
||||
</el-button>
|
||||
</h3>
|
||||
<div v-if="detail.can_view_sensitive && detail.photos?.length > 0" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px">
|
||||
<div v-for="photo in detail.photos" :key="photo.id" style="position:relative;border-radius:6px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.1)">
|
||||
<img :src="photo.photo_url" style="width:100%;height:140px;object-fit:cover;cursor:pointer" @click="previewPhoto(photo.photo_url)" />
|
||||
<div style="padding:6px 8px;font-size:12px;color:#595959;background:#fff">
|
||||
<i class="fas fa-user" style="margin-right:4px"></i>{{ getUploaderName(photo.user_id) }}
|
||||
<el-button v-if="canDeletePhoto(photo)" type="danger" size="small" link style="float:right" @click="deletePhoto(photo)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!detail.can_view_sensitive" class="empty" style="padding:20px">
|
||||
<i class="fas fa-lock"></i>
|
||||
<p>加入班级后可查看照片墙</p>
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
<i class="fas fa-images"></i>
|
||||
<p>暂无照片,快来上传第一张吧</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成员列表 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-users" style="color:#1890ff"></i> 班级成员 ({{ detail.members?.length || 0 }}人)</h3>
|
||||
<el-table :data="detail.members" stripe>
|
||||
<el-table-column label="用户" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#1890ff;color:#fff;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600">
|
||||
{{ (row.user.nickname || row.user.username || 'U').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<span>{{ row.user.nickname || row.user.username }}</span>
|
||||
<span v-if="row.member.role === 'admin'" class="tag primary" style="margin-left:4px">管理员</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="加入方式" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.member.join_method === 'invite' ? 'success' : 'default']">
|
||||
{{ row.member.join_method === 'invite' ? '邀请加入' : '搜索加入' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="加入时间" width="180">
|
||||
<template #default="{ row }">{{ row.member.joined_at }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="detail.can_view_sensitive" label="联系方式" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.contacts?.length" style="font-size:12px;color:#595959">
|
||||
<div v-for="c in row.contacts" :key="c.id" style="margin-bottom:2px">
|
||||
<i :class="contactIcon(c.type)" style="margin-right:4px;color:#1890ff"></i>{{ c.value }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else style="color:#bfbfbf">未填写</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 管理员面板 -->
|
||||
<div v-if="detail.my_role === 'admin'" class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-cog" style="color:#1890ff"></i> 管理员面板</h3>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="6">
|
||||
<el-button type="primary" style="width:100%" @click="showInvite = true">
|
||||
<i class="fas fa-share-alt" style="margin-right:4px"></i>生成邀请码
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-button type="warning" style="width:100%" @click="showTransfer = true">
|
||||
<i class="fas fa-exchange-alt" style="margin-right:4px"></i>转移管理员
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-button type="danger" style="width:100%" @click="resubmit">
|
||||
<i class="fas fa-redo" style="margin-right:4px"></i>重新提交审核
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 上传照片弹窗 -->
|
||||
<el-dialog v-model="showUpload" title="上传照片" width="500px">
|
||||
<el-upload
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:data="{ type: 'photo' }"
|
||||
:show-file-list="false"
|
||||
:on-success="onUploadSuccess"
|
||||
accept="image/*"
|
||||
>
|
||||
<div style="width:100%;height:200px;border:2px dashed #d9d9d9;border-radius:6px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#8c8c8c">
|
||||
<div style="text-align:center">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size:40px;margin-bottom:8px"></i>
|
||||
<div>点击上传照片(每人最多10张)</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div v-if="uploadPreview" style="margin-top:12px;text-align:center">
|
||||
<img :src="uploadPreview" style="max-width:100%;border-radius:6px" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 邀请码弹窗 -->
|
||||
<el-dialog v-model="showInvite" title="生成邀请码" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="使用次数">
|
||||
<el-input-number v-model="inviteForm.max_uses" :min="1" :max="100" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期(天)">
|
||||
<el-input-number v-model="inviteForm.expire_days" :min="1" :max="365" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div v-if="inviteCode" style="margin:12px 0;padding:12px;background:#f6ffed;border:1px solid #b7eb8f;border-radius:4px;text-align:center">
|
||||
<div style="font-size:12px;color:#52c41a;margin-bottom:4px">邀请码已生成</div>
|
||||
<div style="font-size:24px;font-weight:700;letter-spacing:4px;color:#52c41a">{{ inviteCode }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showInvite=false">关闭</el-button>
|
||||
<el-button type="primary" @click="generateInvite">生成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 转移管理员弹窗 -->
|
||||
<el-dialog v-model="showTransfer" title="转移管理员" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="新管理员">
|
||||
<el-select v-model="transferUserId" placeholder="选择成员" style="width:100%">
|
||||
<el-option v-for="m in detail?.members?.filter(x => x.member.role !== 'admin')" :key="m.member.user_id" :label="m.user.nickname || m.user.username" :value="m.member.user_id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:12px">转移后你将变为普通成员</el-alert>
|
||||
<template #footer>
|
||||
<el-button @click="showTransfer=false">取消</el-button>
|
||||
<el-button type="danger" @click="transferAdmin">确认转移</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<el-dialog v-model="showPreview" width="80%" top="5vh">
|
||||
<img :src="previewUrl" style="width:100%;border-radius:6px" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { classApi } from '../../../api'
|
||||
import { useAuth } from '../../../composables/useAuth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { token, user } = useAuth()
|
||||
|
||||
const detail = ref(null)
|
||||
const loading = ref(false)
|
||||
const showUpload = ref(false)
|
||||
const showInvite = ref(false)
|
||||
const showTransfer = ref(false)
|
||||
const showPreview = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const uploadPreview = ref('')
|
||||
const inviteCode = ref('')
|
||||
const transferUserId = ref(null)
|
||||
|
||||
const inviteForm = reactive({ max_uses: 10, expire_days: 30 })
|
||||
const uploadUrl = '/api/upload'
|
||||
const uploadHeaders = computed(() => ({ Authorization: 'Bearer ' + token.value }))
|
||||
|
||||
const statusMap = {
|
||||
pending: { text: '待审核', cls: 'warning' },
|
||||
active: { text: '已通过', cls: 'success' },
|
||||
rejected: { text: '被打回', cls: 'error' },
|
||||
deleted: { text: '已删除', cls: 'default' }
|
||||
}
|
||||
|
||||
function contactIcon(type) {
|
||||
const map = { email: 'fas fa-envelope', phone: 'fas fa-phone', qq: 'fab fa-qq', wechat: 'fab fa-weixin', address: 'fas fa-map-marker-alt', workunit: 'fas fa-building' }
|
||||
return map[type] || 'fas fa-info-circle'
|
||||
}
|
||||
|
||||
function getUploaderName(userId) {
|
||||
const m = detail.value?.members?.find(x => x.member.user_id === userId)
|
||||
return m?.user?.nickname || m?.user?.username || '未知'
|
||||
}
|
||||
|
||||
function canDeletePhoto(photo) {
|
||||
return detail.value?.my_role === 'admin' || photo.user_id === user.value?.id || user.value?.role === 'admin'
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await classApi.detail(route.params.id)
|
||||
detail.value = res.data
|
||||
} catch (e) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function previewPhoto(url) {
|
||||
previewUrl.value = url
|
||||
showPreview.value = true
|
||||
}
|
||||
|
||||
function onUploadSuccess(res) {
|
||||
if (res.code === 0) {
|
||||
uploadPreview.value = res.data.url
|
||||
classApi.uploadPhoto(route.params.id, { photo_url: res.data.url, caption: '' }).then(() => {
|
||||
ElMessage.success('上传成功')
|
||||
showUpload.value = false
|
||||
uploadPreview.value = ''
|
||||
loadDetail()
|
||||
}).catch(e => ElMessage.error(e.message || '上传失败'))
|
||||
} else {
|
||||
ElMessage.error(res.message || '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePhoto(photo) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除这张照片吗?', '提示', { type: 'warning' })
|
||||
await classApi.deletePhoto(route.params.id, photo.id)
|
||||
ElMessage.success('已删除')
|
||||
loadDetail()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function generateInvite() {
|
||||
try {
|
||||
const res = await classApi.createInvitation(route.params.id, inviteForm)
|
||||
inviteCode.value = res.data.code
|
||||
ElMessage.success('邀请码已生成')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function transferAdmin() {
|
||||
if (!transferUserId.value) { ElMessage.warning('请选择新管理员'); return }
|
||||
try {
|
||||
await classApi.transferAdmin(route.params.id, { target_user_id: transferUserId.value })
|
||||
ElMessage.success('管理员已转移')
|
||||
showTransfer.value = false
|
||||
loadDetail()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '转移失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function resubmit() {
|
||||
try {
|
||||
await classApi.resubmit(route.params.id, {})
|
||||
ElMessage.success('已重新提交审核')
|
||||
loadDetail()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '提交失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadDetail() })
|
||||
</script>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-plus-circle"></i>创建班级</div>
|
||||
|
||||
<div class="card">
|
||||
<el-steps :active="step" finish-status="success" align-center style="margin-bottom:32px">
|
||||
<el-step title="选择地区" />
|
||||
<el-step title="选择学校" />
|
||||
<el-step title="班级信息" />
|
||||
<el-step title="毕业照与名单" />
|
||||
</el-steps>
|
||||
|
||||
<!-- 第一步:选择地区 -->
|
||||
<div v-if="step === 0">
|
||||
<el-form label-width="100px" style="max-width:600px">
|
||||
<el-form-item label="国家">
|
||||
<el-select v-model="form.country_id" placeholder="选择国家" style="width:100%" @change="loadProvinces">
|
||||
<el-option v-for="c in countries" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="省/州">
|
||||
<el-select v-model="form.province_id" placeholder="选择省/州" style="width:100%" @change="loadCounties" :disabled="!form.country_id">
|
||||
<el-option v-for="p in provinces" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="区/县">
|
||||
<el-select v-model="form.county_id" placeholder="选择区/县" style="width:100%" @change="loadSchools" :disabled="!form.province_id">
|
||||
<el-option v-for="c in counties" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="text-align:right;margin-top:24px">
|
||||
<el-button type="primary" :disabled="!form.county_id" @click="step=1">下一步<i class="fas fa-arrow-right" style="margin-left:4px"></i></el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:选择学校 -->
|
||||
<div v-if="step === 1">
|
||||
<el-form label-width="100px" style="max-width:600px">
|
||||
<el-form-item label="选择学校">
|
||||
<el-select v-model="form.school_id" placeholder="选择学校" style="width:100%" filterable @change="onSchoolChange">
|
||||
<el-option v-for="s in schools" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="schoolInfo && hasCollege" label="选择学院">
|
||||
<el-select v-model="form.college_id" placeholder="选择学院" style="width:100%">
|
||||
<el-option v-for="c in colleges" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" link @click="showCreateSchool = true">
|
||||
<i class="fas fa-plus" style="margin-right:4px"></i>学校不在列表?创建新学校
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="text-align:right;margin-top:24px">
|
||||
<el-button @click="step=0"><i class="fas fa-arrow-left" style="margin-right:4px"></i>上一步</el-button>
|
||||
<el-button type="primary" :disabled="!form.school_id || (hasCollege && !form.college_id)" @click="step=2">下一步<i class="fas fa-arrow-right" style="margin-left:4px"></i></el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三步:班级信息 -->
|
||||
<div v-if="step === 2">
|
||||
<el-form :model="form" label-width="100px" style="max-width:600px">
|
||||
<el-form-item label="班级名称" required>
|
||||
<el-input v-model="form.name" placeholder="如:连云港市第一中学2015级高三1班" />
|
||||
</el-form-item>
|
||||
<el-form-item label="年级" required>
|
||||
<el-input v-model="form.grade" placeholder="如:2015级" />
|
||||
</el-form-item>
|
||||
<el-form-item label="班号" required>
|
||||
<el-input v-model="form.class_no" placeholder="如:1班" />
|
||||
</el-form-item>
|
||||
<el-form-item label="毕业年份" required>
|
||||
<el-input-number v-model="form.graduate_year" :min="1950" :max="2100" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="text-align:right;margin-top:24px">
|
||||
<el-button @click="step=1"><i class="fas fa-arrow-left" style="margin-right:4px"></i>上一步</el-button>
|
||||
<el-button type="primary" :disabled="!form.name || !form.grade || !form.class_no" @click="step=3">下一步<i class="fas fa-arrow-right" style="margin-left:4px"></i></el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四步:毕业照与名单 -->
|
||||
<div v-if="step === 3">
|
||||
<el-form label-width="100px" style="max-width:700px">
|
||||
<el-form-item label="毕业照">
|
||||
<el-upload
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:data="{ type: 'class' }"
|
||||
:show-file-list="false"
|
||||
:on-success="onPhotoSuccess"
|
||||
accept="image/*"
|
||||
>
|
||||
<div v-if="form.graduation_photo" style="position:relative">
|
||||
<img :src="form.graduation_photo" style="max-width:300px;border-radius:6px;border:1px solid #d9d9d9" />
|
||||
<el-button type="danger" size="small" style="position:absolute;top:8px;right:8px" @click.stop="form.graduation_photo=''">
|
||||
<i class="fas fa-times"></i>
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-else style="width:300px;height:180px;border:2px dashed #d9d9d9;border-radius:6px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#8c8c8c">
|
||||
<div style="text-align:center">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size:32px;margin-bottom:8px"></i>
|
||||
<div>点击上传毕业照</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item label="同学名单" required>
|
||||
<el-input
|
||||
v-model="studentNamesText"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="每行一个同学姓名,或用逗号/空格分隔 至少需要8位同学姓名"
|
||||
/>
|
||||
<div style="margin-top:4px;font-size:12px;color:#8c8c8c">
|
||||
已输入 <span :style="{color: studentCount >= 8 ? '#52c41a' : '#f5222d'}">{{ studentCount }}</span> 位同学(至少8位)
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="老师名单">
|
||||
<el-input
|
||||
v-model="teacherNamesText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="每行一个老师姓名,或用逗号/空格分隔"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="text-align:right;margin-top:24px">
|
||||
<el-button @click="step=2"><i class="fas fa-arrow-left" style="margin-right:4px"></i>上一步</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="studentCount < 8" @click="submit">
|
||||
<i class="fas fa-check" style="margin-right:4px"></i>提交创建
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建学校弹窗 -->
|
||||
<el-dialog v-model="showCreateSchool" title="创建新学校" width="500px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="学校名称" required>
|
||||
<el-input v-model="newSchool.name" placeholder="学校全称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="学校类型" required>
|
||||
<el-select v-model="newSchool.school_type" style="width:100%">
|
||||
<el-option label="大学" value="university" />
|
||||
<el-option label="职业学校/中专" value="vocational" />
|
||||
<el-option label="高中" value="senior_high" />
|
||||
<el-option label="初中" value="junior_high" />
|
||||
<el-option label="小学" value="primary" />
|
||||
<el-option label="幼儿园" value="kindergarten" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="newSchool.address" placeholder="详细地址" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateSchool=false">取消</el-button>
|
||||
<el-button type="primary" @click="createSchool">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { regionApi, schoolApi, classApi } from '../../../api'
|
||||
import { useAuth } from '../../../composables/useAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const { token } = useAuth()
|
||||
|
||||
const step = ref(0)
|
||||
const submitting = ref(false)
|
||||
const showCreateSchool = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
country_id: null, province_id: null, county_id: null,
|
||||
school_id: null, college_id: 0,
|
||||
name: '', grade: '', class_no: '', graduate_year: 2020,
|
||||
graduation_photo: '', student_names: [], teacher_names: []
|
||||
})
|
||||
|
||||
const studentNamesText = ref('')
|
||||
const teacherNamesText = ref('')
|
||||
|
||||
const studentCount = computed(() => {
|
||||
return studentNamesText.value.split(/[\n,,\s]+/).filter(s => s.trim()).length
|
||||
})
|
||||
|
||||
const countries = ref([])
|
||||
const provinces = ref([])
|
||||
const counties = ref([])
|
||||
const schools = ref([])
|
||||
const colleges = ref([])
|
||||
const schoolInfo = ref(null)
|
||||
const hasCollege = computed(() => schoolInfo.value?.school_type === 'university' || schoolInfo.value?.school_type === 'vocational')
|
||||
|
||||
const newSchool = reactive({ name: '', school_type: 'senior_high', address: '' })
|
||||
|
||||
const uploadUrl = '/api/upload'
|
||||
const uploadHeaders = computed(() => ({ Authorization: 'Bearer ' + token.value }))
|
||||
|
||||
function parseNames(text) {
|
||||
return text.split(/[\n,,\s]+/).filter(s => s.trim()).map(s => s.trim())
|
||||
}
|
||||
|
||||
async function loadCountries() {
|
||||
const res = await regionApi.countries()
|
||||
countries.value = res.data || []
|
||||
}
|
||||
async function loadProvinces() {
|
||||
form.province_id = null; form.county_id = null
|
||||
const res = await regionApi.list(form.country_id)
|
||||
provinces.value = res.data || []
|
||||
}
|
||||
async function loadCounties() {
|
||||
form.county_id = null
|
||||
const res = await regionApi.list(form.province_id)
|
||||
counties.value = res.data || []
|
||||
}
|
||||
async function loadSchools() {
|
||||
const res = await schoolApi.list({ region_id: form.county_id })
|
||||
schools.value = res.data || []
|
||||
}
|
||||
async function onSchoolChange() {
|
||||
schoolInfo.value = schools.value.find(s => s.id === form.school_id)
|
||||
form.college_id = 0
|
||||
if (hasCollege.value) {
|
||||
const res = await schoolApi.listColleges(form.school_id)
|
||||
colleges.value = res.data || []
|
||||
}
|
||||
}
|
||||
async function createSchool() {
|
||||
if (!newSchool.name) { ElMessage.warning('请输入学校名称'); return }
|
||||
try {
|
||||
const res = await schoolApi.create({ ...newSchool, region_id: form.county_id })
|
||||
schools.value.push(res.data)
|
||||
form.school_id = res.data.id
|
||||
schoolInfo.value = res.data
|
||||
showCreateSchool.value = false
|
||||
ElMessage.success('学校创建成功')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '创建失败')
|
||||
}
|
||||
}
|
||||
function onPhotoSuccess(res) {
|
||||
if (res.code === 0) {
|
||||
form.graduation_photo = res.data.url
|
||||
ElMessage.success('毕业照上传成功')
|
||||
} else {
|
||||
ElMessage.error(res.message || '上传失败')
|
||||
}
|
||||
}
|
||||
async function submit() {
|
||||
form.student_names = parseNames(studentNamesText.value)
|
||||
form.teacher_names = parseNames(teacherNamesText.value)
|
||||
if (form.student_names.length < 8) {
|
||||
ElMessage.error('同学名单至少需要8位')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await classApi.create(form)
|
||||
ElMessage.success('班级创建成功,等待平台审核')
|
||||
router.push(`/class/detail/${res.data.id}`)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadCountries() })
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-sign-in-alt"></i>加入班级</div>
|
||||
|
||||
<div class="card">
|
||||
<el-tabs v-model="activeTab" stretch>
|
||||
<!-- 搜索加入 -->
|
||||
<el-tab-pane label="搜索加入" name="search">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
搜索到班级后,需要正确说出至少8位同学的姓名才能加入
|
||||
</el-alert>
|
||||
<el-input v-model="keyword" placeholder="输入班级名称或学校名称搜索" size="large" clearable @keyup.enter="search">
|
||||
<template #prefix><i class="fas fa-search" style="color:#8c8c8c"></i></template>
|
||||
<template #append><el-button type="primary" @click="search">搜索</el-button></template>
|
||||
</el-input>
|
||||
|
||||
<div v-if="results.length > 0" style="margin-top:16px">
|
||||
<el-table :data="results" stripe>
|
||||
<el-table-column prop="name" label="班级名称" min-width="200" />
|
||||
<el-table-column prop="grade" label="年级" width="120" />
|
||||
<el-table-column prop="graduate_year" label="毕业年份" width="100" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.status === 'active' ? 'success' : 'warning']">
|
||||
{{ row.status === 'active' ? '已通过' : '待审核' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="showVerify(row)">申请加入</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else-if="searched" class="empty">
|
||||
<i class="fas fa-search"></i>
|
||||
<p>未找到相关班级</p>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 邀请码加入 -->
|
||||
<el-tab-pane label="邀请码加入" name="invite">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
输入管理员生成的邀请码即可直接加入班级
|
||||
</el-alert>
|
||||
<el-form label-width="80px" style="max-width:400px">
|
||||
<el-form-item label="邀请码">
|
||||
<el-input v-model="inviteCode" placeholder="请输入邀请码" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="joining" @click="joinByInvite">
|
||||
<i class="fas fa-check" style="margin-right:4px"></i>加入班级
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 姓名验证弹窗 -->
|
||||
<el-dialog v-model="showVerifyDialog" title="同学姓名验证" width="500px">
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:16px">
|
||||
请输入该班级中至少8位同学的姓名(每行一个),验证通过后即可加入
|
||||
</el-alert>
|
||||
<el-input
|
||||
v-model="verifyNames"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="每行输入一个同学姓名 例如: 张三 李四 王五"
|
||||
/>
|
||||
<div style="margin-top:8px;font-size:12px;color:#8c8c8c">
|
||||
已输入 <span :style="{color: verifyCount >= 8 ? '#52c41a' : '#f5222d'}">{{ verifyCount }}</span> 位(至少8位)
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showVerifyDialog=false">取消</el-button>
|
||||
<el-button type="primary" :disabled="verifyCount < 8" :loading="joining" @click="joinBySearch">
|
||||
验证并加入
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { classApi } from '../../../api'
|
||||
|
||||
const router = useRouter()
|
||||
const activeTab = ref('search')
|
||||
const keyword = ref('')
|
||||
const results = ref([])
|
||||
const searched = ref(false)
|
||||
const joining = ref(false)
|
||||
const inviteCode = ref('')
|
||||
const showVerifyDialog = ref(false)
|
||||
const verifyNames = ref('')
|
||||
const currentClass = ref(null)
|
||||
|
||||
const verifyCount = computed(() => {
|
||||
return verifyNames.value.split(/[\n,,\s]+/).filter(s => s.trim()).length
|
||||
})
|
||||
|
||||
async function search() {
|
||||
if (!keyword.value.trim()) { ElMessage.warning('请输入搜索关键词'); return }
|
||||
searched.value = true
|
||||
try {
|
||||
const res = await classApi.search({ keyword: keyword.value })
|
||||
results.value = (res.data.list || []).filter(c => c.status === 'active')
|
||||
} catch (e) {
|
||||
results.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function showVerify(row) {
|
||||
currentClass.value = row
|
||||
verifyNames.value = ''
|
||||
showVerifyDialog.value = true
|
||||
}
|
||||
|
||||
async function joinBySearch() {
|
||||
const names = verifyNames.value.split(/[\n,,\s]+/).filter(s => s.trim())
|
||||
if (names.length < 8) { ElMessage.error('至少需要8位同学姓名'); return }
|
||||
joining.value = true
|
||||
try {
|
||||
await classApi.joinBySearch({ class_id: currentClass.value.id, student_names: names })
|
||||
ElMessage.success('加入成功')
|
||||
showVerifyDialog.value = false
|
||||
router.push(`/class/detail/${currentClass.value.id}`)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败,请检查同学姓名是否正确')
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function joinByInvite() {
|
||||
if (!inviteCode.value.trim()) { ElMessage.warning('请输入邀请码'); return }
|
||||
joining.value = true
|
||||
try {
|
||||
const res = await classApi.joinByInvite({ code: inviteCode.value })
|
||||
ElMessage.success('加入成功')
|
||||
router.push(`/class/detail/${res.data.class_id}`)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-users"></i>班级列表</div>
|
||||
|
||||
<div class="card">
|
||||
<el-input v-model="keyword" placeholder="搜索班级名称、学校..." clearable style="width:300px;margin-right:12px" @keyup.enter="loadList">
|
||||
<template #prefix><i class="fas fa-search" style="color:#8c8c8c"></i></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="loadList"><i class="fas fa-search" style="margin-right:4px"></i>搜索</el-button>
|
||||
<el-button @click="keyword='';loadList()"><i class="fas fa-redo" style="margin-right:4px"></i>重置</el-button>
|
||||
<el-button type="success" style="float:right" @click="$router.push('/class/edit')">
|
||||
<i class="fas fa-plus" style="margin-right:4px"></i>创建班级
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<el-table :data="list" stripe v-loading="loading">
|
||||
<el-table-column prop="name" label="班级名称" min-width="200" />
|
||||
<el-table-column prop="grade" label="年级" width="120" />
|
||||
<el-table-column prop="class_no" label="班号" width="80" />
|
||||
<el-table-column prop="graduate_year" label="毕业年份" width="100" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', statusMap[row.status]?.cls]">{{ statusMap[row.status]?.text }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="$router.push(`/class/detail/${row.id}`)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="margin-top:16px;text-align:right">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10,20,50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="loadList"
|
||||
@current-change="loadList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { classApi } from '../../../api'
|
||||
|
||||
const keyword = ref('')
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const statusMap = {
|
||||
pending: { text: '待审核', cls: 'warning' },
|
||||
active: { text: '已通过', cls: 'success' },
|
||||
rejected: { text: '被打回', cls: 'error' },
|
||||
deleted: { text: '已删除', cls: 'default' }
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await classApi.search({ keyword: keyword.value, page: page.value, page_size: pageSize.value })
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-home"></i>首页</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="card">
|
||||
<el-input v-model="keyword" placeholder="搜索班级名称、学校名称..." size="large" clearable @keyup.enter="search">
|
||||
<template #prefix><i class="fas fa-search" style="color:#8c8c8c"></i></template>
|
||||
<template #append>
|
||||
<el-button type="primary" @click="search">搜索</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 快速入口 -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px">
|
||||
<el-col :xs="12" :sm="8" :md="6">
|
||||
<div class="stat-card" @click="$router.push('/class/edit')" style="cursor:pointer">
|
||||
<div class="stat-icon blue"><i class="fas fa-plus"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">创建</div>
|
||||
<div class="stat-label">创建我的班级</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="6">
|
||||
<div class="stat-card" @click="$router.push('/class/join')" style="cursor:pointer">
|
||||
<div class="stat-icon green"><i class="fas fa-sign-in-alt"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">加入</div>
|
||||
<div class="stat-label">加入已有班级</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="6">
|
||||
<div class="stat-card" @click="$router.push('/class')" style="cursor:pointer">
|
||||
<div class="stat-icon orange"><i class="fas fa-list"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">浏览</div>
|
||||
<div class="stat-label">浏览所有班级</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="6">
|
||||
<div class="stat-card" @click="$router.push('/profile')" style="cursor:pointer">
|
||||
<div class="stat-icon purple"><i class="fas fa-user"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">我的</div>
|
||||
<div class="stat-label">个人中心</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 地区浏览 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-globe-asia" style="color:#1890ff"></i> 按地区浏览学校</h3>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24" :sm="8" :md="6">
|
||||
<el-select v-model="selectedCountry" placeholder="选择国家" style="width:100%" @change="loadProvinces">
|
||||
<el-option v-for="c in countries" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="6">
|
||||
<el-select v-model="selectedProvince" placeholder="选择省/州" style="width:100%" @change="loadCounties" :disabled="!selectedCountry">
|
||||
<el-option v-for="p in provinces" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="6">
|
||||
<el-select v-model="selectedCounty" placeholder="选择区/县" style="width:100%" @change="loadSchools" :disabled="!selectedProvince">
|
||||
<el-option v-for="c in counties" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div v-if="schools.length > 0" style="margin-top:16px">
|
||||
<el-table :data="schools" stripe>
|
||||
<el-table-column prop="name" label="学校名称" />
|
||||
<el-table-column prop="school_type" label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="tag primary">{{ typeMap[row.school_type] || row.school_type }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="address" label="地址" />
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else-if="selectedCounty" class="empty">
|
||||
<i class="fas fa-school"></i>
|
||||
<p>该地区暂无学校,快去创建第一个吧</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-if="searchResults.length > 0" class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-search" style="color:#1890ff"></i> 搜索结果</h3>
|
||||
<el-table :data="searchResults" stripe>
|
||||
<el-table-column prop="name" label="班级名称" />
|
||||
<el-table-column prop="grade" label="年级" width="120" />
|
||||
<el-table-column prop="graduate_year" label="毕业年份" width="100" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="$router.push(`/class/detail/${row.id}`)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { regionApi, schoolApi, classApi } from '../../api'
|
||||
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const searchResults = ref([])
|
||||
const countries = ref([])
|
||||
const provinces = ref([])
|
||||
const counties = ref([])
|
||||
const schools = ref([])
|
||||
const selectedCountry = ref()
|
||||
const selectedProvince = ref()
|
||||
const selectedCounty = ref()
|
||||
|
||||
const typeMap = {
|
||||
university: '大学', vocational: '职业学校', senior_high: '高中',
|
||||
junior_high: '初中', primary: '小学', kindergarten: '幼儿园', other: '其他'
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!keyword.value.trim()) {
|
||||
ElMessage.warning('请输入搜索关键词')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await classApi.search({ keyword: keyword.value })
|
||||
searchResults.value = res.data.list || []
|
||||
} catch (e) {
|
||||
ElMessage.error('搜索失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCountries() {
|
||||
const res = await regionApi.countries()
|
||||
countries.value = res.data || []
|
||||
}
|
||||
|
||||
async function loadProvinces() {
|
||||
selectedProvince.value = null
|
||||
selectedCounty.value = null
|
||||
counties.value = []
|
||||
schools.value = []
|
||||
const res = await regionApi.list(selectedCountry.value)
|
||||
provinces.value = res.data || []
|
||||
}
|
||||
|
||||
async function loadCounties() {
|
||||
selectedCounty.value = null
|
||||
schools.value = []
|
||||
const res = await regionApi.list(selectedProvince.value)
|
||||
counties.value = res.data || []
|
||||
}
|
||||
|
||||
async function loadSchools() {
|
||||
const res = await schoolApi.list({ region_id: selectedCounty.value })
|
||||
schools.value = res.data || []
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCountries()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">日志详情</div>
|
||||
<div class="empty">日志详情功能开发中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">日志管理 - 操作日志列表</div>
|
||||
<div class="empty">日志管理功能开发中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">角色详情</div>
|
||||
<div class="empty">角色详情功能开发中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">角色设置 - 编辑角色</div>
|
||||
<div class="empty">角色编辑功能开发中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">角色设置 - 角色列表</div>
|
||||
<div class="empty">角色管理功能开发中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-user-cog"></i>个人中心</div>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<!-- 左侧:基本信息 -->
|
||||
<el-col :xs="24" :md="8">
|
||||
<div class="card" style="text-align:center">
|
||||
<div style="width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,#1890ff,#40a9ff);color:#fff;display:flex;align-items:center;justify-content:center;font-size:32px;font-weight:700;margin:0 auto 12px">
|
||||
{{ (user?.nickname || user?.username || 'U').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<h3 style="margin-bottom:4px">{{ user?.nickname || user?.username }}</h3>
|
||||
<p style="color:#8c8c8c;font-size:13px;margin-bottom:16px">@{{ user?.username }}</p>
|
||||
<el-tag v-if="user?.role === 'admin'" type="primary" size="large">平台管理员</el-tag>
|
||||
<el-tag v-else type="success" size="large">普通用户</el-tag>
|
||||
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #f0f0f0;text-align:left">
|
||||
<div style="margin-bottom:8px;color:#595959"><i class="fas fa-envelope" style="color:#1890ff;width:20px"></i>{{ user?.email || '未设置邮箱' }}</div>
|
||||
<div style="color:#595959"><i class="fas fa-clock" style="color:#1890ff;width:20px"></i>注册于 {{ formatDate(user?.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧:编辑区 -->
|
||||
<el-col :xs="24" :md="16">
|
||||
<el-tabs v-model="activeTab" stretch>
|
||||
<!-- 基本资料 -->
|
||||
<el-tab-pane label="基本资料" name="profile">
|
||||
<div class="card">
|
||||
<el-form :model="profileForm" label-width="100px">
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="profileForm.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="profileForm.email" placeholder="请输入邮箱(用于找回密码)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="个人简介">
|
||||
<el-input v-model="profileForm.bio" type="textarea" :rows="4" placeholder="介绍一下自己吧" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveProfile"><i class="fas fa-save" style="margin-right:4px"></i>保存资料</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 学习履历 -->
|
||||
<el-tab-pane label="学习履历" name="education">
|
||||
<div class="card">
|
||||
<div style="margin-bottom:16px">
|
||||
<el-button type="primary" @click="showEduDialog = true"><i class="fas fa-plus" style="margin-right:4px"></i>添加履历</el-button>
|
||||
<el-tag type="info" style="margin-left:12px">有学习履历才能创建班级</el-tag>
|
||||
</div>
|
||||
<el-timeline v-if="educationList.length > 0">
|
||||
<el-timeline-item v-for="edu in educationList" :key="edu.id" :timestamp="`${edu.start_year} - ${edu.end_year || '至今'}`" placement="top">
|
||||
<el-card shadow="never">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
||||
<div>
|
||||
<h4 style="margin-bottom:4px">{{ edu.school_name }}</h4>
|
||||
<p style="color:#595959;font-size:13px;margin-bottom:4px">
|
||||
{{ edu.college_name || '' }} {{ edu.grade || '' }} {{ edu.class_no || '' }}
|
||||
</p>
|
||||
<p style="color:#8c8c8c;font-size:12px">{{ edu.major || '' }}</p>
|
||||
</div>
|
||||
<el-button type="danger" link @click="deleteEdu(edu)"><i class="fas fa-trash"></i></el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<div v-else class="empty">
|
||||
<i class="fas fa-graduation-cap"></i>
|
||||
<p>暂无学习履历,添加后才能创建班级</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<el-tab-pane label="联系方式" name="contacts">
|
||||
<div class="card">
|
||||
<div style="margin-bottom:16px">
|
||||
<el-button type="primary" @click="showContactDialog = true"><i class="fas fa-plus" style="margin-right:4px"></i>添加联系方式</el-button>
|
||||
</div>
|
||||
<el-table :data="contacts" stripe>
|
||||
<el-table-column label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<i :class="contactIcon(row.type)" style="color:#1890ff;margin-right:6px"></i>{{ contactTypeMap[row.type] }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="value" label="内容" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="deleteContact(row)"><i class="fas fa-trash"></i></el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-alert type="info" :closable="false" style="margin-top:16px">
|
||||
联系方式上限:邮箱2个、QQ5个、微信2个、手机号5个、住址1个、工作单位1个
|
||||
</el-alert>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 我的班级 -->
|
||||
<el-tab-pane label="我的班级" name="classes">
|
||||
<div class="card">
|
||||
<el-table :data="myClasses" stripe>
|
||||
<el-table-column prop="name" label="班级名称" min-width="200" />
|
||||
<el-table-column label="我的角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'primary' : 'success'" size="small">
|
||||
{{ row.role === 'admin' ? '管理员' : '成员' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="$router.push(`/class/detail/${row.class_id}`)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 添加履历弹窗 -->
|
||||
<el-dialog v-model="showEduDialog" title="添加学习履历" width="500px">
|
||||
<el-form :model="eduForm" label-width="100px">
|
||||
<el-form-item label="学校名称" required><el-input v-model="eduForm.school_name" /></el-form-item>
|
||||
<el-form-item label="学院/系"><el-input v-model="eduForm.college_name" /></el-form-item>
|
||||
<el-form-item label="专业"><el-input v-model="eduForm.major" /></el-form-item>
|
||||
<el-form-item label="年级"><el-input v-model="eduForm.grade" /></el-form-item>
|
||||
<el-form-item label="班级"><el-input v-model="eduForm.class_no" /></el-form-item>
|
||||
<el-form-item label="入学年份"><el-input-number v-model="eduForm.start_year" :min="1950" :max="2100" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="毕业年份"><el-input-number v-model="eduForm.end_year" :min="1950" :max="2100" style="width:100%" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEduDialog=false">取消</el-button>
|
||||
<el-button type="primary" @click="addEdu">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加联系方式弹窗 -->
|
||||
<el-dialog v-model="showContactDialog" title="添加联系方式" width="400px">
|
||||
<el-form :model="contactForm" label-width="80px">
|
||||
<el-form-item label="类型" required>
|
||||
<el-select v-model="contactForm.type" style="width:100%">
|
||||
<el-option label="邮箱" value="email" />
|
||||
<el-option label="QQ" value="qq" />
|
||||
<el-option label="微信" value="wechat" />
|
||||
<el-option label="手机号" value="phone" />
|
||||
<el-option label="家庭住址" value="address" />
|
||||
<el-option label="工作单位" value="workunit" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" required><el-input v-model="contactForm.value" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showContactDialog=false">取消</el-button>
|
||||
<el-button type="primary" @click="addContact">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { userApi, classApi } from '../../../../api'
|
||||
import { useAuth } from '../../../../composables/useAuth'
|
||||
|
||||
const { user, setAuth } = useAuth()
|
||||
const activeTab = ref('profile')
|
||||
const profileForm = reactive({ nickname: '', email: '', bio: '' })
|
||||
const educationList = ref([])
|
||||
const contacts = ref([])
|
||||
const myClasses = ref([])
|
||||
const showEduDialog = ref(false)
|
||||
const showContactDialog = ref(false)
|
||||
const eduForm = reactive({ school_name: '', college_name: '', major: '', grade: '', class_no: '', start_year: 2015, end_year: 2018 })
|
||||
const contactForm = reactive({ type: 'email', value: '' })
|
||||
|
||||
const contactTypeMap = { email: '邮箱', qq: 'QQ', wechat: '微信', phone: '手机号', address: '家庭住址', workunit: '工作单位' }
|
||||
function contactIcon(type) {
|
||||
const map = { email: 'fas fa-envelope', phone: 'fas fa-phone', qq: 'fab fa-qq', wechat: 'fab fa-weixin', address: 'fas fa-map-marker-alt', workunit: 'fas fa-building' }
|
||||
return map[type] || 'fas fa-info-circle'
|
||||
}
|
||||
function formatDate(d) {
|
||||
if (!d) return ''
|
||||
return d.substring(0, 10)
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
const res = await userApi.profile()
|
||||
Object.assign(profileForm, res.data)
|
||||
}
|
||||
async function loadEducation() {
|
||||
const res = await userApi.listEducation()
|
||||
educationList.value = res.data || []
|
||||
}
|
||||
async function loadContacts() {
|
||||
const res = await userApi.listContacts()
|
||||
contacts.value = res.data || []
|
||||
}
|
||||
async function loadMyClasses() {
|
||||
try {
|
||||
const res = await classApi.mine()
|
||||
myClasses.value = res.data || []
|
||||
} catch (e) { myClasses.value = [] }
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
try {
|
||||
const res = await userApi.updateProfile(profileForm)
|
||||
setAuth(user.value.token, res.data)
|
||||
ElMessage.success('资料已保存')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
}
|
||||
}
|
||||
async function addEdu() {
|
||||
if (!eduForm.school_name) { ElMessage.warning('请输入学校名称'); return }
|
||||
try {
|
||||
await userApi.addEducation(eduForm)
|
||||
ElMessage.success('已添加')
|
||||
showEduDialog.value = false
|
||||
loadEducation()
|
||||
} catch (e) { ElMessage.error(e.message || '添加失败') }
|
||||
}
|
||||
async function deleteEdu(edu) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除这条履历吗?', '提示', { type: 'warning' })
|
||||
await userApi.deleteEducation(edu.id)
|
||||
ElMessage.success('已删除')
|
||||
loadEducation()
|
||||
} catch (e) {}
|
||||
}
|
||||
async function addContact() {
|
||||
if (!contactForm.value) { ElMessage.warning('请输入内容'); return }
|
||||
try {
|
||||
await userApi.addContact(contactForm)
|
||||
ElMessage.success('已添加')
|
||||
showContactDialog.value = false
|
||||
loadContacts()
|
||||
} catch (e) { ElMessage.error(e.message || '添加失败') }
|
||||
}
|
||||
async function deleteContact(c) {
|
||||
try {
|
||||
await userApi.deleteContact(c.id)
|
||||
ElMessage.success('已删除')
|
||||
loadContacts()
|
||||
} catch (e) { ElMessage.error(e.message || '删除失败') }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile()
|
||||
loadEducation()
|
||||
loadContacts()
|
||||
loadMyClasses()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">用户设置 - 编辑资料</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>昵称</label>
|
||||
<input v-model="form.nickname" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>个人简介</label>
|
||||
<textarea v-model="form.bio" rows="3"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="save">保存</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { userApi } from '../../../../api'
|
||||
|
||||
const form = ref({ nickname: '', bio: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
const profile = await userApi.profile()
|
||||
form.value.nickname = profile.user.nickname
|
||||
form.value.bio = profile.user.bio
|
||||
})
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await userApi.updateProfile(form.value)
|
||||
alert('保存成功')
|
||||
} catch (e) {
|
||||
alert('保存失败:' + e.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-title">个人资料</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input :value="profile?.user.username" disabled style="background:#f5f7fa" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>昵称</label>
|
||||
<input v-model="editForm.nickname" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>个人简介</label>
|
||||
<textarea v-model="editForm.bio" rows="2" placeholder="一句话介绍自己"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="saveProfile" :disabled="saving">{{ saving?'保存中...':'保存资料' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<div class="card">
|
||||
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span>联系方式</span>
|
||||
<button class="btn btn-primary btn-sm" @click="showAddContact=true">添加联系方式</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-bottom:12px">
|
||||
邮箱最多2个、QQ最多5个、微信最多2个、手机号最多5个、家庭住址和工作单位各1个。同班同学可互相查看。
|
||||
</div>
|
||||
<table v-if="profile?.contacts.length">
|
||||
<thead><tr><th>类型</th><th>内容</th><th>主要</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="c in profile.contacts" :key="c.id">
|
||||
<td>{{ contactTypeText(c.type) }}</td>
|
||||
<td>{{ c.value }}</td>
|
||||
<td><span v-if="c.is_primary" class="tag tag-primary">主要</span></td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteContact(c.id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">暂无联系方式</div>
|
||||
</div>
|
||||
|
||||
<!-- 学习履历 -->
|
||||
<div class="card">
|
||||
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span>学习履历</span>
|
||||
<button class="btn btn-primary btn-sm" @click="openEduForm()">添加履历</button>
|
||||
</div>
|
||||
<div class="alert alert-info" v-if="profile?.education.length===0">
|
||||
还没有学习履历。<strong>有学习履历才能创建班级</strong>,请先添加。
|
||||
</div>
|
||||
<div v-for="e in profile?.education" :key="e.id" style="border:1px solid #ebeef5;border-radius:6px;padding:12px;margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<div>
|
||||
<strong>{{ e.school_name }}</strong>
|
||||
<span v-if="e.college_name" style="color:#606266"> · {{ e.college_name }}</span>
|
||||
<span v-if="e.major" style="color:#606266"> · {{ e.major }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-default btn-sm" @click="openEduForm(e)" style="margin-right:4px">编辑</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteEdu(e.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:13px;color:#909399;margin-top:4px">
|
||||
{{ e.start_year }} - {{ e.end_year || '至今' }}
|
||||
<span v-if="e.degree" style="margin-left:8px">{{ e.degree }}</span>
|
||||
</div>
|
||||
<div v-if="e.description" style="font-size:13px;color:#606266;margin-top:4px">{{ e.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的班级 -->
|
||||
<div class="card">
|
||||
<div class="card-title">我的班级</div>
|
||||
<div v-if="myClasses.length===0" class="empty">
|
||||
还没有加入任何班级,<router-link to="/class/edit">创建一个</router-link> 或 <router-link to="/class/join">加入一个</router-link>
|
||||
</div>
|
||||
<table v-else>
|
||||
<thead><tr><th>班级名称</th><th>毕业年份</th><th>我的角色</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="c in myClasses" :key="c.class.id">
|
||||
<td>{{ c.class.name }}</td>
|
||||
<td>{{ c.class.graduate_year }}</td>
|
||||
<td><span class="tag" :class="c.my_role==='admin'?'tag-warning':'tag-info'">{{ c.my_role==='admin'?'管理员':'成员' }}</span></td>
|
||||
<td><span class="tag" :class="statusTag(c.class.status)">{{ statusText(c.class.status) }}</span></td>
|
||||
<td><button class="btn btn-primary btn-sm" @click="$router.push('/class/detail/'+c.class.id)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 添加联系方式弹窗 -->
|
||||
<div v-if="showAddContact" class="card" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:400px;z-index:200;box-shadow:0 4px 20px rgba(0,0,0,0.2)">
|
||||
<div class="card-title">添加联系方式</div>
|
||||
<div class="form-group">
|
||||
<label>类型</label>
|
||||
<select v-model="contactForm.type">
|
||||
<option value="email">邮箱</option>
|
||||
<option value="qq">QQ</option>
|
||||
<option value="wechat">微信</option>
|
||||
<option value="phone">手机号</option>
|
||||
<option value="address">家庭住址</option>
|
||||
<option value="workunit">工作单位</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>内容</label>
|
||||
<input v-model="contactForm.value" placeholder="请输入" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" v-model="contactForm.is_primary" style="width:auto;margin-right:4px" />设为主要联系方式</label>
|
||||
</div>
|
||||
<div v-if="contactError" class="alert alert-danger">{{ contactError }}</div>
|
||||
<div style="text-align:right">
|
||||
<button class="btn btn-default" @click="showAddContact=false" style="margin-right:8px">取消</button>
|
||||
<button class="btn btn-primary" @click="addContact">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showAddContact" @click="showAddContact=false" style="position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:199"></div>
|
||||
|
||||
<!-- 履历编辑弹窗 -->
|
||||
<div v-if="showEduForm" class="card" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:500px;max-height:85vh;overflow-y:auto;z-index:200;box-shadow:0 4px 20px rgba(0,0,0,0.2)">
|
||||
<div class="card-title">{{ eduForm.id?'编辑':'添加' }}学习履历</div>
|
||||
<div class="form-group">
|
||||
<label>学校名称 <span style="color:#f56c6c">*</span></label>
|
||||
<input v-model="eduForm.school_name" placeholder="如:连云港市第一中学" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>学院/系</label>
|
||||
<input v-model="eduForm.college_name" placeholder="如:计算机学院" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>专业</label>
|
||||
<input v-model="eduForm.major" placeholder="如:计算机科学与技术" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>入学年份</label>
|
||||
<input type="number" v-model.number="eduForm.start_year" placeholder="2018" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>毕业年份</label>
|
||||
<input type="number" v-model.number="eduForm.end_year" placeholder="2022" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>学历</label>
|
||||
<select v-model="eduForm.degree">
|
||||
<option value="">请选择</option>
|
||||
<option value="幼儿园">幼儿园</option>
|
||||
<option value="小学">小学</option>
|
||||
<option value="初中">初中</option>
|
||||
<option value="高中">高中</option>
|
||||
<option value="专科">专科</option>
|
||||
<option value="本科">本科</option>
|
||||
<option value="硕士">硕士</option>
|
||||
<option value="博士">博士</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>备注</label>
|
||||
<textarea v-model="eduForm.description" rows="2"></textarea>
|
||||
</div>
|
||||
<div v-if="eduError" class="alert alert-danger">{{ eduError }}</div>
|
||||
<div style="text-align:right">
|
||||
<button class="btn btn-default" @click="showEduForm=false" style="margin-right:8px">取消</button>
|
||||
<button class="btn btn-primary" @click="saveEdu">{{ eduForm.id?'保存':'添加' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showEduForm" @click="showEduForm=false" style="position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:199"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { userApi, classApi } from '../../../../api'
|
||||
|
||||
const profile = ref(null)
|
||||
const myClasses = ref([])
|
||||
const saving = ref(false)
|
||||
|
||||
const editForm = ref({ nickname: '', bio: '' })
|
||||
|
||||
// 联系方式
|
||||
const showAddContact = ref(false)
|
||||
const contactForm = ref({ type: 'email', value: '', is_primary: false })
|
||||
const contactError = ref('')
|
||||
|
||||
// 履历
|
||||
const showEduForm = ref(false)
|
||||
const eduForm = ref({ id: 0, school_name: '', college_name: '', major: '', degree: '', start_year: 0, end_year: 0, description: '' })
|
||||
const eduError = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile()
|
||||
loadMyClasses()
|
||||
})
|
||||
|
||||
async function loadProfile() {
|
||||
profile.value = await userApi.profile()
|
||||
editForm.value.nickname = profile.value.user.nickname
|
||||
editForm.value.bio = profile.value.user.bio
|
||||
}
|
||||
|
||||
async function loadMyClasses() {
|
||||
myClasses.value = await classApi.mine()
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
saving.value = true
|
||||
try {
|
||||
await userApi.updateProfile(editForm.value)
|
||||
alert('保存成功')
|
||||
loadProfile()
|
||||
} catch (e) {
|
||||
alert('保存失败:' + e.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addContact() {
|
||||
contactError.value = ''
|
||||
if (!contactForm.value.value) {
|
||||
contactError.value = '请输入内容'
|
||||
return
|
||||
}
|
||||
try {
|
||||
await userApi.addContact(contactForm.value)
|
||||
showAddContact.value = false
|
||||
contactForm.value = { type: 'email', value: '', is_primary: false }
|
||||
loadProfile()
|
||||
} catch (e) {
|
||||
contactError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact(id) {
|
||||
if (!confirm('确定删除?')) return
|
||||
try {
|
||||
await userApi.deleteContact(id)
|
||||
loadProfile()
|
||||
} catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
function openEduForm(e) {
|
||||
if (e) {
|
||||
eduForm.value = { ...e }
|
||||
} else {
|
||||
eduForm.value = { id: 0, school_name: '', college_name: '', major: '', degree: '', start_year: 0, end_year: 0, description: '' }
|
||||
}
|
||||
eduError.value = ''
|
||||
showEduForm.value = true
|
||||
}
|
||||
|
||||
async function saveEdu() {
|
||||
eduError.value = ''
|
||||
if (!eduForm.value.school_name) {
|
||||
eduError.value = '请输入学校名称'
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (eduForm.value.id) {
|
||||
await userApi.updateEducation(eduForm.value.id, eduForm.value)
|
||||
} else {
|
||||
await userApi.addEducation(eduForm.value)
|
||||
}
|
||||
showEduForm.value = false
|
||||
loadProfile()
|
||||
} catch (e) {
|
||||
eduError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEdu(id) {
|
||||
if (!confirm('确定删除这条履历?')) return
|
||||
try {
|
||||
await userApi.deleteEducation(id)
|
||||
loadProfile()
|
||||
} catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
function contactTypeText(t) {
|
||||
const map = { email: '邮箱', qq: 'QQ', wechat: '微信', phone: '手机号', address: '家庭住址', workunit: '工作单位' }
|
||||
return map[t] || t
|
||||
}
|
||||
function statusText(s) {
|
||||
const map = { pending: '待审核', active: '正常', rejected: '被打回', deleted: '已删除' }
|
||||
return map[s] || s
|
||||
}
|
||||
function statusTag(s) {
|
||||
const map = { pending: 'tag-warning', active: 'tag-success', rejected: 'tag-danger', deleted: 'tag-info' }
|
||||
return map[s] || 'tag-info'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 8011,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8010',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:8010',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user