Files
jiaoyu/frontend/src/views/Profile.vue
T
Zhang Jing Xuan 0841b1a103 智教助手平台:完整初始化
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
2026-07-29 16:29:10 +08:00

1287 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>