0841b1a103
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等) - 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板 - AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容 - 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled - 测试账号:13900999999 / test1234
1287 lines
35 KiB
Vue
1287 lines
35 KiB
Vue
<template>
|
||
<div class="profile-page" v-loading="loading">
|
||
<section class="profile-hero">
|
||
<div class="profile-avatar">{{ avatarText }}</div>
|
||
<div>
|
||
<span class="eyebrow">账号中心</span>
|
||
<h1>{{ form.name || '未命名教师' }}</h1>
|
||
<p>{{ profileLine }}</p>
|
||
</div>
|
||
<button type="button" class="primary-btn" :disabled="saving" @click="saveProfile">
|
||
<el-icon><Check /></el-icon>
|
||
{{ saving ? '保存中' : '保存资料' }}
|
||
</button>
|
||
</section>
|
||
|
||
<section class="profile-layout">
|
||
<main class="profile-card">
|
||
<div class="section-head">
|
||
<strong>基础资料</strong>
|
||
<span>用于生成更贴合学段、学科和校本场景的教学内容。</span>
|
||
</div>
|
||
|
||
<el-form :model="form" label-position="top" class="profile-form">
|
||
<div class="form-grid">
|
||
<el-form-item label="姓名">
|
||
<el-input v-model="form.name" placeholder="请输入教师姓名" />
|
||
</el-form-item>
|
||
<el-form-item label="手机号">
|
||
<el-input v-model="form.phone" disabled />
|
||
</el-form-item>
|
||
<el-form-item label="任教学科">
|
||
<el-select v-model="form.subject" filterable allow-create clearable placeholder="选择或输入学科" style="width: 100%">
|
||
<el-option v-for="item in subjects" :key="item" :label="item" :value="item" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="任教学段/年级">
|
||
<el-input v-model="form.grade" placeholder="例如:六年级、初二、高一" />
|
||
</el-form-item>
|
||
<el-form-item label="学校/机构">
|
||
<el-input v-model="form.school" placeholder="请输入学校或机构名称" />
|
||
</el-form-item>
|
||
<el-form-item label="角色">
|
||
<el-input :model-value="roleLabel" disabled />
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="头像">
|
||
<div class="avatar-upload-row">
|
||
<div class="avatar-preview" :style="avatarStyle">
|
||
<span v-if="!form.avatar">{{ avatarText }}</span>
|
||
</div>
|
||
<div class="avatar-controls">
|
||
<input ref="avatarFileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" class="hidden-file-input" @change="onAvatarFileChange" />
|
||
<button type="button" class="upload-btn" :disabled="uploadingAvatar" @click="avatarFileInput?.click()">
|
||
<el-icon><Upload /></el-icon>
|
||
{{ uploadingAvatar ? '上传中' : '上传图片' }}
|
||
</button>
|
||
<el-input v-model="form.avatar" placeholder="或填写图片 URL" size="small" />
|
||
</div>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
</main>
|
||
|
||
<main class="profile-card">
|
||
<div class="section-head">
|
||
<strong>账号安全</strong>
|
||
<span>定期修改密码,保障账号安全。</span>
|
||
</div>
|
||
<el-form :model="pwdForm" label-position="top" class="profile-form">
|
||
<div class="form-grid">
|
||
<el-form-item label="当前密码">
|
||
<el-input v-model="pwdForm.old_password" type="password" show-password placeholder="请输入当前密码" />
|
||
</el-form-item>
|
||
<el-form-item label="新密码">
|
||
<el-input v-model="pwdForm.new_password" type="password" show-password placeholder="6-50 位,需含字母和数字" />
|
||
</el-form-item>
|
||
<el-form-item label="确认新密码">
|
||
<el-input v-model="pwdForm.confirm" type="password" show-password placeholder="再次输入新密码" />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="pwd-actions">
|
||
<button type="button" class="primary-btn" :disabled="changingPwd" @click="changePassword">
|
||
<el-icon><Lock /></el-icon>
|
||
{{ changingPwd ? '修改中' : '修改密码' }}
|
||
</button>
|
||
</div>
|
||
</el-form>
|
||
</main>
|
||
|
||
<aside class="profile-side">
|
||
<div class="stats-card">
|
||
<div class="side-head">
|
||
<strong>创作看板</strong>
|
||
<span>实时统计</span>
|
||
</div>
|
||
<div class="hero-stat">
|
||
<div class="stat-number">{{ stats.credits }}</div>
|
||
<span>剩余积分</span>
|
||
</div>
|
||
<div class="stats-grid">
|
||
<div>
|
||
<b>{{ stats.total_works }}</b>
|
||
<span>累计作品</span>
|
||
</div>
|
||
<div>
|
||
<b>{{ stats.draft_works }}</b>
|
||
<span>草稿</span>
|
||
</div>
|
||
<div>
|
||
<b>{{ stats.published_resources }}</b>
|
||
<span>已发布</span>
|
||
</div>
|
||
<div>
|
||
<b>{{ stats.favorite_resources }}</b>
|
||
<span>收藏</span>
|
||
</div>
|
||
<div>
|
||
<b>{{ stats.classroom_activities }}</b>
|
||
<span>课堂活动</span>
|
||
</div>
|
||
<div>
|
||
<b>{{ stats.total_credits_used }}</b>
|
||
<span>已用积分</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="benefit-card">
|
||
<div>
|
||
<span>积分权益</span>
|
||
<strong>每日领取 {{ benefits.daily_amount }} 积分</strong>
|
||
<small>{{ benefits.next_claim_text || (benefits.daily_claimed ? '明日可继续领取' : '今日可领取') }}</small>
|
||
</div>
|
||
<button type="button" :disabled="claimingDaily || benefits.daily_claimed" @click="claimDailyCredits">
|
||
<el-icon><Medal /></el-icon>
|
||
{{ benefits.daily_claimed ? '已领取' : claimingDaily ? '领取中' : '立即领取' }}
|
||
</button>
|
||
<div class="checkin-row" v-if="benefits.recent_checkins.length">
|
||
<span v-for="item in benefits.recent_checkins.slice(0, 7)" :key="item.id">+{{ item.amount }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-card">
|
||
<div class="side-head">
|
||
<strong>资源运营</strong>
|
||
<button type="button" @click="router.push('/resources?tab=mine')">管理</button>
|
||
</div>
|
||
<div class="metric-list">
|
||
<div>
|
||
<span>浏览量</span>
|
||
<b>{{ stats.total_views }}</b>
|
||
</div>
|
||
<div>
|
||
<span>下载量</span>
|
||
<b>{{ stats.total_downloads }}</b>
|
||
</div>
|
||
<div>
|
||
<span>被收藏</span>
|
||
<b>{{ stats.total_favorites }}</b>
|
||
</div>
|
||
<div>
|
||
<span>私密发布</span>
|
||
<b>{{ stats.private_resources }}</b>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-card">
|
||
<div class="side-head">
|
||
<strong>教学互动</strong>
|
||
<button type="button" @click="router.push('/classroom')">进入课堂</button>
|
||
</div>
|
||
<div class="metric-list">
|
||
<div>
|
||
<span>进行中活动</span>
|
||
<b>{{ stats.active_classroom_activities }}</b>
|
||
</div>
|
||
<div>
|
||
<span>学生提交</span>
|
||
<b>{{ stats.classroom_responses }}</b>
|
||
</div>
|
||
<div>
|
||
<span>社区发布</span>
|
||
<b>{{ stats.community_posts }}</b>
|
||
</div>
|
||
<div>
|
||
<span>模板收藏</span>
|
||
<b>{{ stats.community_favorites }}</b>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="quick-card">
|
||
<strong>快捷入口</strong>
|
||
<button type="button" @click="router.push('/courseware')">
|
||
<el-icon><Document /></el-icon>
|
||
我的作品
|
||
</button>
|
||
<button type="button" @click="router.push('/resources')">
|
||
<el-icon><FolderOpened /></el-icon>
|
||
资源广场
|
||
</button>
|
||
<button type="button" @click="router.push('/resources?tab=mine')">
|
||
<el-icon><UploadFilled /></el-icon>
|
||
我的发布
|
||
</button>
|
||
<button type="button" @click="router.push('/resources?tab=favorites')">
|
||
<el-icon><Star /></el-icon>
|
||
我的收藏
|
||
</button>
|
||
<button type="button" @click="router.push('/classroom')">
|
||
<el-icon><Monitor /></el-icon>
|
||
课堂数据回收
|
||
</button>
|
||
<button type="button" @click="router.push('/community')">
|
||
<el-icon><ChatDotRound /></el-icon>
|
||
模板社区
|
||
</button>
|
||
<button type="button" @click="router.push('/community?scope=mine')">
|
||
<el-icon><EditPen /></el-icon>
|
||
我的模板
|
||
</button>
|
||
<button type="button" @click="router.push('/community?scope=favorite')">
|
||
<el-icon><Collection /></el-icon>
|
||
收藏模板
|
||
</button>
|
||
<button type="button" class="logout" @click="logout">
|
||
<el-icon><ArrowLeft /></el-icon>
|
||
退出登录
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
</section>
|
||
|
||
<section class="profile-activity">
|
||
<div class="activity-panel">
|
||
<div class="section-head">
|
||
<strong>最近作品</strong>
|
||
<button type="button" @click="router.push('/courseware')">全部作品</button>
|
||
</div>
|
||
<div v-if="stats.recent_works.length" class="activity-list">
|
||
<button v-for="item in stats.recent_works" :key="`${item.item_type}-${item.id}`" type="button" @click="openWork(item)">
|
||
<span>{{ typeLabel(item.item_type) }}</span>
|
||
<strong>{{ item.title }}</strong>
|
||
<small>{{ item.status ? statusLabel(item.status) : '已保存' }} · {{ formatDate(item.updated_at) }}</small>
|
||
</button>
|
||
</div>
|
||
<div v-else class="empty-box">
|
||
<strong>还没有作品</strong>
|
||
<span>从课件、动画、练习、教案或试卷开始创建,数据会自动汇总到这里。</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="activity-panel">
|
||
<div class="section-head">
|
||
<strong>最近发布</strong>
|
||
<button type="button" @click="router.push('/resources?tab=mine')">我的发布</button>
|
||
</div>
|
||
<div v-if="stats.recent_resources.length" class="activity-list resource-list">
|
||
<button v-for="item in stats.recent_resources" :key="`${item.item_type}-${item.id}`" type="button" @click="router.push(`/resources/${item.id}`)">
|
||
<span>{{ item.subtitle || '资源' }}</span>
|
||
<strong>{{ item.title }}</strong>
|
||
<small>{{ typeLabel(item.item_type) }} · {{ formatDate(item.updated_at) }}</small>
|
||
</button>
|
||
</div>
|
||
<div v-else class="empty-box">
|
||
<strong>还没有发布资源</strong>
|
||
<span>在“我的作品”中发布后,可进入资源广场复用、编辑或下架。</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="activity-panel">
|
||
<div class="section-head">
|
||
<strong>最近互动</strong>
|
||
<button type="button" @click="router.push('/classroom')">课堂工具</button>
|
||
</div>
|
||
<div v-if="stats.recent_interactions.length" class="activity-list interaction-list">
|
||
<button v-for="item in stats.recent_interactions" :key="`${item.item_type}-${item.id}`" type="button" @click="openItem(item)">
|
||
<span>{{ typeLabel(item.item_type) }}</span>
|
||
<strong>{{ item.title }}</strong>
|
||
<small>{{ item.subtitle || (item.status ? statusLabel(item.status) : '已更新') }} · {{ formatDate(item.updated_at) }}</small>
|
||
</button>
|
||
</div>
|
||
<div v-else class="empty-box">
|
||
<strong>还没有互动数据</strong>
|
||
<span>课堂投票、随堂测验和社区模板发布后,会在这里显示最近动态。</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="activity-panel">
|
||
<div class="section-head">
|
||
<strong>积分流水</strong>
|
||
<span>生成与解析消耗</span>
|
||
</div>
|
||
<div v-if="stats.recent_credit_transactions.length" class="credit-list">
|
||
<div v-for="item in stats.recent_credit_transactions" :key="item.id">
|
||
<span :class="{ plus: item.amount > 0 }">{{ item.amount > 0 ? '+' : '' }}{{ item.amount }}</span>
|
||
<strong>{{ creditActionLabel(item.action) }}</strong>
|
||
<small>{{ item.description || '创作权益' }} · 余额 {{ item.balance_after }} · {{ formatDate(item.created_at) }}</small>
|
||
</div>
|
||
</div>
|
||
<div v-else class="empty-box">
|
||
<strong>暂无积分流水</strong>
|
||
<span>生成课件、动画、练习、教案、试卷或解析材料后会记录在这里。</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="activity-panel">
|
||
<div class="section-head">
|
||
<strong>课件分享</strong>
|
||
<button type="button" @click="router.push('/courseware')">管理作品</button>
|
||
</div>
|
||
<div v-if="shares.length" class="share-list">
|
||
<div v-for="item in shares" :key="item.id">
|
||
<span>{{ item.views || 0 }} 次查看</span>
|
||
<strong>{{ item.title }}</strong>
|
||
<small>{{ item.subject || '通用' }} · {{ formatDate(item.updated_at) }}</small>
|
||
<div>
|
||
<button type="button" @click="openShare(item)">打开</button>
|
||
<button type="button" @click="copyShare(item)">复制</button>
|
||
<button type="button" class="danger" @click="revokeShare(item)">撤销</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="empty-box">
|
||
<strong>暂无公开分享</strong>
|
||
<span>在课件详情中点击“分享链接”后,会在这里管理公开访问链接。</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import { changePassword as changePasswordApi, claimDailyCheckin, getCreditBenefits, getProfileStats, updateProfile, uploadAvatar } from '@/api'
|
||
import { getMyCoursewareShares, revokeCoursewareShare } from '@/api/courseware'
|
||
import { useUserStore } from '@/stores/user'
|
||
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const claimingDaily = ref(false)
|
||
const shares = ref<any[]>([])
|
||
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '政治', '音乐', '美术', '体育', '信息技术', '课堂工具']
|
||
|
||
const form = reactive({
|
||
phone: '',
|
||
name: '',
|
||
avatar: '',
|
||
subject: '',
|
||
school: '',
|
||
grade: '',
|
||
role: 'teacher',
|
||
})
|
||
const pwdForm = reactive({ old_password: '', new_password: '', confirm: '' })
|
||
const changingPwd = ref(false)
|
||
const avatarFileInput = ref<HTMLInputElement | null>(null)
|
||
const uploadingAvatar = ref(false)
|
||
const avatarStyle = computed(() => form.avatar ? { backgroundImage: `url(${form.avatar})`, backgroundSize: 'cover', backgroundPosition: 'center' } : {})
|
||
|
||
interface RecentItem {
|
||
id: number
|
||
item_type: string
|
||
title: string
|
||
subtitle?: string
|
||
status?: string
|
||
updated_at?: string
|
||
}
|
||
|
||
interface CreditTransaction {
|
||
id: number
|
||
amount: number
|
||
action: string
|
||
description?: string
|
||
balance_after: number
|
||
created_at?: string
|
||
}
|
||
|
||
const stats = reactive({
|
||
credits: 0,
|
||
total_credits_granted: 0,
|
||
total_credits_used: 0,
|
||
total_works: 0,
|
||
draft_works: 0,
|
||
published_works: 0,
|
||
published_resources: 0,
|
||
public_resources: 0,
|
||
private_resources: 0,
|
||
favorite_resources: 0,
|
||
total_views: 0,
|
||
total_downloads: 0,
|
||
total_favorites: 0,
|
||
classroom_activities: 0,
|
||
active_classroom_activities: 0,
|
||
classroom_responses: 0,
|
||
community_posts: 0,
|
||
community_favorites: 0,
|
||
community_comments: 0,
|
||
works_by_type: {} as Record<string, number>,
|
||
resources_by_type: {} as Record<string, number>,
|
||
recent_works: [] as RecentItem[],
|
||
recent_resources: [] as RecentItem[],
|
||
recent_interactions: [] as RecentItem[],
|
||
recent_credit_transactions: [] as CreditTransaction[],
|
||
})
|
||
|
||
const benefits = reactive({
|
||
credits: 0,
|
||
daily_amount: 10,
|
||
daily_claimed: false,
|
||
next_claim_text: '',
|
||
recent_checkins: [] as CreditTransaction[],
|
||
})
|
||
|
||
const avatarText = computed(() => form.name?.[0] || '师')
|
||
const roleLabel = computed(() => form.role === 'admin' ? '管理员' : '教师')
|
||
const profileLine = computed(() => {
|
||
const parts = [form.school, form.grade, form.subject].filter(Boolean)
|
||
return parts.length ? parts.join(' · ') : '完善资料后,生成内容会自动带入你的教学场景。'
|
||
})
|
||
|
||
function fillForm(user: any) {
|
||
form.phone = user?.phone || ''
|
||
form.name = user?.name || ''
|
||
form.avatar = user?.avatar || ''
|
||
form.subject = user?.subject || ''
|
||
form.school = user?.school || ''
|
||
form.grade = user?.grade || ''
|
||
form.role = user?.role || 'teacher'
|
||
}
|
||
|
||
function fillStats(payload: any) {
|
||
Object.assign(stats, {
|
||
credits: payload?.credits || 0,
|
||
total_credits_granted: payload?.total_credits_granted || 0,
|
||
total_credits_used: payload?.total_credits_used || 0,
|
||
total_works: payload?.total_works || 0,
|
||
draft_works: payload?.draft_works || 0,
|
||
published_works: payload?.published_works || 0,
|
||
published_resources: payload?.published_resources || 0,
|
||
public_resources: payload?.public_resources || 0,
|
||
private_resources: payload?.private_resources || 0,
|
||
favorite_resources: payload?.favorite_resources || 0,
|
||
total_views: payload?.total_views || 0,
|
||
total_downloads: payload?.total_downloads || 0,
|
||
total_favorites: payload?.total_favorites || 0,
|
||
classroom_activities: payload?.classroom_activities || 0,
|
||
active_classroom_activities: payload?.active_classroom_activities || 0,
|
||
classroom_responses: payload?.classroom_responses || 0,
|
||
community_posts: payload?.community_posts || 0,
|
||
community_favorites: payload?.community_favorites || 0,
|
||
community_comments: payload?.community_comments || 0,
|
||
works_by_type: payload?.works_by_type || {},
|
||
resources_by_type: payload?.resources_by_type || {},
|
||
recent_works: payload?.recent_works || [],
|
||
recent_resources: payload?.recent_resources || [],
|
||
recent_interactions: payload?.recent_interactions || [],
|
||
recent_credit_transactions: payload?.recent_credit_transactions || [],
|
||
})
|
||
}
|
||
|
||
function fillBenefits(payload: any) {
|
||
Object.assign(benefits, {
|
||
credits: payload?.credits || 0,
|
||
daily_amount: payload?.daily_amount || 10,
|
||
daily_claimed: Boolean(payload?.daily_claimed),
|
||
next_claim_text: payload?.next_claim_text || '',
|
||
recent_checkins: payload?.recent_checkins || [],
|
||
})
|
||
}
|
||
|
||
function typeLabel(type: string) {
|
||
const labels: Record<string, string> = {
|
||
courseware: '课件',
|
||
animation: '动画',
|
||
exercise: '练习',
|
||
lesson_plan: '教案',
|
||
essay_grade: '作文批改',
|
||
exam: '试卷',
|
||
mindmap: '思维导图',
|
||
classroom_activity: '课堂',
|
||
community_post: '社区',
|
||
other: '资料',
|
||
}
|
||
return labels[type] || '作品'
|
||
}
|
||
|
||
function statusLabel(status: string) {
|
||
const labels: Record<string, string> = {
|
||
draft: '草稿',
|
||
published: '已发布',
|
||
active: '进行中',
|
||
closed: '已结束',
|
||
archived: '已归档',
|
||
}
|
||
return labels[status] || status
|
||
}
|
||
|
||
function creditActionLabel(action: string) {
|
||
const labels: Record<string, string> = {
|
||
courseware_generate: '生成课件',
|
||
animation_generate: '生成动画',
|
||
exercise_generate: '生成练习',
|
||
lesson_plan_generate: '生成教案',
|
||
exam_generate: '智能命题',
|
||
essay_grade: '作文批改',
|
||
mindmap_generate: '生成思维导图',
|
||
material_parse: '解析材料',
|
||
grant: '积分发放',
|
||
daily_checkin: '每日领取',
|
||
}
|
||
return labels[action] || '积分变动'
|
||
}
|
||
|
||
function formatDate(value?: string) {
|
||
if (!value) return '刚刚'
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return '刚刚'
|
||
return `${date.getMonth() + 1}/${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||
}
|
||
|
||
function openWork(item: RecentItem) {
|
||
const routes: Record<string, string> = {
|
||
courseware: `/courseware/${item.id}`,
|
||
animation: `/animation?open=${item.id}`,
|
||
exercise: `/exercise?open=${item.id}`,
|
||
lesson_plan: `/lesson-plan?open=${item.id}`,
|
||
essay_grade: `/essay-grade?open=${item.id}`,
|
||
exam: `/exam?open=${item.id}`,
|
||
mindmap: `/mindmap?open=${item.id}`,
|
||
}
|
||
router.push(routes[item.item_type] || '/courseware')
|
||
}
|
||
|
||
function openItem(item: RecentItem) {
|
||
const routes: Record<string, string> = {
|
||
classroom_activity: `/classroom?open=${item.id}`,
|
||
community_post: `/community/${item.id}`,
|
||
}
|
||
router.push(routes[item.item_type] || '/profile')
|
||
}
|
||
|
||
function shareFullUrl(item: any) {
|
||
if (item.share_url?.startsWith('http')) return item.share_url
|
||
return `${window.location.origin}${item.share_url || `/preview/share/${item.token}`}`
|
||
}
|
||
|
||
function openShare(item: any) {
|
||
window.open(shareFullUrl(item), '_blank')
|
||
}
|
||
|
||
async function copyShare(item: any) {
|
||
await navigator.clipboard?.writeText(shareFullUrl(item))
|
||
ElMessage.success('分享链接已复制')
|
||
}
|
||
|
||
async function revokeShare(item: any) {
|
||
await revokeCoursewareShare(item.id)
|
||
shares.value = shares.value.filter(share => share.id !== item.id)
|
||
ElMessage.success('分享已撤销')
|
||
}
|
||
|
||
async function saveProfile() {
|
||
if (!form.name.trim()) {
|
||
ElMessage.warning('请填写姓名')
|
||
return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
const updated: any = await updateProfile({
|
||
name: form.name,
|
||
avatar: form.avatar,
|
||
subject: form.subject,
|
||
school: form.school,
|
||
grade: form.grade,
|
||
})
|
||
userStore.user = updated.data || updated
|
||
fillForm(userStore.user)
|
||
ElMessage.success('资料已保存')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function claimDailyCredits() {
|
||
if (benefits.daily_claimed || claimingDaily.value) return
|
||
claimingDaily.value = true
|
||
try {
|
||
const res: any = await claimDailyCheckin()
|
||
const payload = res?.data || res
|
||
fillBenefits(payload)
|
||
stats.credits = payload?.credits || stats.credits
|
||
stats.total_credits_granted += benefits.daily_amount
|
||
userStore.user = {
|
||
...(userStore.user || {}),
|
||
credits: stats.credits,
|
||
total_credits_granted: stats.total_credits_granted,
|
||
}
|
||
const statRes: any = await getProfileStats()
|
||
fillStats(statRes?.data || statRes)
|
||
ElMessage.success(`已领取 ${benefits.daily_amount} 积分`)
|
||
} finally {
|
||
claimingDaily.value = false
|
||
}
|
||
}
|
||
|
||
async function onAvatarFileChange(e: Event) {
|
||
const target = e.target as HTMLInputElement
|
||
const file = target.files?.[0]
|
||
if (!file) return
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
ElMessage.warning('图片不能超过 5MB')
|
||
target.value = ''
|
||
return
|
||
}
|
||
uploadingAvatar.value = true
|
||
try {
|
||
const res: any = await uploadAvatar(file)
|
||
const data = res.data || res
|
||
if (data.avatar) {
|
||
form.avatar = data.avatar
|
||
ElMessage.success('头像上传成功')
|
||
}
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.detail || '头像上传失败')
|
||
} finally {
|
||
uploadingAvatar.value = false
|
||
target.value = ''
|
||
}
|
||
}
|
||
|
||
async function changePassword() {
|
||
if (!pwdForm.old_password || !pwdForm.new_password || !pwdForm.confirm) {
|
||
ElMessage.warning('请填写完整的密码信息')
|
||
return
|
||
}
|
||
if (pwdForm.new_password.length < 6) {
|
||
ElMessage.warning('新密码至少 6 位')
|
||
return
|
||
}
|
||
if (!/[a-zA-Z]/.test(pwdForm.new_password) || !/\d/.test(pwdForm.new_password)) {
|
||
ElMessage.warning('新密码必须同时含有字母和数字')
|
||
return
|
||
}
|
||
if (pwdForm.new_password !== pwdForm.confirm) {
|
||
ElMessage.warning('两次输入的新密码不一致')
|
||
return
|
||
}
|
||
if (pwdForm.old_password === pwdForm.new_password) {
|
||
ElMessage.warning('新密码不能与当前密码相同')
|
||
return
|
||
}
|
||
changingPwd.value = true
|
||
try {
|
||
await changePasswordApi({ old_password: pwdForm.old_password, new_password: pwdForm.new_password })
|
||
ElMessage.success('密码修改成功')
|
||
pwdForm.old_password = ''
|
||
pwdForm.new_password = ''
|
||
pwdForm.confirm = ''
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.detail || '密码修改失败')
|
||
} finally {
|
||
changingPwd.value = false
|
||
}
|
||
}
|
||
|
||
function logout() {
|
||
userStore.clearToken()
|
||
router.push('/')
|
||
}
|
||
|
||
onMounted(async () => {
|
||
loading.value = true
|
||
try {
|
||
const [user, statRes, benefitRes, shareRes] = await Promise.all([
|
||
userStore.user || userStore.fetchProfile(),
|
||
getProfileStats(),
|
||
getCreditBenefits(),
|
||
getMyCoursewareShares({ limit: 8 }),
|
||
])
|
||
fillForm(user)
|
||
fillStats((statRes as any).data || statRes)
|
||
fillBenefits((benefitRes as any).data || benefitRes)
|
||
shares.value = Array.isArray((shareRes as any)?.data) ? (shareRes as any).data : Array.isArray(shareRes) ? shareRes : []
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.profile-page {
|
||
min-height: calc(100vh - var(--header-height));
|
||
padding: 28px 44px 36px;
|
||
background: #f7faf6;
|
||
color: #173f35;
|
||
}
|
||
|
||
.profile-hero,
|
||
.profile-layout,
|
||
.profile-activity {
|
||
width: min(100%, 1180px);
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.profile-hero {
|
||
display: grid;
|
||
grid-template-columns: 72px minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 18px;
|
||
margin-bottom: 18px;
|
||
border: 1px solid #dce8de;
|
||
border-radius: 16px;
|
||
background: #fff;
|
||
padding: 22px;
|
||
box-shadow: 0 14px 32px rgba(17, 69, 52, 0.06);
|
||
}
|
||
|
||
.profile-avatar {
|
||
width: 72px;
|
||
height: 72px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 18px;
|
||
background: linear-gradient(135deg, #d9f0d6, #8ecf9a);
|
||
color: #00543d;
|
||
font-size: 30px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.eyebrow {
|
||
display: inline-flex;
|
||
height: 24px;
|
||
align-items: center;
|
||
border-radius: 12px;
|
||
background: #e6f6e5;
|
||
color: #00543d;
|
||
padding: 0 10px;
|
||
font-size: 12px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.profile-hero h1 {
|
||
margin: 9px 0 6px;
|
||
color: #00543d;
|
||
font-size: 28px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.profile-hero p {
|
||
color: #617971;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.avatar-upload-row { display: flex; gap: 14px; align-items: center; width: 100%; }
|
||
.avatar-preview { width: 64px; height: 64px; border-radius: 50%; background: #f1f5f9; display: flex; align-items: center; justify-content: center; font-size: 22px; font-weight: 700; color: #4f46e5; flex-shrink: 0; border: 2px solid #e2e8f0; }
|
||
.avatar-controls { flex: 1; display: flex; flex-direction: column; gap: 8px; }
|
||
.upload-btn { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 6px 14px; border: 1px solid #c7d2fe; border-radius: 8px; background: #eef2ff; color: #4f46e5; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
|
||
.upload-btn:hover:not(:disabled) { background: #e0e7ff; }
|
||
.upload-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.hidden-file-input { display: none; }
|
||
.pwd-actions { display: flex; justify-content: flex-start; margin-top: 8px; }
|
||
.primary-btn {
|
||
height: 38px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 7px;
|
||
border: none;
|
||
border-radius: 19px;
|
||
background: #00543d;
|
||
color: #fff;
|
||
padding: 0 18px;
|
||
font-weight: 900;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.primary-btn:disabled {
|
||
opacity: .62;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.profile-layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 300px;
|
||
gap: 18px;
|
||
}
|
||
|
||
.profile-card,
|
||
.stats-card,
|
||
.quick-card,
|
||
.activity-panel {
|
||
border: 1px solid #dce8de;
|
||
border-radius: 14px;
|
||
background: #fff;
|
||
}
|
||
|
||
.profile-card {
|
||
padding: 22px;
|
||
}
|
||
|
||
.section-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 18px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.section-head strong,
|
||
.side-head strong,
|
||
.quick-card strong {
|
||
color: #173f35;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.section-head span,
|
||
.side-head span {
|
||
color: #75867f;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.section-head button,
|
||
.side-head button {
|
||
border: none;
|
||
background: transparent;
|
||
color: #00543d;
|
||
font-weight: 900;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||
gap: 0 14px;
|
||
}
|
||
|
||
.profile-form :deep(.el-form-item) {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.profile-side {
|
||
display: grid;
|
||
gap: 14px;
|
||
align-content: start;
|
||
}
|
||
|
||
.stats-card {
|
||
padding: 18px;
|
||
}
|
||
|
||
.benefit-card {
|
||
display: grid;
|
||
gap: 12px;
|
||
border: 1px solid rgba(0, 84, 61, .18);
|
||
border-radius: 14px;
|
||
background:
|
||
radial-gradient(circle at 86% 18%, rgba(255, 222, 108, .46), transparent 34%),
|
||
linear-gradient(135deg, #f8fff5, #e7f6e5);
|
||
padding: 18px;
|
||
}
|
||
|
||
.benefit-card > div:first-child {
|
||
display: grid;
|
||
gap: 5px;
|
||
}
|
||
|
||
.benefit-card span {
|
||
color: #6d7f77;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.benefit-card strong {
|
||
color: #00543d;
|
||
font-size: 18px;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.benefit-card small {
|
||
color: #51665f;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.benefit-card button {
|
||
height: 36px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 7px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
background: #00543d;
|
||
color: #fff;
|
||
font-weight: 900;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.benefit-card button:disabled {
|
||
background: #9eb8ad;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.checkin-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||
gap: 5px;
|
||
}
|
||
|
||
.checkin-row span {
|
||
height: 24px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 7px;
|
||
background: rgba(255, 255, 255, .76);
|
||
color: #00543d;
|
||
font-size: 11px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.side-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.hero-stat {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.stat-number {
|
||
color: #00543d;
|
||
font-size: 42px;
|
||
font-weight: 900;
|
||
line-height: 1;
|
||
}
|
||
|
||
.hero-stat span {
|
||
display: block;
|
||
margin-top: 6px;
|
||
color: #51665f;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.stats-grid div {
|
||
min-width: 0;
|
||
border-radius: 10px;
|
||
background: #f6fbf6;
|
||
padding: 10px;
|
||
}
|
||
|
||
.stats-grid b,
|
||
.metric-list b {
|
||
display: block;
|
||
color: #173f35;
|
||
font-size: 20px;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.stats-grid span,
|
||
.metric-list span {
|
||
display: block;
|
||
margin-top: 4px;
|
||
color: #75867f;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.metric-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.metric-list div {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
border-bottom: 1px solid #eef4ef;
|
||
padding-bottom: 10px;
|
||
}
|
||
|
||
.metric-list div:last-child {
|
||
border-bottom: none;
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
.metric-list span {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.profile-activity {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||
gap: 18px;
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.activity-panel {
|
||
min-width: 0;
|
||
padding: 18px;
|
||
}
|
||
|
||
.activity-panel .section-head {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.activity-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.credit-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.share-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.share-list > div {
|
||
min-width: 0;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 4px 10px;
|
||
align-items: center;
|
||
border: 1px solid #e2ece4;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
padding: 10px;
|
||
}
|
||
|
||
.share-list span {
|
||
grid-column: 1 / -1;
|
||
color: #00543d;
|
||
font-size: 12px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.share-list strong {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
color: #173f35;
|
||
font-size: 14px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.share-list small {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
color: #7a8b84;
|
||
font-size: 12px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.share-list div div {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.share-list button {
|
||
height: 28px;
|
||
border: 1px solid #dbe8de;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
color: #31584d;
|
||
padding: 0 8px;
|
||
font-size: 12px;
|
||
font-weight: 900;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.share-list button:hover {
|
||
border-color: #9ed0aa;
|
||
background: #f6fbf6;
|
||
color: #00543d;
|
||
}
|
||
|
||
.share-list button.danger {
|
||
color: #a64028;
|
||
}
|
||
|
||
.credit-list div {
|
||
display: grid;
|
||
grid-template-columns: 48px minmax(0, 1fr);
|
||
gap: 3px 10px;
|
||
align-items: center;
|
||
border: 1px solid #e2ece4;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
padding: 10px;
|
||
}
|
||
|
||
.credit-list span {
|
||
grid-row: span 2;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 34px;
|
||
border-radius: 8px;
|
||
background: #fff2e2;
|
||
color: #a64028;
|
||
font-size: 13px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.credit-list span.plus {
|
||
background: #eaf7e9;
|
||
color: #00543d;
|
||
}
|
||
|
||
.credit-list strong {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
color: #173f35;
|
||
font-size: 14px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.credit-list small {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
color: #7a8b84;
|
||
font-size: 12px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.activity-list button {
|
||
min-width: 0;
|
||
display: grid;
|
||
grid-template-columns: 56px minmax(0, 1fr);
|
||
align-items: center;
|
||
gap: 4px 10px;
|
||
border: 1px solid #e2ece4;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
padding: 10px;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.activity-list button:hover {
|
||
border-color: #9ed0aa;
|
||
background: #f6fbf6;
|
||
}
|
||
|
||
.activity-list span {
|
||
grid-row: span 2;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 34px;
|
||
border-radius: 8px;
|
||
background: #eaf7e9;
|
||
color: #00543d;
|
||
font-size: 12px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.resource-list span {
|
||
background: #fff4cf;
|
||
color: #7a5600;
|
||
}
|
||
|
||
.interaction-list span {
|
||
background: #eef2ff;
|
||
color: #334ca3;
|
||
}
|
||
|
||
.activity-list strong {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
color: #173f35;
|
||
font-size: 14px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.activity-list small {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
color: #7a8b84;
|
||
font-size: 12px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.empty-box {
|
||
min-height: 126px;
|
||
display: grid;
|
||
align-content: center;
|
||
gap: 8px;
|
||
border: 1px dashed #cfe0d3;
|
||
border-radius: 12px;
|
||
background: #fbfdfb;
|
||
padding: 16px;
|
||
}
|
||
|
||
.empty-box strong {
|
||
color: #173f35;
|
||
}
|
||
|
||
.empty-box span {
|
||
color: #75867f;
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.quick-card {
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 18px;
|
||
}
|
||
|
||
.quick-card button {
|
||
height: 38px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
gap: 8px;
|
||
border: 1px solid #dbe8de;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
color: #31584d;
|
||
padding: 0 12px;
|
||
font-weight: 900;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.quick-card button:hover {
|
||
border-color: #9ed0aa;
|
||
background: #f6fbf6;
|
||
color: #00543d;
|
||
}
|
||
|
||
.quick-card .logout {
|
||
color: #a64028;
|
||
}
|
||
|
||
@media (max-width: 920px) {
|
||
.profile-page {
|
||
padding: 16px;
|
||
}
|
||
|
||
.profile-layout,
|
||
.profile-hero,
|
||
.profile-activity {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.profile-hero {
|
||
align-items: flex-start;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 620px) {
|
||
.form-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.section-head {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
</style>
|