教师端数据看板: - 新增 TeacherDashboardResponse/TeacherLessonVO/TeacherLessonTrendVO - 新增 TeacherWeeklyStatsResponse 周统计响应 - 新增 TeacherActivityLevel 枚举和 TeacherActivityRankResponse 活跃度排行 - 实现教师端课程统计、任务完成详情、任务反馈接口 学校端课程统计: - 新增 CourseUsageVO/CourseUsageStatsVO/CoursePackageVO - 新增 SchoolCourseResponse 和学校端课程使用查询接口 - 实现学校端统计数据和课程趋势接口 用户资料功能: - 新增 UpdateProfileRequest/UpdateProfileResponse - 实现用户资料更新接口 前后端对齐: - 更新 OpenAPI 规范和前端 API 类型生成 - 优化 DashboardView 组件和 API 调用 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
335 lines
8.6 KiB
Vue
335 lines
8.6 KiB
Vue
<template>
|
|
<div class="profile-view">
|
|
<div class="page-header">
|
|
<h1>
|
|
<UserOutlined /> 个人信息
|
|
</h1>
|
|
<p>查看和修改您的账户信息</p>
|
|
</div>
|
|
|
|
<a-spin :spinning="loading">
|
|
<div class="profile-content" v-if="profile">
|
|
<div class="profile-card">
|
|
<div class="avatar-section">
|
|
<a-avatar :size="96" class="profile-avatar">
|
|
<img v-if="avatarUrl" :src="avatarUrl" alt="头像" />
|
|
<UserOutlined v-else />
|
|
</a-avatar>
|
|
</div>
|
|
|
|
<div class="info-section">
|
|
<a-descriptions :column="1" bordered size="small">
|
|
<a-descriptions-item label="姓名">
|
|
{{ profile.name || '-' }}
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="账号">
|
|
{{ profile.username || '-' }}
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="角色">
|
|
{{ roleLabel }}
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="手机号">
|
|
{{ profile.phone || '-' }}
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="邮箱">
|
|
{{ profile.email || '-' }}
|
|
</a-descriptions-item>
|
|
</a-descriptions>
|
|
</div>
|
|
|
|
<div class="action-section">
|
|
<a-button type="primary" @click="enterEditMode">
|
|
<EditOutlined /> 编辑资料
|
|
</a-button>
|
|
<a-button @click="showChangePasswordModal" style="margin-left: 12px">
|
|
<LockOutlined /> 修改密码
|
|
</a-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<a-empty v-else-if="!loading" description="加载失败,请刷新重试" />
|
|
</a-spin>
|
|
|
|
<!-- 编辑资料弹窗 -->
|
|
<a-modal
|
|
v-model:open="editModalOpen"
|
|
title="编辑个人信息"
|
|
@ok="submitEdit"
|
|
:confirmLoading="editLoading"
|
|
width="480px"
|
|
>
|
|
<a-form
|
|
:model="editForm"
|
|
:label-col="{ span: 6 }"
|
|
:wrapper-col="{ span: 16 }"
|
|
>
|
|
<a-form-item
|
|
label="姓名"
|
|
name="name"
|
|
:rules="[
|
|
{ required: true, message: '请输入姓名' },
|
|
{ pattern: /^[\u4e00-\u9fa5a-zA-Z\s]{2,20}$/, message: '姓名长度为 2-20 位,只能包含中文或英文' }
|
|
]"
|
|
>
|
|
<a-input v-model:value="editForm.name" placeholder="请输入姓名" />
|
|
</a-form-item>
|
|
|
|
<a-form-item
|
|
label="手机号"
|
|
name="phone"
|
|
:rules="[
|
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' }
|
|
]"
|
|
>
|
|
<a-input v-model:value="editForm.phone" placeholder="请输入手机号" />
|
|
</a-form-item>
|
|
|
|
<a-form-item
|
|
label="邮箱"
|
|
name="email"
|
|
:rules="[
|
|
{ type: 'email', message: '请输入正确的邮箱格式' }
|
|
]"
|
|
>
|
|
<a-input v-model:value="editForm.email" placeholder="请输入邮箱" />
|
|
</a-form-item>
|
|
</a-form>
|
|
</a-modal>
|
|
|
|
<!-- 修改密码弹窗 -->
|
|
<a-modal
|
|
v-model:open="passwordModalOpen"
|
|
title="修改密码"
|
|
@ok="submitChangePassword"
|
|
:confirmLoading="passwordLoading"
|
|
width="480px"
|
|
>
|
|
<a-form
|
|
:model="passwordForm"
|
|
:label-col="{ span: 6 }"
|
|
:wrapper-col="{ span: 16 }"
|
|
>
|
|
<a-form-item
|
|
label="旧密码"
|
|
name="oldPassword"
|
|
:rules="[{ required: true, message: '请输入旧密码' }]"
|
|
>
|
|
<a-input-password v-model:value="passwordForm.oldPassword" placeholder="请输入旧密码" />
|
|
</a-form-item>
|
|
|
|
<a-form-item
|
|
label="新密码"
|
|
name="newPassword"
|
|
:rules="[
|
|
{ required: true, message: '请输入新密码' },
|
|
{ min: 6, message: '密码长度不能少于 6 位' }
|
|
]"
|
|
>
|
|
<a-input-password v-model:value="passwordForm.newPassword" placeholder="请输入新密码" />
|
|
</a-form-item>
|
|
|
|
<a-form-item
|
|
label="确认密码"
|
|
name="confirmPassword"
|
|
:rules="[
|
|
{ required: true, message: '请确认新密码' },
|
|
{ validator: validateConfirmPassword }
|
|
]"
|
|
>
|
|
<a-input-password v-model:value="passwordForm.confirmPassword" placeholder="请再次输入新密码" />
|
|
</a-form-item>
|
|
</a-form>
|
|
</a-modal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue';
|
|
import { UserOutlined, EditOutlined, LockOutlined } from '@ant-design/icons-vue';
|
|
import { message } from 'ant-design-vue';
|
|
import { getProfile, type UserProfile, updateProfile, changePassword, type UpdateProfileDto } from '@/api/auth';
|
|
import { useRouter } from 'vue-router';
|
|
|
|
const router = useRouter();
|
|
const loading = ref(false);
|
|
const profile = ref<UserProfile | null>(null);
|
|
|
|
// 编辑资料相关
|
|
const editModalOpen = ref(false);
|
|
const editLoading = ref(false);
|
|
const editForm = ref<UpdateProfileDto>({
|
|
name: '',
|
|
phone: '',
|
|
email: '',
|
|
});
|
|
|
|
// 修改密码相关
|
|
const passwordModalOpen = ref(false);
|
|
const passwordLoading = ref(false);
|
|
const passwordForm = ref({
|
|
oldPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
});
|
|
|
|
const avatarUrl = computed(() => {
|
|
const p = profile.value;
|
|
if (!p) return '';
|
|
return (p.avatarUrl || p.avatar || '') as string;
|
|
});
|
|
|
|
const roleLabel = computed(() => {
|
|
const role = profile.value?.role;
|
|
const map: Record<string, string> = {
|
|
admin: '超管',
|
|
school: '学校管理员',
|
|
teacher: '教师',
|
|
parent: '家长',
|
|
};
|
|
return role ? map[role] || role : '-';
|
|
});
|
|
|
|
const loadProfile = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const data = await getProfile();
|
|
profile.value = data as UserProfile;
|
|
} catch (error: any) {
|
|
console.error('获取个人信息失败', error);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// 进入编辑模式
|
|
const enterEditMode = () => {
|
|
if (profile.value) {
|
|
editForm.value = {
|
|
name: profile.value.name || '',
|
|
phone: profile.value.phone || '',
|
|
email: profile.value.email || '',
|
|
};
|
|
}
|
|
editModalOpen.value = true;
|
|
};
|
|
|
|
// 提交编辑
|
|
const submitEdit = async () => {
|
|
try {
|
|
editLoading.value = true;
|
|
await updateProfile(editForm.value);
|
|
message.success('修改成功');
|
|
editModalOpen.value = false;
|
|
await loadProfile();
|
|
} catch (error: any) {
|
|
console.error('修改失败', error);
|
|
message.error(error.message || '修改失败,请重试');
|
|
} finally {
|
|
editLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// 显示修改密码弹窗
|
|
const showChangePasswordModal = () => {
|
|
passwordForm.value = {
|
|
oldPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
};
|
|
passwordModalOpen.value = true;
|
|
};
|
|
|
|
// 验证确认密码
|
|
const validateConfirmPassword = async (_rule: any, value: string) => {
|
|
if (value !== passwordForm.value.newPassword) {
|
|
throw new Error('两次输入的密码不一致');
|
|
}
|
|
};
|
|
|
|
// 提交修改密码
|
|
const submitChangePassword = async () => {
|
|
try {
|
|
passwordLoading.value = true;
|
|
await changePassword(passwordForm.value.oldPassword, passwordForm.value.newPassword);
|
|
message.success('密码修改成功,请重新登录');
|
|
passwordModalOpen.value = false;
|
|
|
|
// 清除本地存储并跳转到登录页
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('userInfo');
|
|
setTimeout(() => {
|
|
router.push('/login');
|
|
}, 1000);
|
|
} catch (error: any) {
|
|
console.error('修改密码失败', error);
|
|
message.error(error.message || '修改密码失败,请重试');
|
|
} finally {
|
|
passwordLoading.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
loadProfile();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.profile-view {
|
|
padding: 24px;
|
|
min-height: 100vh;
|
|
background: linear-gradient(180deg, #FFF8F0 0%, #FFFFFF 100%);
|
|
}
|
|
|
|
.page-header {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.page-header h1 {
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
color: #333;
|
|
margin: 0 0 8px 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.page-header p {
|
|
font-size: 14px;
|
|
color: #666;
|
|
margin: 0;
|
|
}
|
|
|
|
.profile-card {
|
|
background: white;
|
|
border-radius: 16px;
|
|
padding: 32px;
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
|
max-width: 560px;
|
|
}
|
|
|
|
.avatar-section {
|
|
text-align: center;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.profile-avatar {
|
|
background: linear-gradient(135deg, #FF8C42 0%, #FFB347 100%);
|
|
color: white;
|
|
}
|
|
|
|
.info-section :deep(.ant-descriptions-item-label) {
|
|
width: 100px;
|
|
font-weight: 500;
|
|
color: #666;
|
|
}
|
|
|
|
.action-section {
|
|
margin-top: 24px;
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
}
|
|
</style>
|