first commit

This commit is contained in:
2026-02-27 10:37:11 +08:00
commit 74f19aad0b
86 changed files with 18642 additions and 0 deletions

548
templates/profile.html Normal file
View File

@@ -0,0 +1,548 @@
{% extends "base.html" %}
{% block title %}个人中心 - 智联青云{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto space-y-6">
<!-- 顶部横幅与头像:高级游戏化设计 -->
<div class="bg-white rounded-3xl shadow-xl border border-slate-100 overflow-hidden relative group">
<div class="h-48 bg-gradient-to-r from-indigo-600 via-purple-600 to-blue-600 relative overflow-hidden">
<!-- 高级光影动画背景 -->
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width=\'40\' height=\'40\' viewBox=\'0 0 40 40\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M20 20.5V18H0v-2h20v-2H0v-2h20v-2H0V8h20V6H0V4h20V2H0V0h22v20h2V0h2v20h2V0h2v20h2V0h2v20h2V0h2v20h2v2H20v-1.5zM0 20h2v20H0V20zm4 0h2v20H4V20zm4 0h2v20H8V20zm4 0h2v20h-2V20zm4 0h2v20h-2V20zm4 4h20v2H20v-2zm0 4h20v2H20v-2zm0 4h20v2H20v-2zm0 4h20v2H20v-2z\' fill=\'%23ffffff\' fill-opacity=\'0.05\' fill-rule=\'evenodd\'/%3E%3C/svg%3E')] opacity-50"></div>
<div class="absolute top-0 right-0 w-96 h-96 bg-white/20 blur-3xl rounded-full translate-x-1/2 -translate-y-1/2 transform group-hover:scale-110 transition-transform duration-1000"></div>
<div class="absolute bottom-0 left-0 w-64 h-64 bg-indigo-500/30 blur-3xl rounded-full -translate-x-1/2 translate-y-1/2"></div>
</div>
<div class="px-6 pb-8 sm:px-12 relative">
<div class="flex flex-col sm:flex-row items-center sm:items-end -mt-20 sm:-mt-24 sm:space-x-8">
<!-- 头像区域(呼吸发光效果) -->
<div class="relative group/avatar">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full blur-md opacity-40 group-hover/avatar:opacity-60 animate-pulse transition-opacity"></div>
<div id="avatar-display" class="relative w-36 h-36 rounded-full overflow-hidden border-4 border-white shadow-xl flex items-center justify-center bg-gradient-to-br from-indigo-50 to-blue-50 text-indigo-600 text-5xl font-black cursor-pointer transform group-hover/avatar:scale-105 group-hover/avatar:rotate-3 transition-all duration-300 z-10" onclick="uploadAvatar()">
{% if profile_user.avatar %}
<img src="{{ profile_user.avatar }}" class="w-full h-full object-cover" id="avatar-img">
{% else %}
<span id="avatar-letter">{{ profile_user.name[0]|upper }}</span>
{% endif %}
</div>
{% if profile_user.id == user.id %}
<div class="absolute bottom-1 right-1 bg-white p-2.5 rounded-full shadow-lg border-2 border-slate-100 cursor-pointer text-slate-500 hover:text-indigo-600 hover:border-indigo-200 transition-all z-20 transform hover:scale-110" onclick="uploadAvatar()">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
{% endif %}
<!-- 浮动等级徽章 -->
<div class="absolute -top-2 -right-2 bg-slate-900 text-white text-xs font-black px-3 py-1.5 rounded-full shadow-lg border-2 border-white z-20 transform -rotate-12 group-hover/avatar:rotate-0 transition-transform">
Lv.{{ level }}
</div>
</div>
<!-- 个人信息 -->
<div class="mt-5 sm:mt-0 text-center sm:text-left flex-1 pb-2">
<h1 class="text-4xl font-extrabold text-slate-900 flex items-center justify-center sm:justify-start gap-4 tracking-tight drop-shadow-sm">
{{ get_display_name(profile_user.id, profile_user.name) if profile_user.id == user.id else profile_user.name }}
{% if profile_user.role == 'admin' %}
<span class="px-3 py-1 rounded-xl text-xs font-bold bg-gradient-to-r from-red-500 to-rose-600 text-white shadow-sm shadow-red-200 transform hover:scale-105 transition-transform">管理员</span>
{% elif profile_user.role == 'teacher' %}
<span class="px-3 py-1 rounded-xl text-xs font-bold bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-sm shadow-blue-200 transform hover:scale-105 transition-transform flex items-center gap-1">👨‍🏫 认证教师</span>
{% else %}
<span class="px-3 py-1 rounded-xl text-xs font-bold bg-gradient-to-r from-emerald-400 to-teal-500 text-white shadow-sm shadow-emerald-200 transform hover:scale-105 transition-transform flex items-center gap-1">🎓 学生</span>
{% endif %}
</h1>
<div class="flex flex-wrap items-center justify-center sm:justify-start gap-5 mt-4 text-sm font-medium text-slate-500">
{% if profile_user.email %}
<span class="flex items-center gap-1.5 bg-slate-50 px-3 py-1.5 rounded-lg border border-slate-100 hover:bg-slate-100 transition-colors"><svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>{{ profile_user.email }}</span>
{% endif %}
{% if profile_user.phone %}
<span class="flex items-center gap-1.5 bg-slate-50 px-3 py-1.5 rounded-lg border border-slate-100 hover:bg-slate-100 transition-colors"><svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>{{ profile_user.phone }}</span>
{% endif %}
<span class="flex items-center gap-1.5 bg-indigo-50 text-indigo-600 px-3 py-1.5 rounded-lg border border-indigo-100"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>加入于 {{ profile_user.created_at.strftime('%Y-%m-%d') }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左侧:统计与信息 -->
<div class="space-y-6 lg:col-span-1">
<!-- 游戏化数据统计Bento 风格) -->
<div class="bg-white rounded-3xl p-6 shadow-sm border border-slate-100 relative overflow-hidden group">
<div class="absolute top-0 right-0 w-32 h-32 bg-indigo-50 rounded-bl-full -z-10 group-hover:scale-110 transition-transform duration-500"></div>
<h3 class="text-xl font-extrabold text-slate-900 mb-5 flex items-center">
<span class="w-8 h-8 bg-indigo-100 text-indigo-600 rounded-xl flex items-center justify-center mr-3 shadow-sm">📊</span>
活跃数据
</h3>
<!-- 积分进度条 -->
<div class="mb-6 bg-slate-50 rounded-2xl p-4 border border-slate-100">
<div class="flex justify-between text-xs font-bold text-slate-600 mb-2">
<span>当前等级进度</span>
<span class="text-indigo-600">{{ points }} / {{ (level * 100) }} XP</span>
</div>
<div class="h-3 w-full bg-slate-200 rounded-full overflow-hidden shadow-inner relative">
{% set progress = (points / (level * 100) * 100) | int %}
{% set p_width = progress if progress <= 100 else 100 %}
<div class="absolute top-0 left-0 h-full bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full transition-all duration-1000 ease-out overflow-hidden" style="width: {{ p_width }}%;">
<div class="absolute inset-0 bg-white/30 w-full h-full transform -skew-x-12 translate-x-full" style="animation: shimmer 2s infinite;"></div>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-gradient-to-br from-indigo-50 to-blue-50 rounded-2xl p-4 text-center border border-indigo-100/50 hover:shadow-md hover:-translate-y-1 transition-all duration-300">
<div class="text-3xl font-black text-indigo-600 drop-shadow-sm">{{ points }}</div>
<div class="text-[11px] font-bold text-indigo-400 uppercase tracking-widest mt-1">总积分</div>
</div>
<div class="bg-gradient-to-br from-emerald-50 to-teal-50 rounded-2xl p-4 text-center border border-emerald-100/50 hover:shadow-md hover:-translate-y-1 transition-all duration-300">
<div class="text-3xl font-black text-emerald-600 drop-shadow-sm">{{ post_count }}</div>
<div class="text-[11px] font-bold text-emerald-500 uppercase tracking-widest mt-1">发帖数</div>
</div>
<div class="bg-gradient-to-br from-amber-50 to-orange-50 rounded-2xl p-4 text-center border border-amber-100/50 hover:shadow-md hover:-translate-y-1 transition-all duration-300">
<div class="text-3xl font-black text-amber-600 drop-shadow-sm">{{ reply_count }}</div>
<div class="text-[11px] font-bold text-amber-500 uppercase tracking-widest mt-1">回复数</div>
</div>
<div class="bg-gradient-to-br from-rose-50 to-pink-50 rounded-2xl p-4 text-center border border-rose-100/50 hover:shadow-md hover:-translate-y-1 transition-all duration-300">
<div class="text-3xl font-black text-rose-500 drop-shadow-sm">{{ likes_received }}</div>
<div class="text-[11px] font-bold text-rose-400 uppercase tracking-widest mt-1">获赞数</div>
</div>
</div>
</div>
<!-- 账号设置 -->
{% if profile_user.id == user.id %}
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h3 class="text-lg font-bold text-slate-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
账号设置
</h3>
<div class="space-y-4">
<div class="flex items-center justify-between p-3 bg-slate-50 rounded-xl">
<div>
<div class="text-sm font-medium text-slate-900">用户名</div>
<div class="text-xs text-slate-500 mt-0.5" id="display-username">{{ profile_user.name }}</div>
</div>
<button onclick="changeName()" class="px-3 py-1.5 text-xs font-medium text-primary bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors">修改</button>
</div>
{% if user.role == 'student' %}
<a href="/apply-teacher" class="flex items-center justify-between p-3 bg-slate-50 rounded-xl hover:bg-slate-100 transition-colors group">
<div>
<div class="text-sm font-medium text-slate-900 group-hover:text-primary transition-colors">申请成为老师</div>
<div class="text-xs text-slate-500 mt-0.5">获取发布赛事和考试的权限</div>
</div>
<svg class="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</a>
{% endif %}
{% if user.role == 'admin' or user.role == 'teacher' %}
<a href="/admin" class="flex items-center justify-between p-3 bg-slate-50 rounded-xl hover:bg-slate-100 transition-colors group">
<div>
<div class="text-sm font-medium text-slate-900 group-hover:text-primary transition-colors">进入管理后台</div>
<div class="text-xs text-slate-500 mt-0.5">管理系统各项数据</div>
</div>
<svg class="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
<!-- 右侧:主要内容 -->
<div class="space-y-6 lg:col-span-2">
<!-- 快捷入口(玻璃拟物化卡片) -->
{% if profile_user.id == user.id %}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<a href="/notifications" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:shadow-lg hover:border-blue-200 hover:-translate-y-1 transition-all duration-300 text-center group">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-blue-100 to-indigo-100 rounded-2xl flex items-center justify-center text-blue-600 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform mb-3">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
</div>
<div class="text-sm font-bold text-slate-700 group-hover:text-blue-600 transition-colors">通知中心</div>
</a>
<a href="/chat" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:shadow-lg hover:border-emerald-200 hover:-translate-y-1 transition-all duration-300 text-center group">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-emerald-100 to-teal-100 rounded-2xl flex items-center justify-center text-emerald-600 shadow-inner group-hover:scale-110 group-hover:-rotate-6 transition-transform mb-3">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
</div>
<div class="text-sm font-bold text-slate-700 group-hover:text-emerald-600 transition-colors">我的消息</div>
</a>
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:shadow-lg hover:border-purple-200 hover:-translate-y-1 transition-all duration-300 text-center group cursor-pointer" onclick="document.getElementById('exam-history-tab').scrollIntoView({behavior:'smooth'})">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-purple-100 to-fuchsia-100 rounded-2xl flex items-center justify-center text-purple-600 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform mb-3">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
</div>
<div class="text-sm font-bold text-slate-700 group-hover:text-purple-600 transition-colors">考试记录</div>
</div>
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:shadow-lg hover:border-amber-200 hover:-translate-y-1 transition-all duration-300 text-center group cursor-pointer" onclick="document.getElementById('bookmarks-tab').scrollIntoView({behavior:'smooth'})">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-amber-100 to-orange-100 rounded-2xl flex items-center justify-center text-amber-600 shadow-inner group-hover:scale-110 group-hover:-rotate-6 transition-transform mb-3">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
</div>
<div class="text-sm font-bold text-slate-700 group-hover:text-amber-600 transition-colors">我的收藏</div>
</div>
</div>
{% endif %}
<!-- 高级选项卡内容区 -->
<div class="bg-white shadow-sm rounded-3xl border border-slate-100 overflow-hidden">
<!-- 游戏化标签栏 -->
<div class="p-2 bg-slate-50/80 border-b border-slate-100">
<div class="flex gap-2 overflow-x-auto hide-scrollbar">
<button class="flex-1 min-w-[120px] px-6 py-3.5 text-sm font-bold text-indigo-600 bg-white rounded-xl shadow-sm transition-all duration-300 whitespace-nowrap" id="tab-btn-history" onclick="switchTab('history')">📜 考试经历</button>
<button class="flex-1 min-w-[120px] px-6 py-3.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-white/60 bg-transparent rounded-xl transition-all duration-300 whitespace-nowrap" id="tab-btn-posts" onclick="switchTab('posts')">📝 我的帖子</button>
<button class="flex-1 min-w-[120px] px-6 py-3.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-white/60 bg-transparent rounded-xl transition-all duration-300 whitespace-nowrap" id="tab-btn-bookmarks" onclick="switchTab('bookmarks')">⭐ 收藏试卷</button>
<button class="flex-1 min-w-[120px] px-6 py-3.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-white/60 bg-transparent rounded-xl transition-all duration-300 whitespace-nowrap" id="tab-btn-friends" onclick="switchTab('friends')">👥 好友列表</button>
</div>
</div>
<div class="p-6 min-h-[400px]">
<!-- 考试经历 -->
<div id="tab-content-history" class="space-y-4" id="exam-history-tab">
<div id="exam-history">
<div class="flex justify-center items-center py-12 text-slate-400">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
加载中...
</div>
</div>
</div>
<!-- 我的帖子 -->
<div id="tab-content-posts" class="space-y-4 hidden">
<div id="my-posts"></div>
</div>
<!-- 收藏试卷 -->
<div id="tab-content-bookmarks" class="space-y-4 hidden" id="bookmarks-tab">
<div id="bookmarked-exams"></div>
</div>
<!-- 好友列表 -->
<div id="tab-content-friends" class="space-y-4 hidden">
<!-- 搜索用户 -->
{% if profile_user.id == user.id %}
<div class="bg-slate-50 rounded-xl p-4 space-y-3">
<h4 class="text-sm font-bold text-slate-700">搜索用户</h4>
<div class="flex gap-2">
<input id="friend-search-input" type="text" placeholder="输入用户名搜索..." class="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
<button onclick="searchUsers()" class="px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-blue-600 transition-colors">搜索</button>
</div>
<div id="search-results"></div>
</div>
<!-- 好友请求 -->
<div class="bg-amber-50 rounded-xl p-4 space-y-3">
<h4 class="text-sm font-bold text-amber-700">好友请求</h4>
<div id="friend-requests"></div>
</div>
{% endif %}
<!-- 好友列表 -->
<div id="friends-list" class="grid grid-cols-1 sm:grid-cols-2 gap-4"></div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function uploadAvatar() {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*';
inp.onchange = () => { if (inp.files.length) doUploadAvatar(inp.files[0]); };
inp.click();
}
function cameraAvatar() {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.capture = 'user';
inp.onchange = () => { if (inp.files.length) doUploadAvatar(inp.files[0]); };
inp.click();
}
async function doUploadAvatar(file) {
if (file.size > 10*1024*1024) { alert('文件不能超过10MB'); return; }
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/api/user/avatar', {method:'POST', body: fd});
const d = await res.json();
if (d.success) {
const display = document.getElementById('avatar-display');
display.innerHTML = `<img src="${d.url}" class="w-full h-full object-cover" id="avatar-img">`;
alert('头像更新成功');
} else { alert(d.message); }
} catch(e) { alert('上传失败'); }
}
async function loadFriends() {
const container = document.getElementById('friends-list');
try {
const res = await fetch('/api/user/friends');
const data = await res.json();
if (!data.success) throw new Error(data.message);
if (data.friends.length === 0) {
container.innerHTML = '<div class="col-span-2 text-center py-4 text-slate-400">暂无好友</div>';
return;
}
let html = '';
data.friends.forEach(f => {
html += `
<div class="flex items-center space-x-3 p-3 border border-slate-100 rounded-xl bg-white">
<div class="w-10 h-10 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-sm font-bold overflow-hidden">
${f.avatar ? `<img src="${f.avatar}" class="w-full h-full object-cover">` : f.name.charAt(0).toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-900 truncate">${f.name}</div>
<div class="text-xs text-slate-400">好友 · ${f.created_at}</div>
</div>
<a href="/chat?dm=${f.id}" class="px-3 py-1.5 text-xs bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors font-medium">私聊</a>
</div>`;
});
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="col-span-2 text-center py-4 text-red-500">加载失败</div>';
}
loadFriendRequests();
}
async function searchUsers() {
const q = document.getElementById('friend-search-input').value.trim();
const container = document.getElementById('search-results');
if (!q) { container.innerHTML = ''; return; }
try {
const res = await fetch('/api/users/search?q=' + encodeURIComponent(q));
const data = await res.json();
if (!data.success || data.users.length === 0) {
container.innerHTML = '<div class="text-sm text-slate-400 py-2">未找到用户</div>';
return;
}
let html = '';
data.users.forEach(u => {
let actionBtn = '';
if (u.friend_status === 'accepted') {
actionBtn = '<span class="text-xs text-green-600 font-medium">已是好友</span>';
} else if (u.friend_status === 'pending') {
actionBtn = '<span class="text-xs text-amber-600 font-medium">已申请</span>';
} else {
actionBtn = `<button onclick="addFriend(${u.id}, this)" class="px-3 py-1 text-xs bg-primary text-white rounded-lg hover:bg-blue-600 transition-colors">加好友</button>`;
}
html += `
<div class="flex items-center space-x-3 p-2 rounded-lg hover:bg-white transition-colors">
<div class="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-xs font-bold overflow-hidden">
${u.avatar ? `<img src="${u.avatar}" class="w-full h-full object-cover">` : u.name.charAt(0).toUpperCase()}
</div>
<div class="flex-1 min-w-0"><div class="text-sm font-medium text-slate-800 truncate">${u.name}</div></div>
${actionBtn}
</div>`;
});
container.innerHTML = html;
} catch(e) { container.innerHTML = '<div class="text-sm text-red-500 py-2">搜索失败</div>'; }
}
async function addFriend(userId, btn) {
try {
btn.disabled = true; btn.textContent = '发送中...';
const res = await fetch('/api/friend/add', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({friend_id: userId})});
const data = await res.json();
if (data.success) { btn.outerHTML = '<span class="text-xs text-amber-600 font-medium">已申请</span>'; }
else { alert(data.message); btn.disabled = false; btn.textContent = '加好友'; }
} catch(e) { alert('发送失败'); btn.disabled = false; btn.textContent = '加好友'; }
}
async function loadFriendRequests() {
const container = document.getElementById('friend-requests');
if (!container) return;
try {
const res = await fetch('/api/friend/requests');
const data = await res.json();
if (!data.success || data.requests.length === 0) {
container.innerHTML = '<div class="text-sm text-amber-600/60 py-1">暂无待处理请求</div>';
return;
}
let html = '';
data.requests.forEach(r => {
html += `
<div class="flex items-center space-x-3 p-2 rounded-lg bg-white" id="freq-${r.id}">
<div class="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-xs font-bold overflow-hidden">
${r.avatar ? `<img src="${r.avatar}" class="w-full h-full object-cover">` : r.name.charAt(0).toUpperCase()}
</div>
<div class="flex-1 min-w-0"><div class="text-sm font-medium text-slate-800 truncate">${r.name}</div></div>
<button onclick="acceptFriend(${r.id})" class="px-2.5 py-1 text-xs bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">同意</button>
<button onclick="rejectFriend(${r.id})" class="px-2.5 py-1 text-xs bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">拒绝</button>
</div>`;
});
container.innerHTML = html;
} catch(e) { container.innerHTML = '<div class="text-sm text-red-500 py-1">加载失败</div>'; }
}
async function acceptFriend(id) {
try {
const res = await fetch('/api/friend/accept/' + id, {method:'POST'});
const data = await res.json();
if (data.success) { document.getElementById('freq-' + id).remove(); loadFriends(); }
else alert(data.message);
} catch(e) { alert('操作失败'); }
}
async function rejectFriend(id) {
try {
const res = await fetch('/api/friend/reject/' + id, {method:'POST'});
const data = await res.json();
if (data.success) { document.getElementById('freq-' + id).remove(); }
else alert(data.message);
} catch(e) { alert('操作失败'); }
}
async function loadPosts() {
const container = document.getElementById('my-posts');
try {
const res = await fetch('/api/user/posts');
const data = await res.json();
if (!data.success) throw new Error(data.message);
if (data.posts.length === 0) {
container.innerHTML = '<div class="text-center py-4 text-slate-400">暂无帖子</div>';
return;
}
let html = '';
data.posts.slice(0, 5).forEach(p => {
html += `
<div class="p-3 border border-slate-100 rounded-lg hover:bg-slate-50 cursor-pointer" onclick="location.href='/forum#post-${p.id}'">
<div class="text-sm font-medium text-slate-900 truncate">${p.title}</div>
<div class="text-xs text-slate-500 mt-1">${p.created_at} · ${p.replies} 回复 · ${p.likes} 赞</div>
</div>`;
});
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="text-center py-4 text-red-500">加载失败</div>';
}
}
async function changeName() {
const newName = prompt('请输入新用户名(每月仅可修改一次):');
if (!newName || !newName.trim()) return;
try {
const res = await fetch('/api/user/change-name', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: newName.trim()})
});
const d = await res.json();
if (d.success) {
document.getElementById('display-username').textContent = d.name;
alert('用户名修改成功');
location.reload();
} else {
alert(d.message);
}
} catch(e) { alert('修改失败'); }
}
async function loadExamHistory() {
const container = document.getElementById('exam-history');
try {
const res = await fetch('/api/user/exam-history');
const data = await res.json();
if (!data.success) throw new Error(data.message);
if (data.history.length === 0) {
container.innerHTML = '<div class="text-center py-4 text-slate-400">暂无考试经历</div>';
return;
}
let html = '';
data.history.forEach(h => {
let scoreText = '';
if (!h.graded) {
scoreText = '<span class="text-amber-500">待批改</span>';
} else if (h.score === null) {
scoreText = '<span class="text-slate-400">成绩未公布</span>';
} else {
scoreText = `<span class="text-green-600 font-medium">${h.score}/${h.total_score}</span>`;
}
const contestTag = h.contest_name ? `<span class="text-xs bg-indigo-50 text-indigo-600 px-1.5 py-0.5 rounded">${h.contest_name}</span>` : '';
html += `
<div class="p-3 border border-slate-100 rounded-lg hover:bg-slate-50 cursor-pointer flex items-center justify-between" onclick="location.href='/exams/${h.exam_id}/result'">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-900 truncate">${h.title} ${contestTag}</div>
<div class="text-xs text-slate-500 mt-1">${h.submitted_at}</div>
</div>
<div class="ml-3 text-sm">${scoreText}</div>
</div>`;
});
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="text-center py-4 text-red-500">加载失败</div>';
}
}
async function loadBookmarks() {
const container = document.getElementById('bookmarked-exams');
try {
const res = await fetch('/api/user/exam-bookmarks');
const data = await res.json();
if (!data.success) throw new Error(data.message);
if (data.bookmarks.length === 0) {
container.innerHTML = '<div class="text-center py-4 text-slate-400">暂无收藏试卷</div>';
return;
}
let html = '';
data.bookmarks.slice(0, 5).forEach(e => {
html += `
<div class="p-3 border border-slate-100 rounded-lg hover:bg-slate-50 cursor-pointer" onclick="location.href='/exams/${e.id}'">
<div class="text-sm font-medium text-slate-900 truncate">${e.title}</div>
<div class="text-xs text-slate-500 mt-1">${e.subject} · 收藏于 ${e.bookmarked_at}</div>
</div>`;
});
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="text-center py-4 text-red-500">加载失败</div>';
}
}
function switchTab(tabName) {
// 隐藏所有内容并重置动画
['history', 'posts', 'bookmarks', 'friends'].forEach(t => {
const content = document.getElementById(`tab-content-${t}`);
if(content) {
content.classList.add('hidden');
content.style.animation = 'none';
}
const btn = document.getElementById(`tab-btn-${t}`);
if(btn) {
btn.classList.remove('text-indigo-600', 'bg-white', 'shadow-sm', 'font-bold');
btn.classList.add('text-slate-500', 'bg-transparent', 'hover:bg-white/60', 'font-medium');
}
});
// 显示选中内容并添加动画
const activeContent = document.getElementById(`tab-content-${tabName}`);
if(activeContent) {
activeContent.classList.remove('hidden');
void activeContent.offsetWidth; // 触发重绘
activeContent.style.animation = 'fadeIn 0.3s ease-out';
}
// 激活按钮样式
const activeBtn = document.getElementById(`tab-btn-${tabName}`);
if(activeBtn) {
activeBtn.classList.remove('text-slate-500', 'bg-transparent', 'hover:bg-white/60', 'font-medium');
activeBtn.classList.add('text-indigo-600', 'bg-white', 'shadow-sm', 'font-bold');
}
}
loadExamHistory();
loadFriends();
loadPosts();
loadBookmarks();
</script>
<style>
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shimmer {
100% { transform: translateX(100%); }
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
{% endblock %}