更新
This commit is contained in:
+13
-6
@@ -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
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1002
-103
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user