This commit is contained in:
2026-09-03 17:55:42 +08:00
parent 7c38e84e30
commit c80a3d4a2d
35 changed files with 6795 additions and 597 deletions
+13 -6
View File
@@ -9,14 +9,14 @@
<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 v-if="menu.children && menu.children.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"
<div v-for="child in menu.children" :key="child.id"
class="menu-item" :class="{ active: $route.path === child.path }"
@click="navigate(child.path)">
<i :class="child.icon || 'fas fa-circle'"></i>
@@ -43,8 +43,8 @@
<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>
<div class="avatar">{{ (user.real_name || user.nickname || user.username || 'U').charAt(0).toUpperCase() }}</div>
<span class="username">{{ user.real_name || 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>
@@ -91,9 +91,16 @@ onMounted(() => {
})
})
// 过滤可见菜单
// 过滤可见菜单(排除包含动态路径参数如 :id 的页面)
const isNavMenu = (m) => !!m && m.visible && (!m.path || !m.path.includes(':'))
const visibleMenus = computed(() => {
return menus.value.filter(m => m.visible)
return (menus.value || [])
.filter(isNavMenu)
.map(m => ({
...m,
children: (m.children || []).filter(isNavMenu)
}))
})
const currentTitle = computed(() => {
+34 -1
View File
@@ -55,9 +55,11 @@ export const userApi = {
profile: () => http.get('/user/profile'),
updateProfile: data => http.put('/user/profile', data),
hasEducation: () => http.get('/user/has-education'),
listContacts: () => http.get('/user/profile').then(r => ({ data: r.data?.contacts || [] })),
addContact: data => http.post('/user/contacts', data),
updateContact: (id, data) => http.put(`/user/contacts/${id}`, data),
deleteContact: id => http.delete(`/user/contacts/${id}`),
listEducation: () => http.get('/user/profile').then(r => ({ data: r.data?.education || [] })),
addEducation: data => http.post('/user/education', data),
updateEducation: (id, data) => http.put(`/user/education/${id}`, data),
deleteEducation: id => http.delete(`/user/education/${id}`),
@@ -66,7 +68,7 @@ export const userApi = {
// ============ 地区 ============
export const regionApi = {
list: parentId => http.get('/regions', { params: { parent_id: parentId || 0 } }),
list: (parentId, params = {}) => http.get('/regions', { params: { parent_id: parentId || 0, ...params } }),
countries: () => http.get('/regions/countries'),
search: keyword => http.get('/regions/search', { params: { keyword } }),
path: id => http.get(`/regions/${id}/path`)
@@ -109,6 +111,7 @@ export const classApi = {
// ============ 平台管理员 ============
export const adminApi = {
stats: () => http.get('/admin/stats'),
listUsers: params => http.get('/admin/users', { params }),
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),
@@ -181,3 +184,33 @@ export const sensitiveApi = {
delete: id => http.delete(`/admin/sensitive-words/${id}`),
toggle: (id, enabled) => http.put(`/admin/sensitive-words/${id}/toggle`, { enabled })
}
// ============ 基础数据管理(管理员:地区与学校) ============
export const adminBaseDataApi = {
// 地区接口
getRegionTree: parentId => http.get('/admin/regions/tree', { params: { parent_id: parentId || 0 } }),
getRegionList: parentId => http.get('/regions', { params: { parent_id: parentId || 0 } }),
searchRegions: keyword => http.get('/regions/search', { params: { keyword } }),
getRegionPath: id => http.get(`/regions/${id}/path`),
createRegion: data => http.post('/admin/regions', data),
updateRegion: (id, data) => http.put(`/admin/regions/${id}`, data),
deleteRegion: id => http.delete(`/admin/regions/${id}`),
initChinaRegions: () => http.post('/admin/regions/init-china'),
// 学校接口
getSchoolStats: () => http.get('/admin/schools/stats'),
getSchools: params => http.get('/admin/schools', { params }),
getSchool: id => http.get(`/schools/${id}`),
createSchool: data => http.post('/admin/schools', data),
updateSchool: (id, data) => http.put(`/admin/schools/${id}`, data),
deleteSchool: id => http.delete(`/admin/schools/${id}`),
approveSchool: id => http.post(`/admin/schools/${id}/approve`),
rejectSchool: (id, reason) => http.post(`/admin/schools/${id}/reject`, { reason }),
// 学院接口
getColleges: schoolId => http.get(`/schools/${schoolId}/colleges`),
createCollege: (schoolId, name) => http.post(`/admin/schools/${schoolId}/colleges`, { name }),
updateCollege: (collegeId, name) => http.put(`/admin/colleges/${collegeId}`, { name }),
deleteCollege: collegeId => http.delete(`/admin/colleges/${collegeId}`)
}
+168 -14
View File
@@ -31,22 +31,41 @@
<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>
<i class="fas fa-user-friends" style="color:#1890ff"></i> 毕业照站位与人员名单 ({{ totalPeople }}人)
<el-tag v-if="totalPeople < 8" type="danger" style="margin-left:8px">不足8人,需打回补齐</el-tag>
<span v-if="detail?.row_count > 1" style="font-size:12px;color:#8c8c8c;font-weight:normal;margin-left:8px">
共 {{ detail.row_count }} 排,从前至后、从左往右
</span>
</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 v-if="rosterRows.length > 0" class="audit-roster-rows">
<div v-for="row in rosterRows" :key="row.rowNo" class="audit-roster-row">
<div class="audit-roster-row-header">
<span class="audit-row-num">第 {{ row.rowNo }} 排</span>
<span class="audit-row-desc">{{ row.rowNo === 1 ? '前排就坐 / 从左往右' : '从左往右' }}</span>
<span class="audit-row-count">({{ row.items.length }}人)</span>
</div>
<div class="audit-roster-tags">
<span
v-for="person in row.items"
:key="person.id || person.name"
:class="['roster-tag-item', person.role === 'teacher' ? 'is-teacher' : 'is-student']"
>
<span class="p-pos">#{{ person.col_no }}</span>
<span class="p-name">{{ person.name }}</span>
<span v-if="person.role === 'teacher'" class="p-role">老师</span>
<span v-if="person.is_claimed" class="p-claim claimed">已绑定</span>
<span v-else class="p-claim unclaimed">待认领</span>
</span>
</div>
</div>
</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 v-else 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>
@@ -93,7 +112,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { classApi, adminApi } from '../../../../api'
@@ -105,6 +124,29 @@ const loading = ref(false)
const showReject = ref(false)
const rejectReason = ref('')
const rosterRows = computed(() => {
const list = detail.value?.roster || []
if (!list.length) return []
const groupMap = {}
list.forEach(item => {
const r = item.row_no || 1
if (!groupMap[r]) groupMap[r] = []
groupMap[r].push(item)
})
return Object.keys(groupMap)
.map(Number)
.sort((a, b) => a - b)
.map(r => ({
rowNo: r,
items: groupMap[r].sort((a, b) => (a.col_no || 0) - (b.col_no || 0))
}))
})
const totalPeople = computed(() => {
if (detail.value?.roster?.length) return detail.value.roster.length
return (detail.value?.student_names?.length || 0) + (detail.value?.teacher_names?.length || 0)
})
const statusMap = {
pending: { text: '待审核', cls: 'warning' },
active: { text: '已通过', cls: 'success' },
@@ -114,9 +156,15 @@ const statusMap = {
const actionMap = { approve: '通过', reject: '打回', manual_delete: '删除', resubmit: '重新提交', create: '创建' }
async function loadDetail() {
const auditId = route.params.id
if (!auditId || auditId === ':id' || isNaN(Number(auditId))) {
ElMessage.error('无效的审核记录 ID')
router.replace('/admin/audit')
return
}
loading.value = true
try {
const res = await classApi.detail(route.params.id)
const res = await classApi.detail(auditId)
detail.value = res.data
} catch (e) {
ElMessage.error('加载失败')
@@ -160,3 +208,109 @@ onMounted(() => {
loadLogs()
})
</script>
<style scoped>
.audit-roster-rows {
display: flex;
flex-direction: column;
gap: 12px;
}
.audit-roster-row {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 10px 14px;
}
.audit-roster-row-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.audit-row-num {
background: #1890ff;
color: #fff;
font-size: 12px;
font-weight: 700;
padding: 1px 8px;
border-radius: 10px;
}
.audit-row-desc {
font-size: 12px;
color: #8c8c8c;
}
.audit-row-count {
font-size: 12px;
color: #595959;
}
.audit-roster-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.roster-tag-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 4px;
font-size: 13px;
border: 1px solid transparent;
}
.roster-tag-item.is-student {
background: #e6f7ff;
border-color: #bae7ff;
color: #0050b3;
}
.roster-tag-item.is-teacher {
background: #fff7e6;
border-color: #ffd591;
color: #d46b08;
}
.p-pos {
font-size: 11px;
font-weight: 700;
opacity: 0.65;
}
.p-name {
font-weight: 500;
}
.p-role {
font-size: 10px;
background: rgba(212, 107, 8, 0.15);
color: #d46b08;
padding: 0 4px;
border-radius: 2px;
}
.p-claim {
font-size: 10px;
padding: 1px 5px;
border-radius: 2px;
margin-left: 2px;
}
.p-claim.claimed {
background: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
.p-claim.unclaimed {
background: #f5f5f5;
color: #8c8c8c;
border: 1px solid #d9d9d9;
}
</style>
File diff suppressed because it is too large Load Diff
+633 -79
View File
@@ -1,78 +1,258 @@
<template>
<div>
<div class="menu-admin-container">
<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 class="title-left">
<i class="fas fa-sitemap"></i> 菜单与动态路由管理
</div>
<div class="title-desc">
数据库存储的树形菜单与权限路由,登录后根据角色动态挂载前端路由
</div>
</div>
<div class="card">
<el-alert type="info" :closable="false" style="margin-bottom:16px">
菜单数据存储在数据库中,登录后根据用户角色动态加载路由。修改菜单后需重新登录生效。
<el-alert type="info" :closable="false" style="margin-bottom: 16px" show-icon>
菜单数据以树形层级结构组织。修改菜单、路由或权限配置后,需重新登录或刷新页面以重新加载动态路由。
</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">
<!-- 操作工具条 -->
<div class="menu-action-bar">
<div class="action-left">
<el-button type="primary" @click="showAdd(null)">
<i class="fas fa-plus" style="margin-right: 6px"></i> 新增顶级菜单
</el-button>
<el-button @click="toggleExpandAll">
<i :class="isAllExpanded ? 'fas fa-compress-alt' : 'fas fa-expand-alt'" style="margin-right: 6px"></i>
{{ isAllExpanded ? '全部折叠' : '全部展开' }}
</el-button>
<el-button @click="load">
<i class="fas fa-sync-alt" style="margin-right: 6px"></i> 刷新
</el-button>
</div>
<div class="action-right">
<el-input
v-model="searchKeyword"
placeholder="搜索菜单标题 / 路由标识 / 路径"
clearable
style="width: 280px"
>
<template #prefix>
<i class="fas fa-search" style="color: #8c8c8c"></i>
</template>
</el-input>
</div>
</div>
<!-- 树形表格 -->
<el-table
:key="tableKey"
:data="filteredTreeData"
row-key="id"
:tree-props="{ children: 'children' }"
:default-expand-all="isAllExpanded"
v-loading="loading"
stripe
style="width: 100%"
>
<el-table-column prop="title" label="菜单标题" min-width="220">
<template #default="{ row }">
<code style="font-size:12px;color:#1890ff">{{ row.component || '-' }}</code>
<span class="menu-title-cell">
<i v-if="row.icon" :class="row.icon" class="menu-icon"></i>
<i v-else class="fas fa-file-alt menu-icon default-icon"></i>
<span class="menu-title-text">{{ row.title }}</span>
<el-tag
v-if="!row.component && (!row.children || row.children.length === 0)"
size="small"
type="info"
effect="plain"
style="margin-left: 6px; transform: scale(0.85)"
>
空目录
</el-tag>
<el-tag
v-else-if="!row.component"
size="small"
type="primary"
effect="plain"
style="margin-left: 6px; transform: scale(0.85)"
>
目录
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column prop="icon" label="图标" width="100">
<el-table-column prop="name" label="路由标识 (name)" min-width="150">
<template #default="{ row }">
<i :class="row.icon" style="color:#1890ff"></i>
<code class="code-badge-name">{{ row.name }}</code>
</template>
</el-table-column>
<el-table-column label="权限" width="120">
<el-table-column prop="path" label="路由路径 (path)" min-width="160">
<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>
<code class="code-badge">{{ row.path }}</code>
</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column label="显示" width="80">
<el-table-column prop="component" label="组件路径 (component)" min-width="180">
<template #default="{ row }">
<el-switch v-model="row.visible" @change="toggleVisible(row)" />
<span v-if="row.component" class="comp-text">{{ row.component }}</span>
<span v-else-if="row.redirect" class="redirect-text">
<i class="fas fa-external-link-alt" style="margin-right: 4px; font-size: 11px"></i>{{ row.redirect }}
</span>
<span v-else class="empty-text">目录节点</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<el-table-column label="权限配置" width="130" align="center">
<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>
<el-tag v-if="row.require_admin" type="danger" size="small" effect="dark">
<i class="fas fa-shield-alt" style="margin-right: 3px"></i>管理员
</el-tag>
<el-tag v-else-if="row.require_auth" type="warning" size="small">
<i class="fas fa-lock" style="margin-right: 3px"></i>需登录
</el-tag>
<el-tag v-else type="success" size="small">
<i class="fas fa-globe" style="margin-right: 3px"></i>公开
</el-tag>
</template>
</el-table-column>
<el-table-column label="排序" width="80" align="center">
<template #default="{ row }">
<span class="sort-number">{{ row.sort ?? row.sort_order ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column label="显示" width="90" align="center">
<template #default="{ row }">
<el-tooltip
v-if="row.path?.includes(':')"
content="动态参数路由(如 :id)不可在导航栏直接显示"
placement="top"
>
<el-switch :model-value="false" disabled />
</el-tooltip>
<el-switch
v-else
v-model="row.visible"
:loading="row._switchLoading"
@change="toggleVisible(row)"
/>
</template>
</el-table-column>
<el-table-column label="缓存" width="75" align="center">
<template #default="{ row }">
<el-tag v-if="row.keep_alive" type="success" size="small" effect="plain">是</el-tag>
<span v-else style="color: #bfbfbf; font-size: 12px">否</span>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showAdd(row)">
<i class="fas fa-plus" style="margin-right: 2px"></i>子菜单
</el-button>
<el-button type="primary" link size="small" @click="edit(row)">
<i class="fas fa-edit" style="margin-right: 2px"></i>编辑
</el-button>
<el-button type="danger" link size="small" @click="del(row)">
<i class="fas fa-trash-alt" style="margin-right: 2px"></i>删除
</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-dialog
v-model="showDialog"
:title="form.id ? '编辑菜单' : '新增菜单'"
width="600px"
destroy-on-close
>
<el-form :model="form" label-width="110px" :rules="formRules" ref="formRef">
<el-form-item label="父级菜单" prop="parent_id">
<el-select v-model="form.parent_id" style="width: 100%">
<el-option label="[顶级菜单] (根目录)" :value="0" />
<el-option
v-for="m in parentSelectOptions"
:key="m.id"
:label="m.indentedTitle"
: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-form-item label="菜单标题" prop="title" required>
<el-input v-model="form.title" placeholder="显示在侧边导航栏的标题,例如:基础数据" />
</el-form-item>
<el-form-item label="路由标识" prop="name" required>
<el-input v-model="form.name" placeholder="唯一英文字符串,例如:AdminBaseData" />
</el-form-item>
<el-form-item label="路由路径" prop="path" required>
<el-input v-model="form.path" placeholder="浏览器访问路径,例如:/admin/basedata" />
</el-form-item>
<el-form-item label="组件路径" prop="component">
<el-input
v-model="form.component"
placeholder="相对 views/ 目录下的组件,例如:admin/basedata/index(目录留空)"
/>
</el-form-item>
<el-form-item label="重定向路径" prop="redirect">
<el-input v-model="form.redirect" placeholder="可选,例如:/admin/dashboard(用于目录点击自动跳转)" />
</el-form-item>
<el-form-item label="图标类名" prop="icon">
<div style="display: flex; gap: 10px; width: 100%; align-items: center">
<el-input v-model="form.icon" placeholder="FontAwesome 类名,例如:fas fa-database" style="flex: 1" />
<div class="icon-preview-box" :title="form.icon">
<i v-if="form.icon" :class="form.icon"></i>
<span v-else style="font-size: 12px; color: #bfbfbf">无</span>
</div>
</div>
</el-form-item>
<el-form-item label="排序权重" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" style="width: 100%" />
</el-form-item>
<el-form-item label="访问权限">
<el-radio-group v-model="authRadio">
<el-radio label="public">公开访问</el-radio>
<el-radio label="user">需要登录</el-radio>
<el-radio label="admin">仅管理员</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-item label="显示状态">
<div style="display: flex; align-items: center; gap: 8px">
<el-switch v-model="form.visible" :disabled="form.path?.includes(':')" />
<span v-if="form.path?.includes(':')" style="font-size: 12px; color: #f5222d">
含动态参数路由(如 :id)不可在侧边栏显示
</span>
<span v-else style="font-size: 12px; color: #8c8c8c">
{{ form.visible ? '在导航栏正常展示' : '隐藏(仅可通过路由路径访问)' }}
</span>
</div>
</el-form-item>
<el-form-item label="页面缓存">
<div style="display: flex; align-items: center; gap: 8px">
<el-switch v-model="form.keep_alive" />
<span style="font-size: 12px; color: #8c8c8c">开启后路由切换将保持页面组件状态</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog=false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
@@ -85,18 +265,179 @@ import { menuApi } from '../../../api'
const treeData = ref([])
const loading = ref(false)
const saving = 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 formRef = ref(null)
const flatMenus = computed(() => {
const result = []
function flatten(list) {
list.forEach(m => {
result.push(m)
if (m.children) flatten(m.children)
const isAllExpanded = ref(true)
const tableKey = ref(1)
const searchKeyword = ref('')
const form = reactive({
id: null,
parent_id: 0,
name: '',
title: '',
path: '',
component: '',
redirect: '',
icon: '',
sort: 0,
require_auth: true,
require_admin: false,
visible: true,
keep_alive: false
})
const authRadio = ref('user')
const formRules = {
title: [{ required: true, message: '请输入菜单标题', trigger: 'blur' }],
name: [{ required: true, message: '请输入路由标识(name)', trigger: 'blur' }],
path: [{ required: true, message: '请输入路由路径(path)', trigger: 'blur' }]
}
// 切换展开/折叠全部
function toggleExpandAll() {
isAllExpanded.value = !isAllExpanded.value
tableKey.value++
}
// 将后台数据规格化为干净的树结构
function normalizeTree(data) {
if (!Array.isArray(data)) return []
// 判断是否已是嵌套树(根节点含有 children 字段)
const hasNestedChildren = data.some(item => Array.isArray(item.children))
if (hasNestedChildren) {
// 深度克隆并清理空 children 数组
function cleanTree(nodes) {
return nodes.map(node => {
const item = {
...node,
sort: node.sort ?? node.sort_order ?? 0
}
if (Array.isArray(node.children) && node.children.length > 0) {
item.children = cleanTree(node.children)
} else {
delete item.children
}
return item
})
}
return cleanTree(data)
}
// 扁平列表转树结构
const map = {}
const roots = []
const list = data.map(item => ({
...item,
sort: item.sort ?? item.sort_order ?? 0,
children: []
}))
list.forEach(item => {
map[item.id] = item
})
list.forEach(item => {
if (item.parent_id && map[item.parent_id]) {
map[item.parent_id].children.push(item)
} else {
roots.push(item)
}
})
function finalize(nodes) {
nodes.sort((a, b) => (a.sort || 0) - (b.sort || 0))
nodes.forEach(node => {
if (node.children && node.children.length > 0) {
finalize(node.children)
} else {
delete node.children
}
})
}
flatten(treeData.value)
finalize(roots)
return roots
}
// 过滤搜索树结构
const filteredTreeData = computed(() => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return treeData.value
function filterNodes(nodes) {
const result = []
nodes.forEach(node => {
const matchSelf =
(node.title && node.title.toLowerCase().includes(kw)) ||
(node.name && node.name.toLowerCase().includes(kw)) ||
(node.path && node.path.toLowerCase().includes(kw)) ||
(node.component && node.component.toLowerCase().includes(kw))
let matchedChildren = []
if (node.children && node.children.length > 0) {
matchedChildren = filterNodes(node.children)
}
if (matchSelf || matchedChildren.length > 0) {
result.push({
...node,
children: matchedChildren.length > 0 ? matchedChildren : undefined
})
}
})
return result
}
return filterNodes(treeData.value)
})
// 生成选择父级菜单时带层级缩进的选项(自动排除当前编辑的节点及其所有子节点以防止自循环)
const parentSelectOptions = computed(() => {
const result = []
function getDescendantIds(nodes, targetId) {
const ids = new Set([targetId])
function traverse(list) {
list.forEach(n => {
if (ids.has(n.parent_id)) {
ids.add(n.id)
}
if (n.children) traverse(n.children)
})
}
// 遍历多次直到无新增
let prevSize = 0
while (ids.size !== prevSize) {
prevSize = ids.size
traverse(nodes)
}
return ids
}
const excludedIds = form.id ? getDescendantIds(treeData.value, form.id) : new Set()
function traverse(nodes, depth = 0) {
nodes.forEach(node => {
if (!excludedIds.has(node.id)) {
const indent = ' '.repeat(depth) + (depth > 0 ? '└─ ' : '')
result.push({
id: node.id,
indentedTitle: `${indent}${node.title} (${node.path})`
})
}
if (node.children && node.children.length > 0) {
traverse(node.children, depth + 1)
}
})
}
traverse(treeData.value, 0)
return result
})
@@ -104,58 +445,271 @@ async function load() {
loading.value = true
try {
const res = await menuApi.getAll()
treeData.value = res.data || []
treeData.value = normalizeTree(res.data || [])
} catch (e) {
ElMessage.error('加载失败')
ElMessage.error(e.message || '加载菜单列表失败')
} 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 })
authRadio.value = parent?.require_admin ? 'admin' : parent?.require_auth ? 'user' : 'public'
Object.assign(form, {
id: null,
parent_id: parent ? parent.id : 0,
name: '',
title: '',
path: '',
component: '',
redirect: '',
icon: parent ? '' : 'fas fa-folder',
sort: 0,
require_auth: true,
require_admin: false,
visible: true,
keep_alive: false
})
showDialog.value = true
}
function edit(row) {
Object.assign(form, row)
if (row.require_admin) {
authRadio.value = 'admin'
} else if (row.require_auth) {
authRadio.value = 'user'
} else {
authRadio.value = 'public'
}
Object.assign(form, {
id: row.id,
parent_id: row.parent_id || 0,
name: row.name || '',
title: row.title || '',
path: row.path || '',
component: row.component || '',
redirect: row.redirect || '',
icon: row.icon || '',
sort: row.sort ?? row.sort_order ?? 0,
require_auth: row.require_auth ?? true,
require_admin: row.require_admin ?? false,
visible: row.visible ?? true,
keep_alive: row.keep_alive ?? false
})
showDialog.value = true
}
async function save() {
if (!form.title) { ElMessage.warning('请输入菜单名称'); return }
try {
if (form.id) {
await menuApi.update(form.id, form)
ElMessage.success('已更新')
if (!formRef.value) return
await formRef.value.validate(async valid => {
if (!valid) return
// 同步单选框权限到字段
if (authRadio.value === 'admin') {
form.require_auth = true
form.require_admin = true
} else if (authRadio.value === 'user') {
form.require_auth = true
form.require_admin = false
} else {
await menuApi.create(form)
ElMessage.success('已创建')
form.require_auth = false
form.require_admin = false
}
showDialog.value = false
load()
} catch (e) {
ElMessage.error(e.message || '保存失败')
}
if (form.path?.includes(':')) {
form.visible = false
}
saving.value = true
try {
const payload = {
parent_id: form.parent_id,
name: form.name.trim(),
title: form.title.trim(),
path: form.path.trim(),
component: form.component.trim(),
redirect: form.redirect.trim(),
icon: form.icon.trim(),
sort: form.sort,
require_auth: form.require_auth,
require_admin: form.require_admin,
visible: form.visible,
keep_alive: form.keep_alive
}
if (form.id) {
await menuApi.update(form.id, payload)
ElMessage.success('菜单已更新')
} else {
await menuApi.create(payload)
ElMessage.success('菜单已创建')
}
showDialog.value = false
load()
} catch (e) {
ElMessage.error(e.message || '保存失败')
} finally {
saving.value = false
}
})
}
async function del(row) {
try {
await ElMessageBox.confirm(`确定删除菜单「${row.title}」吗?子菜单也会一并删除!`, '警告', { type: 'danger' })
await ElMessageBox.confirm(
`确定删除菜单「${row.title}」吗?如果该菜单包含下级子菜单,子菜单也将被一并级联删除!`,
'删除确认',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}
)
await menuApi.delete(row.id)
ElMessage.success('已删除')
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('更新失败')
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
onMounted(() => { load() })
async function toggleVisible(row) {
row._switchLoading = true
try {
await menuApi.update(row.id, { visible: row.visible })
ElMessage.success(`已${row.visible ? '开启' : '关闭'}菜单显示`)
} catch (e) {
row.visible = !row.visible
ElMessage.error(e.message || '更新显示状态失败')
} finally {
row._switchLoading = false
}
}
onMounted(() => {
load()
})
</script>
<style scoped>
.menu-admin-container {
padding: 0;
}
.page-title {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 16px;
font-size: 20px;
font-weight: 700;
color: #262626;
}
.title-left i {
color: #1890ff;
margin-right: 6px;
}
.title-desc {
font-size: 13px;
color: #8c8c8c;
font-weight: normal;
}
.card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.menu-action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.action-left {
display: flex;
gap: 10px;
align-items: center;
}
.menu-title-cell {
display: inline-flex;
align-items: center;
}
.menu-icon {
margin-right: 8px;
color: #1890ff;
font-size: 14px;
width: 16px;
text-align: center;
}
.default-icon {
color: #8c8c8c;
}
.menu-title-text {
font-weight: 600;
color: #262626;
}
.code-badge-name {
font-size: 12px;
color: #722ed1;
background: #f9f0ff;
padding: 2px 6px;
border-radius: 4px;
}
.code-badge {
font-size: 12px;
color: #096dd9;
background: #e6f7ff;
padding: 2px 6px;
border-radius: 4px;
}
.comp-text {
font-size: 12px;
color: #595959;
}
.redirect-text {
font-size: 12px;
color: #fa8c16;
}
.empty-text {
color: #bfbfbf;
font-size: 12px;
}
.sort-number {
font-weight: 600;
color: #595959;
}
.icon-preview-box {
width: 32px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
color: #1890ff;
font-size: 16px;
}
</style>
+126 -30
View File
@@ -3,48 +3,76 @@
<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>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:12px">
<el-radio-group v-model="activeTab" @change="load">
<el-radio-button label="all">全部用户</el-radio-button>
<el-radio-button label="active">正常用户</el-radio-button>
<el-radio-button label="banned">封禁用户</el-radio-button>
</el-radio-group>
<div style="display:flex;gap:8px">
<el-input
v-model="keyword"
placeholder="搜索用户名、姓名、昵称、手机号..."
clearable
style="width:280px"
@keyup.enter="load"
>
<template #prefix><i class="fas fa-search" style="color:#8c8c8c"></i></template>
</el-input>
<el-button type="primary" @click="load">搜索</el-button>
</div>
</div>
<el-table :data="list" stripe v-loading="loading">
<el-table-column label="用户" min-width="150">
<el-table-column label="用户" min-width="170">
<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() }}
{{ (row.real_name || row.nickname || row.username || 'U').charAt(0).toUpperCase() }}
</div>
<div>
<div>{{ row.nickname || row.username }}</div>
<div style="font-weight:600">{{ row.real_name || 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">
<el-table-column prop="real_name" label="姓名" width="100">
<template #default="{ row }">{{ row.real_name || '-' }}</template>
</el-table-column>
<el-table-column prop="gender" label="性别" width="70">
<template #default="{ row }">{{ row.gender || '-' }}</template>
</el-table-column>
<el-table-column prop="nation" label="民族" width="90">
<template #default="{ row }">{{ row.nation || '-' }}</template>
</el-table-column>
<el-table-column prop="political_status" label="政治身份" width="110">
<template #default="{ row }">{{ row.political_status || '-' }}</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" width="130">
<template #default="{ row }">{{ row.phone || '-' }}</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.email || '-' }}</template>
</el-table-column>
<el-table-column label="角色" width="90">
<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">
<el-table-column label="状态" width="90">
<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">
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="viewDetail(row)">详情</el-button>
<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>
@@ -52,13 +80,58 @@
</el-table>
</div>
<!-- 用户详情弹窗 -->
<el-dialog v-model="showDetail" title="用户详细资料" width="560px">
<div v-if="currentUser">
<el-descriptions :column="2" border>
<el-descriptions-item label="用户名">{{ currentUser.username }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ currentUser.real_name || '未填写' }}</el-descriptions-item>
<el-descriptions-item label="个性昵称">{{ currentUser.nickname || '未设置' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ currentUser.gender || '未设置' }}</el-descriptions-item>
<el-descriptions-item label="民族">{{ currentUser.nation || '未设置' }}</el-descriptions-item>
<el-descriptions-item label="政治面貌">{{ currentUser.political_status || '未设置' }}</el-descriptions-item>
<el-descriptions-item label="身份证号" :span="2">
<span style="font-family:monospace">{{ currentUser.id_card || '未填写' }}</span>
</el-descriptions-item>
<el-descriptions-item label="手机号码">{{ currentUser.phone || '未填写' }}</el-descriptions-item>
<el-descriptions-item label="电子邮箱">{{ currentUser.email || '未填写' }}</el-descriptions-item>
<el-descriptions-item label="家庭住址" :span="2">{{ currentUser.address || '未填写' }}</el-descriptions-item>
<el-descriptions-item v-if="currentUser.contacts && currentUser.contacts.length" label="其他联系方式" :span="2">
<div style="display:flex;flex-wrap:wrap;gap:8px">
<el-tag v-for="c in currentUser.contacts" :key="c.id || c.value" size="small" type="info">
{{ contactTypeText(c.type) }}: {{ c.value }}{{ c.is_primary ? ' (主)' : '' }}
</el-tag>
</div>
</el-descriptions-item>
<el-descriptions-item label="个人简介" :span="2">{{ currentUser.bio || '无' }}</el-descriptions-item>
<el-descriptions-item label="用户角色">
<el-tag :type="currentUser.role === 'admin' ? 'danger' : 'success'" size="small">
{{ currentUser.role === 'admin' ? '管理员' : '普通用户' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="currentUser.status === 'banned' ? 'danger' : 'success'" size="small">
{{ currentUser.status === 'banned' ? '已封禁' : '正常' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="currentUser.status === 'banned'" label="封禁原因" :span="2">
<span style="color:#f5222d">{{ currentUser.ban_reason }}</span>
</el-descriptions-item>
<el-descriptions-item label="注册时间" :span="2">{{ currentUser.created_at }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="showDetail=false">关闭</el-button>
</template>
</el-dialog>
<!-- 封禁弹窗 -->
<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="用户">{{ currentUser?.real_name || 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>
@@ -74,26 +147,25 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { banApi, userApi } from '../../../api'
import { banApi, adminApi, userApi } from '../../../api'
const activeTab = ref('all')
const keyword = ref('')
const list = ref([])
const loading = ref(false)
const showBan = ref(false)
const showDetail = 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 || []
}
const res = await adminApi.listUsers({
status: activeTab.value,
keyword: keyword.value.trim()
})
list.value = res.data || []
} catch (e) {
list.value = []
} finally {
@@ -101,6 +173,26 @@ async function load() {
}
}
async function viewDetail(row) {
currentUser.value = { ...row }
showDetail.value = true
try {
const res = await userApi.publicProfile(row.id)
if (res.data?.user) {
currentUser.value = {
...row,
...res.data.user,
contacts: res.data.contacts || []
}
}
} catch (e) {}
}
function contactTypeText(type) {
const map = { email: '邮箱', qq: 'QQ', wechat: '微信', phone: '手机号', address: '住址', workunit: '工作单位' }
return map[type] || type
}
function ban(row) {
currentUser.value = row
banReason.value = ''
@@ -121,12 +213,16 @@ async function confirmBan() {
async function unban(row) {
try {
await ElMessageBox.confirm(`确定解封用户「${row.nickname || row.username}」吗?`, '提示', { type: 'warning' })
await ElMessageBox.confirm(`确定解封用户「${row.real_name || row.nickname || row.username}」吗?`, '提示', { type: 'warning' })
await banApi.unban(row.id)
ElMessage.success('已解封')
load()
} catch (e) {}
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.message || '解封失败')
}
}
onMounted(() => { load() })
onMounted(() => {
load()
})
</script>
+197 -9
View File
@@ -28,11 +28,46 @@
</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>
<h3 style="margin-bottom:12px">
<i class="fas fa-user-friends" style="color:#1890ff"></i> 毕业照站位与人员名单 ({{ totalRosterCount }}人)
<span v-if="detail?.row_count > 1" style="font-size:12px;color:#8c8c8c;font-weight:normal;margin-left:8px">
共 {{ detail.row_count }} 排,从前至后、从左往右
</span>
</h3>
<div v-if="detail.can_view_sensitive">
<!-- 按排结构化展示 -->
<div v-if="rosterRows.length > 0" class="detail-roster-rows">
<div v-for="row in rosterRows" :key="row.rowNo" class="detail-roster-row">
<div class="detail-roster-row-header">
<span class="detail-row-num">第 {{ row.rowNo }} 排</span>
<span class="detail-row-desc">{{ row.rowNo === 1 ? '前排就坐 / 从左往右' : '从左往右' }}</span>
<span class="detail-row-count">({{ row.items.length }}人)</span>
</div>
<div class="detail-roster-tags">
<span
v-for="person in row.items"
:key="person.id || person.name"
:class="['roster-detail-tag', person.role === 'teacher' ? 'is-teacher' : 'is-student']"
>
<span class="person-pos">#{{ person.col_no }}</span>
<span class="person-name">{{ person.name }}</span>
<span v-if="person.role === 'teacher'" class="person-role-tag">老师</span>
<span v-if="person.is_claimed" class="person-claim-tag claimed" title="已绑定系统账号">
<i class="fas fa-check-circle"></i>已绑定
</span>
<span v-else class="person-claim-tag unclaimed" title="该同学尚未在系统注册绑定">
待认领
</span>
</span>
</div>
</div>
</div>
<!-- 兜底旧单列表 -->
<div v-else 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-else class="empty" style="padding:20px">
<i class="fas fa-lock"></i>
@@ -73,13 +108,20 @@
<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">
<el-table-column label="用户" min-width="170">
<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() }}
{{ (row.user.real_name || row.user.nickname || row.user.username || 'U').charAt(0).toUpperCase() }}
</div>
<div>
<div>
<span style="font-weight:600">{{ row.user.real_name || row.user.nickname || row.user.username }}</span>
<span v-if="row.user.real_name && row.user.nickname" style="font-size:12px;color:#8c8c8c;margin-left:4px">({{ row.user.nickname }})</span>
<span v-if="row.user.gender" style="font-size:12px;color:#8c8c8c;margin-left:6px">[{{ row.user.gender }}]</span>
</div>
<div style="font-size:12px;color:#8c8c8c">@{{ row.user.username }}</div>
</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>
@@ -97,10 +139,21 @@
<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">
<div v-for="c in row.contacts" :key="c.id || c.value" style="margin-bottom:2px">
<i :class="contactIcon(c.type)" style="margin-right:4px;color:#1890ff"></i>{{ c.value }}
</div>
</div>
<div v-else-if="row.user?.phone || row.user?.email || row.user?.address" style="font-size:12px;color:#595959">
<div v-if="row.user.phone" style="margin-bottom:2px">
<i class="fas fa-phone" style="margin-right:4px;color:#1890ff"></i>{{ row.user.phone }}
</div>
<div v-if="row.user.email" style="margin-bottom:2px">
<i class="fas fa-envelope" style="margin-right:4px;color:#1890ff"></i>{{ row.user.email }}
</div>
<div v-if="row.user.address" style="margin-bottom:2px">
<i class="fas fa-map-marker-alt" style="margin-right:4px;color:#1890ff"></i>{{ row.user.address }}
</div>
</div>
<span v-else style="color:#bfbfbf">未填写</span>
</template>
</el-table-column>
@@ -220,6 +273,29 @@ const inviteForm = reactive({ max_uses: 10, expire_days: 30 })
const uploadUrl = '/api/upload'
const uploadHeaders = computed(() => ({ Authorization: 'Bearer ' + token.value }))
const rosterRows = computed(() => {
const list = detail.value?.roster || []
if (!list.length) return []
const groupMap = {}
list.forEach(item => {
const r = item.row_no || 1
if (!groupMap[r]) groupMap[r] = []
groupMap[r].push(item)
})
return Object.keys(groupMap)
.map(Number)
.sort((a, b) => a - b)
.map(r => ({
rowNo: r,
items: groupMap[r].sort((a, b) => (a.col_no || 0) - (b.col_no || 0))
}))
})
const totalRosterCount = computed(() => {
if (detail.value?.roster?.length) return detail.value.roster.length
return (detail.value?.student_names?.length || 0) + (detail.value?.teacher_names?.length || 0)
})
const statusMap = {
pending: { text: '待审核', cls: 'warning' },
active: { text: '已通过', cls: 'success' },
@@ -242,9 +318,15 @@ function canDeletePhoto(photo) {
}
async function loadDetail() {
const classId = route.params.id
if (!classId || classId === ':id' || isNaN(Number(classId))) {
ElMessage.error('无效的班级 ID')
router.replace('/class/list')
return
}
loading.value = true
try {
const res = await classApi.detail(route.params.id)
const res = await classApi.detail(classId)
detail.value = res.data
} catch (e) {
ElMessage.error('加载失败')
@@ -315,3 +397,109 @@ async function resubmit() {
onMounted(() => { loadDetail() })
</script>
<style scoped>
.detail-roster-rows {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-roster-row {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 10px 14px;
}
.detail-roster-row-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.detail-row-num {
background: #1890ff;
color: #fff;
font-size: 12px;
font-weight: 700;
padding: 1px 8px;
border-radius: 10px;
}
.detail-row-desc {
font-size: 12px;
color: #8c8c8c;
}
.detail-row-count {
font-size: 12px;
color: #595959;
}
.detail-roster-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.roster-detail-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 4px;
font-size: 13px;
border: 1px solid transparent;
}
.roster-detail-tag.is-student {
background: #e6f7ff;
border-color: #bae7ff;
color: #0050b3;
}
.roster-detail-tag.is-teacher {
background: #fff7e6;
border-color: #ffd591;
color: #d46b08;
}
.person-pos {
font-size: 11px;
font-weight: 700;
opacity: 0.65;
}
.person-name {
font-weight: 500;
}
.person-role-tag {
font-size: 10px;
background: rgba(212, 107, 8, 0.15);
color: #d46b08;
padding: 0 4px;
border-radius: 2px;
}
.person-claim-tag {
font-size: 10px;
padding: 1px 5px;
border-radius: 2px;
margin-left: 2px;
}
.person-claim-tag.claimed {
background: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
.person-claim-tag.unclaimed {
background: #f5f5f5;
color: #8c8c8c;
border: 1px solid #d9d9d9;
}
</style>
File diff suppressed because it is too large Load Diff
+74 -4
View File
@@ -2,7 +2,27 @@
<div>
<div class="page-title"><i class="fas fa-sign-in-alt"></i>加入班级</div>
<div class="card">
<!-- 检查履历中 -->
<div v-if="checkingEdu" class="card" v-loading="true" style="min-height: 260px"></div>
<!-- 未完善个人学习履历拦截提醒 -->
<div v-else-if="!hasEdu" class="card">
<el-result
icon="warning"
title="请先完善个人学习履历"
sub-title="根据平台规定,只有完善个人学习履历的用户才能加入班级。请先前往个人中心添加至少一条学习履历。"
>
<template #extra>
<el-button type="primary" @click="$router.push('/profile?tab=education&action=add&from=class_join')">
<i class="fas fa-graduation-cap" style="margin-right:4px"></i>前往完善学习履历
</el-button>
<el-button @click="$router.push('/')">返回首页</el-button>
</template>
</el-result>
</div>
<!-- 正常流程:已有履历 -->
<div v-else class="card">
<el-tabs v-model="activeTab" stretch>
<!-- 搜索加入 -->
<el-tab-pane label="搜索加入" name="search">
@@ -83,10 +103,10 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { classApi } from '../../../api'
import { ElMessage, ElMessageBox } from 'element-plus'
import { classApi, userApi } from '../../../api'
const router = useRouter()
const activeTab = ref('search')
@@ -99,11 +119,45 @@ const showVerifyDialog = ref(false)
const verifyNames = ref('')
const currentClass = ref(null)
const checkingEdu = ref(true)
const hasEdu = ref(false)
const verifyCount = computed(() => {
return verifyNames.value.split(/[\n,,\s]+/).filter(s => s.trim()).length
})
async function checkEducation() {
checkingEdu.value = true
try {
const res = await userApi.hasEducation()
hasEdu.value = !!res.data?.has_education
if (!hasEdu.value) {
ElMessageBox.confirm(
'根据平台规定,只有完善个人学习履历的用户才能加入班级。是否立即前往添加学习履历?',
'提示',
{
confirmButtonText: '前往完善',
cancelButtonText: '暂不完善',
type: 'warning',
distinguishCancelAndClose: true
}
).then(() => {
router.push('/profile?tab=education&action=add&from=class_join')
}).catch(() => {})
}
} catch (e) {
ElMessage.error(e.message || '检查用户学习履历失败')
hasEdu.value = false
} finally {
checkingEdu.value = false
}
}
async function search() {
if (!hasEdu.value) {
ElMessage.warning('请先完善个人学习履历,有学习履历的用户才能加入班级')
return
}
if (!keyword.value.trim()) { ElMessage.warning('请输入搜索关键词'); return }
searched.value = true
try {
@@ -115,12 +169,20 @@ async function search() {
}
function showVerify(row) {
if (!hasEdu.value) {
ElMessage.warning('请先完善个人学习履历,有学习履历的用户才能加入班级')
return
}
currentClass.value = row
verifyNames.value = ''
showVerifyDialog.value = true
}
async function joinBySearch() {
if (!hasEdu.value) {
ElMessage.warning('请先完善个人学习履历,有学习履历的用户才能加入班级')
return
}
const names = verifyNames.value.split(/[\n,,\s]+/).filter(s => s.trim())
if (names.length < 8) { ElMessage.error('至少需要8位同学姓名'); return }
joining.value = true
@@ -137,6 +199,10 @@ async function joinBySearch() {
}
async function joinByInvite() {
if (!hasEdu.value) {
ElMessage.warning('请先完善个人学习履历,有学习履历的用户才能加入班级')
return
}
if (!inviteCode.value.trim()) { ElMessage.warning('请输入邀请码'); return }
joining.value = true
try {
@@ -149,4 +215,8 @@ async function joinByInvite() {
joining.value = false
}
}
onMounted(() => {
checkEducation()
})
</script>
+114 -17
View File
@@ -56,18 +56,23 @@
<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-col :xs="24" :sm="12" :md="6">
<el-select v-model="selectedCountry" placeholder="选择国家" filterable 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-col :xs="24" :sm="12" :md="6">
<el-select v-model="selectedProvince" :placeholder="isChina ? '选择省/直辖市' : '选择省/州'" filterable style="width:100%" @change="loadCities" :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-col :xs="24" :sm="12" :md="6">
<el-select v-model="selectedCity" :placeholder="isChina ? '选择市' : '选择市/地区'" filterable style="width:100%" @change="loadCounties" :disabled="!selectedProvince">
<el-option v-for="c in cities" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-select v-model="selectedCounty" placeholder="选择区/县" clearable filterable style="width:100%" @change="loadSchools" :disabled="!selectedCity && cities.length > 0">
<el-option v-for="c in counties" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-col>
@@ -76,15 +81,22 @@
<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">
<el-table-column prop="school_type" label="类型" min-width="140">
<template #default="{ row }">
<span class="tag primary">{{ typeMap[row.school_type] || row.school_type }}</span>
<span
v-for="t in schoolTypeNames(row)"
:key="t"
class="tag primary"
style="margin-right: 4px; margin-bottom: 2px; display: inline-block"
>
{{ t }}
</span>
</template>
</el-table-column>
<el-table-column prop="address" label="地址" />
</el-table>
</div>
<div v-else-if="selectedCounty" class="empty">
<div v-else-if="selectedCity || selectedCounty" class="empty">
<i class="fas fa-school"></i>
<p>该地区暂无学校,快去创建第一个吧</p>
</div>
@@ -118,10 +130,12 @@ const keyword = ref('')
const searchResults = ref([])
const countries = ref([])
const provinces = ref([])
const cities = ref([])
const counties = ref([])
const schools = ref([])
const selectedCountry = ref()
const selectedProvince = ref()
const selectedCity = ref()
const selectedCounty = ref()
const typeMap = {
@@ -129,6 +143,14 @@ const typeMap = {
junior_high: '初中', primary: '小学', kindergarten: '幼儿园', other: '其他'
}
function schoolTypeNames(row) {
if (!row) return []
const list = Array.isArray(row.school_types) && row.school_types.length > 0
? row.school_types
: (row.school_type ? [row.school_type] : [])
return list.map(t => typeMap[t] || t)
}
async function search() {
if (!keyword.value.trim()) {
ElMessage.warning('请输入搜索关键词')
@@ -142,30 +164,105 @@ async function search() {
}
}
// 是否为中国行政区划体系(默认 true)
const isChina = computed(() => {
if (!selectedCountry.value) return true
const c = countries.value.find(item => item.id === selectedCountry.value)
return !c || c.code === 'CN' || c.name === '中国'
})
async function loadCountries() {
const res = await regionApi.countries()
countries.value = res.data || []
try {
const res = await regionApi.countries()
countries.value = res.data || []
if (!selectedCountry.value && countries.value.length > 0) {
const cn = countries.value.find(c => c.code === 'CN' || c.name === '中国')
if (cn) {
selectedCountry.value = cn.id
await loadProvinces()
}
}
} catch (e) {
ElMessage.error('加载国家列表失败')
}
}
async function loadProvinces() {
selectedProvince.value = null
selectedCity.value = null
selectedCounty.value = null
provinces.value = []
cities.value = []
counties.value = []
schools.value = []
const res = await regionApi.list(selectedCountry.value)
provinces.value = res.data || []
if (!selectedCountry.value) return
try {
const res = await regionApi.list(selectedCountry.value)
provinces.value = res.data || []
} catch (e) {
ElMessage.error('加载省/州列表失败')
}
}
async function loadCities() {
selectedCity.value = null
selectedCounty.value = null
cities.value = []
counties.value = []
schools.value = []
if (!selectedProvince.value) return
try {
const res = await regionApi.list(selectedProvince.value)
const list = res.data || []
if (isChina.value) {
cities.value = list.filter(item => item.level === 'city')
counties.value = []
} else {
if (list.length > 0 && list[0].level === 'county') {
counties.value = list
cities.value = []
} else {
cities.value = list.filter(item => item.level === 'city' || !item.level)
counties.value = []
}
}
loadSchools()
} catch (e) {
ElMessage.error(isChina.value ? '加载城市列表失败' : '加载市/地区列表失败')
}
}
async function loadCounties() {
selectedCounty.value = null
counties.value = []
schools.value = []
const res = await regionApi.list(selectedProvince.value)
counties.value = res.data || []
if (!selectedCity.value) {
loadSchools()
return
}
try {
const res = await regionApi.list(selectedCity.value)
const list = res.data || []
counties.value = list.filter(item => item.level === 'county' || !item.level)
loadSchools()
} catch (e) {
ElMessage.error('加载区/县列表失败')
}
}
async function loadSchools() {
const res = await schoolApi.list({ region_id: selectedCounty.value })
schools.value = res.data || []
const targetId = selectedCounty.value || selectedCity.value || selectedProvince.value
if (!targetId) {
schools.value = []
return
}
try {
const res = await schoolApi.list({ region_id: targetId })
const list = res.data?.list || res.data || []
schools.value = Array.isArray(list) ? list : []
} catch (e) {
schools.value = []
}
}
onMounted(() => {
@@ -3,42 +3,256 @@
<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() }}
{{ (profileForm.real_name || profileForm.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>
<h3 style="margin-bottom:4px">
{{ profileForm.real_name || profileForm.nickname || user?.username }}
<span v-if="profileForm.real_name && profileForm.nickname" style="font-size:13px;color:#8c8c8c;font-weight:normal">
({{ profileForm.nickname }})
</span>
</h3>
<p style="color:#8c8c8c;font-size:13px;margin-bottom:12px">@{{ 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 style="margin-top:16px;padding-top:16px;border-top:1px solid #f0f0f0;text-align:left;font-size:13px;display:flex;flex-direction:column;gap:10px">
<div style="color:#595959">
<i class="fas fa-id-badge" style="color:#1890ff;width:20px"></i>
<span style="color:#8c8c8c">真实姓名:</span>
<span style="font-weight:600;color:#262626">{{ profileForm.real_name || '未填写' }}</span>
</div>
<div style="color:#595959">
<i class="fas fa-venus-mars" style="color:#1890ff;width:20px"></i>
<span style="color:#8c8c8c">性  别:</span>
<span>{{ profileForm.gender || '未设置' }}</span>
</div>
<div style="color:#595959">
<i class="fas fa-users" style="color:#1890ff;width:20px"></i>
<span style="color:#8c8c8c">民  族:</span>
<span>{{ profileForm.nation || '未设置' }}</span>
</div>
<div style="color:#595959">
<i class="fas fa-landmark" style="color:#1890ff;width:20px"></i>
<span style="color:#8c8c8c">政治面貌:</span>
<span>{{ profileForm.political_status || '未设置' }}</span>
</div>
<div style="color:#595959">
<i class="fas fa-id-card" style="color:#1890ff;width:20px"></i>
<span style="color:#8c8c8c">身份证号:</span>
<span style="font-family:monospace">{{ maskedIdCard }}</span>
</div>
<div style="color:#595959">
<i class="fas fa-phone" style="color:#1890ff;width:20px"></i>
<span style="color:#8c8c8c">联系电话:</span>
<span>{{ profileForm.phone || '未设置' }}</span>
</div>
<div style="color:#595959">
<i class="fas fa-envelope" style="color:#1890ff;width:20px"></i>
<span style="color:#8c8c8c">电子邮箱:</span>
<span>{{ profileForm.email || user?.email || '未设置邮箱' }}</span>
</div>
<div style="color:#595959">
<i class="fas fa-home" style="color:#1890ff;width:20px"></i>
<span style="color:#8c8c8c">家庭住址:</span>
<span :title="profileForm.address" style="word-break:break-all">{{ profileForm.address || '未填写' }}</span>
</div>
<div v-if="contacts.some(c => c.type === 'wechat')" style="color:#595959">
<i class="fab fa-weixin" style="color:#52c41a;width:20px"></i>
<span style="color:#8c8c8c">微  信:</span>
<span>{{ contacts.find(c => c.type === 'wechat')?.value }}</span>
</div>
<div v-if="contacts.some(c => c.type === 'qq')" style="color:#595959">
<i class="fab fa-qq" style="color:#1890ff;width:20px"></i>
<span style="color:#8c8c8c">Q  Q:</span>
<span>{{ contacts.find(c => c.type === 'qq')?.value }}</span>
</div>
<div v-if="contacts.some(c => c.type === 'workunit')" style="color:#595959">
<i class="fas fa-building" style="color:#1890ff;width:20px"></i>
<span style="color:#8c8c8c">工作单位:</span>
<span>{{ contacts.find(c => c.type === 'workunit')?.value }}</span>
</div>
<div style="color:#595959">
<i class="fas fa-clock" style="color:#1890ff;width:20px"></i>
<span style="color:#8c8c8c">注册时间:</span>
<span>{{ formatDate(user?.created_at) }}</span>
</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 ref="profileFormRef" :model="profileForm" :rules="profileRules" label-width="100px">
<!-- 第一部分:个人身份信息 -->
<div style="font-size:15px;font-weight:600;color:#262626;margin-bottom:16px;display:flex;align-items:center">
<i class="fas fa-id-card-alt" style="color:#1890ff;margin-right:8px"></i>
<span>个人身份档案</span>
</div>
<el-row :gutter="16">
<el-col :xs="24" :sm="12">
<el-form-item label="真实姓名" prop="real_name">
<el-input v-model="profileForm.real_name" placeholder="请输入姓名(如:张三)" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="profileForm.gender">
<el-radio label="男">男</el-radio>
<el-radio label="女">女</el-radio>
<el-radio label="保密">保密</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :xs="24" :sm="12">
<el-form-item label="民族" prop="nation">
<el-select
v-model="profileForm.nation"
placeholder="请选择或输入民族"
filterable
allow-create
default-first-option
style="width:100%"
>
<el-option v-for="n in nationOptions" :key="n" :label="n" :value="n" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="政治身份" prop="political_status">
<el-select
v-model="profileForm.political_status"
placeholder="请选择或输入政治身份"
filterable
allow-create
default-first-option
style="width:100%"
>
<el-option v-for="p in politicalStatusOptions" :key="p" :label="p" :value="p" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="身份证号" prop="id_card">
<el-input
v-model="profileForm.id_card"
placeholder="请输入18位二代居民身份证号"
maxlength="18"
show-word-limit
clearable
/>
<div style="font-size:12px;color:#8c8c8c;margin-top:4px">
<i class="fas fa-shield-alt" style="color:#52c41a;margin-right:4px"></i>
用于校友实名档案及安全认证,非本人或平台管理员查看时自动掩码脱敏
</div>
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="profileForm.email" placeholder="请输入邮箱(用于找回密码)" />
<el-form-item label="个性昵称" prop="nickname">
<el-input v-model="profileForm.nickname" placeholder="请输入个性昵称" clearable />
</el-form-item>
<el-form-item label="个人简介">
<el-input v-model="profileForm.bio" type="textarea" :rows="4" placeholder="介绍一下自己吧" />
<el-form-item label="个人简介" prop="bio">
<el-input v-model="profileForm.bio" type="textarea" :rows="2" 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>
<!-- 第二部分:联系方式(深度融入基本资料) -->
<div style="margin:24px 0 16px;padding-top:16px;border-top:1px solid #f0f0f0;display:flex;justify-content:space-between;align-items:center">
<div style="font-size:15px;font-weight:600;color:#262626;display:flex;align-items:center">
<i class="fas fa-address-book" style="color:#1890ff;margin-right:8px"></i>
<span>联系方式与通讯录</span>
<span style="font-size:12px;color:#8c8c8c;font-weight:normal;margin-left:8px">(校友录通讯资料,同班同学与班级管理员可互相查看)</span>
</div>
<el-button type="primary" size="small" plain @click="openAddContactDialog">
<i class="fas fa-plus" style="margin-right:4px"></i>添加更多联系方式
</el-button>
</div>
<el-row :gutter="16">
<el-col :xs="24" :sm="12">
<el-form-item label="常用手机" prop="phone">
<el-input v-model="profileForm.phone" placeholder="请输入常用手机号" clearable>
<template #prefix><i class="fas fa-phone" style="color:#8c8c8c"></i></template>
</el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="电子邮箱" prop="email">
<el-input v-model="profileForm.email" placeholder="请输入电子邮箱(找回密码与通知)" clearable>
<template #prefix><i class="fas fa-envelope" style="color:#8c8c8c"></i></template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="常住住址" prop="address">
<el-input
v-model="profileForm.address"
type="textarea"
:rows="2"
placeholder="请输入常住家庭住址(省/市/区县/街道详细住址)"
clearable
/>
</el-form-item>
<el-form-item style="margin-bottom:12px">
<el-button type="primary" :loading="saving" @click="saveProfile">
<i class="fas fa-save" style="margin-right:4px"></i>保存基本资料
</el-button>
</el-form-item>
<!-- 第三部分:已登记的多渠道联系方式清单 -->
<div style="margin-top:20px;padding-top:16px;border-top:1px dashed #e8e8e8">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<div style="font-size:13px;font-weight:600;color:#595959">
已登记的多渠道联系方式(QQ、微信、多手机、工作单位等):
</div>
<div style="font-size:12px;color:#8c8c8c">
上限:邮箱2个、QQ5个、微信2个、手机号5个、住址1个、工作单位1个
</div>
</div>
<el-table v-if="contacts.length > 0" :data="contacts" size="small" stripe border style="width:100%">
<el-table-column label="联系类型" width="130">
<template #default="{ row }">
<i :class="contactIcon(row.type)" style="color:#1890ff;margin-right:6px"></i>
<span>{{ contactTypeMap[row.type] || row.type }}</span>
</template>
</el-table-column>
<el-table-column prop="value" label="联系内容 / 账号" min-width="180" />
<el-table-column label="属性" width="100">
<template #default="{ row }">
<el-tag v-if="row.is_primary" type="success" size="small">主要</el-tag>
<el-button v-else-if="['phone','email','address'].includes(row.type)" type="primary" link size="small" @click="setPrimaryContact(row)">
设为主
</el-button>
<span v-else style="color:#bfbfbf;font-size:12px">普通</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button type="danger" link size="small" @click="deleteContact(row)">
<i class="fas fa-trash"></i>
</el-button>
</template>
</el-table-column>
</el-table>
<div v-else style="background:#fafafa;border:1px dashed #d9d9d9;border-radius:6px;padding:16px;text-align:center;color:#8c8c8c;font-size:13px">
暂无其他附加联系方式,点击右上角「添加更多联系方式」登记 QQ、微信、备用手机或工作单位
</div>
</div>
</el-form>
</div>
</el-tab-pane>
@@ -48,7 +262,7 @@
<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>
<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">
@@ -68,36 +282,11 @@
</el-timeline>
<div v-else class="empty">
<i class="fas fa-graduation-cap"></i>
<p>暂无学习履历,添加后才能创建班级</p>
<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">
@@ -140,92 +329,287 @@
</el-dialog>
<!-- 添加联系方式弹窗 -->
<el-dialog v-model="showContactDialog" title="添加联系方式" width="400px">
<el-form :model="contactForm" label-width="80px">
<el-dialog v-model="showContactDialog" title="添加联系方式" width="440px">
<el-form :model="contactForm" label-width="90px">
<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="email" />
<el-option label="微信" value="wechat" />
<el-option label="QQ" value="qq" />
<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-item label="内容" required>
<el-input v-model="contactForm.value" :placeholder="contactPlaceholder" clearable />
</el-form-item>
<el-form-item v-if="['phone','email','address'].includes(contactForm.type)">
<el-checkbox v-model="contactForm.is_primary">设为该类型的首选/主要联系方式</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showContactDialog=false">取消</el-button>
<el-button type="primary" @click="addContact">保存</el-button>
<el-button type="primary" :loading="contactSaving" @click="addContact">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { userApi, classApi } from '../../../../api'
import { useAuth } from '../../../../composables/useAuth'
const { user, setAuth } = useAuth()
const route = useRoute()
const router = useRouter()
const { token, user, setAuth } = useAuth()
const activeTab = ref('profile')
const profileForm = reactive({ nickname: '', email: '', bio: '' })
const saving = ref(false)
const contactSaving = ref(false)
const profileFormRef = ref(null)
const profileForm = reactive({
real_name: '',
gender: '男',
nation: '汉族',
political_status: '群众',
id_card: '',
address: '',
phone: '',
nickname: '',
email: '',
bio: ''
})
// 56个民族标准列表
const nationOptions = [
'汉族', '蒙古族', '回族', '藏族', '维吾尔族', '苗族', '彝族', '壮族', '布依族', '朝鲜族',
'满族', '侗族', '瑶族', '白族', '土家族', '哈尼族', '哈萨克族', '傣族', '黎族', '傈僳族',
'佤族', '畲族', '高山族', '拉祜族', '水族', '东乡族', '纳西族', '景颇族', '柯尔克孜族', '土族',
'达斡尔族', '仫佬族', '羌族', '布朗族', '撒拉族', '毛南族', '仡佬族', '锡伯族', '阿昌族', '普米族',
'塔吉克族', '怒族', '乌孜别克族', '俄罗斯族', '鄂温克族', '德昂族', '保安族', '裕固族', '京族', '塔塔尔族',
'独龙族', '鄂伦春族', '赫哲族', '门巴族', '珞巴族', '基诺族'
]
// 政治身份/政治面貌常见选项
const politicalStatusOptions = [
'群众',
'共青团员',
'中共党员',
'中共预备党员',
'民革党员',
'民盟盟员',
'民建会员',
'民进会员',
'农工党党员',
'致公党党员',
'九三学社社员',
'台盟盟员',
'无党派人士'
]
// 身份证号前端校验
const validateIdCardRule = (rule, value, callback) => {
if (!value) return callback()
const val = String(value).trim().toUpperCase()
if (val.includes('*')) return callback()
const reg = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9X]$/
if (!reg.test(val)) {
return callback(new Error('请输入18位有效中国居民身份证号码'))
}
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
let sum = 0
for (let i = 0; i < 17; i++) {
sum += parseInt(val.charAt(i), 10) * weights[i]
}
if (val.charAt(17) !== checkCodes[sum % 11]) {
return callback(new Error(`身份证校验码错误(末位应为 ${checkCodes[sum % 11]})`))
}
callback()
}
const profileRules = {
real_name: [
{ max: 30, message: '真实姓名长度不能超过30个字符', trigger: 'blur' }
],
id_card: [
{ validator: validateIdCardRule, trigger: ['blur', 'change'] }
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址格式', trigger: ['blur', 'change'] }
]
}
const maskedIdCard = computed(() => {
const id = profileForm.id_card
if (!id) return '未填写'
if (id.length === 18) {
return id.substring(0, 6) + '********' + id.substring(14)
}
return id
})
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 contactForm = reactive({ type: 'phone', value: '', is_primary: false })
const contactTypeMap = {
phone: '手机号',
email: '电子邮箱',
wechat: '微信',
qq: 'QQ',
address: '家庭住址',
workunit: '工作单位'
}
const contactPlaceholder = computed(() => {
switch (contactForm.type) {
case 'phone': return '请输入手机号码'
case 'email': return '请输入邮箱地址,如 abc@example.com'
case 'wechat': return '请输入微信号'
case 'qq': return '请输入QQ号'
case 'address': return '请输入详细住址'
case 'workunit': return '请输入工作单位全称'
default: return '请输入联系方式内容'
}
})
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' }
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)
}
function openAddContactDialog() {
contactForm.type = 'phone'
contactForm.value = ''
contactForm.is_primary = false
showContactDialog.value = true
}
async function loadProfile() {
const res = await userApi.profile()
Object.assign(profileForm, res.data)
try {
const res = await userApi.profile()
const u = res.data?.user || res.data || {}
profileForm.real_name = u.real_name || ''
profileForm.gender = u.gender || '男'
profileForm.nation = u.nation || '汉族'
profileForm.political_status = u.political_status || '群众'
profileForm.id_card = u.id_card || ''
profileForm.address = u.address || ''
profileForm.phone = u.phone || ''
profileForm.nickname = u.nickname || ''
profileForm.email = u.email || ''
profileForm.bio = u.bio || ''
// 同步提取返回的联系方式列表
if (res.data?.contacts) {
contacts.value = res.data.contacts
}
} catch (e) {
ElMessage.error(e.message || '加载个人资料失败')
}
}
async function loadEducation() {
const res = await userApi.listEducation()
educationList.value = res.data || []
try {
const res = await userApi.listEducation()
educationList.value = res.data || []
} catch (e) {
educationList.value = []
}
}
async function loadContacts() {
const res = await userApi.listContacts()
contacts.value = res.data || []
try {
const res = await userApi.listContacts()
contacts.value = res.data || []
} catch (e) {
contacts.value = []
}
}
async function loadMyClasses() {
try {
const res = await classApi.mine()
myClasses.value = res.data || []
} catch (e) { myClasses.value = [] }
} catch (e) {
myClasses.value = []
}
}
async function saveProfile() {
if (profileFormRef.value) {
const valid = await profileFormRef.value.validate().catch(() => false)
if (!valid) {
ElMessage.warning('请检查输入信息格式是否正确')
return
}
}
saving.value = true
try {
const res = await userApi.updateProfile(profileForm)
setAuth(user.value.token, res.data)
ElMessage.success('资料已保存')
if (res.data && res.data.id) {
setAuth(token.value, res.data)
}
ElMessage.success('基本资料与联系方式保存成功')
await loadProfile()
await loadContacts()
} catch (e) {
ElMessage.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
async function addEdu() {
if (!eduForm.school_name) { ElMessage.warning('请输入学校名称'); return }
try {
await userApi.addEducation(eduForm)
ElMessage.success('已添加')
ElMessage.success('学习履历添加成功')
showEduDialog.value = false
loadEducation()
// 若从创建班级或加入班级跳转过来,引导返回
if (route.query.from === 'class_create') {
ElMessageBox.confirm('学习履历已添加成功!是否立即前往创建班级?', '提示', {
confirmButtonText: '前往创建班级',
cancelButtonText: '留在本页',
type: 'success'
}).then(() => {
router.push('/class/edit')
}).catch(() => {})
} else if (route.query.from === 'class_join') {
ElMessageBox.confirm('学习履历已添加成功!是否立即前往加入班级?', '提示', {
confirmButtonText: '前往加入班级',
cancelButtonText: '留在本页',
type: 'success'
}).then(() => {
router.push('/class/join')
}).catch(() => {})
}
} catch (e) { ElMessage.error(e.message || '添加失败') }
}
async function deleteEdu(edu) {
try {
await ElMessageBox.confirm('确定删除这条履历吗?', '提示', { type: 'warning' })
@@ -234,23 +618,72 @@ async function deleteEdu(edu) {
loadEducation()
} catch (e) {}
}
async function addContact() {
if (!contactForm.value) { ElMessage.warning('请输入内容'); return }
if (!contactForm.value || !contactForm.value.trim()) {
ElMessage.warning('请输入联系方式内容')
return
}
contactSaving.value = true
try {
await userApi.addContact(contactForm)
ElMessage.success('已添加')
await userApi.addContact({
type: contactForm.type,
value: contactForm.value.trim(),
is_primary: contactForm.is_primary
})
ElMessage.success('联系方式添加成功')
showContactDialog.value = false
loadContacts()
} catch (e) { ElMessage.error(e.message || '添加失败') }
contactForm.value = ''
contactForm.is_primary = false
await loadProfile()
await loadContacts()
} catch (e) {
ElMessage.error(e.message || '添加失败')
} finally {
contactSaving.value = false
}
}
async function setPrimaryContact(row) {
try {
await userApi.updateContact(row.id, { is_primary: true })
ElMessage.success('已设为主要联系方式')
await loadProfile()
await loadContacts()
} catch (e) {
ElMessage.error(e.message || '设置失败')
}
}
async function deleteContact(c) {
try {
await ElMessageBox.confirm(`确定删除该联系方式「${c.value}」吗?`, '提示', { type: 'warning' })
await userApi.deleteContact(c.id)
ElMessage.success('已删除')
loadContacts()
} catch (e) { ElMessage.error(e.message || '删除失败') }
await loadProfile()
await loadContacts()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.message || '删除失败')
}
}
watch(
() => route.query,
(q) => {
if (q.tab) {
if (q.tab === 'contacts') {
activeTab.value = 'profile'
} else {
activeTab.value = String(q.tab)
}
}
if (q.action === 'add' && (q.tab === 'education' || activeTab.value === 'education')) {
showEduDialog.value = true
}
},
{ immediate: true }
)
onMounted(() => {
loadProfile()
loadEducation()
@@ -1,17 +1,57 @@
<template>
<div class="card">
<div class="card-title">用户设置 - 编辑资料</div>
<div class="card-title">用户设置 - 编辑个人基本资料</div>
<div class="form-row">
<div class="form-group">
<label>昵称</label>
<input v-model="form.nickname" />
<label>真实姓名</label>
<input v-model="form.real_name" placeholder="请输入姓名" />
</div>
<div class="form-group">
<label>性别</label>
<select v-model="form.gender">
<option value="男">男</option>
<option value="女">女</option>
<option value="保密">保密</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>民族</label>
<input v-model="form.nation" placeholder="如:汉族" />
</div>
<div class="form-group">
<label>政治身份</label>
<input v-model="form.political_status" placeholder="如:群众、中共党员" />
</div>
</div>
<div class="form-group">
<label>个人简介</label>
<textarea v-model="form.bio" rows="3"></textarea>
<label>身份证号</label>
<input v-model="form.id_card" maxlength="18" placeholder="18位中国居民身份证号码" />
</div>
<button class="btn btn-primary" @click="save">保存</button>
<div class="form-group">
<label>家庭住址</label>
<input v-model="form.address" placeholder="详细常住家庭住址" />
</div>
<div class="form-row">
<div class="form-group">
<label>联系手机</label>
<input v-model="form.phone" placeholder="手机号" />
</div>
<div class="form-group">
<label>电子邮箱</label>
<input v-model="form.email" placeholder="常用邮箱" />
</div>
</div>
<div class="form-group">
<label>个性昵称</label>
<input v-model="form.nickname" placeholder="昵称" />
</div>
<div class="form-group">
<label>个人简介</label>
<textarea v-model="form.bio" rows="3" placeholder="一句话介绍自己"></textarea>
</div>
<button class="btn btn-primary" @click="save">保存基本资料</button>
</div>
</template>
@@ -19,12 +59,36 @@
import { ref, onMounted } from 'vue'
import { userApi } from '../../../../api'
const form = ref({ nickname: '', bio: '' })
const form = ref({
real_name: '',
gender: '男',
nation: '汉族',
political_status: '群众',
id_card: '',
address: '',
phone: '',
email: '',
nickname: '',
bio: ''
})
onMounted(async () => {
const profile = await userApi.profile()
form.value.nickname = profile.user.nickname
form.value.bio = profile.user.bio
try {
const res = await userApi.profile()
const u = res.data?.user || res.data || {}
form.value = {
real_name: u.real_name || '',
gender: u.gender || '男',
nation: u.nation || '汉族',
political_status: u.political_status || '群众',
id_card: u.id_card || '',
address: u.address || '',
phone: u.phone || '',
email: u.email || '',
nickname: u.nickname || '',
bio: u.bio || ''
}
} catch (e) {}
})
async function save() {
@@ -1,47 +1,92 @@
<template>
<div>
<!-- 个人基本资料(已融入联系方式) -->
<div class="card">
<div class="card-title">个人资料</div>
<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" />
<label>真实姓名</label>
<input v-model="editForm.real_name" placeholder="请输入姓名" />
</div>
<div class="form-group">
<label>性别</label>
<select v-model="editForm.gender">
<option value="男">男</option>
<option value="女">女</option>
<option value="保密">保密</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>民族</label>
<input v-model="editForm.nation" placeholder="如:汉族" />
</div>
<div class="form-group">
<label>政治身份</label>
<input v-model="editForm.political_status" placeholder="如:群众、中共党员" />
</div>
<div class="form-group">
<label>身份证号</label>
<input v-model="editForm.id_card" maxlength="18" placeholder="18位居民身份证号" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>常用手机</label>
<input v-model="editForm.phone" placeholder="联系手机号码" />
</div>
<div class="form-group">
<label>绑定邮箱</label>
<input v-model="editForm.email" placeholder="电子邮箱地址" />
</div>
<div class="form-group">
<label>个性昵称</label>
<input v-model="editForm.nickname" placeholder="昵称" />
</div>
</div>
<div class="form-group">
<label>家庭住址</label>
<input v-model="editForm.address" placeholder="详细常住家庭住址" />
</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 style="margin-bottom:16px">
<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 style="margin-top:20px;padding-top:16px;border-top:1px solid #eee">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<div>
<strong>多渠道联系方式(QQ / 微信 / 多手机号 / 工作单位等)</strong>
<div class="hint" style="margin-top:4px">
邮箱最多2个、QQ最多5个、微信最多2个、手机号最多5个、家庭住址和工作单位各1个。同班同学与班级管理员可查看。
</div>
</div>
<button class="btn btn-primary btn-sm" @click="showAddContact=true">添加联系方式</button>
</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="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>
<!-- 学习履历 -->
@@ -99,10 +144,10 @@
<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="email">邮箱</option>
<option value="wechat">微信</option>
<option value="qq">QQ</option>
<option value="address">家庭住址</option>
<option value="workunit">工作单位</option>
</select>
@@ -111,7 +156,7 @@
<label>内容</label>
<input v-model="contactForm.value" placeholder="请输入" />
</div>
<div class="form-group">
<div class="form-group" v-if="['phone','email','address'].includes(contactForm.type)">
<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>
@@ -185,11 +230,22 @@ const profile = ref(null)
const myClasses = ref([])
const saving = ref(false)
const editForm = ref({ nickname: '', bio: '' })
const editForm = ref({
real_name: '',
gender: '男',
nation: '汉族',
political_status: '群众',
id_card: '',
address: '',
phone: '',
email: '',
nickname: '',
bio: ''
})
// 联系方式
const showAddContact = ref(false)
const contactForm = ref({ type: 'email', value: '', is_primary: false })
const contactForm = ref({ type: 'phone', value: '', is_primary: false })
const contactError = ref('')
// 履历
@@ -203,20 +259,37 @@ onMounted(() => {
})
async function loadProfile() {
profile.value = await userApi.profile()
editForm.value.nickname = profile.value.user.nickname
editForm.value.bio = profile.value.user.bio
const res = await userApi.profile()
profile.value = res.data || res
const u = profile.value?.user || {}
editForm.value = {
real_name: u.real_name || '',
gender: u.gender || '男',
nation: u.nation || '汉族',
political_status: u.political_status || '群众',
id_card: u.id_card || '',
address: u.address || '',
phone: u.phone || '',
email: u.email || '',
nickname: u.nickname || '',
bio: u.bio || ''
}
}
async function loadMyClasses() {
myClasses.value = await classApi.mine()
try {
const res = await classApi.mine()
myClasses.value = res.data || []
} catch (e) {
myClasses.value = []
}
}
async function saveProfile() {
saving.value = true
try {
await userApi.updateProfile(editForm.value)
alert('保存成功')
alert('基本资料保存成功')
loadProfile()
} catch (e) {
alert('保存失败:' + e.message)
@@ -234,7 +307,7 @@ async function addContact() {
try {
await userApi.addContact(contactForm.value)
showAddContact.value = false
contactForm.value = { type: 'email', value: '', is_primary: false }
contactForm.value = { type: 'phone', value: '', is_primary: false }
loadProfile()
} catch (e) {
contactError.value = e.message
@@ -242,18 +315,20 @@ async function addContact() {
}
async function deleteContact(id) {
if (!confirm('确定删除?')) return
if (!confirm('确定删除此联系方式?')) return
try {
await userApi.deleteContact(id)
loadProfile()
} catch (e) { alert(e.message) }
} 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: '' }
eduForm.value = { id: 0, school_name: '', college_name: '', major: '', degree: '', start_year: new Date().getFullYear(), end_year: 0, description: '' }
}
eduError.value = ''
showEduForm.value = true
@@ -279,23 +354,27 @@ async function saveEdu() {
}
async function deleteEdu(id) {
if (!confirm('确定删除这条履历?')) return
if (!confirm('确定删除此履历?')) return
try {
await userApi.deleteEducation(id)
loadProfile()
} catch (e) { alert(e.message) }
} catch (e) {
alert('删除失败:' + e.message)
}
}
function contactTypeText(t) {
function contactTypeText(type) {
const map = { email: '邮箱', qq: 'QQ', wechat: '微信', phone: '手机号', address: '家庭住址', workunit: '工作单位' }
return map[t] || t
return map[type] || type
}
function statusText(s) {
const map = { pending: '待审核', active: '正常', rejected: '被打回', deleted: '已删除' }
return map[s] || s
const m = { pending: '待审核', approved: '已通过', rejected: '已驳回', disbanded: '已解散' }
return m[s] || s
}
function statusTag(s) {
const map = { pending: 'tag-warning', active: 'tag-success', rejected: 'tag-danger', deleted: 'tag-info' }
return map[s] || 'tag-info'
const m = { pending: 'tag-warning', approved: 'tag-success', rejected: 'tag-danger', disbanded: 'tag-info' }
return m[s] || ''
}
</script>