首次提交:初始化项目代码

This commit is contained in:
sunct
2026-07-31 14:44:48 +08:00
commit a4fe393571
119 changed files with 29105 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>未授权访问 - 简历管理后台</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
padding: 3.75rem 2.50rem;
}
.error-icon {
width: 7.50rem;
height: 7.50rem;
margin: 0 auto 2rem;
border-radius: 2rem;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1.25rem 3.75rem rgba(251, 191, 36, 0.3);
animation: float 6s ease-in-out infinite;
}
.error-icon i {
font-size: 3.50rem;
color: #d97706;
}
.error-number {
font-size: 5rem;
font-weight: 700;
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1rem;
letter-spacing: -0.25rem;
}
.error-title {
font-size: 1.50rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.75rem;
}
.error-description {
font-size: 0.94rem;
color: #64748b;
max-width: 26.25rem;
margin: 0 auto 2rem;
line-height: 1.6;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.50rem;
padding: 0.88rem 2rem;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 0.88rem;
font-size: 0.94rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0.25rem 0.94rem rgba(99, 102, 241, 0.4);
text-decoration: none;
}
.btn-primary:hover {
transform: translateY(-0.12rem);
box-shadow: 0 0.38rem 1.25rem rgba(99, 102, 241, 0.5);
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-0.62rem); }
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
z-index: -1;
}
.bg-decoration::before {
content: '';
position: absolute;
top: -12.50rem;
right: -6.25rem;
width: 25rem;
height: 25rem;
background: radial-gradient(circle, rgba(99, 102, 241, 0.1) 0%, transparent 70%);
border-radius: 50%;
}
.bg-decoration::after {
content: '';
position: absolute;
bottom: -9.38rem;
left: -6.25rem;
width: 18.75rem;
height: 18.75rem;
background: radial-gradient(circle, rgba(251, 191, 36, 0.1) 0%, transparent 70%);
border-radius: 50%;
}
</style>
</head>
<body>
<div class="bg-decoration"></div>
<div class="error-container">
<div class="error-icon">
<i class="fas fa-lock"></i>
</div>
<div class="error-number">401</div>
<h1 class="error-title">未授权访问</h1>
<p class="error-description">
您没有权限访问此页面。请先登录后台管理系统,或联系管理员获取访问权限。
</p>
</div>
</body>
</html>
+180
View File
@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>页面未找到 - 简历管理后台</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
padding: 3.75rem 2.50rem;
}
.error-icon {
width: 7.50rem;
height: 7.50rem;
margin: 0 auto 2rem;
border-radius: 2rem;
background: linear-gradient(135deg, #cffafe 0%, #a5f3fc 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1.25rem 3.75rem rgba(6, 182, 212, 0.3);
animation: float 6s ease-in-out infinite;
}
.error-icon i {
font-size: 3.50rem;
color: #0891b2;
}
.error-number {
font-size: 5rem;
font-weight: 700;
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1rem;
letter-spacing: -0.25rem;
}
.error-title {
font-size: 1.50rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.75rem;
}
.error-description {
font-size: 0.94rem;
color: #64748b;
max-width: 26.25rem;
margin: 0 auto 2rem;
line-height: 1.6;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.50rem;
padding: 0.88rem 2rem;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 0.88rem;
font-size: 0.94rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0.25rem 0.94rem rgba(99, 102, 241, 0.4);
text-decoration: none;
}
.btn-primary:hover {
transform: translateY(-0.12rem);
box-shadow: 0 0.38rem 1.25rem rgba(99, 102, 241, 0.5);
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.50rem;
padding: 0.88rem 2rem;
background: white;
color: #64748b;
border: 0.06rem solid #e2e8f0;
border-radius: 0.88rem;
font-size: 0.94rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-left: 0.75rem;
text-decoration: none;
}
.btn-secondary:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #334155;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-0.62rem); }
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
z-index: -1;
}
.bg-decoration::before {
content: '';
position: absolute;
top: -12.50rem;
right: -6.25rem;
width: 25rem;
height: 25rem;
background: radial-gradient(circle, rgba(99, 102, 241, 0.1) 0%, transparent 70%);
border-radius: 50%;
}
.bg-decoration::after {
content: '';
position: absolute;
bottom: -9.38rem;
left: -6.25rem;
width: 18.75rem;
height: 18.75rem;
background: radial-gradient(circle, rgba(6, 182, 212, 0.1) 0%, transparent 70%);
border-radius: 50%;
}
</style>
</head>
<body>
<div class="bg-decoration"></div>
<div class="error-container">
<div class="error-icon">
<i class="fas fa-search"></i>
</div>
<div class="error-number">404</div>
<h1 class="error-title">页面未找到</h1>
<p class="error-description">
您访问的页面不存在或已被移除。请检查网址是否正确,或返回后台首页继续操作。
</p>
<div class="flex items-center justify-center gap-3">
<a href="/dashboard/home" class="btn-primary">
<i class="fas fa-home"></i> 返回后台首页
</a>
<button onclick="history.back()" class="btn-secondary">
<i class="fas fa-arrow-left"></i> 返回上一页
</button>
</div>
</div>
</body>
</html>
File diff suppressed because it is too large Load Diff
+277
View File
@@ -0,0 +1,277 @@
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<div class="stat-card bg-gradient-to-br from-blue-500 to-blue-600">
<div class="stat-icon">
<i class="fas fa-users"></i>
</div>
<div>
<p class="stat-label">总用户数</p>
<h3 class="stat-number">{{.Stats.TotalUsers}}</h3>
</div>
</div>
<div class="stat-card bg-gradient-to-br from-emerald-500 to-emerald-600">
<div class="stat-icon">
<i class="fas fa-file-alt"></i>
</div>
<div>
<p class="stat-label">总简历数</p>
<h3 class="stat-number">{{.Stats.TotalResumes}}</h3>
</div>
</div>
<div class="stat-card bg-gradient-to-br from-violet-500 to-violet-600">
<div class="stat-icon">
<i class="fas fa-folder-open"></i>
</div>
<div>
<p class="stat-label">作品集项目</p>
<h3 class="stat-number">{{.Stats.TotalPortfolio}}</h3>
</div>
</div>
<div class="stat-card bg-gradient-to-br from-orange-500 to-orange-600">
<div class="stat-icon">
<i class="fas fa-clipboard-list"></i>
</div>
<div>
<p class="stat-label">答题记录</p>
<h3 class="stat-number">{{.Stats.TotalQuizRecords}}</h3>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-5 mt-5">
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4">
<div class="w-12 h-12 rounded-lg bg-amber-50 flex items-center justify-center text-amber-500">
<i class="fas fa-star text-xl"></i>
</div>
<div>
<p class="text-sm text-slate-500">收藏题目</p>
<h4 class="text-xl font-bold text-slate-800">{{.Stats.TotalFavorites}}</h4>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4">
<div class="w-12 h-12 rounded-lg bg-rose-50 flex items-center justify-center text-rose-500">
<i class="fas fa-times-circle text-xl"></i>
</div>
<div>
<p class="text-sm text-slate-500">错题总数</p>
<h4 class="text-xl font-bold text-slate-800">{{.Stats.TotalWrongAnswers}}</h4>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4">
<div class="w-12 h-12 rounded-lg bg-sky-50 flex items-center justify-center text-sky-500">
<i class="fas fa-clock text-xl"></i>
</div>
<div>
<p class="text-sm text-slate-500">运行时长</p>
<h4 class="text-xl font-bold text-slate-800">{{.Uptime}}</h4>
</div>
</div>
</div>
<div class="mt-5 grid grid-cols-1 lg:grid-cols-3 gap-5">
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-users text-blue-500"></i> 最近用户
</h3>
<a href="{{.AdminPath}}/users" class="btn btn-secondary btn-sm">
<i class="fas fa-list"></i> 查看全部
</a>
</div>
<div class="divide-y divide-slate-50">
{{ range .RecentUsers }}
<div class="px-5 py-4 flex items-center gap-4 hover:bg-slate-50 transition-colors">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white font-medium text-sm flex-shrink-0">
{{if .Name}}{{substr .Name 0 1}}{{else}}U{{end}}
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-slate-800 truncate">{{.Name}}</p>
<p class="text-sm text-slate-500 truncate">{{.Email}}</p>
</div>
<div class="flex gap-2 flex-shrink-0">
<a href="{{$.AdminPath}}/user/{{.ID}}/resume" class="btn btn-primary btn-xs">
<i class="fas fa-file-alt"></i> 简历
</a>
<a href="{{$.AdminPath}}/user/{{.ID}}/portfolio" class="btn btn-info btn-xs">
<i class="fas fa-folder-open"></i> 作品集
</a>
<a href="{{$.AdminPath}}/user/{{.ID}}/quiz" class="btn btn-success btn-xs">
<i class="fas fa-question-circle"></i> 答题
</a>
</div>
</div>
{{ else }}
<div class="text-center py-12">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-users text-slate-400 text-2xl"></i>
</div>
<p class="text-slate-500">暂无用户</p>
</div>
{{ end }}
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-file-alt text-emerald-500"></i> 最近简历
</h3>
<a href="{{.AdminPath}}/resumes" class="btn btn-secondary btn-sm">
<i class="fas fa-list"></i> 全部
</a>
</div>
<div class="divide-y divide-slate-50">
{{ range .RecentResumes }}
<div class="px-5 py-4 flex items-center gap-3 hover:bg-slate-50 transition-colors">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center text-white flex-shrink-0">
<i class="fas fa-file-alt"></i>
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-slate-800 truncate text-sm">{{.BasicInfo.Name}}</p>
<p class="text-xs text-slate-500 truncate">{{.BasicInfo.Title}}</p>
</div>
<a href="/{{.Route}}" target="_blank" class="text-blue-500 hover:text-blue-600 text-sm flex-shrink-0">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
{{ else }}
<div class="text-center py-12">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-file-alt text-slate-400 text-2xl"></i>
</div>
<p class="text-slate-500">暂无简历</p>
</div>
{{ end }}
</div>
</div>
</div>
<div class="mt-5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5">
<a href="{{.AdminPath}}/users" class="quick-action-card">
<div class="quick-action-icon bg-blue-50 text-blue-500">
<i class="fas fa-user-plus"></i>
</div>
<div>
<h4 class="font-medium text-slate-800">用户管理</h4>
<p class="text-sm text-slate-500">添加/编辑用户信息</p>
</div>
<i class="fas fa-chevron-right text-slate-300"></i>
</a>
<a href="{{.AdminPath}}/resumes" class="quick-action-card">
<div class="quick-action-icon bg-emerald-50 text-emerald-500">
<i class="fas fa-file-medical"></i>
</div>
<div>
<h4 class="font-medium text-slate-800">简历管理</h4>
<p class="text-sm text-slate-500">查看和编辑简历</p>
</div>
<i class="fas fa-chevron-right text-slate-300"></i>
</a>
<a href="{{.AdminPath}}/portfolios" class="quick-action-card">
<div class="quick-action-icon bg-violet-50 text-violet-500">
<i class="fas fa-folder-plus"></i>
</div>
<div>
<h4 class="font-medium text-slate-800">作品集管理</h4>
<p class="text-sm text-slate-500">管理项目作品集</p>
</div>
<i class="fas fa-chevron-right text-slate-300"></i>
</a>
<a href="{{.AdminPath}}/settings" class="quick-action-card">
<div class="quick-action-icon bg-slate-50 text-slate-500">
<i class="fas fa-cog"></i>
</div>
<div>
<h4 class="font-medium text-slate-800">系统设置</h4>
<p class="text-sm text-slate-500">配置系统参数</p>
</div>
<i class="fas fa-chevron-right text-slate-300"></i>
</a>
</div>
<style>
.stat-card {
position: relative;
border-radius: 0.5rem;
padding: 1.25rem;
color: white;
display: flex;
align-items: center;
gap: 1rem;
overflow: hidden;
box-shadow: 0 0.25rem 0.38rem -0.06rem rgba(0, 0, 0, 0.1);
}
.stat-card::before {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 7.50rem;
height: 7.50rem;
background: rgba(255,255,255,0.1);
border-radius: 50%;
}
.stat-card::after {
content: '';
position: absolute;
bottom: -30%;
right: 10%;
width: 5rem;
height: 5rem;
background: rgba(255,255,255,0.08);
border-radius: 50%;
}
.stat-icon {
width: 3.25rem;
height: 3.25rem;
background: rgba(255,255,255,0.2);
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
flex-shrink: 0;
position: relative;
z-index: 1;
backdrop-filter: blur(0.25rem);
}
.stat-label {
font-size: 0.875rem;
opacity: 0.9;
margin-bottom: 0.25rem;
position: relative;
z-index: 1;
}
.stat-number {
font-size: 1.75rem;
font-weight: 700;
position: relative;
z-index: 1;
}
.quick-action-card {
background: white;
border: 0.06rem solid #f1f5f9;
border-radius: 0.75rem;
padding: 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
text-decoration: none;
transition: all 0.2s ease;
box-shadow: 0 0.06rem 0.12rem rgba(0,0,0,0.04);
}
.quick-action-card:hover {
border-color: #e2e8f0;
transform: translateY(-0.12rem);
box-shadow: 0 0.25rem 0.75rem rgba(0,0,0,0.06);
}
.quick-action-icon {
width: 2.75rem;
height: 2.75rem;
border-radius: 0.625rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.125rem;
flex-shrink: 0;
}
</style>
+483
View File
@@ -0,0 +1,483 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .SystemConfig.WebsiteTitle }} - 后台登录</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary: #7B8EA2;
--primary-hover: #6B7E92;
--accent: #987E68;
--text-primary: #2F343A;
--text-secondary: #6C737C;
--text-muted: #9CA3AF;
--bg: #F8F7F4;
--bg-card: #FFFFFF;
--border: #E5E3DF;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Noto Sans SC', sans-serif;
background-color: var(--bg);
color: var(--text-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
width: 100%;
max-width: 26.25rem;
padding: 3rem;
background: var(--bg-card);
border-radius: 1rem;
box-shadow: 0 0.25rem 1.50rem rgba(47, 52, 58, 0.08);
border: 0.06rem solid var(--border);
}
.brand-section {
text-align: center;
margin-bottom: 2.50rem;
}
.brand-logo {
width: 3.50rem;
height: 3.50rem;
margin: 0 auto 1.25rem;
background: var(--primary);
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.50rem;
transition: all 0.3s ease;
}
.brand-logo img {
border-radius: 0.50rem;
}
.brand-logo:hover {
transform: scale(1.05);
}
.brand-title {
font-size: 1.38rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.38rem;
letter-spacing: 0.12rem;
}
.brand-subtitle {
font-size: 0.81rem;
color: var(--text-secondary);
}
.input-group {
margin-bottom: 1.25rem;
}
.input-group label {
display: block;
font-size: 0.81rem;
color: var(--text-secondary);
margin-bottom: 0.50rem;
font-weight: 500;
}
.input-group input {
width: 100%;
padding: 0.75rem 0.88rem;
border: 0.06rem solid var(--border);
border-radius: 0.50rem;
font-size: 0.88rem;
color: var(--text-primary);
background: var(--bg);
transition: all 0.25s ease;
outline: none;
font-family: inherit;
}
.input-group input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 0.19rem rgba(123, 142, 162, 0.1);
background: white;
}
.input-group input::placeholder {
color: var(--text-muted);
}
.input-wrapper {
position: relative;
}
.input-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.88rem;
color: var(--text-muted);
pointer-events: none;
}
.input-group input {
padding-left: 2.50rem;
}
.input-toggle {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.88rem;
color: var(--text-muted);
cursor: pointer;
transition: color 0.2s ease;
}
.input-toggle:hover {
color: var(--primary);
}
.captcha-row {
display: flex;
gap: 0.75rem;
align-items: center;
}
.captcha-row .input-group {
flex: 1;
margin-bottom: 0;
}
.captcha-img {
width: 6.25rem;
height: 2.75rem;
border-radius: 0.50rem;
cursor: pointer;
border: 0.06rem solid var(--border);
background: var(--bg);
transition: all 0.25s ease;
}
.captcha-img:hover {
border-color: var(--primary);
transform: scale(1.02);
}
.btn-login {
width: 100%;
padding: 0.81rem;
border: none;
border-radius: 0.50rem;
font-size: 0.88rem;
font-weight: 500;
color: white;
background: var(--primary);
cursor: pointer;
transition: all 0.25s ease;
margin-top: 1.50rem;
letter-spacing: 0.06rem;
}
.btn-login:hover {
background: var(--primary-hover);
transform: translateY(-0.06rem);
box-shadow: 0 0.25rem 0.75rem rgba(123, 142, 162, 0.25);
}
.btn-login:active {
transform: translateY(0);
box-shadow: none;
}
.btn-login:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.footer-text {
text-align: center;
margin-top: 2rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.alert-overlay {
position: fixed;
inset: 0;
background: rgba(47, 52, 58, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.alert-overlay.show {
opacity: 1;
visibility: visible;
}
.alert-box {
background: white;
border-radius: 0.75rem;
padding: 2rem;
max-width: 22.50rem;
width: 90%;
text-align: center;
box-shadow: 0 0.75rem 2.50rem rgba(47, 52, 58, 0.15);
transform: scale(0.9) translateY(1.25rem);
transition: all 0.3s ease;
}
.alert-overlay.show .alert-box {
transform: scale(1) translateY(0);
}
.alert-icon {
width: 3rem;
height: 3rem;
margin: 0 auto 1rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.38rem;
}
.alert-icon.error {
background: #FEF2F2;
color: #DC2626;
}
.alert-icon.success {
background: #ECFDF5;
color: #16A34A;
}
.alert-icon.info {
background: #EFF6FF;
color: #2563EB;
}
.alert-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.50rem;
}
.alert-message {
font-size: 0.81rem;
color: var(--text-secondary);
margin-bottom: 1.25rem;
line-height: 1.6;
}
.btn-alert {
padding: 0.62rem 1.50rem;
border: none;
border-radius: 0.38rem;
font-size: 0.81rem;
font-weight: 500;
color: white;
background: var(--primary);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-alert:hover {
background: var(--primary-hover);
}
</style>
</head>
<body>
<div class="login-container">
<div class="brand-section">
<div class="brand-logo">
{{ if .SystemConfig.LogoPath }}
<img src="{{.SystemConfig.LogoPath}}" alt="Logo">
{{ else }}
<i class="fas fa-file-alt"></i>
{{ end }}
</div>
<div class="brand-title">{{ .SystemConfig.WebsiteTitle }}</div>
<div class="brand-subtitle">{{ .SystemConfig.WebsiteDesc }}</div>
</div>
<form id="loginForm" onsubmit="login(event)">
<div class="input-group">
<label for="username">用户名</label>
<div class="input-wrapper">
<i class="fas fa-user input-icon"></i>
<input type="text" id="username" placeholder="请输入用户名" required>
</div>
</div>
<div class="input-group">
<label for="password">密码</label>
<div class="input-wrapper">
<i class="fas fa-lock input-icon"></i>
<input type="password" id="password" placeholder="请输入密码" required>
<i class="fas fa-eye input-toggle" id="passwordToggle" onclick="togglePassword()"></i>
</div>
</div>
<div class="captcha-row">
<div class="input-group">
<label for="captcha">验证码</label>
<div class="input-wrapper">
<i class="fas fa-shield-halved input-icon"></i>
<input type="text" id="captcha" placeholder="验证码" required>
</div>
</div>
<div class="flex flex-col justify-center">
<img id="captchaImg" src="" alt="验证码" class="captcha-img" onclick="refreshCaptcha()">
</div>
</div>
<button type="submit" id="loginBtn" class="btn-login">登 录</button>
</form>
<div class="footer-text">
© 2026 {{ .SystemConfig.WebsiteTitle }}. All rights reserved.
</div>
</div>
<div id="customAlert" class="alert-overlay">
<div class="alert-box" id="alertContent">
<div id="alertIcon" class="alert-icon"></div>
<h3 id="alertTitle" class="alert-title"></h3>
<p id="alertMessage" class="alert-message"></p>
<button onclick="closeCustomAlert()" class="btn-alert">确定</button>
</div>
</div>
<script>
const adminPath = "{{.adminPath}}";
let captchaToken = '';
function togglePassword() {
const password = document.getElementById('password');
const toggle = document.getElementById('passwordToggle');
if (password.type === 'password') {
password.type = 'text';
toggle.classList.remove('fa-eye');
toggle.classList.add('fa-eye-slash');
} else {
password.type = 'password';
toggle.classList.remove('fa-eye-slash');
toggle.classList.add('fa-eye');
}
}
function refreshCaptcha() {
const img = document.getElementById('captchaImg');
const xhr = new XMLHttpRequest();
xhr.open('GET', adminPath + '/captcha?' + Date.now(), true);
xhr.responseType = 'blob';
xhr.onload = function() {
if (this.status === 200) {
captchaToken = this.getResponseHeader('X-Captcha-Token') || '';
const blob = this.response;
img.src = URL.createObjectURL(blob);
}
};
xhr.send();
}
function showCustomAlert(title, message, type) {
const alert = document.getElementById('customAlert');
const icon = document.getElementById('alertIcon');
const titleEl = document.getElementById('alertTitle');
const messageEl = document.getElementById('alertMessage');
titleEl.textContent = title;
messageEl.textContent = message;
icon.className = 'alert-icon ' + type;
if (type === 'error') {
icon.innerHTML = '<i class="fas fa-times-circle"></i>';
} else if (type === 'success') {
icon.innerHTML = '<i class="fas fa-check-circle"></i>';
} else {
icon.innerHTML = '<i class="fas fa-info-circle"></i>';
}
alert.classList.add('show');
}
function closeCustomAlert() {
const alert = document.getElementById('customAlert');
alert.classList.remove('show');
}
async function login(event) {
event.preventDefault();
const btn = document.getElementById('loginBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> 登录中...';
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const captcha = document.getElementById('captcha').value;
try {
const response = await fetch(adminPath + '/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
captcha,
captcha_token: captchaToken
})
});
const result = await response.json();
if (result.success) {
showCustomAlert('登录成功', '正在跳转...', 'success');
setTimeout(() => {
window.location.href = result.redirect || '/dashboard/home';
}, 1500);
} else {
showCustomAlert('登录失败', result.message || '请检查用户名、密码和验证码', 'error');
refreshCaptcha();
}
} catch (error) {
showCustomAlert('网络错误', '无法连接服务器,请稍后重试', 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '登 录';
}
}
window.addEventListener('load', function() {
const xhr = new XMLHttpRequest();
xhr.open('GET', adminPath + '/captcha', true);
xhr.responseType = 'blob';
xhr.onload = function() {
if (this.status === 200) {
captchaToken = this.getResponseHeader('X-Captcha-Token') || '';
const blob = this.response;
document.getElementById('captchaImg').src = URL.createObjectURL(blob);
}
};
xhr.send();
});
</script>
</body>
</html>
+290
View File
@@ -0,0 +1,290 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-folder-open text-purple-500"></i> {{ if .Item.ID }}编辑项目{{ else }}新增项目{{ end }}
</h3>
<p class="text-sm text-slate-500 mt-1">{{ if .Item.ID }}修改作品集项目信息{{ else }}创建新的作品集项目{{ end }}</p>
</div>
</div>
<div class="p-6">
<form id="portfolioForm">
<input type="hidden" id="portfolioId" value="{{.Item.ID}}">
<div class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">选择人员 <span class="text-red-500">*</span></label>
<select id="portfolioUserId" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
<option value="">请选择人员</option>
{{ range .Users }}
<option value="{{.ID}}" {{ if eq .ID $.Item.UserID }}selected{{ end }}>{{.Name}} - {{.Email}}</option>
{{ end }}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目名称 <span class="text-red-500">*</span></label>
<input type="text" id="portfolioName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.Name}}" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目描述</label>
<textarea id="portfolioDescription" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">{{.Item.Description}}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">技术栈</label>
<input type="text" id="portfolioTechStack" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{ join .Item.TechStack "," }}" placeholder="多个技术栈用逗号分隔">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目链接</label>
<input type="url" id="portfolioUrl" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.URL}}" placeholder="https://">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">开始日期</label>
<input type="date" id="portfolioStartDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.StartDate}}">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">结束日期</label>
<input type="date" id="portfolioEndDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.EndDate}}">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">访问密码</label>
<input type="password" id="portfolioPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.Password}}" placeholder="留空则无需密码">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">关联简历项目ID</label>
<div class="space-y-2">
<select id="portfolioProjectResumeId" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
<option value="">请选择简历</option>
{{ range .Resumes }}
<option value="{{.ID}}" {{ if eq .ID $.Item.ResumeID }}selected{{ end }}>{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</option>
{{ end }}
</select>
<select id="portfolioProjectId" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
<option value="">请选择项目</option>
</select>
</div>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="portfolioShowInResume" {{ if .Item.ShowInResume }}checked{{ end }} class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500">
<span class="text-sm font-medium text-slate-700">在简历中显示</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="portfolioHidden" {{ if .Item.Hidden }}checked{{ end }} class="w-4 h-4 text-primary-600 rounded focus:ring-2 focus:ring-primary-500">
<span class="text-sm font-medium text-slate-700">隐藏项目</span>
</label>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6" style="height: calc(100vh - 26.25rem); min-height: 31.25rem;">
<div class="flex flex-col">
<label class="block text-sm font-medium text-slate-700 mb-1">项目文档 <span class="text-slate-400 text-xs">(Markdown格式,支持图片、表格、流程图)</span></label>
<div class="flex-1 relative overflow-hidden rounded-lg border border-slate-300">
<textarea id="portfolioDoc" class="w-full h-full px-4 py-2 font-mono text-sm resize-none">{{.Item.Doc}}</textarea>
</div>
</div>
<div class="flex flex-col">
<label class="block text-sm font-medium text-slate-700 mb-1">预览</label>
<div id="docPreview" class="flex-1 p-4 border border-slate-200 rounded-lg bg-slate-50 markdown-body overflow-y-auto"></div>
</div>
</div>
</div>
</form>
</div>
</div>
<div class="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 px-6 py-4 shadow-lg z-50">
<div class="flex items-center justify-center gap-3">
<button onclick="goBack()" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> 返回
</button>
<button onclick="savePortfolio()" class="btn btn-primary">
<i class="fas fa-save"></i> 保存
</button>
</div>
</div>
<div style="height: 5rem;"></div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.5.0/github-markdown.min.css">
<script>
let simplemde;
document.addEventListener('DOMContentLoaded', function() {
simplemde = new SimpleMDE({
element: document.getElementById('portfolioDoc'),
toolbar: [
'bold', 'italic', 'strikethrough', 'heading',
'heading-smaller', 'heading-bigger', 'heading-1', 'heading-2', 'heading-3',
'|',
'code', 'quote', 'unordered-list', 'ordered-list',
'|',
'link', 'image', 'table', 'horizontal-rule',
'|',
'preview', 'side-by-side', 'fullscreen',
'|',
'guide'
],
spellChecker: false,
autoDownloadFontAwesome: true,
previewRender: function(plainText) {
return marked.parse(plainText) || '';
}
});
simplemde.codemirror.on('change', function() {
const text = simplemde.value();
const preview = document.getElementById('docPreview');
try {
preview.innerHTML = marked.parse(text) || '<p class="text-slate-400">暂无内容</p>';
} catch (err) {
preview.innerHTML = '<p class="text-red-500">预览解析错误</p>';
}
});
const initialText = simplemde.value();
if (initialText) {
try {
document.getElementById('docPreview').innerHTML = marked.parse(initialText) || '<p class="text-slate-400">暂无内容</p>';
} catch (err) {
document.getElementById('docPreview').innerHTML = '<p class="text-red-500">预览解析错误</p>';
}
} else {
document.getElementById('docPreview').innerHTML = '<p class="text-slate-400">暂无内容,开始编写项目文档...</p>';
}
document.getElementById('portfolioUserId').addEventListener('change', function() {
loadResumesByUser(this.value, '');
});
document.getElementById('portfolioProjectResumeId').addEventListener('change', function() {
loadProjectsByResume(this.value, 0);
});
const userId = document.getElementById('portfolioUserId').value;
const savedResumeId = "{{.Item.ResumeID}}";
const savedProjectId = {{ if .Item.ProjectID }}{{.Item.ProjectID}}{{ else }}0{{ end }};
if (userId) {
loadResumesByUser(userId, savedResumeId, savedProjectId);
}
});
function loadResumesByUser(userId, selectedResumeId, autoLoadProjectId) {
const resumeSelect = document.getElementById('portfolioProjectResumeId');
resumeSelect.innerHTML = '<option value="">请选择简历</option>';
if (!userId) return;
fetch('/api/user/resumes?user_id=' + userId)
.then(response => response.json())
.then(result => {
if (!result.success) return;
const resumes = result.data || [];
resumes.forEach(resume => {
const option = document.createElement('option');
option.value = resume.id;
option.textContent = (resume.basic_info.name || '未命名') + ' - ' + (resume.basic_info.title || '');
if (selectedResumeId && resume.id === selectedResumeId) {
option.selected = true;
}
resumeSelect.appendChild(option);
});
// 恢复选中后,按需加载该简历下的项目
if (selectedResumeId && autoLoadProjectId) {
loadProjectsByResume(selectedResumeId, autoLoadProjectId);
}
})
.catch(() => {});
}
function loadProjectsByResume(resumeId, selectedProjectId) {
const projectSelect = document.getElementById('portfolioProjectId');
projectSelect.innerHTML = '<option value="">请选择项目</option>';
if (!resumeId) return;
fetch('/dashboard/api/resume/' + resumeId)
.then(response => response.json())
.then(result => {
if (!result.success) return;
const resume = result.data || {};
const projects = resume.projects || [];
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.name;
if (selectedProjectId && project.id === selectedProjectId) {
option.selected = true;
}
projectSelect.appendChild(option);
});
})
.catch(() => {});
}
function goBack() {
window.history.back();
}
function savePortfolio() {
const techStackStr = document.getElementById('portfolioTechStack').value;
const techStack = techStackStr ? techStackStr.split(',').map(s => s.trim()).filter(s => s) : [];
const portfolioId = document.getElementById('portfolioId').value;
const projectIdVal = document.getElementById('portfolioProjectId').value;
const data = {
id: portfolioId ? parseInt(portfolioId) : 0,
user_id: document.getElementById('portfolioUserId').value,
name: document.getElementById('portfolioName').value,
description: document.getElementById('portfolioDescription').value,
tech_stack: techStack,
url: document.getElementById('portfolioUrl').value,
start_date: document.getElementById('portfolioStartDate').value,
end_date: document.getElementById('portfolioEndDate').value,
doc: simplemde.value(),
show_in_resume: document.getElementById('portfolioShowInResume').checked,
resume_id: document.getElementById('portfolioProjectResumeId').value,
project_id: projectIdVal ? parseInt(projectIdVal) : 0,
password: document.getElementById('portfolioPassword').value,
hidden: document.getElementById('portfolioHidden').checked
};
if (!data.user_id) {
showCustomAlert('error', '错误', '请选择人员');
return;
}
if (!data.name) {
showCustomAlert('error', '错误', '请输入项目名称');
return;
}
const isEdit = parseInt(portfolioId) > 0;
const url = isEdit ? '/dashboard/api/portfolio/' + portfolioId : '/dashboard/api/portfolio';
const method = isEdit ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', isEdit ? '项目更新成功' : '项目创建成功');
setTimeout(() => goBack(), 1500);
} else {
showCustomAlert('error', '错误', '保存失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '保存失败');
});
}
</script>
+321
View File
@@ -0,0 +1,321 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-folder-open text-purple-500"></i> 作品集管理
</h3>
<p class="text-sm text-slate-500 mt-1">管理所有用户的作品集项目</p>
</div>
<a href="{{$.AdminPath}}/portfolio/add" class="btn btn-primary">
<i class="fas fa-plus"></i> 新增项目
</a>
</div>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr class="bg-slate-50 border-b border-slate-200">
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">项目名称</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">用户</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">链接</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">隐藏状态</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">首页展示</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600 actions-col">操作</th>
</tr>
</thead>
<tbody>
{{ range .Items }}
<tr class="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td class="px-6 py-4">
<span class="font-medium text-slate-800">{{.Name}}</span>
</td>
<td class="px-6 py-4 text-slate-600">
{{ if .UserID }}
{{ $user := index $.UserMap .UserID }}
{{ if $user }}
{{$user.Name}}{{$user.Username}}
{{ else }}
<span class="text-slate-400">已失效</span>
{{ end }}
{{ else }}
<span class="text-slate-400">未分配</span>
{{ end }}
</td>
<td class="px-6 py-4 text-slate-600">
{{ if .URL }}
<a href="{{.URL}}" target="_blank" class="text-blue-600 hover:underline">点击访问</a>
{{ else }}
-
{{ end }}
</td>
<td class="px-6 py-4">
{{ if .Hidden }}
<span class="badge badge-danger">已隐藏</span>
{{ else }}
<span class="badge badge-success">显示</span>
{{ end }}
</td>
<td class="px-6 py-4">
{{ if .ShowInResume }}
<span class="badge badge-success"></span>
{{ else }}
<span class="badge badge-slate"></span>
{{ end }}
</td>
<td class="px-6 py-4 actions-col">
<div class="btn-group">
<a href="{{$.AdminPath}}/portfolio/{{.ID}}/edit" class="btn btn-warning btn-xs" title="编辑">
<i class="fas fa-edit"></i> 编辑
</a>
<button onclick="deletePortfolio('{{.ID}}')" class="btn btn-danger btn-xs" title="删除">
<i class="fas fa-trash"></i> 删除
</button>
</div>
</td>
</tr>
{{ else }}
<tr>
<td colspan="6" class="px-6 py-12 text-center text-slate-500">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-folder-open text-slate-400 text-2xl"></i>
</div>
<p>暂无作品集项目</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ if gt .TotalPages 1 }}
<div class="border-t border-slate-200 px-6 py-4 bg-slate-50 flex items-center justify-between">
<div class="text-sm text-slate-500">共 {{ .Total }} 条记录,第 {{ .Page }} / {{ .TotalPages }} 页</div>
<div class="flex items-center gap-2">
<a href="?page=1" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
<i class="fas fa-chevron-left"></i>
</a>
<a href="?page={{ if eq .Page 1 }}1{{ else }}{{ subtract .Page 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
</a>
{{ range $i := to 1 $.TotalPages }}
{{ if or (eq $i 1) (eq $i $.TotalPages) (and (ge $i (subtract $.Page 2)) (le $i (add $.Page 2))) }}
<a href="?page={{ $i }}" class="btn btn-sm {{ if eq $i $.Page }}btn-primary{{ else }}btn-slate{{ end }}">{{ $i }}</a>
{{ else if and (eq $i 2) (gt (subtract $.Page 3) 1) }}
<span class="text-slate-400 px-2">...</span>
{{ else if and (eq $i (subtract $.TotalPages 1)) (lt (add $.Page 3) $.TotalPages) }}
<span class="text-slate-400 px-2">...</span>
{{ end }}
{{ end }}
<a href="?page={{ if eq .Page .TotalPages }}{{ .TotalPages }}{{ else }}{{ add .Page 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
</a>
<a href="?page={{ .TotalPages }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
<i class="fas fa-chevron-right"></i>
</a>
</div>
</div>
{{ end }}
</div>
<div class="modal hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="confirmModal">
<div class="modal-content bg-white rounded-xl shadow-xl w-full max-w-sm mx-4">
<div class="flex items-center justify-between p-4 border-b border-slate-200">
<h3 class="font-semibold text-slate-800">确认删除</h3>
<button onclick="closeConfirmModal()" class="btn btn-secondary btn-sm">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-500"></i>
</div>
<p class="text-slate-700">确定要删除这个项目吗?此操作不可撤销。</p>
</div>
<div class="flex gap-3">
<button onclick="closeConfirmModal()" class="flex-1 btn btn-secondary">取消</button>
<button onclick="confirmDeleteAction()" class="flex-1 btn btn-danger">确认删除</button>
</div>
</div>
</div>
</div>
<div class="modal hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="portfolioModal">
<div class="modal-content bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
<div class="flex items-center justify-between p-4 border-b border-slate-200">
<h3 class="font-semibold text-slate-800" id="portfolioModalTitle">新增项目</h3>
<button onclick="closePortfolioModal()" class="btn btn-secondary btn-sm">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-6">
<form id="portfolioForm" onsubmit="savePortfolio(event)">
<input type="hidden" id="portfolioId">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">选择人员</label>
<select id="portfolioUserId" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
<option value="">请选择人员</option>
{{ range .Users }}
<option value="{{.ID}}">{{.Name}} - {{.Email}}</option>
{{ end }}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目名称</label>
<input type="text" id="portfolioName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目描述</label>
<textarea id="portfolioDescription" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">技术栈 (每行一个)</label>
<textarea id="portfolioTechStack" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="Go&#10;React&#10;MySQL"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目链接</label>
<input type="url" id="portfolioURL" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">开始日期</label>
<input type="date" id="portfolioStartDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">结束日期</label>
<input type="date" id="portfolioEndDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
</div>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2">
<input type="checkbox" id="portfolioHidden" class="rounded border-slate-300 text-primary-600 focus:ring-primary-500">
<span class="text-sm text-slate-700">隐藏项目</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" id="portfolioShowInResume" class="rounded border-slate-300 text-primary-600 focus:ring-primary-500">
<span class="text-sm text-slate-700">首页展示</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">访问密码 (可选)</label>
<input type="text" id="portfolioPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="留空则无需密码">
</div>
</div>
<div class="mt-6 flex gap-3">
<button type="button" onclick="closePortfolioModal()" class="flex-1 btn btn-secondary">取消</button>
<button type="submit" class="flex-1 btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
</div>
<script>
function openAddPortfolioModal() {
document.getElementById('portfolioModalTitle').textContent = '新增项目';
document.getElementById('portfolioForm').reset();
document.getElementById('portfolioId').value = '';
const modal = document.getElementById('portfolioModal');
modal.classList.remove('hidden');
modal.classList.add('active');
}
function closePortfolioModal() {
const modal = document.getElementById('portfolioModal');
modal.classList.add('hidden');
modal.classList.remove('active');
}
function editPortfolio(id) {
fetch('/api/portfolio/' + id)
.then(response => response.json())
.then(data => {
if (data.success) {
const item = data.data;
document.getElementById('portfolioModalTitle').textContent = '编辑项目';
document.getElementById('portfolioId').value = item.id;
document.getElementById('portfolioUserId').value = item.user_id;
document.getElementById('portfolioName').value = item.name;
document.getElementById('portfolioDescription').value = item.description || '';
document.getElementById('portfolioTechStack').value = (item.tech_stack || []).join('\n');
document.getElementById('portfolioURL').value = item.url || '';
document.getElementById('portfolioStartDate').value = item.start_date || '';
document.getElementById('portfolioEndDate').value = item.end_date || '';
document.getElementById('portfolioHidden').checked = item.hidden || false;
document.getElementById('portfolioShowInResume').checked = item.show_in_resume || false;
document.getElementById('portfolioPassword').value = item.password || '';
const modal = document.getElementById('portfolioModal');
modal.classList.remove('hidden');
modal.classList.add('active');
}
});
}
function savePortfolio(event) {
event.preventDefault();
const id = document.getElementById('portfolioId').value;
const url = id ? '/api/portfolio/' + id : '/api/portfolio';
const method = id ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_id: document.getElementById('portfolioUserId').value,
name: document.getElementById('portfolioName').value,
description: document.getElementById('portfolioDescription').value,
tech_stack: document.getElementById('portfolioTechStack').value.split('\n').filter(s => s.trim()),
url: document.getElementById('portfolioURL').value,
start_date: document.getElementById('portfolioStartDate').value,
end_date: document.getElementById('portfolioEndDate').value,
hidden: document.getElementById('portfolioHidden').checked,
show_in_resume: document.getElementById('portfolioShowInResume').checked,
password: document.getElementById('portfolioPassword').value,
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
closePortfolioModal();
location.reload();
} else {
showToast(data.message || '保存失败', 'error');
}
});
}
var deleteTargetId = null;
function deletePortfolio(id) {
deleteTargetId = id;
const modal = document.getElementById('confirmModal');
modal.classList.remove('hidden');
modal.classList.add('active');
}
function closeConfirmModal() {
deleteTargetId = null;
const modal = document.getElementById('confirmModal');
modal.classList.add('hidden');
modal.classList.remove('active');
}
function confirmDeleteAction() {
if (!deleteTargetId) return;
fetch('/api/portfolio/' + deleteTargetId, {
method: 'DELETE',
})
.then(response => response.json())
.then(data => {
closeConfirmModal();
if (data.success) {
location.reload();
} else {
showToast(data.message || '删除失败', 'error');
}
});
}
</script>
+482
View File
@@ -0,0 +1,482 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-robot text-orange-500"></i> AI生成题目
</h3>
<p class="text-sm text-slate-500 mt-1">基于简历或关键词智能生成面试题目</p>
</div>
<div class="btn-group">
<button onclick="goBack()" class="btn btn-secondary btn-sm">
<i class="fas fa-arrow-left"></i> 返回
</button>
</div>
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="lg:col-span-1">
<div class="bg-slate-50 rounded-lg p-4 sticky top-6">
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
<i class="fas fa-sliders-h text-primary-500"></i> 生成设置
</h4>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">选择简历(可选)</label>
<select id="quizResumeId" class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm">
<option value="">不选择简历(使用自定义关键词)</option>
{{ range .Resumes }}
<option value="{{.ID}}">{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</option>
{{ end }}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">自定义关键词(可选)</label>
<textarea id="quizKeywords" rows="3" class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm resize-none" placeholder="如:Go,MySQL,微服务,Redis&#10;不选简历时必填"></textarea>
</div>
<button onclick="doGenerateQuestions()" id="generateBtn" class="w-full btn btn-primary">
<i class="fas fa-robot"></i> 生成题目
</button>
</div>
</div>
</div>
<div class="lg:col-span-3">
<div id="questionsContainer" class="hidden">
<div class="bg-slate-50 rounded-lg p-6">
<div class="flex items-center justify-between mb-6">
<h4 class="font-medium text-slate-700 flex items-center gap-2">
<i class="fas fa-question-circle text-primary-500"></i> 请回答以下问题
</h4>
<span class="text-sm text-slate-500"><span id="questionCount">0</span></span>
</div>
<div id="questionsList" class="space-y-6"></div>
<div class="mt-6 flex gap-3">
<button onclick="goBack()" class="flex-1 btn btn-secondary">取消</button>
<button onclick="submitAnswers()" class="flex-1 btn btn-primary">提交答案</button>
</div>
</div>
</div>
<div id="emptyState" class="bg-slate-50 rounded-lg p-12 text-center">
<div class="w-20 h-20 mx-auto mb-4 rounded-full bg-orange-100 flex items-center justify-center">
<i class="fas fa-robot text-orange-500 text-4xl"></i>
</div>
<h4 class="font-medium text-slate-700 mb-2">开始生成题目</h4>
<p class="text-sm text-slate-500">选择简历或输入关键词,点击生成按钮开始</p>
</div>
</div>
</div>
</div>
</div>
<input type="hidden" id="user-id" value="{{ with .User }}{{.ID}}{{ end }}">
<script>
var currentQuestions = [];
function goBack() {
window.history.back();
}
function doGenerateQuestions() {
const resumeId = document.getElementById('quizResumeId').value;
const keywords = document.getElementById('quizKeywords').value.trim();
if (!resumeId && !keywords) {
showToast('请选择简历或输入关键词', 'error');
return;
}
const btn = document.getElementById('generateBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 生成中...';
fetch('/api/ai/agent/generate-questions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
resume_id: resumeId,
keywords: keywords,
})
})
.then(response => response.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-robot"></i> 生成题目';
const resultData = data.data || data;
if (data.success && resultData.questions && resultData.questions.length > 0) {
currentQuestions = resultData.questions;
renderQuestions(resultData.questions);
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('questionsContainer').classList.remove('hidden');
} else {
showToast(data.message || '生成题目失败', 'error');
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-robot"></i> 生成题目';
showToast('生成题目失败', 'error');
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderQuestions(questions) {
const container = document.getElementById('questionsList');
const countSpan = document.getElementById('questionCount');
container.innerHTML = '';
countSpan.textContent = questions.length;
questions.forEach((q, index) => {
const qDiv = document.createElement('div');
qDiv.className = 'bg-white p-5 rounded-lg border border-slate-200 shadow-sm';
let typeLabel = '';
let typeColor = '';
switch(q.type) {
case 'mcq': typeLabel = '选择题'; typeColor = 'bg-blue-100 text-blue-700'; break;
case 'fill': typeLabel = '填空题'; typeColor = 'bg-purple-100 text-purple-700'; break;
case 'sa': typeLabel = '简答题'; typeColor = 'bg-green-100 text-green-700'; break;
case 'algo': typeLabel = '算法题'; typeColor = 'bg-orange-100 text-orange-700'; break;
default: typeLabel = '问答题'; typeColor = 'bg-gray-100 text-gray-700';
}
const category = q.category || '综合能力';
const difficulty = q.difficulty || '初级';
const hints = q.hints || [];
let hintsHtml = '';
if (hints.length > 0) {
hintsHtml = '<div class="mt-3 p-3 bg-yellow-50 rounded-lg border border-yellow-200">' +
'<div class="flex items-center gap-2 text-sm font-medium text-yellow-700 mb-2">' +
'<i class="fas fa-lightbulb"></i> 回答要点提示</div>' +
'<ul class="text-xs text-yellow-600 space-y-1">';
hints.forEach(h => {
hintsHtml += '<li class="flex items-start gap-2"><span class="text-yellow-500">•</span>' + escapeHtml(h) + '</li>';
});
hintsHtml += '</ul></div>';
}
let optionsHtml = '';
if (q.options && q.options.length > 0) {
optionsHtml = '<div class="space-y-2 mt-3">';
q.options.forEach((opt, optIndex) => {
const letter = String.fromCharCode(65 + optIndex);
optionsHtml +=
'<label class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 cursor-pointer border border-slate-100 transition-colors">' +
'<input type="radio" name="q' + index + '" value="' + letter + '" class="w-4 h-4 text-primary-600">' +
'<span class="text-sm text-slate-700">' + letter + '. ' + escapeHtml(opt) + '</span>' +
'</label>';
});
optionsHtml += '</div>';
} else if (q.type === 'fill') {
optionsHtml =
'<div class="mt-3">' +
'<input type="text" id="ans' + index + '" class="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm" placeholder="请输入答案" autocomplete="off" >' +
'</div>';
} else {
optionsHtml =
'<div class="mt-3">' +
'<textarea id="ans' + index + '" rows="4" class="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm resize-none" placeholder="请输入您的回答"></textarea>' +
'</div>';
}
qDiv.innerHTML =
'<div class="flex flex-wrap items-center justify-between mb-3">' +
'<div class="flex flex-wrap items-center gap-2">' +
'<span class="px-2.5 py-1 ' + typeColor + ' rounded-full text-xs font-medium">' + typeLabel + '</span>' +
'<span class="px-2.5 py-1 ' + getDifficultyColor(difficulty) + ' rounded-full text-xs font-medium">' + difficulty + '</span>' +
'<span class="px-2.5 py-1 bg-indigo-100 text-indigo-700 rounded-full text-xs font-medium">' + category + '</span>' +
'<span class="text-xs text-slate-400">第 ' + (index + 1) + ' 题 · ' + q.score + ' 分</span>' +
'</div>' +
'<button onclick="toggleQuestionFavorite(' + index + ')" id="favBtn' + index + '" class="btn btn-xs btn-yellow flex items-center gap-1 hover:bg-yellow-100 transition-colors" title="收藏本题">' +
'<i class="fas fa-star"></i> 收藏' +
'</button>' +
'</div>' +
'<p class="text-slate-800 font-medium leading-relaxed">' + escapeHtml(q.text) + '</p>' +
optionsHtml +
hintsHtml;
container.appendChild(qDiv);
});
}
var favoriteQuestions = [];
function toggleQuestionFavorite(index) {
const q = currentQuestions[index];
const btn = document.getElementById(`favBtn${index}`);
const idx = favoriteQuestions.indexOf(index);
if (idx > -1) {
favoriteQuestions.splice(idx, 1);
btn.classList.remove('bg-yellow-100', 'text-yellow-700');
btn.innerHTML = '<i class="fas fa-star"></i> 收藏';
} else {
favoriteQuestions.push(index);
btn.classList.add('bg-yellow-100', 'text-yellow-700');
btn.innerHTML = '<i class="fas fa-star"></i> 已收藏';
showToast('题目已收藏', 'success');
}
}
function submitAnswers() {
const answers = {};
const unanswered = [];
currentQuestions.forEach((q, index) => {
let answer = '';
if (q.type === 'mcq') {
const radios = document.getElementsByName('q' + index);
for (let radio of radios) {
if (radio.checked) {
answer = radio.value;
break;
}
}
} else {
const input = document.getElementById('ans' + index);
answer = input ? input.value.trim() : '';
}
if (!answer) {
unanswered.push(index + 1);
}
answers['q' + index] = answer;
});
if (unanswered.length > 0) {
const unansweredList = unanswered.join('、');
showCustomConfirm(
'确认提交',
`您还有 ${unanswered.length} 道题未作答:第 ${unansweredList} 题。\n\n确定要继续提交吗?未作答的题目将不计分。`,
function(confirmed) {
if (confirmed) {
doSubmit(answers);
}
}
);
return;
}
doSubmit(answers);
}
function doSubmit(answers) {
let score = 0;
let correctCount = 0;
let unansweredCount = 0;
currentQuestions.forEach((q, index) => {
const userAnswer = answers['q' + index];
if (!userAnswer) {
unansweredCount++;
return;
}
if (isAnswerCorrect(q, userAnswer)) {
score += q.score;
correctCount++;
}
});
const resultData = {
questions: currentQuestions,
user_answers: answers,
score: score,
correct_count: correctCount,
total_count: currentQuestions.length,
unanswered_count: unansweredCount
};
fetch('/api/ai/agent/submit-answers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(resultData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
saveQuizRecord(answers, score, correctCount, currentQuestions.length, unansweredCount);
} else {
showToast(data.error || '提交失败', 'error');
}
})
.catch(() => {
showToast('提交失败', 'error');
});
}
function isAnswerCorrect(question, userAnswer) {
const userAns = userAnswer.trim().toLowerCase();
const correctAns = String(question.answer).trim().toLowerCase();
if (question.type === 'mcq') {
return userAns === correctAns;
}
if (question.type === 'fill') {
return userAns === correctAns;
}
if (question.type === 'sa' || question.type === 'algo') {
return userAns.length > 0;
}
return userAns === correctAns;
}
function saveQuizRecord(answers, score, correctCount, totalCount, unansweredCount) {
const resumeId = document.getElementById('quizResumeId').value;
const keywords = document.getElementById('quizKeywords').value.trim();
const recordId = 'quiz-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
const userAnswers = {};
currentQuestions.forEach((q, index) => {
userAnswers[index] = answers['q' + index] || '';
});
const now = new Date();
const title = now.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const userId = document.getElementById('user-id').value;
const recordData = {
id: recordId,
user_id: userId,
resume_id: resumeId,
resume_route: resumeId ? '' : 'custom-quiz',
title: '答题记录 - ' + title,
questions: currentQuestions.map(q => ({
type: q.type,
text: q.text,
options: q.options || [],
answer: q.answer,
analysis: q.analysis || '',
score: q.score,
keywords: q.keywords || []
})),
user_answers: userAnswers,
score: score,
correct_count: correctCount,
total_count: totalCount
};
fetch('/api/quiz/record', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(recordData)
})
.then(response => response.json())
.then(data => {
try {
saveFavoriteAndWrongQuestions(resumeId, answers);
} catch (e) {
console.error('Failed to save favorite/wrong questions:', e);
}
let message = `答题完成!`;
if (unansweredCount > 0) {
message += `\n得分:${score}分(${correctCount}/${totalCount - unansweredCount}正确,${unansweredCount}题未作答)`;
} else {
message += `\n得分:${score}分(${correctCount}/${totalCount}正确)`;
}
showToast(message, 'success');
setTimeout(() => {
const url = new URL(window.location.href);
url.pathname = url.pathname.replace('/quiz/generate', '/quiz');
url.searchParams.set('record', recordId);
window.location.href = url.toString();
}, 2000);
})
.catch(() => {
let message = `答题完成!`;
if (unansweredCount > 0) {
message += `\n得分:${score}分(${correctCount}/${totalCount - unansweredCount}正确,${unansweredCount}题未作答)`;
} else {
message += `\n得分:${score}分(${correctCount}/${totalCount}正确)`;
}
showToast(message, 'success');
setTimeout(() => {
const url = new URL(window.location.href);
url.pathname = url.pathname.replace('/quiz/generate', '/quiz');
url.searchParams.set('record', recordId);
window.location.href = url.toString();
}, 2000);
});
}
function saveFavoriteAndWrongQuestions(resumeId, answers) {
const userId = document.getElementById('user-id').value;
currentQuestions.forEach((q, index) => {
const userAnswer = answers['q' + index];
const isCorrect = userAnswer && isAnswerCorrect(q, userAnswer);
const isFavorite = favoriteQuestions.includes(index);
if (!isCorrect || isFavorite) {
const favData = {
id: generateUUID(),
user_id: userId,
resume_id: resumeId,
resume_route: '',
question: {
type: q.type,
text: q.text,
options: q.options || [],
answer: q.answer,
analysis: q.analysis || '',
score: q.score,
keywords: q.keywords || [],
difficulty: q.difficulty || '初级',
category: q.category || '综合能力'
},
user_answer: userAnswer || '',
is_correct: isCorrect,
is_favorite: isFavorite
};
fetch('/api/favorite', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(favData)
}).catch(() => {});
}
});
}
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
function getDifficultyColor(difficulty) {
switch(difficulty) {
case '入门': return 'bg-green-100 text-green-700';
case '初级': return 'bg-blue-100 text-blue-700';
case '中级': return 'bg-yellow-100 text-yellow-700';
case '进阶': return 'bg-orange-100 text-orange-700';
case '高级': return 'bg-red-100 text-red-700';
default: return 'bg-gray-100 text-gray-700';
}
}
</script>
+270
View File
@@ -0,0 +1,270 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<a href="{{.AdminPath}}/user/{{.User.ID}}/quiz?tab=records" class="text-slate-400 hover:text-slate-600 transition-colors">
<i class="fas fa-arrow-left"></i>
</a>
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-file-alt text-blue-500"></i> {{ if .Record.Title }}{{.Record.Title}}{{ else }}答题详情{{ end }}
</h3>
<p class="text-sm text-slate-500 mt-1">{{ if .User.Name }}{{.User.Name}}{{ else }}{{.User.Username}}{{ end }} 的答题记录</p>
</div>
</div>
<div class="flex items-center gap-6">
<div class="text-center">
<p class="text-xs text-slate-500 mb-1">答题时间</p>
<p class="text-sm font-medium text-slate-700">{{.Record.CreatedAt}}</p>
</div>
<div class="text-center">
<p class="text-xs text-slate-500 mb-1">得分</p>
<p class="text-3xl font-bold {{ if ge .Record.Score 60.0 }}text-green-600{{ else }}text-red-600{{ end }}">{{.Record.Score}}<span class="text-sm font-normal text-slate-400 ml-1"></span></p>
</div>
<div class="text-center">
<p class="text-xs text-slate-500 mb-1">正确率</p>
<p class="text-sm font-medium text-slate-700">{{.Record.CorrectCount}}/{{.Record.TotalCount}} 题</p>
</div>
</div>
</div>
</div>
<div class="p-6">
<div class="space-y-4">
{{ range $index, $q := .Questions }}
<div class="border border-slate-200 rounded-xl overflow-hidden" data-question-index="{{$index}}">
<div class="flex items-center justify-between px-4 py-3 bg-slate-50 border-b border-slate-200">
<div class="flex items-center gap-3">
<span class="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-sm font-medium text-slate-600">{{ add $index 1 }}</span>
<div class="flex items-center gap-2">
{{ if eq $q.Type "mcq" }}<span class="px-2 py-0.5 bg-blue-100 text-blue-600 rounded text-xs">选择题</span>{{ end }}
{{ if eq $q.Type "fill" }}<span class="px-2 py-0.5 bg-green-100 text-green-600 rounded text-xs">填空题</span>{{ end }}
{{ if eq $q.Type "sa" }}<span class="px-2 py-0.5 bg-purple-100 text-purple-600 rounded text-xs">简答题</span>{{ end }}
{{ if eq $q.Type "algo" }}<span class="px-2 py-0.5 bg-orange-100 text-orange-600 rounded text-xs">算法题</span>{{ end }}
{{ if $q.Difficulty }}
<span class="px-2 py-0.5 rounded text-xs"
{{ if eq $q.Difficulty "入门" }}style="background-color: #dcfce7; color: #166534;"{{ end }}
{{ if eq $q.Difficulty "初级" }}style="background-color: #dbeafe; color: #1e40af;"{{ end }}
{{ if eq $q.Difficulty "中级" }}style="background-color: #fef3c7; color: #92400e;"{{ end }}
{{ if eq $q.Difficulty "进阶" }}style="background-color: #ffedd5; color: #c2410c;"{{ end }}
{{ if eq $q.Difficulty "高级" }}style="background-color: #fee2e2; color: #991b1b;"{{ end }}>
{{ $q.Difficulty }}
</span>
{{ end }}
{{ if $q.Category }}<span class="px-2 py-0.5 bg-indigo-100 text-indigo-600 rounded text-xs">{{$q.Category}}</span>{{ end }}
</div>
</div>
<div class="flex items-center gap-3">
<button class="toggle-favorite-btn flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm transition-all hover:bg-slate-100"
data-index="{{$index}}" data-user-id="{{$.User.ID}}" data-resume-id="{{$.Record.ResumeID}}" data-resume-route="{{$.Record.ResumeRoute}}">
<i class="fas fa-star text-slate-400"></i>
<span class="text-slate-500">收藏</span>
</button>
<span class="px-2 py-1 rounded-full text-xs font-medium {{ if index $.Correct $index }}bg-green-100 text-green-700{{ else }}bg-red-100 text-red-700{{ end }}">
{{ if index $.Correct $index }}回答正确{{ else }}回答错误{{ end }}
</span>
</div>
</div>
<div class="p-5">
<p class="text-slate-800 font-medium mb-4">{{ $q.Text }}</p>
{{ if $q.Options }}
<div class="space-y-2 mb-4">
{{ range $optIndex, $opt := $q.Options }}
{{ $letter := printf "%c" (index "ABCDE" $optIndex) }}
{{ $isCorrect := eq $letter $q.Answer }}
{{ $userAnswer := index $.UserAnswers $index }}
{{ $isUserAns := eq $letter $userAnswer }}
<div class="flex items-center gap-3 p-3 rounded-lg {{ if $isCorrect }}bg-green-50 border border-green-200{{ else if and $isUserAns (not $isCorrect) }}bg-red-50 border border-red-200{{ else }}bg-slate-50{{ end }}">
<span class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium {{ if $isCorrect }}bg-green-500 text-white{{ else if and $isUserAns (not $isCorrect) }}bg-red-500 text-white{{ else }}bg-slate-200 text-slate-600{{ end }}">{{ $letter }}</span>
<span class="text-sm {{ if $isCorrect }}text-green-700 font-medium{{ else if and $isUserAns (not $isCorrect) }}text-red-700{{ else }}text-slate-600{{ end }}">{{ $opt }}</span>
{{ if $isCorrect }}<i class="fas fa-check text-green-500 ml-auto text-sm"></i>{{ end }}
{{ if and $isUserAns (not $isCorrect) }}<i class="fas fa-times text-red-500 ml-auto text-sm"></i>{{ end }}
</div>
{{ end }}
</div>
{{ end }}
<div class="flex items-center gap-6 mb-4 text-sm">
<div class="flex items-center gap-2">
<span class="text-slate-500">正确答案:</span>
<span class="font-medium text-green-600 markdown-answer">{{ $q.Answer }}</span>
</div>
{{ with index $.UserAnswers $index }}
<div class="flex items-center gap-2">
<span class="text-slate-500">你的答案:</span>
<span class="font-medium {{ if index $.Correct $index }}text-green-600{{ else }}text-red-600{{ end }}">{{ . }}</span>
</div>
{{ end }}
</div>
{{ if $q.Analysis }}
<div class="p-4 bg-blue-50 rounded-lg border border-blue-100">
<div class="font-medium text-blue-700 text-sm mb-2 flex items-center gap-2">
<i class="fas fa-lightbulb text-xs"></i> 答案解析
</div>
<div class="text-sm text-slate-600 markdown-body markdown-analysis">{{ $q.Analysis }}</div>
</div>
{{ end }}
</div>
</div>
{{ end }}
</div>
</div>
</div>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.5.0/github-markdown.min.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<style>
.markdown-body { background-color: transparent !important; font-size: 0.88rem; }
.markdown-body p { margin: 0.5em 0; }
.markdown-body pre { background: #1e293b; padding: 0.75rem; border-radius: 0.38rem; overflow-x: auto; }
.markdown-body code { background: rgba(0,0,0,0.06); padding: 0.12rem 0.38rem; border-radius: 0.25rem; font-size: 0.81rem; }
.markdown-body pre code { background: transparent; padding: 0; color: #e2e8f0; }
.toggle-favorite-btn.favorited i { color: #fbbf24; }
.toggle-favorite-btn.favorited span { color: #d97706; }
.toggle-favorite-btn.favorited { background-color: #fef3c7; }
</style>
<script>
function decodeUnicode(str) {
return str.replace(/\\u([0-9a-fA-F]{4})/g, function (_, hex) {
return String.fromCharCode(parseInt(hex, 16));
});
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
document.addEventListener('DOMContentLoaded', function () {
marked.setOptions({ breaks: true, gfm: true });
document.querySelectorAll('.markdown-analysis').forEach(function (el) {
let md = decodeUnicode(el.textContent || '');
if (!md.trim()) return;
md = escapeHtml(md);
el.innerHTML = DOMPurify.sanitize(marked.parse(md));
el.classList.add('markdown-body');
});
document.querySelectorAll('.markdown-answer').forEach(function (el) {
let md = decodeUnicode(el.textContent || '');
if (!md.trim()) return;
md = escapeHtml(md);
el.innerHTML = DOMPurify.sanitize(marked.parseInline(md));
});
document.querySelectorAll('.toggle-favorite-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const index = parseInt(this.dataset.index);
const userId = this.dataset.userId;
const resumeId = this.dataset.resumeId;
const resumeRoute = this.dataset.resumeRoute;
const questionEl = this.closest('[data-question-index]');
const questionType = questionEl.querySelector('[class*="bg-blue-100"], [class*="bg-green-100"], [class*="bg-purple-100"], [class*="bg-orange-100"]')?.textContent || '综合';
const questionText = questionEl.querySelector('.text-slate-800.font-medium')?.textContent || '';
const questionOptions = [];
questionEl.querySelectorAll('.space-y-2 .text-sm').forEach(function(opt) {
questionOptions.push(opt.textContent.trim());
});
const questionAnswer = questionEl.querySelector('.text-green-600.markdown-answer')?.textContent || '';
const questionAnalysis = questionEl.querySelector('.markdown-analysis')?.textContent || '';
const questionDifficulty = questionEl.querySelector('[style*="background-color"]')?.textContent || '初级';
const questionCategory = questionEl.querySelector('.bg-indigo-100')?.textContent || '综合';
const isCorrectEl = this.nextElementSibling;
const isCorrect = isCorrectEl.textContent.includes('正确');
const userAnswerEl = questionEl.querySelector('.text-red-600') || questionEl.querySelector('.text-green-600');
const userAnswer = userAnswerEl ? userAnswerEl.textContent.trim().replace('你的答案:', '') : '';
const questionData = {
type: questionType.includes('选择') ? 'mcq' :
questionType.includes('填空') ? 'fill' :
questionType.includes('简答') ? 'sa' : 'algo',
text: questionText,
options: questionOptions.length > 0 ? questionOptions : null,
answer: questionAnswer,
analysis: questionAnalysis,
score: 10,
keywords: [],
difficulty: questionDifficulty,
category: questionCategory
};
const payload = {
user_id: userId,
resume_id: resumeId,
resume_route: resumeRoute,
question: questionData,
user_answer: userAnswer,
is_correct: isCorrect,
is_favorite: true
};
fetch('/api/quiz/favorite', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
if (data.success || data.code === 201) {
this.classList.add('favorited');
this.querySelector('i').classList.remove('text-slate-400');
this.querySelector('i').classList.add('text-yellow-500');
this.querySelector('span').textContent = '已收藏';
showToast('收藏成功');
} else {
showToast('收藏失败: ' + (data.message || '未知错误'));
}
})
.catch(error => {
showToast('收藏失败: ' + error.message);
});
});
});
});
function showToast(message) {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
top: 1.25rem;
right: 1.25rem;
padding: 0.75rem 1.50rem;
background: rgba(0,0,0,0.8);
color: white;
border-radius: 0.50rem;
z-index: 1000;
font-size: 0.88rem;
animation: slideIn 0.3s ease;
`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 2000);
}
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
</script>
+220
View File
@@ -0,0 +1,220 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-question-circle text-purple-500"></i> 面试答题管理
</h3>
<p class="text-sm text-slate-500 mt-1">管理用户的面试题库、答题记录和学习进度</p>
</div>
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl p-5 border border-blue-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-blue-600 font-medium">总用户数</p>
<p class="text-2xl font-bold text-blue-800 mt-1">{{ len .UsersWithStats }}</p>
</div>
<div class="w-12 h-12 rounded-full bg-blue-200 flex items-center justify-center">
<i class="fas fa-users text-blue-600"></i>
</div>
</div>
</div>
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-5 border border-green-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-green-600 font-medium">总答题次数</p>
<p class="text-2xl font-bold text-green-800 mt-1">{{ .TotalStats.TotalAttempts }}</p>
</div>
<div class="w-12 h-12 rounded-full bg-green-200 flex items-center justify-center">
<i class="fas fa-check-circle text-green-600"></i>
</div>
</div>
</div>
<div class="bg-gradient-to-br from-yellow-50 to-yellow-100 rounded-xl p-5 border border-yellow-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-yellow-600 font-medium">收藏题目</p>
<p class="text-2xl font-bold text-yellow-800 mt-1">{{ .TotalStats.TotalFavorites }}</p>
</div>
<div class="w-12 h-12 rounded-full bg-yellow-200 flex items-center justify-center">
<i class="fas fa-star text-yellow-600"></i>
</div>
</div>
</div>
<div class="bg-gradient-to-br from-red-50 to-red-100 rounded-xl p-5 border border-red-200">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-red-600 font-medium">错题数量</p>
<p class="text-2xl font-bold text-red-800 mt-1">{{ .TotalStats.TotalWrong }}</p>
</div>
<div class="w-12 h-12 rounded-full bg-red-200 flex items-center justify-center">
<i class="fas fa-times-circle text-red-600"></i>
</div>
</div>
</div>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr class="bg-slate-50 border-b border-slate-200">
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">用户信息</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">答题统计</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">正确率</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">收藏/错题</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">最近答题</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">操作</th>
</tr>
</thead>
<tbody>
{{ range .UsersWithStats }}
<tr class="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center text-white font-bold shadow-md">
{{ if .User.Name }}{{ substr .User.Name 0 1 }}{{ else }}{{ substr .User.Username 0 1 }}{{ end }}
</div>
<div>
<p class="font-medium text-slate-800">{{ if .User.Name }}{{ .User.Name }}{{ else }}{{ .User.Username }}{{ end }}</p>
<p class="text-sm text-slate-500">{{ .User.Email }}</p>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-4">
<div>
<p class="text-xs text-slate-500">答题次数</p>
<p class="font-semibold text-slate-800">{{ .TotalAttempts }}</p>
</div>
<div class="w-px h-10 bg-slate-200"></div>
<div>
<p class="text-xs text-slate-500">平均得分</p>
<p class="font-semibold text-slate-800">{{ printf "%.1f" .AvgScore }}</p>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-20 h-3 bg-slate-100 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all"
style="width: {{ .CorrectRate }}%; background-color: {{ if ge .CorrectRate 80 }}#22c55e{{ else if ge .CorrectRate 60 }}#eab308{{ else }}#ef4444{{ end }}"></div>
</div>
<span class="text-sm font-medium text-slate-700">{{ .CorrectRate }}%</span>
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<span class="flex items-center gap-1 text-yellow-600">
<i class="fas fa-star"></i>
<span class="text-sm font-medium">{{ .FavoriteCount }}</span>
</span>
<span class="flex items-center gap-1 text-red-500">
<i class="fas fa-times-circle"></i>
<span class="text-sm font-medium">{{ .WrongCount }}</span>
</span>
</div>
</td>
<td class="px-6 py-4">
<span class="text-sm text-slate-500">{{ if .LastAttemptDate }}{{ .LastAttemptDate }}{{ else }}暂无记录{{ end }}</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<a href="{{$.AdminPath}}/user/{{.User.ID}}/quiz/generate" class="btn btn-primary btn-xs" title="AI生成题目">
<i class="fas fa-robot"></i> 生成题目
</a>
<a href="{{$.AdminPath}}/user/{{.User.ID}}/quiz" class="btn btn-info btn-xs" title="查看题库">
<i class="fas fa-book-open"></i> 题库
</a>
</div>
</td>
</tr>
{{ else }}
<tr>
<td colspan="6" class="px-6 py-12 text-center text-slate-500">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-users text-slate-400 text-2xl"></i>
</div>
<p>暂无用户,请先添加用户</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ if gt .TotalPages 1 }}
<div class="border-t border-slate-200 px-6 py-4 bg-slate-50 flex items-center justify-between">
<div class="text-sm text-slate-500">共 {{ .TotalRecords }} 条记录,第 {{ .CurrentPage }} / {{ .TotalPages }} 页</div>
<div class="flex items-center gap-2">
<a href="?page=1" class="btn btn-sm btn-slate {{ if eq .CurrentPage 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
<i class="fas fa-chevron-left"></i>
</a>
<a href="?page={{ if eq .CurrentPage 1 }}1{{ else }}{{ subtract .CurrentPage 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .CurrentPage 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
</a>
{{ range $i := to 1 $.TotalPages }}
{{ if or (eq $i 1) (eq $i $.TotalPages) (and (ge $i (subtract $.CurrentPage 2)) (le $i (add $.CurrentPage 2))) }}
<a href="?page={{ $i }}" class="btn btn-sm {{ if eq $i $.CurrentPage }}btn-primary{{ else }}btn-slate{{ end }}">{{ $i }}</a>
{{ else if and (eq $i 2) (gt (subtract $.CurrentPage 3) 1) }}
<span class="text-slate-400 px-2">...</span>
{{ else if and (eq $i (subtract $.TotalPages 1)) (lt (add $.CurrentPage 3) $.TotalPages) }}
<span class="text-slate-400 px-2">...</span>
{{ end }}
{{ end }}
<a href="?page={{ if eq .CurrentPage .TotalPages }}{{ .TotalPages }}{{ else }}{{ add .CurrentPage 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .CurrentPage .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
</a>
<a href="?page={{ .TotalPages }}" class="btn btn-sm btn-slate {{ if eq .CurrentPage .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
<i class="fas fa-chevron-right"></i>
</a>
</div>
</div>
{{ end }}
<div class="mt-6 bg-slate-50 rounded-xl p-5">
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
<i class="fas fa-chart-line text-purple-500"></i> 功能说明
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white p-4 rounded-lg border border-slate-200">
<div class="flex items-center gap-3 mb-2">
<div class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
<i class="fas fa-robot text-blue-600"></i>
</div>
<span class="font-medium text-slate-700">AI生成题目</span>
</div>
<p class="text-sm text-slate-500">基于简历内容或关键词智能生成面试题目,支持多种难度级别</p>
</div>
<div class="bg-white p-4 rounded-lg border border-slate-200">
<div class="flex items-center gap-3 mb-2">
<div class="w-8 h-8 rounded-full bg-yellow-100 flex items-center justify-center">
<i class="fas fa-star text-yellow-600"></i>
</div>
<span class="font-medium text-slate-700">收藏题目</span>
</div>
<p class="text-sm text-slate-500">收藏重要题目,方便复习回顾,支持快速筛选查看</p>
</div>
<div class="bg-white p-4 rounded-lg border border-slate-200">
<div class="flex items-center gap-3 mb-2">
<div class="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center">
<i class="fas fa-times-circle text-red-600"></i>
</div>
<span class="font-medium text-slate-700">错题本</span>
</div>
<p class="text-sm text-slate-500">自动记录答错的题目,支持针对性练习和错题回顾</p>
</div>
<div class="bg-white p-4 rounded-lg border border-slate-200">
<div class="flex items-center gap-3 mb-2">
<div class="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<i class="fas fa-chart-bar text-green-600"></i>
</div>
<span class="font-medium text-slate-700">数据统计</span>
</div>
<p class="text-sm text-slate-500">查看答题记录、正确率、得分趋势等学习数据</p>
</div>
</div>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+661
View File
@@ -0,0 +1,661 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-file-alt text-purple-500"></i> 简历管理
</h3>
<p class="text-sm text-slate-500 mt-1">管理所有用户的简历</p>
</div>
<div class="flex gap-2">
<button onclick="openUploadDocumentModal()" class="btn btn-success">
<i class="fas fa-upload"></i> 上传文档生成简历
</button>
<a href="{{$.AdminPath}}/resume/add" class="btn btn-primary">
<i class="fas fa-plus"></i> 添加简历
</a>
</div>
</div>
</div>
<div class="px-6 py-4 border-b border-slate-100 bg-white">
<form id="filterForm" method="GET" action="{{$.AdminPath}}/resumes" class="flex flex-wrap items-center gap-3">
<div class="flex-1 min-w-[11.25rem]">
<input type="text" name="keyword" value="{{.FilterKeyword}}" placeholder="搜索姓名/路由..." class="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<select name="user_id" class="px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">全部人员</option>
{{ range .Users }}
<option value="{{.ID}}" {{ if eq $.FilterUserID .ID }}selected{{ end }}>{{.Name}}{{.Username}}</option>
{{ end }}
</select>
<select name="template" class="px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">全部模板</option>
<option value="modern" {{ if eq .FilterTemplate "modern" }}selected{{ end }}>现代商务</option>
<option value="classic" {{ if eq .FilterTemplate "classic" }}selected{{ end }}>传统经典</option>
<option value="tech" {{ if eq .FilterTemplate "tech" }}selected{{ end }}>科技极客</option>
<option value="sidebar" {{ if eq .FilterTemplate "sidebar" }}selected{{ end }}>经典侧边栏</option>
<option value="elegant" {{ if eq .FilterTemplate "elegant" }}selected{{ end }}>优雅轻奢</option>
<option value="fresh" {{ if eq .FilterTemplate "fresh" }}selected{{ end }}>清新自然</option>
<option value="minimalist" {{ if eq .FilterTemplate "minimalist" }}selected{{ end }}>极简风格</option>
<option value="professional" {{ if eq .FilterTemplate "professional" }}selected{{ end }}>专业商务</option>
</select>
<select name="show_in_home" class="px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">首页展示</option>
<option value="1" {{ if eq .FilterShowInHome "1" }}selected{{ end }}>已展示</option>
<option value="0" {{ if eq .FilterShowInHome "0" }}selected{{ end }}>未展示</option>
</select>
<button type="submit" class="btn btn-primary btn-sm">
<i class="fas fa-search"></i> 筛选
</button>
<a href="{{$.AdminPath}}/resumes" class="btn btn-secondary btn-sm">
<i class="fas fa-redo"></i> 重置
</a>
</form>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr class="bg-slate-50 border-b border-slate-200">
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">姓名</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">职位</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">所属人员</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">访问路由</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">首页展示</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">更新时间</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600 actions-col">操作</th>
</tr>
</thead>
<tbody>
{{ range .Resumes }}
<tr class="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td class="px-6 py-4">
<span class="font-medium text-slate-800">{{.BasicInfo.Name}}</span>
</td>
<td class="px-6 py-4 text-slate-600">{{.BasicInfo.Title}}</td>
<td class="px-6 py-4">
{{ if .UserID }}
{{ $user := index $.UserMap .UserID }}
{{ if $user }}
<span class="badge badge-success">{{$user.Name}}{{$user.Username}}</span>
{{ else }}
<span class="badge badge-warning">已失效</span>
{{ end }}
{{ else }}
<span class="badge badge-danger">未绑定</span>
{{ end }}
</td>
<td class="px-6 py-4 text-slate-600">/{{.Route}}</td>
<td class="px-6 py-4">
{{ if .ShowInHome }}
<span class="badge badge-success"></span>
{{ else }}
<span class="badge badge-slate"></span>
{{ end }}
</td>
<td class="px-6 py-4 text-slate-500 text-sm">{{.UpdatedAt}}</td>
<td class="px-6 py-4 actions-col">
<div class="btn-group flex-wrap gap-1">
<a href="/{{.Route}}" target="_blank" class="btn btn-info btn-xs" title="预览">
<i class="fas fa-eye"></i> 预览
</a>
{{ if .UserID }}
<a href="{{$.AdminPath}}/user/{{.UserID}}/resume/{{.ID}}/edit" class="btn btn-primary btn-xs" title="编辑">
<i class="fas fa-edit"></i> 编辑
</a>
{{ else }}
<a href="{{$.AdminPath}}/resume/{{.ID}}/edit" class="btn btn-primary btn-xs" title="编辑">
<i class="fas fa-edit"></i> 编辑
</a>
{{ end }}
<button onclick="toggleShowInHome('{{.ID}}', {{.ShowInHome}})" class="btn btn-warning btn-xs" title="{{if .ShowInHome}}取消展示{{else}}展示{{end}}">
<i class="fas fa-home"></i> {{if .ShowInHome}}取消{{else}}展示{{end}}
</button>
<button onclick="updatePassword('{{.ID}}')" class="btn btn-secondary btn-xs" title="密码">
<i class="fas fa-lock"></i> 密码
</button>
{{ if .UserID }}
<button onclick="unbindUser('{{.ID}}')" class="btn btn-orange btn-xs" title="解绑人员">
<i class="fas fa-unlink"></i> 解绑
</button>
{{ else }}
<button onclick="bindUser('{{.ID}}')" class="btn btn-success btn-xs" title="绑定人员">
<i class="fas fa-link"></i> 绑定
</button>
{{ end }}
<button onclick="deleteResume('{{.ID}}')" class="btn btn-danger btn-xs" title="删除">
<i class="fas fa-trash"></i> 删除
</button>
</div>
</td>
</tr>
{{ else }}
<tr>
<td colspan="7" class="px-6 py-12 text-center text-slate-500">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-file-alt text-slate-400 text-2xl"></i>
</div>
<p>暂无简历</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ if gt .TotalPages 1 }}
<div class="border-t border-slate-200 px-6 py-4 bg-slate-50 flex items-center justify-between">
<div class="text-sm text-slate-500">共 {{ .Total }} 条记录,第 {{ .Page }} / {{ .TotalPages }} 页</div>
<div class="flex items-center gap-2">
<a href="?page=1{{ .FilterQuery }}" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
<i class="fas fa-chevron-left"></i>
</a>
<a href="?page={{ if eq .Page 1 }}1{{ else }}{{ subtract .Page 1 }}{{ end }}{{ .FilterQuery }}" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
</a>
{{ if gt .TotalPages 7 }}
{{ if gt .Page 3 }}
<a href="?page=1{{ .FilterQuery }}" class="btn btn-sm btn-slate">1</a>
{{ if gt .Page 4 }}
<span class="text-slate-400 text-sm">...</span>
{{ end }}
{{ end }}
{{ end }}
{{ range $i := to 1 .TotalPages }}
{{ if and (ge $i (subtract $.Page 2)) (le $i (add $.Page 2)) }}
<a href="?page={{ $i }}{{ $.FilterQuery }}" class="btn btn-sm {{ if eq $i $.Page }}btn-primary{{ else }}btn-slate{{ end }}">{{ $i }}</a>
{{ end }}
{{ end }}
{{ if gt .TotalPages 7 }}
{{ if lt .Page (subtract .TotalPages 2) }}
{{ if lt .Page (subtract .TotalPages 3) }}
<span class="text-slate-400 text-sm">...</span>
{{ end }}
<a href="?page={{ .TotalPages }}{{ .FilterQuery }}" class="btn btn-sm btn-slate">{{ .TotalPages }}</a>
{{ end }}
{{ end }}
<a href="?page={{ if eq .Page .TotalPages }}{{ .TotalPages }}{{ else }}{{ add .Page 1 }}{{ end }}{{ .FilterQuery }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
</a>
<a href="?page={{ .TotalPages }}{{ .FilterQuery }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
<i class="fas fa-chevron-right"></i>
</a>
</div>
</div>
{{ end }}
</div>
<div class="modal" id="addResumeModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="addResumeModalTitle">
<i class="fas fa-file-plus text-blue-500"></i> 添加简历
</h3>
<div class="modal-close" onclick="closeAddResumeModal()">
<i class="fas fa-times"></i>
</div>
</div>
<div class="modal-body">
<form id="addResumeForm" onsubmit="saveResume(event)">
<input type="hidden" id="resumeId">
<div class="space-y-4">
<div>
<label class="form-label">姓名</label>
<input type="text" id="resumeName" class="form-input" required>
</div>
<div>
<label class="form-label">职位</label>
<input type="text" id="resumeTitle" class="form-input" required>
</div>
<div>
<label class="form-label">访问路由(如 zhangsan</label>
<input type="text" id="resumeRoute" class="form-input" required>
</div>
<div>
<label class="form-label">绑定人员(可选,可后续绑定)</label>
<select id="resumeUserId" class="form-input">
<option value="">不绑定人员</option>
{{ range .Users }}
<option value="{{.ID}}">{{.Name}} - {{.Username}}</option>
{{ end }}
</select>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button onclick="closeAddResumeModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button type="submit" form="addResumeForm" class="btn btn-primary">
<i class="fas fa-save"></i> 保存
</button>
</div>
</div>
</div>
<div class="modal" id="bindUserModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">
<i class="fas fa-link text-green-500"></i> 绑定人员
</h3>
<div class="modal-close" onclick="closeBindUserModal()">
<i class="fas fa-times"></i>
</div>
</div>
<div class="modal-body">
<div class="mb-4">
<label class="form-label">选择要绑定的人员</label>
<select id="bindUserId" class="form-input">
{{ range .Users }}
<option value="{{.ID}}">{{.Name}} - {{.Username}}</option>
{{ end }}
</select>
</div>
</div>
<div class="modal-footer">
<button onclick="closeBindUserModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button onclick="confirmBindUser()" class="btn btn-primary">
<i class="fas fa-check"></i> 确认绑定
</button>
</div>
</div>
</div>
<div class="modal" id="passwordModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">
<i class="fas fa-lock text-yellow-500"></i> 设置访问密码
</h3>
<div class="modal-close" onclick="closePasswordModal()">
<i class="fas fa-times"></i>
</div>
</div>
<div class="modal-body">
<div class="space-y-4">
<div>
<label class="form-label">访问密码(留空则取消密码保护)</label>
<input type="password" id="resumePassword" class="form-input" placeholder="请输入密码">
</div>
<div>
<label class="form-label">确认密码</label>
<input type="password" id="resumePasswordConfirm" class="form-input" placeholder="请再次输入密码">
</div>
</div>
</div>
<div class="modal-footer">
<button onclick="closePasswordModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button onclick="confirmUpdatePassword()" class="btn btn-primary">
<i class="fas fa-save"></i> 保存
</button>
</div>
</div>
</div>
<div class="modal" id="uploadDocumentModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">
<i class="fas fa-upload text-green-500"></i> 上传文档生成简历
</h3>
<div class="modal-close" onclick="closeUploadDocumentModal()">
<i class="fas fa-times"></i>
</div>
</div>
<div class="modal-body">
<form id="uploadDocumentForm" enctype="multipart/form-data" onsubmit="uploadDocument(event)">
<div class="space-y-4">
<div>
<label class="form-label">选择文档</label>
<div class="border-2 border-dashed border-slate-300 rounded-lg p-8 text-center hover:border-green-500 transition-colors" id="fileDropArea">
<i class="fas fa-file-upload text-4xl text-slate-400 mb-4"></i>
<p class="text-slate-600 mb-2">点击或拖拽文件到此处</p>
<p class="text-sm text-slate-400">支持格式:PDF、DOCX、XLSX、MD、TXT</p>
<input type="file" id="documentFile" name="file" accept=".pdf,.docx,.xlsx,.md,.txt" class="hidden" onchange="handleFileSelect(this)">
</div>
<div id="selectedFileInfo" class="mt-2 hidden">
<div class="flex items-center gap-2 text-sm text-slate-600">
<i class="fas fa-file"></i>
<span id="selectedFileName"></span>
<button type="button" onclick="clearFileSelection()" class="text-red-500 hover:text-red-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<div>
<label class="form-label">绑定人员(可选)</label>
<select id="documentUserId" class="form-input">
<option value="">不绑定人员</option>
{{ range .Users }}
<option value="{{.ID}}">{{.Name}} - {{.Username}}</option>
{{ end }}
</select>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button onclick="closeUploadDocumentModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button type="submit" form="uploadDocumentForm" class="btn btn-success" id="uploadSubmitBtn">
<i class="fas fa-upload"></i> 上传生成
</button>
</div>
</div>
</div>
<script>
var currentBindResumeId = '';
var currentPasswordResumeId = '';
function openUploadDocumentModal() {
document.getElementById('uploadDocumentModal').classList.add('active');
document.getElementById('documentFile').value = '';
document.getElementById('selectedFileInfo').classList.add('hidden');
document.getElementById('selectedFileName').textContent = '';
document.getElementById('uploadSubmitBtn').disabled = false;
document.getElementById('uploadSubmitBtn').innerHTML = '<i class="fas fa-upload"></i> 上传生成';
}
function closeUploadDocumentModal() {
document.getElementById('uploadDocumentModal').classList.remove('active');
}
document.getElementById('fileDropArea').addEventListener('click', function() {
document.getElementById('documentFile').click();
});
document.getElementById('fileDropArea').addEventListener('dragover', function(e) {
e.preventDefault();
this.classList.add('border-green-500');
});
document.getElementById('fileDropArea').addEventListener('dragleave', function(e) {
e.preventDefault();
this.classList.remove('border-green-500');
});
document.getElementById('fileDropArea').addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('border-green-500');
var files = e.dataTransfer.files;
if (files.length > 0) {
document.getElementById('documentFile').files = files;
handleFileSelect(document.getElementById('documentFile'));
}
});
function handleFileSelect(input) {
if (input.files.length > 0) {
var file = input.files[0];
document.getElementById('selectedFileName').textContent = file.name;
document.getElementById('selectedFileInfo').classList.remove('hidden');
}
}
function clearFileSelection() {
document.getElementById('documentFile').value = '';
document.getElementById('selectedFileInfo').classList.add('hidden');
document.getElementById('selectedFileName').textContent = '';
}
function uploadDocument(event) {
event.preventDefault();
var fileInput = document.getElementById('documentFile');
var userId = document.getElementById('documentUserId').value;
if (fileInput.files.length === 0) {
showCustomAlert('error', '错误', '请选择要上传的文件');
return;
}
var file = fileInput.files[0];
var allowedExts = ['.pdf', '.docx', '.xlsx', '.md', '.txt'];
var fileExt = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
if (!allowedExts.includes(fileExt)) {
showCustomAlert('error', '错误', '不支持的文件格式,支持:PDF、DOCX、XLSX、MD、TXT');
return;
}
var formData = new FormData();
formData.append('file', file);
if (userId) {
formData.append('user_id', userId);
}
var submitBtn = document.getElementById('uploadSubmitBtn');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
fetch('/dashboard/api/resume/generate-from-document', {
method: 'POST',
body: formData,
credentials: 'include'
})
.then(response => {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return response.json();
})
.then(result => {
console.log('API Response:', result);
if (result.success) {
showCustomAlert('success', '成功', result.message || '简历生成成功!');
closeUploadDocumentModal();
setTimeout(() => location.reload(), 2000);
} else {
showCustomAlert('error', '错误', result.message || '生成失败');
}
})
.catch(error => {
console.error('Upload error:', error);
showCustomAlert('error', '错误', '上传失败: ' + error.message);
})
.finally(() => {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-upload"></i> 上传生成';
});
}
function openAddResumeModal() {
document.getElementById('resumeId').value = '';
document.getElementById('resumeName').value = '';
document.getElementById('resumeTitle').value = '';
document.getElementById('resumeRoute').value = '';
document.getElementById('resumeUserId').value = '';
document.getElementById('addResumeModal').classList.add('active');
}
function closeAddResumeModal() {
document.getElementById('addResumeModal').classList.remove('active');
}
function saveResume(event) {
event.preventDefault();
const id = document.getElementById('resumeId').value;
const name = document.getElementById('resumeName').value;
const title = document.getElementById('resumeTitle').value;
const route = document.getElementById('resumeRoute').value;
const userId = document.getElementById('resumeUserId').value;
if (!name || !title || !route) {
showCustomAlert('error', '错误', '请填写完整信息');
return;
}
fetch('/dashboard/api/resume/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: userId,
route: route,
basic_info: {
name: name,
title: title
}
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '添加成功');
closeAddResumeModal();
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '添加失败:' + (result.message || '未知错误'));
}
})
.catch(() => {
showCustomAlert('error', '错误', '添加失败');
});
}
function bindUser(resumeId) {
currentBindResumeId = resumeId;
document.getElementById('bindUserModal').classList.add('active');
}
function closeBindUserModal() {
document.getElementById('bindUserModal').classList.remove('active');
currentBindResumeId = '';
}
function confirmBindUser() {
const userId = document.getElementById('bindUserId').value;
if (!userId) {
showCustomAlert('error', '错误', '请选择要绑定的人员');
return;
}
fetch('/dashboard/api/resume/' + currentBindResumeId + '/user', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId })
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '绑定成功');
closeBindUserModal();
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '绑定失败:' + (result.message || '未知错误'));
}
})
.catch(() => {
showCustomAlert('error', '错误', '绑定失败');
});
}
function unbindUser(resumeId) {
showCustomConfirm('确认解绑', '确定要解绑该简历与人员的关联吗?', function(confirmed) {
if (!confirmed) return;
fetch('/dashboard/api/resume/' + resumeId + '/user', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: '' })
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '解绑成功');
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '解绑失败:' + (result.message || '未知错误'));
}
})
.catch(() => {
showCustomAlert('error', '错误', '解绑失败');
});
});
}
function toggleShowInHome(resumeId, currentState) {
fetch('/dashboard/api/resume/' + resumeId + '/show_in_home', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ show_in_home: !currentState })
})
.then(response => response.json())
.then(result => {
if (result.success !== undefined) {
showCustomAlert('success', '成功', currentState ? '已取消首页展示' : '已设置首页展示');
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '操作失败');
}
})
.catch(() => {
showCustomAlert('error', '错误', '操作失败');
});
}
function updatePassword(resumeId) {
currentPasswordResumeId = resumeId;
document.getElementById('resumePassword').value = '';
document.getElementById('resumePasswordConfirm').value = '';
document.getElementById('passwordModal').classList.add('active');
}
function closePasswordModal() {
document.getElementById('passwordModal').classList.remove('active');
currentPasswordResumeId = '';
}
function confirmUpdatePassword() {
const password = document.getElementById('resumePassword').value;
const confirmPassword = document.getElementById('resumePasswordConfirm').value;
if (password !== confirmPassword) {
showCustomAlert('error', '错误', '两次输入的密码不一致');
return;
}
fetch('/dashboard/api/resume/' + currentPasswordResumeId + '/password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: password })
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', password ? '密码设置成功' : '密码已取消');
closePasswordModal();
} else {
showCustomAlert('error', '错误', '设置失败:' + (result.message || '未知错误'));
}
})
.catch(() => {
showCustomAlert('error', '错误', '设置失败');
});
}
function deleteResume(resumeId) {
showCustomConfirm('确认删除', '确定要删除该简历吗?此操作不可恢复。', function(confirmed) {
if (!confirmed) return;
fetch('/dashboard/api/resume/' + resumeId, {
method: 'DELETE'
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '删除成功');
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '删除失败:' + (result.message || '未知错误'));
}
})
.catch(() => {
showCustomAlert('error', '错误', '删除失败');
});
});
}
</script>
+347
View File
@@ -0,0 +1,347 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-cog text-purple-500"></i> 系统设置
</h3>
<p class="text-sm text-slate-500 mt-1">配置系统参数</p>
</div>
<button onclick="saveSettings()" class="btn btn-primary">
<i class="fas fa-save"></i> 保存设置
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<div class="p-6 bg-slate-50 rounded-lg">
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
<i class="fas fa-globe text-blue-500"></i> 网站基本设置
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">网站域名</label>
<input type="text" id="settingDomain" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.WebsiteDomain}}">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">网站标题</label>
<input type="text" id="settingTitle" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.WebsiteTitle}}">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">网站描述</label>
<textarea id="settingDescription" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.Config.WebsiteDescription}}</textarea>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">网站关键词</label>
<input type="text" id="settingKeywords" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.WebsiteKeywords}}" placeholder="多个关键词用逗号分隔">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">管理员邮箱</label>
<input type="email" id="settingAdminEmail" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.AdminEmail}}">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">管理员姓名</label>
<input type="text" id="settingAdminName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.AdminName}}">
</div>
</div>
</div>
<div class="p-6 bg-slate-50 rounded-lg">
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
<i class="fas fa-image text-blue-500"></i> Logo设置
</h4>
<div class="flex items-center gap-6">
<div class="flex-shrink-0">
{{ if .Config.LogoPath }}
<div class="relative group">
<img id="logoPreview" src="{{.Config.LogoPath}}" data-path="{{.Config.LogoPath}}" class="w-24 h-24 rounded-xl object-cover border-2 border-blue-200 shadow-md">
<button onclick="clearLogo()" class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center shadow-lg hover:bg-red-600">
<i class="fas fa-times text-xs"></i>
</button>
</div>
{{ else }}
<div id="logoPreview" class="w-24 h-24 rounded-xl bg-white border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 hover:border-blue-400 hover:text-blue-500 transition-all cursor-pointer" onclick="document.getElementById('logoFile').click()">
<i class="fas fa-image text-2xl mb-1"></i>
<span class="text-xs">点击上传</span>
</div>
{{ end }}
</div>
<div class="flex-1">
<div id="logoDropZone" class="w-full border-2 border-dashed border-slate-300 rounded-lg p-6 text-center hover:border-blue-400 hover:bg-blue-50 transition-all cursor-pointer" onclick="document.getElementById('logoFile').click()">
<i class="fas fa-cloud-upload-alt text-blue-500 text-3xl mb-3"></i>
<p class="text-sm text-slate-600">点击或拖动文件到此处自动上传</p>
</div>
<input type="file" id="logoFile" accept="image/*" class="hidden" onchange="handleLogoSelect(this)">
</div>
</div>
</div>
<div class="p-6 bg-slate-50 rounded-lg">
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
<i class="fas fa-list text-blue-500"></i> 后台设置
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">后台列表每页数据条数</label>
<input type="number" id="settingAdminPageSize" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.AdminPageSize}}" min="1" max="100">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">SEO 关键词</label>
<input type="text" id="settingSEOKeywords" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.SEOKeywords}}" placeholder="多个关键词用逗号分隔">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700 mb-1">SEO 描述</label>
<textarea id="settingSEODescription" rows="2" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.Config.SEODescription}}</textarea>
</div>
</div>
</div>
</div>
<div class="space-y-6">
<div class="p-6 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border border-blue-200">
<h4 class="font-medium text-blue-800 mb-4 flex items-center gap-2">
<i class="fas fa-server text-blue-600"></i> 系统信息
</h4>
<div class="space-y-3">
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">系统版本</span>
<span class="text-slate-800 font-medium">1.0.0</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">Go 版本</span>
<span class="text-slate-800 font-medium" id="sysGoVersion">-</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">数据库</span>
<span class="text-slate-800 font-medium">SQLite</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">操作系统</span>
<span class="text-slate-800 font-medium" id="sysOS">-</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">架构</span>
<span class="text-slate-800 font-medium" id="sysArch">-</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">启动时间</span>
<span class="text-slate-800 font-medium" id="sysStartTime">-</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">运行时长</span>
<span class="text-slate-800 font-medium" id="sysUptime">-</span>
</div>
</div>
</div>
<div class="p-6 bg-slate-50 rounded-lg">
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
<i class="fas fa-history text-blue-500"></i> 最近登录记录
</h4>
<div id="loginHistoryList" class="space-y-3 max-h-60 overflow-y-auto">
<div class="text-center text-slate-400 text-sm py-4">加载中...</div>
</div>
</div>
</div>
</div>
</div>
<script>
function handleLogoSelect(input) {
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
const preview = document.getElementById('logoPreview');
if (preview.tagName === 'IMG') {
preview.src = e.target.result;
// 上传未完成前先清空 data-path,等待 uploadLogo 完成后再回填服务端路径
preview.removeAttribute('data-path');
} else {
preview.outerHTML = '<div id="logoPreview" class="relative group"><img src="' + e.target.result + '" class="w-24 h-24 rounded-xl object-cover border-2 border-blue-200 shadow-md"><button onclick="clearLogo()" class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center shadow-lg hover:bg-red-600"><i class="fas fa-times text-xs"></i></button></div>';
}
}
reader.readAsDataURL(input.files[0]);
uploadLogo(input.files[0]);
}
}
function clearLogo() {
const preview = document.getElementById('logoPreview');
preview.outerHTML = '<div id="logoPreview" class="w-24 h-24 rounded-xl bg-white border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 hover:border-blue-400 hover:text-blue-500 transition-all cursor-pointer" onclick="document.getElementById(\'logoFile\').click()"><i class="fas fa-image text-2xl mb-1"></i><span class="text-xs">点击上传</span></div>';
showCustomAlert('success', '成功', '已清除预览,请点击保存设置生效');
}
function setupDropZone() {
const dropZone = document.getElementById('logoDropZone');
const fileInput = document.getElementById('logoFile');
dropZone.addEventListener('dragover', function(e) {
e.preventDefault();
dropZone.classList.add('border-blue-500', 'bg-blue-50');
});
dropZone.addEventListener('dragleave', function(e) {
e.preventDefault();
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
const files = e.dataTransfer.files;
if (files.length > 0 && files[0].type.startsWith('image/')) {
fileInput.files = files;
handleLogoSelect(fileInput);
} else {
showCustomAlert('error', '错误', '请上传图片文件');
}
});
}
function uploadLogo(file) {
const formData = new FormData();
formData.append('logo', file);
fetch('/dashboard/api/settings/logo', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(result => {
const logoPath = result.data && result.data.logo_path;
if (result.success && logoPath) {
const preview = document.getElementById('logoPreview');
if (preview.tagName === 'IMG') {
preview.src = logoPath;
preview.setAttribute('data-path', logoPath);
} else {
const img = preview.querySelector('img');
if (img) {
img.src = logoPath;
img.setAttribute('data-path', logoPath);
}
}
showCustomAlert('success', '成功', 'Logo上传成功');
} else {
showCustomAlert('error', '错误', '上传失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '上传失败');
});
}
function saveSettings() {
const logoPreview = document.getElementById('logoPreview');
let logoPath = '';
// 优先读取自定义 data-path 属性(保存的是相对路径),避免浏览器把 src 转为绝对 URL
if (logoPreview.tagName === 'IMG') {
logoPath = logoPreview.getAttribute('data-path') || '';
} else {
const img = logoPreview.querySelector('img');
if (img) logoPath = img.getAttribute('data-path') || '';
}
const adminPageSize = parseInt(document.getElementById('settingAdminPageSize').value, 10);
if (isNaN(adminPageSize) || adminPageSize < 1 || adminPageSize > 100) {
showCustomAlert('error', '错误', '后台列表每页数据条数必须在 1-100 之间');
return;
}
const data = {
website_domain: document.getElementById('settingDomain').value,
website_title: document.getElementById('settingTitle').value,
website_description: document.getElementById('settingDescription').value,
website_keywords: document.getElementById('settingKeywords').value,
admin_email: document.getElementById('settingAdminEmail').value,
admin_name: document.getElementById('settingAdminName').value,
logo_path: logoPath,
seo_keywords: document.getElementById('settingSEOKeywords').value,
seo_description: document.getElementById('settingSEODescription').value,
admin_page_size: adminPageSize || 20
};
fetch('/dashboard/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '设置保存成功');
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '保存失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '保存失败');
});
}
function loadSystemInfo() {
fetch('/dashboard/api/settings/system-info')
.then(response => response.json())
.then(result => {
if (result.success) {
const data = result.data;
document.getElementById('sysGoVersion').textContent = data.go_version || '-';
document.getElementById('sysOS').textContent = data.os || '-';
document.getElementById('sysArch').textContent = data.arch || '-';
document.getElementById('sysStartTime').textContent = data.start_time || '-';
document.getElementById('sysUptime').textContent = data.uptime || '-';
}
})
.catch(error => {
console.error('Error loading system info:', error);
});
}
function loadLoginHistory() {
fetch('/dashboard/api/settings/login-history?page=1&pageSize=5')
.then(response => response.json())
.then(result => {
if (result.success) {
const historyList = document.getElementById('loginHistoryList');
if (result.data.list.length === 0) {
historyList.innerHTML = '<div class="text-center text-slate-400 text-sm py-4">暂无登录记录</div>';
return;
}
historyList.innerHTML = '';
result.data.list.forEach(item => {
const statusIcon = item.success
? '<i class="fas fa-check-circle text-green-500"></i>'
: '<i class="fas fa-times-circle text-red-500"></i>';
const statusText = item.success ? '成功' : '失败';
const itemDiv = document.createElement('div');
itemDiv.className = 'flex items-center justify-between p-3 bg-white rounded-lg border border-slate-100';
itemDiv.innerHTML = `
<div class="flex items-center gap-2">
${statusIcon}
<span class="text-sm text-slate-700">${item.user_name || '未知用户'}</span>
</div>
<div class="text-right">
<div class="text-xs text-slate-500">${item.ip}</div>
<div class="text-xs text-slate-400">${item.created_at}</div>
</div>
`;
historyList.appendChild(itemDiv);
});
}
})
.catch(error => {
console.error('Error loading login history:', error);
document.getElementById('loginHistoryList').innerHTML = '<div class="text-center text-slate-400 text-sm py-4">加载失败</div>';
});
}
setupDropZone();
loadSystemInfo();
loadLoginHistory();
</script>
+278
View File
@@ -0,0 +1,278 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-folder-open text-purple-500"></i> {{ if .User }}{{.User.Name}}{{ else }}未知用户{{ end }} 的作品集管理
</h3>
<p class="text-sm text-slate-500 mt-1">管理用户的作品集项目</p>
</div>
<div class="flex items-center gap-3">
<a href="{{.AdminPath}}/portfolio/add" class="btn btn-primary">
<i class="fas fa-plus"></i> 添加项目
</a>
<a href="{{.AdminPath}}/users" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> 返回列表
</a>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{{ range .PortfolioItems }}
<div class="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-primary-300 transition-all {{ if .Hidden }}opacity-50{{ end }}">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<h4 class="font-medium text-slate-800">{{.Name}}</h4>
{{ if .Hidden }}
<span class="px-2 py-0.5 bg-gray-200 text-gray-600 rounded text-xs">已隐藏</span>
{{ end }}
{{ if .Password }}
<span class="px-2 py-0.5 bg-amber-100 text-amber-700 rounded text-xs"><i class="fas fa-lock"></i></span>
{{ end }}
</div>
<div class="btn-group">
<a href="{{$.AdminPath}}/portfolio/{{.ID}}/edit" class="btn btn-warning">
<i class="fas fa-edit"></i> 编辑
</a>
<button onclick="togglePortfolioHidden('{{.ID}}', {{.Hidden}})" class="btn btn-{{ if .Hidden }}success{{ else }}secondary{{ end }}">
<i class="fas {{ if .Hidden }}fa-eye{{ else }}fa-eye-slash{{ end }}"></i> {{ if .Hidden }}显示{{ else }}隐藏{{ end }}
</button>
<button onclick="deletePortfolio('{{.ID}}')" class="btn btn-danger">
<i class="fas fa-trash"></i> 删除
</button>
</div>
</div>
<p class="text-sm text-slate-600 mb-3">{{.Description}}</p>
<div class="flex flex-wrap gap-1 mb-3">
{{ range .TechStack }}
<span class="px-2 py-0.5 bg-slate-200 text-slate-600 rounded text-xs">{{.}}</span>
{{ end }}
</div>
<div class="flex items-center justify-between text-xs text-slate-500">
<span>{{.StartDate}} ~ {{.EndDate}}</span>
{{ if .URL }}
<a href="{{.URL}}" target="_blank" class="text-primary-600 hover:text-primary-700">
<i class="fas fa-external-link-alt"></i> 查看
</a>
{{ end }}
</div>
</div>
{{ else }}
<div class="col-span-full text-center py-12">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-folder-open text-slate-400 text-2xl"></i>
</div>
<h4 class="text-lg font-semibold text-slate-700 mb-2">暂无作品集项目</h4>
<p class="text-slate-500">该用户还没有添加作品集项目</p>
</div>
{{ end }}
</div>
</div>
<div class="modal hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="portfolioModal">
<div class="modal-content bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
<div class="flex items-center justify-between p-4 border-b border-slate-200">
<h3 class="font-semibold text-slate-800" id="portfolioModalTitle">添加项目</h3>
<button onclick="closePortfolioModal()" class="btn btn-secondary btn-sm">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-6">
<form id="portfolioForm" onsubmit="savePortfolio(event)">
<input type="hidden" id="portfolioId">
<input type="hidden" id="portfolioUserId" value="{{ with .User }}{{.ID}}{{ end }}">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目名称</label>
<input type="text" id="portfolioName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目描述</label>
<textarea id="portfolioDescription" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">技术栈 (每行一个)</label>
<textarea id="portfolioTechStack" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="Go&#10;React&#10;MySQL"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目链接</label>
<input type="url" id="portfolioURL" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">开始日期</label>
<input type="text" id="portfolioStartDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="2024-01">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">结束日期</label>
<input type="text" id="portfolioEndDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="2024-12">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">关联简历项目</label>
<select id="portfolioProjectID" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
<option value="0">不关联简历项目</option>
</select>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="portfolioShowInResume" class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500">
<span class="text-sm font-medium text-slate-700">在简历中显示</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="portfolioHidden" class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500">
<span class="text-sm font-medium text-slate-700">隐藏项目</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">访问密码(留空则不设密码)</label>
<input type="password" id="portfolioPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="输入访问密码">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">项目文档 (Markdown格式)</label>
<textarea id="portfolioDoc" rows="8" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm" placeholder="# 项目概述&#10;&#10;这里编写项目详细说明..."></textarea>
</div>
</div>
<div class="flex items-center justify-center gap-3 mt-6">
<button type="button" onclick="closePortfolioModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> 保存
</button>
</div>
</form>
</div>
</div>
</div>
<script>
function openAddPortfolioModal() {
document.getElementById('portfolioModalTitle').textContent = '添加项目';
document.getElementById('portfolioId').value = '';
document.getElementById('portfolioName').value = '';
document.getElementById('portfolioDescription').value = '';
document.getElementById('portfolioTechStack').value = '';
document.getElementById('portfolioURL').value = '';
document.getElementById('portfolioStartDate').value = '';
document.getElementById('portfolioEndDate').value = '';
document.getElementById('portfolioProjectID').value = '0';
document.getElementById('portfolioShowInResume').checked = false;
document.getElementById('portfolioHidden').checked = false;
document.getElementById('portfolioPassword').value = '';
document.getElementById('portfolioDoc').value = '';
document.getElementById('portfolioModal').classList.remove('hidden');
}
function closePortfolioModal() {
document.getElementById('portfolioModal').classList.add('hidden');
}
function editPortfolio(id) {
fetch('/dashboard/api/portfolio/' + id)
.then(response => response.json())
.then(result => {
if (!result.success || !result.data) {
showCustomAlert('error', '错误', '获取项目信息失败');
return;
}
const item = result.data;
document.getElementById('portfolioModalTitle').textContent = '编辑项目';
document.getElementById('portfolioId').value = item.id;
document.getElementById('portfolioName').value = item.name;
document.getElementById('portfolioDescription').value = item.description || '';
document.getElementById('portfolioTechStack').value = (item.tech_stack || []).join('\n');
document.getElementById('portfolioURL').value = item.url || '';
document.getElementById('portfolioStartDate').value = item.start_date || '';
document.getElementById('portfolioEndDate').value = item.end_date || '';
document.getElementById('portfolioProjectID').value = item.project_id || '0';
document.getElementById('portfolioShowInResume').checked = item.show_in_resume || false;
document.getElementById('portfolioHidden').checked = item.hidden || false;
document.getElementById('portfolioPassword').value = item.password || '';
document.getElementById('portfolioDoc').value = item.doc || '';
document.getElementById('portfolioModal').classList.remove('hidden');
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '获取项目信息失败');
});
}
function deletePortfolio(id) {
showCustomConfirm('确认删除', '确定要删除该项目吗?', function(confirmed) {
if (!confirmed) return;
fetch('/dashboard/api/portfolio/' + id, { method: 'DELETE' })
.then(response => response.json())
.then(result => {
if (result.success) {
location.reload();
} else {
showCustomAlert('error', '错误', '删除失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '删除失败');
});
});
}
function savePortfolio(event) {
event.preventDefault();
const id = document.getElementById('portfolioId').value;
const userId = document.getElementById('portfolioUserId').value;
const data = {
user_id: userId,
name: document.getElementById('portfolioName').value,
description: document.getElementById('portfolioDescription').value,
tech_stack: document.getElementById('portfolioTechStack').value.split('\n').map(s => s.trim()).filter(s => s),
url: document.getElementById('portfolioURL').value,
start_date: document.getElementById('portfolioStartDate').value,
end_date: document.getElementById('portfolioEndDate').value,
project_id: parseInt(document.getElementById('portfolioProjectID').value) || 0,
show_in_resume: document.getElementById('portfolioShowInResume').checked,
hidden: document.getElementById('portfolioHidden').checked,
password: document.getElementById('portfolioPassword').value,
doc: document.getElementById('portfolioDoc').value
};
const url = id ? '/dashboard/api/portfolio/' + id : '/dashboard/api/portfolio';
const method = id ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '保存成功');
closePortfolioModal();
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '保存失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '保存失败');
});
}
function togglePortfolioHidden(id, isHidden) {
fetch('/dashboard/api/portfolio/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: !isHidden })
})
.then(response => response.json())
.then(result => {
if (result.success) {
location.reload();
} else {
showCustomAlert('error', '错误', '操作失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '操作失败');
});
}
</script>
+394
View File
@@ -0,0 +1,394 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-question-circle text-orange-500"></i> {{ with .User }}{{.Name}}{{ else }}未知用户{{ end }} 的面试题库
</h3>
<p class="text-sm text-slate-500 mt-1">管理用户的面试题目和答题记录</p>
</div>
<div class="flex items-center gap-3">
<a href="{{.AdminPath}}/user/{{.User.ID}}/quiz/generate" class="btn btn-primary">
<i class="fas fa-robot"></i> AI生成题目
</a>
<a href="{{.AdminPath}}/quiz" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> 返回列表
</a>
</div>
</div>
</div>
<div class="grid grid-cols-4 gap-4 p-6 border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white">
<div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600">
<i class="fas fa-list-alt text-xl"></i>
</div>
<div>
<p class="text-2xl font-bold text-slate-800">{{.TotalRecords}}</p>
<p class="text-sm text-slate-500">答题记录</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center text-green-600">
<i class="fas fa-check-circle text-xl"></i>
</div>
<div>
<p class="text-2xl font-bold text-slate-800">{{.FavoriteCount}}</p>
<p class="text-sm text-slate-500">收藏题目</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center text-red-600">
<i class="fas fa-times-circle text-xl"></i>
</div>
<div>
<p class="text-2xl font-bold text-slate-800">{{.WrongCount}}</p>
<p class="text-sm text-slate-500">错题数量</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-xl bg-purple-100 flex items-center justify-center text-purple-600">
<i class="fas fa-percentage text-xl"></i>
</div>
<div>
<p class="text-2xl font-bold text-slate-800">{{.AvgScore}}%</p>
<p class="text-sm text-slate-500">平均正确率</p>
</div>
</div>
</div>
</div>
<div class="border-b border-slate-200">
<div class="flex">
<button onclick="switchTab('records')" id="tab-records" class="tab-btn active px-6 py-4 text-sm font-medium border-b-2 border-transparent transition-colors">
<i class="fas fa-list-alt mr-2"></i>答题记录
</button>
<button onclick="switchTab('favorites')" id="tab-favorites" class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-transparent transition-colors">
<i class="fas fa-star mr-2"></i>收藏题目
</button>
<button onclick="switchTab('wrong')" id="tab-wrong" class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-transparent transition-colors">
<i class="fas fa-times-circle mr-2"></i>错题本
</button>
</div>
</div>
<div class="p-6">
<div id="panel-records" class="tab-panel">
<div class="space-y-3">
{{ range .QuizRecords }}
<a href="{{$.AdminPath}}/user/{{$.User.ID}}/quiz/record/{{.ID}}" class="block bg-slate-50 p-4 rounded-lg border border-slate-200 hover:border-primary-300 hover:shadow-sm transition-all group">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-white border border-slate-200 flex items-center justify-center text-slate-500 group-hover:text-primary-500 transition-colors">
<i class="fas fa-file-alt"></i>
</div>
<div>
<h4 class="font-medium text-slate-800">{{ if .Title }}{{.Title}}{{ else }}答题记录{{ end }}</h4>
<p class="text-xs text-slate-500 mt-0.5">{{.CreatedAt}} · {{.TotalCount}} 道题</p>
</div>
</div>
<div class="flex items-center gap-4">
<div class="text-right">
<div class="flex items-center gap-2">
<span class="text-2xl font-bold {{ if ge .Score 60.0 }}text-green-600{{ else }}text-red-600{{ end }}">{{.Score}}</span>
<span class="text-sm text-slate-400"></span>
</div>
<p class="text-xs text-slate-500">{{.CorrectCount}}/{{.TotalCount}} 正确</p>
</div>
<div class="text-slate-400 group-hover:text-primary-500 transition-colors">
<i class="fas fa-chevron-right"></i>
</div>
</div>
</div>
</a>
{{ else }}
<div class="empty-state">
<div class="empty-state-icon">
<i class="fas fa-file-alt"></i>
</div>
<p>暂无答题记录</p>
</div>
{{ end }}
</div>
{{ if gt .TotalPages 1 }}
<div class="border-t border-slate-200 mt-6 pt-4 flex items-center justify-between">
<div class="text-sm text-slate-500">共 {{ .TotalRecords }} 条记录,第 {{ .CurrentPage }} / {{ .TotalPages }} 页</div>
<div class="flex items-center gap-2">
<a href="?page=1&tab=records" class="btn btn-sm btn-slate {{ if eq .CurrentPage 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i><i class="fas fa-chevron-left"></i>
</a>
<a href="?page={{ if eq .CurrentPage 1 }}1{{ else }}{{ subtract .CurrentPage 1 }}{{ end }}&tab=records" class="btn btn-sm btn-slate {{ if eq .CurrentPage 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
</a>
{{ range $i := to 1 $.TotalPages }}
{{ if or (eq $i 1) (eq $i $.TotalPages) (and (ge $i (subtract $.CurrentPage 2)) (le $i (add $.CurrentPage 2))) }}
<a href="?page={{ $i }}&tab=records" class="btn btn-sm {{ if eq $i $.CurrentPage }}btn-primary{{ else }}btn-slate{{ end }}">{{ $i }}</a>
{{ else if and (eq $i 2) (gt (subtract $.CurrentPage 3) 1) }}
<span class="text-slate-400 px-2">...</span>
{{ else if and (eq $i (subtract $.TotalPages 1)) (lt (add $.CurrentPage 3) $.TotalPages) }}
<span class="text-slate-400 px-2">...</span>
{{ end }}
{{ end }}
<a href="?page={{ if eq .CurrentPage .TotalPages }}{{ .TotalPages }}{{ else }}{{ add .CurrentPage 1 }}{{ end }}&tab=records" class="btn btn-sm btn-slate {{ if eq .CurrentPage .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
</a>
<a href="?page={{ .TotalPages }}&tab=records" class="btn btn-sm btn-slate {{ if eq .CurrentPage .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i><i class="fas fa-chevron-right"></i>
</a>
</div>
</div>
{{ end }}
</div>
<div id="panel-favorites" class="tab-panel hidden">
<div class="space-y-3">
{{ range .FavoriteQuestions }}
{{ if .IsFavorite }}
<div class="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
<div class="flex items-start justify-between mb-2">
<div class="flex flex-wrap items-center gap-2">
<button onclick="toggleFavorite('{{.ID}}')" class="text-yellow-500 hover:text-yellow-600 text-lg" title="取消收藏">
<i class="fas fa-star"></i>
</button>
{{ if not .IsCorrect }}
<span class="px-2 py-0.5 bg-red-100 text-red-600 rounded text-xs">错题</span>
{{ end }}
{{ if .Question.Type }}
<span class="px-2 py-0.5 rounded text-xs"
{{ if eq .Question.Type "mcq" }}style="background-color: #dbeafe; color: #1e40af;"{{ end }}
{{ if eq .Question.Type "fill" }}style="background-color: #ede9fe; color: #5b21b6;"{{ end }}
{{ if eq .Question.Type "sa" }}style="background-color: #dcfce7; color: #166534;"{{ end }}
{{ if eq .Question.Type "algo" }}style="background-color: #ffedd5; color: #c2410c;"{{ end }}>
{{ if eq .Question.Type "mcq" }}选择题{{ else if eq .Question.Type "fill" }}填空题{{ else if eq .Question.Type "sa" }}简答题{{ else if eq .Question.Type "algo" }}算法题{{ else }}{{.Question.Type}}{{ end }}
</span>
{{ end }}
{{ if .Question.Difficulty }}
<span class="px-2 py-0.5 rounded text-xs"
{{ if eq .Question.Difficulty "入门" }}style="background-color: #dcfce7; color: #166534;"{{ end }}
{{ if eq .Question.Difficulty "初级" }}style="background-color: #dbeafe; color: #1e40af;"{{ end }}
{{ if eq .Question.Difficulty "中级" }}style="background-color: #fef3c7; color: #92400e;"{{ end }}
{{ if eq .Question.Difficulty "进阶" }}style="background-color: #ffedd5; color: #c2410c;"{{ end }}
{{ if eq .Question.Difficulty "高级" }}style="background-color: #fee2e2; color: #991b1b;"{{ end }}>
{{.Question.Difficulty}}
</span>
{{ end }}
{{ if .Question.Category }}
<span class="px-2 py-0.5 bg-indigo-100 text-indigo-600 rounded text-xs">{{.Question.Category}}</span>
{{ end }}
<span class="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">{{.Question.Score}}分</span>
</div>
<button onclick="deleteFavorite('{{.ID}}')" class="btn btn-danger btn-xs" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
<p class="text-sm text-slate-800 mb-3 font-medium leading-relaxed">{{.Question.Text}}</p>
{{ if .Question.Options }}
<div class="bg-white p-3 rounded-lg mb-3 space-y-2">
{{ range $i, $opt := .Question.Options }}
<div class="flex items-start gap-2 text-sm">
<span class="w-5 h-5 rounded-full bg-slate-100 flex items-center justify-center text-xs text-slate-600 font-medium flex-shrink-0">{{index "ABCDE" $i}}</span>
<span class="text-slate-600">{{$opt}}</span>
</div>
{{ end }}
</div>
{{ end }}
<div class="flex flex-wrap items-center gap-4 mb-3">
<div class="flex items-center gap-1">
<span class="text-xs text-slate-500">正确答案:</span>
<span class="text-sm text-green-600 font-medium">{{.Question.Answer}}</span>
</div>
{{ if .UserAnswer }}
<div class="flex items-center gap-1">
<span class="text-xs text-slate-500">你的答案:</span>
<span class="text-sm {{ if .IsCorrect }}text-green-600{{ else }}text-red-600{{ end }}">{{.UserAnswer}}</span>
</div>
{{ end }}
</div>
{{ if .Question.Analysis }}
<div class="p-3 bg-white rounded-lg text-sm text-slate-600 border border-yellow-100">
<div class="font-medium text-slate-700 mb-1 flex items-center gap-1">
<i class="fas fa-lightbulb text-yellow-500"></i> 答案解析
</div>
<div class="markdown-content">{{.Question.Analysis}}</div>
</div>
{{ end }}
</div>
{{ end }}
{{ else }}
<div class="empty-state">
<div class="empty-state-icon">
<i class="fas fa-star"></i>
</div>
<p>暂无收藏题目</p>
</div>
{{ end }}
</div>
</div>
<div id="panel-wrong" class="tab-panel hidden">
<div class="space-y-3">
{{ range .FavoriteQuestions }}
{{ if not .IsCorrect }}
<div class="bg-red-50 p-4 rounded-lg border border-red-200">
<div class="flex items-start justify-between mb-2">
<div class="flex flex-wrap items-center gap-2">
<button onclick="toggleFavorite('{{.ID}}')" class="{{ if .IsFavorite }}text-yellow-500{{ else }}text-slate-300{{ end }} hover:text-yellow-600 text-lg" title="{{ if .IsFavorite }}取消收藏{{ else }}收藏{{ end }}">
<i class="{{ if .IsFavorite }}fas fa-star{{ else }}far fa-star{{ end }}"></i>
</button>
<span class="px-2 py-0.5 bg-red-100 text-red-600 rounded text-xs">错题</span>
{{ if .IsFavorite }}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-600 rounded text-xs">收藏</span>
{{ end }}
{{ if .Question.Type }}
<span class="px-2 py-0.5 rounded text-xs"
{{ if eq .Question.Type "mcq" }}style="background-color: #dbeafe; color: #1e40af;"{{ end }}
{{ if eq .Question.Type "fill" }}style="background-color: #ede9fe; color: #5b21b6;"{{ end }}
{{ if eq .Question.Type "sa" }}style="background-color: #dcfce7; color: #166534;"{{ end }}
{{ if eq .Question.Type "algo" }}style="background-color: #ffedd5; color: #c2410c;"{{ end }}>
{{ if eq .Question.Type "mcq" }}选择题{{ else if eq .Question.Type "fill" }}填空题{{ else if eq .Question.Type "sa" }}简答题{{ else if eq .Question.Type "algo" }}算法题{{ else }}{{.Question.Type}}{{ end }}
</span>
{{ end }}
{{ if .Question.Difficulty }}
<span class="px-2 py-0.5 rounded text-xs"
{{ if eq .Question.Difficulty "入门" }}style="background-color: #dcfce7; color: #166534;"{{ end }}
{{ if eq .Question.Difficulty "初级" }}style="background-color: #dbeafe; color: #1e40af;"{{ end }}
{{ if eq .Question.Difficulty "中级" }}style="background-color: #fef3c7; color: #92400e;"{{ end }}
{{ if eq .Question.Difficulty "进阶" }}style="background-color: #ffedd5; color: #c2410c;"{{ end }}
{{ if eq .Question.Difficulty "高级" }}style="background-color: #fee2e2; color: #991b1b;"{{ end }}>
{{.Question.Difficulty}}
</span>
{{ end }}
{{ if .Question.Category }}
<span class="px-2 py-0.5 bg-indigo-100 text-indigo-600 rounded text-xs">{{.Question.Category}}</span>
{{ end }}
<span class="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">{{.Question.Score}}分</span>
</div>
<button onclick="deleteFavorite('{{.ID}}')" class="btn btn-danger btn-xs" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
<p class="text-sm text-slate-800 mb-3 font-medium leading-relaxed">{{.Question.Text}}</p>
{{ if .Question.Options }}
<div class="bg-white p-3 rounded-lg mb-3 space-y-2">
{{ range $i, $opt := .Question.Options }}
<div class="flex items-start gap-2 text-sm">
<span class="w-5 h-5 rounded-full bg-slate-100 flex items-center justify-center text-xs text-slate-600 font-medium flex-shrink-0">{{index "ABCDE" $i}}</span>
<span class="text-slate-600">{{$opt}}</span>
</div>
{{ end }}
</div>
{{ end }}
<div class="flex flex-wrap items-center gap-4 mb-3">
<div class="flex items-center gap-1">
<span class="text-xs text-slate-500">正确答案:</span>
<span class="text-sm text-green-600 font-medium">{{.Question.Answer}}</span>
</div>
{{ if .UserAnswer }}
<div class="flex items-center gap-1">
<span class="text-xs text-slate-500">你的答案:</span>
<span class="text-sm text-red-600">{{.UserAnswer}}</span>
</div>
{{ end }}
</div>
{{ if .Question.Analysis }}
<div class="p-3 bg-white rounded-lg text-sm text-slate-600 border border-red-100">
<div class="font-medium text-slate-700 mb-1 flex items-center gap-1">
<i class="fas fa-lightbulb text-yellow-500"></i> 答案解析
</div>
<div class="markdown-content">{{.Question.Analysis}}</div>
</div>
{{ end }}
</div>
{{ end }}
{{ else }}
<div class="empty-state">
<div class="empty-state-icon">
<i class="fas fa-times-circle"></i>
</div>
<p>暂无错题记录</p>
</div>
{{ end }}
</div>
</div>
</div>
</div>
<style>
.tab-btn {
color: #64748b;
border-bottom-color: transparent;
cursor: pointer;
background: none;
outline: none;
}
.tab-btn:hover {
color: #334155;
}
.tab-btn.active {
color: var(--primary, #3b82f6);
border-bottom-color: var(--primary, #3b82f6);
}
.tab-panel {
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(0.25rem); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<script>
function switchTab(tabName) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
document.getElementById('tab-' + tabName).classList.add('active');
document.getElementById('panel-' + tabName).classList.remove('hidden');
const url = new URL(window.location);
url.searchParams.set('tab', tabName);
window.history.replaceState({}, '', url);
}
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const tab = urlParams.get('tab') || 'records';
switchTab(tab);
});
function toggleFavorite(id) {
fetch('/dashboard/api/quiz/favorite/' + id + '/toggle', { method: 'POST' })
.then(response => response.json())
.then(result => {
if (result.success) {
location.reload();
} else {
showCustomAlert('error', '错误', '操作失败');
}
})
.catch(() => showCustomAlert('error', '错误', '操作失败'));
}
function deleteFavorite(id) {
showCustomConfirm('确认删除', '确定要删除这条记录吗?', function(confirmed) {
if (!confirmed) return;
fetch('/dashboard/api/quiz/favorite/' + id, { method: 'DELETE' })
.then(response => response.json())
.then(result => {
if (result.success) {
location.reload();
} else {
showCustomAlert('error', '错误', '删除失败');
}
})
.catch(() => showCustomAlert('error', '错误', '删除失败'));
});
}
</script>
+421
View File
@@ -0,0 +1,421 @@
<div class="bg-white rounded-2xl shadow-lg border border-slate-100 p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="font-bold text-slate-800 flex items-center gap-3">
<i class="fas fa-file-alt text-blue-500"></i> {{ if .User }}{{.User.Name}}{{ else }}未知用户{{ end }} 的简历管理
</h3>
<p class="text-sm text-slate-500 mt-1">管理用户的简历信息和内容</p>
</div>
<div class="flex items-center gap-3">
<a href="{{.AdminPath}}/user/{{.User.ID}}/resume/new/edit" class="btn btn-primary">
<i class="fas fa-plus"></i> 创建简历
</a>
<a href="{{.AdminPath}}/users" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> 返回列表
</a>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-slate-50 border-b border-slate-200">
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">姓名</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">职位</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">访问路由</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">首页展示</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">密码保护</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">创建时间</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">操作</th>
</tr>
</thead>
<tbody>
{{ range .Resumes }}
<tr class="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td class="px-6 py-4">
<span class="font-medium text-slate-800">{{.BasicInfo.Name}}</span>
</td>
<td class="px-6 py-4 text-slate-600">{{.BasicInfo.Title}}</td>
<td class="px-6 py-4 text-slate-600">
<a href="/{{.Route}}" target="_blank" class="text-blue-500 hover:text-blue-700 hover:underline">
/{{.Route}}
</a>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium {{ if .ShowInHome }}bg-green-100 text-green-700{{ else }}bg-slate-100 text-slate-500{{ end }}">
{{ if .ShowInHome }}<i class="fas fa-check mr-1"></i>展示{{ else }}隐藏{{ end }}
</span>
</td>
<td class="px-6 py-4">
{{ if .Password }}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
<i class="fas fa-lock mr-1"></i>已设置
</span>
{{ else }}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-500">
<i class="fas fa-unlock mr-1"></i>无密码
</span>
{{ end }}
</td>
<td class="px-6 py-4 text-slate-500 text-sm">{{.CreatedAt}}</td>
<td class="px-6 py-4">
<div class="btn-group">
<a href="/{{.Route}}" target="_blank" class="btn btn-primary btn-xs">
<i class="fas fa-eye"></i> 预览
</a>
<a href="{{$.AdminPath}}/user/{{$.User.ID}}/resume/{{.ID}}/edit" class="btn btn-warning btn-xs">
<i class="fas fa-edit"></i> 编辑
</a>
<button onclick="toggleShowInHome('{{.ID}}', {{.ShowInHome}})" class="btn btn-success btn-xs">
<i class="fas {{ if .ShowInHome }}fa-eye-slash{{ else }}fa-eye{{ end }}"></i> {{ if .ShowInHome }}隐藏{{ else }}展示{{ end }}
</button>
<button onclick="setPassword('{{.ID}}')" class="btn btn-info btn-xs">
<i class="fas fa-key"></i> 密码
</button>
<button onclick="deleteResume('{{.ID}}')" class="btn btn-danger btn-xs">
<i class="fas fa-trash"></i> 删除
</button>
</div>
</td>
</tr>
{{ else }}
<tr>
<td colspan="8" class="px-6 py-12 text-center text-slate-500">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-file-alt text-slate-400 text-2xl"></i>
</div>
<p>暂无简历数据</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
<div class="modal hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="resumeModal">
<div class="modal-content bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
<div class="flex items-center justify-between p-5 border-b border-slate-200">
<h3 class="font-semibold text-slate-800 flex items-center gap-2" id="resumeModalTitle">
<i class="fas fa-file-alt text-blue-500"></i> 新建简历
</h3>
<button onclick="closeResumeModal()" class="text-slate-400 hover:text-slate-600 p-1 hover:bg-slate-100 rounded-lg transition-all">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-5">
<form id="resumeForm" onsubmit="saveResume(event)">
<input type="hidden" id="resumeId">
<input type="hidden" id="resumeUserId">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">访问路由</label>
<div class="relative">
<div class="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400">
<i class="fas fa-link"></i>
</div>
<input type="text" id="resumeRoute" class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" required>
</div>
<p class="text-xs text-slate-500 mt-1">用于访问简历的路径,如 zhangsan,访问地址为 /zhangsan</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">姓名</label>
<div class="relative">
<div class="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400">
<i class="fas fa-user"></i>
</div>
<input type="text" id="resumeName" class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" required>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">职位</label>
<div class="relative">
<div class="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400">
<i class="fas fa-briefcase"></i>
</div>
<input type="text" id="resumeTitle" class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">邮箱</label>
<div class="relative">
<div class="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400">
<i class="fas fa-envelope"></i>
</div>
<input type="email" id="resumeEmail" class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">手机号</label>
<div class="relative">
<div class="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400">
<i class="fas fa-phone"></i>
</div>
<input type="tel" id="resumePhone" class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">所在地</label>
<div class="relative">
<div class="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400">
<i class="fas fa-map-marker-alt"></i>
</div>
<input type="text" id="resumeLocation" class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all">
</div>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="resumeShowInHome" class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500">
<span class="text-sm font-medium text-slate-700">在首页展示</span>
</label>
</div>
</div>
<div class="flex items-center justify-center gap-3 mt-6">
<button type="button" onclick="closeResumeModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> 保存
</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="passwordModal">
<div class="modal-content bg-white rounded-xl shadow-xl w-full max-w-md mx-4">
<div class="flex items-center justify-between p-5 border-b border-slate-200">
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-key text-purple-500"></i> 设置访问密码
</h3>
<button onclick="closePasswordModal()" class="text-slate-400 hover:text-slate-600 p-1 hover:bg-slate-100 rounded-lg transition-all">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-5">
<form id="passwordForm" onsubmit="savePassword(event)">
<input type="hidden" id="passwordResumeId">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">访问密码</label>
<div class="relative">
<div class="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400">
<i class="fas fa-lock"></i>
</div>
<input type="password" id="resumePassword" class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" placeholder="输入密码,留空则取消密码保护">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">确认密码</label>
<div class="relative">
<div class="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400">
<i class="fas fa-lock"></i>
</div>
<input type="password" id="resumePasswordConfirm" class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" placeholder="再次输入密码">
</div>
</div>
</div>
<div class="flex items-center justify-center gap-3 mt-6">
<button type="button" onclick="closePasswordModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> 保存
</button>
</div>
</form>
</div>
</div>
</div>
<script>
function getUserId() {
const pathParts = window.location.pathname.split('/');
return pathParts[pathParts.indexOf('user') + 1];
}
function openAddResumeModal() {
const userId = getUserId();
if (!userId) {
showCustomAlert('error', '错误', '用户ID无效');
return;
}
fetch('/dashboard/api/user/' + userId)
.then(response => response.json())
.then(user => {
document.getElementById('resumeModalTitle').innerHTML = '<i class="fas fa-file-alt text-blue-500"></i> 新建简历';
document.getElementById('resumeId').value = '';
document.getElementById('resumeUserId').value = userId;
document.getElementById('resumeRoute').value = user.username || 'resume-' + userId.substring(0, 8);
document.getElementById('resumeName').value = user.name || '';
document.getElementById('resumeTitle').value = '';
document.getElementById('resumeEmail').value = user.email || '';
document.getElementById('resumePhone').value = user.phone || '';
document.getElementById('resumeLocation').value = '';
document.getElementById('resumeShowInHome').checked = true;
document.getElementById('resumeModal').classList.remove('hidden');
})
.catch(error => {
showCustomAlert('error', '错误', '获取用户信息失败');
});
}
function closeResumeModal() {
document.getElementById('resumeModal').classList.add('hidden');
}
function editResume(id) {
fetch('/dashboard/api/resume/' + id)
.then(response => response.json())
.then(resume => {
document.getElementById('resumeModalTitle').innerHTML = '<i class="fas fa-file-edit text-yellow-500"></i> 编辑简历';
document.getElementById('resumeId').value = resume.id || resume.ID || '';
document.getElementById('resumeUserId').value = resume.user_id || resume.UserID || '';
document.getElementById('resumeRoute').value = resume.route || resume.Route || '';
document.getElementById('resumeName').value = resume.basic_info?.name || resume.BasicInfo?.Name || '';
document.getElementById('resumeTitle').value = resume.basic_info?.title || resume.BasicInfo?.Title || '';
document.getElementById('resumeEmail').value = resume.basic_info?.email || resume.BasicInfo?.Email || '';
document.getElementById('resumePhone').value = resume.basic_info?.phone || resume.BasicInfo?.Phone || '';
document.getElementById('resumeLocation').value = resume.basic_info?.location || resume.BasicInfo?.Location || '';
document.getElementById('resumeShowInHome').checked = resume.show_in_home || resume.ShowInHome || false;
document.getElementById('resumeModal').classList.remove('hidden');
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '获取简历信息失败');
});
}
function saveResume(event) {
event.preventDefault();
const id = document.getElementById('resumeId').value;
const userId = document.getElementById('resumeUserId').value;
const data = {
user_id: userId,
route: document.getElementById('resumeRoute').value,
show_in_home: document.getElementById('resumeShowInHome').checked,
basic_info: {
name: document.getElementById('resumeName').value,
title: document.getElementById('resumeTitle').value,
email: document.getElementById('resumeEmail').value,
phone: document.getElementById('resumePhone').value,
location: document.getElementById('resumeLocation').value
}
};
let url, method;
if (id) {
url = '/dashboard/api/resume/' + id;
method = 'PUT';
} else {
url = '/dashboard/api/resume/create';
method = 'POST';
}
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '保存成功');
closeResumeModal();
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '保存失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '保存失败');
});
}
function toggleShowInHome(id, currentValue) {
fetch('/dashboard/api/resume/' + id + '/show_in_home', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ show_in_home: !currentValue })
})
.then(response => response.json())
.then(result => {
if (result.success) {
location.reload();
} else {
showCustomAlert('error', '错误', '操作失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '操作失败');
});
}
function setPassword(id) {
document.getElementById('passwordResumeId').value = id;
document.getElementById('resumePassword').value = '';
document.getElementById('resumePasswordConfirm').value = '';
document.getElementById('passwordModal').classList.remove('hidden');
}
function closePasswordModal() {
document.getElementById('passwordModal').classList.add('hidden');
}
function savePassword(event) {
event.preventDefault();
const id = document.getElementById('passwordResumeId').value;
const password = document.getElementById('resumePassword').value;
const confirmPassword = document.getElementById('resumePasswordConfirm').value;
if (password !== confirmPassword) {
showCustomAlert('error', '错误', '两次输入的密码不一致');
return;
}
fetch('/dashboard/api/resume/' + id + '/password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: password })
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '密码设置成功');
closePasswordModal();
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '设置失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '设置失败');
});
}
function deleteResume(id) {
showCustomConfirm('确认删除', '确定要删除该简历吗?此操作不可恢复。', function(confirmed) {
if (!confirmed) return;
fetch('/dashboard/api/resume/' + id, { method: 'DELETE' })
.then(response => response.json())
.then(result => {
if (result.success) {
location.reload();
} else {
showCustomAlert('error', '错误', '删除失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '删除失败');
});
});
}
</script>
+258
View File
@@ -0,0 +1,258 @@
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
<i class="fas fa-users text-purple-500"></i> 人员列表
</h3>
<p class="text-sm text-slate-500 mt-1">管理平台用户</p>
</div>
<button onclick="openAddUserModal()" class="btn btn-primary">
<i class="fas fa-plus"></i> 添加用户
</button>
</div>
<div class="mb-6 flex items-center gap-4">
<div class="relative flex-1 max-w-md">
<div class="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400">
<i class="fas fa-search"></i>
</div>
<input type="text" id="searchInput" placeholder="搜索姓名或用户名..." class="w-full pl-10 pr-4 py-2.5 border-2 border-slate-200 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all bg-white">
</div>
<button onclick="filterUsers()" class="btn btn-secondary">
<i class="fas fa-search"></i> 搜索
</button>
<button onclick="location.reload()" class="btn btn-secondary">
<i class="fas fa-redo"></i> 重置
</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr class="bg-slate-50 border-b border-slate-200">
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">姓名</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">用户名</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">邮箱</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">手机号</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">创建时间</th>
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600 actions-col">操作</th>
</tr>
</thead>
<tbody>
{{ range .Users }}
<tr class="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td class="px-6 py-4">
<span class="font-medium text-slate-800">{{.Name}}</span>
</td>
<td class="px-6 py-4 text-slate-600">{{.Username}}</td>
<td class="px-6 py-4 text-slate-600">{{.Email}}</td>
<td class="px-6 py-4 text-slate-600">{{.Phone}}</td>
<td class="px-6 py-4 text-slate-500 text-sm">{{.CreatedAt}}</td>
<td class="px-6 py-4 actions-col">
<div class="btn-group">
<a href="{{$.AdminPath}}/user/{{.ID}}/resume" class="btn btn-primary btn-xs" title="简历">
<i class="fas fa-file-alt"></i> 简历
</a>
<a href="{{$.AdminPath}}/user/{{.ID}}/portfolio" class="btn btn-info btn-xs" title="作品集">
<i class="fas fa-folder-open"></i> 作品集
</a>
<a href="{{$.AdminPath}}/user/{{.ID}}/quiz" class="btn btn-purple btn-xs" title="面试题">
<i class="fas fa-question-circle"></i> 答题
</a>
<button onclick="editUser('{{.ID}}')" class="btn btn-warning btn-xs" title="编辑">
<i class="fas fa-edit"></i> 编辑
</button>
<button onclick="deleteUser('{{.ID}}')" class="btn btn-danger btn-xs" title="删除">
<i class="fas fa-trash"></i> 删除
</button>
</div>
</td>
</tr>
{{ else }}
<tr>
<td colspan="6" class="px-6 py-12 text-center text-slate-500">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
<i class="fas fa-users text-slate-400 text-2xl"></i>
</div>
<p>暂无用户数据</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ if gt .TotalPages 1 }}
<div class="border-t border-slate-200 px-6 py-4 bg-slate-50 flex items-center justify-between">
<div class="text-sm text-slate-500">共 {{ .Total }} 条记录,第 {{ .Page }} / {{ .TotalPages }} 页</div>
<div class="flex items-center gap-2">
<a href="?page=1" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
<i class="fas fa-chevron-left"></i>
</a>
<a href="?page={{ if eq .Page 1 }}1{{ else }}{{ subtract .Page 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-left"></i>
</a>
{{ range $i := to 1 $.TotalPages }}
{{ if or (eq $i 1) (eq $i $.TotalPages) (and (ge $i (subtract $.Page 2)) (le $i (add $.Page 2))) }}
<a href="?page={{ $i }}" class="btn btn-sm {{ if eq $i $.Page }}btn-primary{{ else }}btn-slate{{ end }}">{{ $i }}</a>
{{ else if and (eq $i 2) (gt (subtract $.Page 3) 1) }}
<span class="text-slate-400 px-2">...</span>
{{ else if and (eq $i (subtract $.TotalPages 1)) (lt (add $.Page 3) $.TotalPages) }}
<span class="text-slate-400 px-2">...</span>
{{ end }}
{{ end }}
<a href="?page={{ if eq .Page .TotalPages }}{{ .TotalPages }}{{ else }}{{ add .Page 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
</a>
<a href="?page={{ .TotalPages }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
<i class="fas fa-chevron-right"></i>
<i class="fas fa-chevron-right"></i>
</a>
</div>
</div>
{{ end }}
</div>
<div class="modal" id="userModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="userModalTitle">
<i class="fas fa-user-plus text-blue-500"></i> 添加用户
</h3>
<div class="modal-close" onclick="closeUserModal()">
<i class="fas fa-times"></i>
</div>
</div>
<div class="modal-body">
<form id="userForm" onsubmit="saveUser(event)">
<input type="hidden" id="userId">
<div class="space-y-4">
<div>
<label class="form-label">姓名</label>
<input type="text" id="userName" class="form-input" required>
</div>
<div>
<label class="form-label">用户名</label>
<input type="text" id="userUsername" class="form-input" required>
</div>
<div>
<label class="form-label">邮箱</label>
<input type="email" id="userEmail" class="form-input" required>
</div>
<div>
<label class="form-label">手机号</label>
<input type="tel" id="userPhone" class="form-input">
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button onclick="closeUserModal()" class="btn btn-secondary">
<i class="fas fa-times"></i> 取消
</button>
<button type="submit" form="userForm" class="btn btn-primary">
<i class="fas fa-save"></i> 保存
</button>
</div>
</div>
</div>
<script>
function filterUsers() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const rows = document.querySelectorAll('table tbody tr');
rows.forEach(row => {
const name = row.querySelector('td:nth-child(1)')?.textContent.toLowerCase() || '';
const username = row.querySelector('td:nth-child(2)')?.textContent.toLowerCase() || '';
const email = row.querySelector('td:nth-child(3)')?.textContent.toLowerCase() || '';
const phone = row.querySelector('td:nth-child(4)')?.textContent.toLowerCase() || '';
if (name.includes(searchTerm) || username.includes(searchTerm) || email.includes(searchTerm) || phone.includes(searchTerm)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
function openAddUserModal() {
document.getElementById('userModalTitle').innerHTML = '<i class="fas fa-user-plus text-blue-500"></i> 添加用户';
document.getElementById('userId').value = '';
document.getElementById('userName').value = '';
document.getElementById('userUsername').value = '';
document.getElementById('userEmail').value = '';
document.getElementById('userPhone').value = '';
document.getElementById('userModal').classList.add('active');
}
function closeUserModal() {
document.getElementById('userModal').classList.remove('active');
}
function editUser(id) {
fetch('/dashboard/api/user/' + id)
.then(response => response.json())
.then(res => {
const user = res.data || res;
document.getElementById('userModalTitle').innerHTML = '<i class="fas fa-user-edit text-yellow-500"></i> 编辑用户';
document.getElementById('userId').value = user.id || '';
document.getElementById('userName').value = user.name || '';
document.getElementById('userUsername').value = user.username || '';
document.getElementById('userEmail').value = user.email || '';
document.getElementById('userPhone').value = user.phone || '';
document.getElementById('userModal').classList.add('active');
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '获取用户信息失败');
});
}
function deleteUser(id) {
showCustomConfirm('确认删除', '确定要删除该用户吗?', function(confirmed) {
if (!confirmed) return;
fetch('/dashboard/api/user/' + id, { method: 'DELETE' })
.then(response => response.json())
.then(result => {
if (result.success) {
location.reload();
} else {
showCustomAlert('error', '错误', '删除失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '删除失败');
});
});
}
function saveUser(event) {
event.preventDefault();
const id = document.getElementById('userId').value;
const data = {
name: document.getElementById('userName').value,
username: document.getElementById('userUsername').value,
email: document.getElementById('userEmail').value,
phone: document.getElementById('userPhone').value
};
const url = id ? '/dashboard/api/user/' + id : '/dashboard/api/user';
const method = id ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
showCustomAlert('success', '成功', '保存成功');
closeUserModal();
setTimeout(() => location.reload(), 1500);
} else {
showCustomAlert('error', '错误', '保存失败:' + (result.message || '未知错误'));
}
})
.catch(error => {
console.error('Error:', error);
showCustomAlert('error', '错误', '保存失败');
});
}
</script>
+155
View File
@@ -0,0 +1,155 @@
{{ define "web/404" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - 页面未找到</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #6D8B94;
--secondary: #A8B896;
--accent: #E3E5E0;
--text: #333333;
--text-light: #666666;
--bg: #FFFFFF;
--border: #E8E8E8;
--radius: 0.50rem;
}
body {
font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
color: var(--text);
background: var(--bg);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
-webkit-font-smoothing: antialiased;
position: relative;
overflow: hidden;
}
/* 与首页 hero 一致的柔和光晕装饰 */
body::before {
content: '';
position: absolute;
top: -20%;
left: -10%;
width: 31.25rem;
height: 31.25rem;
background: radial-gradient(circle, rgba(109,139,148,0.06) 0%, transparent 70%);
border-radius: 50%;
}
body::after {
content: '';
position: absolute;
bottom: -20%;
right: -10%;
width: 25rem;
height: 25rem;
background: radial-gradient(circle, rgba(168,184,150,0.06) 0%, transparent 70%);
border-radius: 50%;
}
.error-card {
position: relative;
z-index: 1;
text-align: center;
padding: 2.50rem 2rem;
max-width: 25rem;
}
.error-illust {
font-size: 4rem;
color: var(--primary);
margin-bottom: 1.50rem;
opacity: 0.85;
animation: breathe 3s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { transform: translateY(0); opacity: 0.85; }
50% { transform: translateY(-0.38rem); opacity: 1; }
}
.error-code {
font-size: 5rem;
font-weight: 200;
color: var(--text);
line-height: 1;
margin-bottom: 0.75rem;
letter-spacing: 0.25rem;
}
.error-title {
font-size: 1.1rem;
font-weight: 500;
color: var(--text);
margin-bottom: 0.50rem;
letter-spacing: 0.12rem;
}
.error-desc {
font-size: 0.9rem;
color: var(--text-light);
line-height: 1.7;
margin-bottom: 2rem;
opacity: 0.85;
}
.btn-home {
display: inline-flex;
align-items: center;
gap: 0.50rem;
padding: 0.62rem 1.75rem;
background: var(--primary);
color: white;
border-radius: var(--radius);
text-decoration: none;
font-size: 0.9rem;
transition: all 0.3s;
}
.btn-home:hover {
background: #5A757D;
transform: translateY(-0.06rem);
}
.divider {
width: 2.50rem;
height: 0.06rem;
background: var(--border);
margin: 0 auto 1.50rem;
}
@media (max-width: 37.50rem) {
.error-code { font-size: 4rem; }
.error-illust { font-size: 3rem; }
.error-card { padding: 2rem 1.50rem; }
}
</style>
</head>
<body>
<div class="error-card">
<div class="error-illust">
<i class="far fa-compass"></i>
</div>
<div class="error-code">404</div>
<div class="error-title">页面走丢了</div>
<div class="divider"></div>
<p class="error-desc">
您访问的页面不存在或已被移除
</p>
<a href="/" class="btn-home">
<i class="fas fa-home"></i> 返回首页
</a>
</div>
</body>
</html>
{{ end }}
+424
View File
@@ -0,0 +1,424 @@
{{ define "component-avatar" }}
{{ end }}
{{ define "component-basic-info" }}
<div class="basic-info">
<div class="info-header">
<h1 class="name">{{.Name}}</h1>
<h2 class="title">{{.Title}}</h2>
</div>
<div class="contact-row">
{{ if .Email }}<div class="contact-tag email-protected" onclick="revealEmail(this)" data-email="{{.Email}}"><i class="fas fa-envelope"></i> {{.Email}}</div>{{ end }}
{{ if .Phone }}<div class="contact-tag"><i class="fas fa-phone"></i> {{.Phone}}</div>{{ end }}
{{ if .Location }}<div class="contact-tag"><i class="fas fa-map-marker-alt"></i> {{.Location}}</div>{{ end }}
{{ if .Website }}<div class="contact-tag"><i class="fas fa-globe"></i> <a href="{{.Website}}" target="_blank">{{.Website}}</a></div>{{ end }}
</div>
<div class="meta-row">
{{ if .ExperienceYears }}<div class="meta-tag"><i class="fas fa-clock"></i> {{.ExperienceYears}}</div>{{ end }}
{{ if .JobTarget }}<div class="meta-tag"><i class="fas fa-bullseye"></i> {{.JobTarget}}</div>{{ end }}
{{ if .Industry }}<div class="meta-tag"><i class="fas fa-building"></i> {{.Industry}}</div>{{ end }}
</div>
{{ if .Summary }}
<div class="summary">{{.Summary}}</div>
{{ end }}
</div>
{{ end }}
{{ define "section-core-advantages" }}
{{ if .BasicInfo.CoreAdvantages }}
<section>
<h2 class="section-title"><i class="fas fa-star"></i> 核心优势</h2>
<div class="section-content">
<ul class="advantage-list">
{{ range .BasicInfo.CoreAdvantages }}
<li><i class="fas fa-check-circle"></i> {{.}}</li>
{{ end }}
</ul>
</div>
</section>
{{ end }}
{{ end }}
{{ define "section-experience" }}
{{ if .Experience }}
<section>
<h2 class="section-title"><i class="fas fa-briefcase"></i> 工作经验</h2>
<div class="section-content">
{{ range .Experience }}
<article class="item">
<div class="item-header">
<span class="company">{{.Company}}</span>
<span class="period">{{.StartDate}} - {{.EndDate}}</span>
</div>
<div class="position">{{.Position}}</div>
{{ if .Platforms }}
<div class="platforms">
{{ range .Platforms }}
<span class="platform-tag">{{.}}</span>
{{ end }}
</div>
{{ end }}
{{ if .Description }}
<div class="description">{{.Description}}</div>
{{ end }}
{{ if .Highlights }}
<ul class="highlights">
{{ range .Highlights }}
<li><i class="fas fa-circle"></i> {{.}}</li>
{{ end }}
</ul>
{{ end }}
</article>
{{ end }}
</div>
</section>
{{ end }}
{{ end }}
{{ define "section-education" }}
{{ if .Education }}
<section>
<h2 class="section-title"><i class="fas fa-graduation-cap"></i> 教育背景</h2>
<div class="section-content">
{{ range .Education }}
<article class="item">
<div class="item-header">
<span class="school">{{.School}}</span>
<span class="period">{{.StartDate}} - {{.EndDate}}</span>
</div>
<div class="degree">{{.Degree}} - {{.Major}}</div>
{{ if .GPA }}
<div class="gpa">GPA: {{.GPA}}</div>
{{ end }}
</article>
{{ end }}
</div>
</section>
{{ end }}
{{ end }}
{{ define "section-skills" }}
{{ if .Skills }}
<section>
<h2 class="section-title"><i class="fas fa-code"></i> 技术栈</h2>
<div class="section-content">
{{ $categories := dict }}
{{ range .Skills }}
{{ $cat := .Category }}
{{ if not (index $categories $cat) }}
{{ $_ := set $categories $cat slice }}
{{ end }}
{{ $categories = merge $categories (dict $cat (append (index $categories $cat) .)) }}
{{ end }}
<div class="skill-section">
{{ range $cat, $skills := $categories }}
<div class="skill-category">
<div class="skill-category-title">{{ $cat }}</div>
<div class="skill-items">
{{ range $skills }}
<span class="skill-tag">{{ .Name }}</span>
{{ end }}
</div>
</div>
{{ end }}
</div>
</div>
</section>
{{ end }}
{{ end }}
{{ define "section-skills-progress" }}
{{ if .Skills }}
<section>
<h2 class="section-title"><i class="fas fa-code"></i> 技术栈</h2>
<div class="section-content">
{{ $levelMap := dict "beginner" "25%" "intermediate" "50%" "advanced" "75%" "expert" "100%" }}
{{ $levelText := dict "beginner" "入门" "intermediate" "中级" "advanced" "高级" "expert" "专家" }}
{{ $categories := dict }}
{{ range .Skills }}
{{ $cat := .Category }}
{{ if not (index $categories $cat) }}
{{ $_ := set $categories $cat slice }}
{{ end }}
{{ $categories = merge $categories (dict $cat (append (index $categories $cat) .)) }}
{{ end }}
<div class="skill-section">
{{ range $cat, $skills := $categories }}
<div class="skill-category">
<div class="skill-category-title">{{ $cat }}</div>
{{ range $skills }}
{{ $width := index $levelMap .Level }}
{{ if not $width }}{{ $width = "50%" }}{{ end }}
{{ $text := index $levelText .Level }}
{{ if not $text }}{{ $text = .Level }}{{ end }}
<div class="skill-progress-item">
<div class="skill-progress-header">
<span class="skill-progress-name">{{ .Name }}</span>
<span class="skill-progress-level">{{ $text }}</span>
</div>
<div class="skill-progress-bar">
<div class="skill-progress-fill" style="width: {{ $width }}"></div>
</div>
</div>
{{ end }}
</div>
{{ end }}
</div>
</div>
</section>
{{ end }}
{{ end }}
{{ define "section-projects" }}
{{ if .Projects }}
<section>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.12rem;">
<h2 class="section-title"><i class="fas fa-project-diagram"></i> 项目经验</h2>
<a href="/portfolio" style="color: #3498db; text-decoration: none; font-size: 0.9em; display: flex; align-items: center; gap: 0.31rem; transition: all 0.3s ease;">
<i class="fas fa-folder-open"></i> 查看作品集
</a>
</div>
<div class="section-content">
{{ range $index, $project := .Projects }}
<article class="item">
<div class="item-header">
<span class="project-name">{{$project.Name}}</span>
<span class="period">{{$project.StartDate}} - {{$project.EndDate}}</span>
</div>
{{ if $project.URL }}
<a href="{{$project.URL}}" target="_blank"><i class="fas fa-external-link-alt"></i> 查看项目</a>
{{ end }}
{{ if $project.Description }}
<div class="description">{{$project.Description}}</div>
{{ end }}
{{ if $project.TechStack }}
<div class="tech-stack">
{{ range $project.TechStack }}
<span class="tech-tag">{{.}}</span>
{{ end }}
</div>
{{ end }}
{{ if $project.Highlights }}
<ul class="highlights">
{{ range $project.Highlights }}
<li><i class="fas fa-star"></i> {{.}}</li>
{{ end }}
</ul>
{{ end }}
{{ if $project.Achievements }}
<div class="achievements">
<h4><i class="fas fa-trophy"></i> 项目成果</h4>
<ul>
{{ range $project.Achievements }}
<li><i class="fas fa-award"></i> {{.}}</li>
{{ end }}
</ul>
</div>
{{ end }}
</article>
{{ end }}
</div>
</section>
{{ end }}
{{ end }}
{{ define "section-summary" }}
{{ if .BasicInfo.Summary }}
<section>
<h2 class="section-title"><i class="fas fa-user"></i> 个人简介</h2>
<div class="section-content">
<div class="sidebar-summary">{{.BasicInfo.Summary}}</div>
</div>
</section>
{{ end }}
{{ end }}
{{ define "theme-switch" }}
<style>
.theme-switch {
position: fixed !important;
top: 1.25rem !important;
right: 1.25rem !important;
z-index: 2147483647 !important;
pointer-events: auto !important;
}
.theme-switch * {
pointer-events: auto !important;
}
.theme-switch button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
color: white !important;
border: none !important;
padding: 0.62rem 1.12rem !important;
border-radius: 1.56rem !important;
cursor: pointer !important;
font-size: 0.81rem !important;
font-weight: 500 !important;
display: flex !important;
align-items: center !important;
gap: 0.50rem !important;
box-shadow: 0 0.25rem 0.94rem rgba(102, 126, 234, 0.4) !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
white-space: nowrap !important;
line-height: 1.4 !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
pointer-events: auto !important;
opacity: 1 !important;
visibility: visible !important;
}
.theme-switch button:hover {
transform: translateY(-0.12rem) !important;
box-shadow: 0 0.38rem 1.25rem rgba(102, 126, 234, 0.5) !important;
background: linear-gradient(135deg, #7c8eea 0%, #8b5bb3 100%) !important;
}
.theme-switch button:active {
transform: translateY(0) !important;
}
.theme-switch button i {
margin: 0 !important;
padding: 0 !important;
}
@media print {
.theme-switch { display: none !important; }
}
@media (max-width: 40rem) {
.theme-switch {
top: 0.75rem !important;
right: 0.75rem !important;
}
.theme-switch button {
padding: 0.50rem 0.88rem !important;
font-size: 0.75rem !important;
}
.theme-switch button span {
display: none !important;
}
}
</style>
<div class="theme-switch">
<button id="themeToggle" type="button" title="点击切换简历主题" onclick="(function(){
var themes = ['tech', 'modern', 'classic', 'minimalist', 'fresh', 'professional', 'sidebar', 'elegant'];
var themeNames = {'tech':'科技极客','modern':'现代商务','classic':'传统经典','minimalist':'极简风格','fresh':'清新自然','professional':'专业商务','sidebar':'经典侧边栏','elegant':'优雅轻奢'};
var currentTheme = '{{.Template}}';
var currentRoute = '{{.Route}}' || 'default';
var idx = themes.indexOf(currentTheme);
if (idx < 0) idx = 0;
var nextIdx = (idx + 1) % themes.length;
var newTheme = themes[nextIdx];
var btn = document.getElementById('themeToggle');
if (btn) { btn.style.opacity = '0.6'; btn.style.pointerEvents = 'none'; }
var toast = document.createElement('div');
toast.style.cssText = 'position:fixed;top:1.25rem;left:50%;transform:translateX(-50%);padding:0.75rem 1.50rem;border-radius:0.50rem;color:white;font-size:0.88rem;z-index:2147483647;box-shadow:0 0.25rem 0.75rem rgba(0,0,0,0.15);transition:opacity 0.3s;';
fetch('/api/resume?route=' + encodeURIComponent(currentRoute), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: newTheme })
}).then(function(resp) {
if (resp.ok) {
toast.style.background = '#10b981';
toast.textContent = '切换到' + themeNames[newTheme] + '主题';
document.body.appendChild(toast);
setTimeout(function(){ toast.style.opacity = '0'; }, 1500);
setTimeout(function(){ window.location.reload(); }, 500);
} else {
toast.style.background = '#ef4444';
toast.textContent = '切换失败';
document.body.appendChild(toast);
setTimeout(function(){ toast.style.opacity = '0'; }, 2000);
if (btn) { btn.style.opacity = '1'; btn.style.pointerEvents = 'auto'; }
}
}).catch(function(err) {
toast.style.background = '#ef4444';
toast.textContent = '切换失败,请检查网络';
document.body.appendChild(toast);
setTimeout(function(){ toast.style.opacity = '0'; }, 2000);
if (btn) { btn.style.opacity = '1'; btn.style.pointerEvents = 'auto'; }
});
})()">
<i class="fas fa-palette"></i>
<span>切换主题</span>
</button>
</div>
{{ end }}
{{ define "theme-script" }}
<script>
(function() {
const themes = ['tech', 'modern', 'classic', 'minimalist', 'fresh', 'professional', 'sidebar', 'elegant'];
const themeNames = {
'tech': '科技极客',
'modern': '现代商务',
'classic': '传统经典',
'minimalist': '极简风格',
'fresh': '清新自然',
'professional': '专业商务',
'sidebar': '经典侧边栏',
'elegant': '优雅轻奢'
};
const currentTheme = '{{.Template}}';
const currentRoute = '{{.Route}}';
let currentThemeIndex = themes.indexOf(currentTheme) !== -1 ? themes.indexOf(currentTheme) : 0;
let isSwitching = false;
function showToast(message, type) {
const toast = document.createElement('div');
toast.style.cssText = 'position:fixed;top:1.25rem;left:50%;transform:translateX(-50%);padding:0.75rem 1.50rem;border-radius:0.50rem;color:white;font-size:0.88rem;z-index:2147483647;box-shadow:0 0.25rem 0.75rem rgba(0,0,0,0.15);transition:opacity 0.3s;';
if (type === 'success') toast.style.background = '#10b981';
else if (type === 'error') toast.style.background = '#ef4444';
else toast.style.background = '#3b82f6';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(function() { toast.style.opacity = '0'; }, 2000);
setTimeout(function() { toast.remove(); }, 2500);
}
async function switchTheme() {
if (isSwitching) return;
isSwitching = true;
const nextIndex = (currentThemeIndex + 1) % themes.length;
const newTheme = themes[nextIndex];
const btn = document.getElementById('themeToggle');
btn.style.opacity = '0.6';
btn.style.pointerEvents = 'none';
try {
const response = await fetch('/api/resume?route=' + encodeURIComponent(currentRoute || 'default'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: newTheme })
});
if (response.ok) {
showToast('切换到' + themeNames[newTheme] + '主题', 'success');
currentThemeIndex = nextIndex;
setTimeout(function() { window.location.reload(); }, 500);
} else {
const data = await response.json().catch(function() { return {}; });
showToast('切换失败:' + (data.message || '未知错误'), 'error');
btn.style.opacity = '1';
btn.style.pointerEvents = 'auto';
isSwitching = false;
}
} catch (err) {
console.error('Failed to switch theme:', err);
showToast('切换失败,请检查网络连接', 'error');
btn.style.opacity = '1';
btn.style.pointerEvents = 'auto';
isSwitching = false;
}
}
window.__switchTheme = switchTheme;
const toggleBtn = document.getElementById('themeToggle');
if (toggleBtn) {
toggleBtn.addEventListener('click', switchTheme);
}
})();
</script>
{{ end }}
+314
View File
@@ -0,0 +1,314 @@
{{ define "web/classic" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
a { text-decoration: none; color: unset; }
body {
font-family: Georgia, 'Times New Roman', Times, 'SimSun', serif;
background: #e8e8e8;
min-height: 100vh;
position: relative;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='paper'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.04' numOctaves='5'/%3E%3CfeDiffuseLighting in='noise' lighting-color='%23ffffff' surfaceScale='2'%3E%3CfeDistantLight azimuth='45' elevation='60'/%3E%3C/feDiffuseLighting%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23paper)'/%3E%3C/svg%3E");
opacity: 0.04;
pointer-events: none;
z-index: 0;
}
.theme-switch { position: fixed; top: 1.25rem; right: 1.25rem; z-index: 1000; }
.theme-switch button {
background: white;
border: 0.06rem solid #ccc;
color: #333;
padding: 0.44rem 0.88rem;
border-radius: 0.19rem;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 0.50rem;
}
.theme-switch button:hover {
background: #2c3e50;
color: white;
border-color: #2c3e50;
transform: translateY(-0.06rem);
}
.resume-container {
max-width: 55rem;
margin: 2.50rem auto;
background: white;
box-shadow: 0 0.12rem 0.75rem rgba(0,0,0,0.12);
position: relative;
z-index: 1;
}
header.resume-header {
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
padding: 2.38rem 2.62rem;
color: white;
border-bottom: 0.31rem solid #e94560;
position: relative;
overflow: hidden;
}
header.resume-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 0.12rem;
background: linear-gradient(90deg, transparent, #e94560, transparent);
}
header.resume-header .header-inner { display: flex; align-items: center; gap: 1.88rem; }
header.resume-header .avatar img {
width: 7.50rem;
height: 7.50rem;
border-radius: 0.12rem;
border: 0.19rem solid white;
object-fit: cover;
box-shadow: 0 0.25rem 0.75rem rgba(0,0,0,0.4);
}
header.resume-header .basic-info { display: flex; flex-direction: column; gap: 0.75rem; }
header.resume-header .basic-info .info-header { margin-bottom: 0.12rem; }
header.resume-header .basic-info .name { font-size: 1.9em; font-weight: 700; margin-bottom: 0.38rem; letter-spacing: 0.06rem; line-height: 1.2; }
header.resume-header .basic-info .title { font-size: 1em; color: #bdc3c7; }
header.resume-header .basic-info .contact-row {
display: flex;
flex-wrap: nowrap;
gap: 0.75rem;
font-size: 0.84em;
}
header.resume-header .basic-info .contact-tag {
display: inline-flex;
align-items: center;
gap: 0.38rem;
padding: 0.31rem 0.75rem;
background: rgba(255,255,255,0.08);
border-radius: 0.19rem;
white-space: nowrap;
transition: all 0.3s ease;
border: 0.06rem solid rgba(255,255,255,0.08);
}
header.resume-header .basic-info .contact-tag:hover {
background: rgba(255,255,255,0.12);
border-color: rgba(255,255,255,0.15);
}
header.resume-header .basic-info .contact-tag i { width: 0.88rem; text-align: center; opacity: 0.7; }
header.resume-header .basic-info .contact-tag a { color: white; text-decoration: none; position: relative; }
header.resume-header .basic-info .contact-tag a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.06rem;
left: 0;
background: white;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
header.resume-header .basic-info .contact-tag a:hover::after { width: 100%; }
header.resume-header .basic-info .contact-tag a:hover { color: #3498db; }
header.resume-header .basic-info .meta-row {
display: flex;
flex-wrap: nowrap;
gap: 0.62rem;
font-size: 0.8em;
color: #95a5a6;
}
header.resume-header .basic-info .meta-tag {
display: inline-flex;
align-items: center;
gap: 0.31rem;
padding: 0.25rem 0.62rem;
background: rgba(255,255,255,0.04);
border-radius: 0.12rem;
white-space: nowrap;
}
header.resume-header .basic-info .meta-tag i { width: 0.75rem; text-align: center; opacity: 0.4; }
main.resume-body { display: flex; }
aside.sidebar { width: 32%; background: #f7f7f7; padding: 1.75rem 1.38rem; border-right: 0.06rem solid #e5e5e5; }
aside.sidebar .section { margin-bottom: 1.75rem; }
aside.sidebar .section:last-child { margin-bottom: 0; }
aside.sidebar .section-title {
font-size: 0.95em;
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.88rem;
padding-bottom: 0.38rem;
border-bottom: 0.06rem solid #ddd;
text-transform: uppercase;
letter-spacing: 0.06rem;
}
aside.sidebar .section-title i { color: #3498db; margin-right: 0.38rem; }
aside.sidebar .summary { color: #555; font-size: 0.88em; line-height: 1.65; }
aside.sidebar .skill-items { display: flex; flex-wrap: wrap; gap: 0.38rem; }
aside.sidebar .skill-tag {
padding: 0.31rem 0.62rem;
background: #e6e6e6;
border-radius: 0.12rem;
font-size: 0.82em;
color: #444;
border: 0.06rem solid #d6d6d6;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
aside.sidebar .skill-tag:hover {
background: #3498db;
color: white;
border-color: #3498db;
}
section.main-content { width: 68%; padding: 2rem; }
section.main-content .section { margin-bottom: 2rem; }
section.main-content .section:last-child { margin-bottom: 0; }
section.main-content .section-title {
font-size: 1.1em;
font-weight: 600;
color: #2c3e50;
margin-bottom: 1.12rem;
padding-bottom: 0.50rem;
border-bottom: 0.12rem solid #3498db;
display: flex;
align-items: center;
gap: 0.62rem;
letter-spacing: 0.03rem;
}
section.main-content .section-title i { color: #3498db; }
.section-content .item { margin-bottom: 1.50rem; padding-bottom: 1.25rem; border-bottom: 0.06rem solid #e5e5e5; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.section-content .item:last-child { margin-bottom: 0; border-bottom: none; padding-bottom: 0; }
.section-content .item:hover { background: #fafafa; padding-left: 0.62rem; margin-left: -0.62rem; margin-right: -0.62rem; padding-right: 0.62rem; }
.item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.38rem; }
.item-header .company, .item-header .school, .item-header .project-name { font-weight: 600; color: #2c3e50; font-size: 1em; }
.item-header .period { color: #777; font-size: 0.82em; font-style: italic; }
.item-header a { color: #3498db; position: relative; }
.item-header a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.06rem;
left: 0;
background: #3498db;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.item-header a:hover::after { width: 100%; }
.position { color: #3498db; font-weight: 500; margin-bottom: 0.50rem; }
.degree { color: #555; margin-bottom: 0.31rem; }
.gpa { color: #777; font-size: 0.82em; }
.description { color: #555; line-height: 1.6; margin-bottom: 0.50rem; font-size: 0.9em; }
.highlights { list-style: none; padding-left: 0; }
.highlights li { color: #444; padding: 0.25rem 0; font-size: 0.9em; display: flex; align-items: flex-start; gap: 0.50rem; }
.highlights li i { color: #3498db; font-size: 0.78em; }
.tech-stack { display: flex; flex-wrap: wrap; gap: 0.31rem; margin-top: 0.50rem; }
.tech-tag { padding: 0.19rem 0.56rem; background: #2c3e50; color: white; border-radius: 0.12rem; font-size: 0.78em; }
.platforms { display: flex; flex-wrap: wrap; gap: 0.31rem; margin-top: 0.50rem; }
.platform-tag { padding: 0.12rem 0.50rem; background: rgba(52,152,219,0.1); color: #3498db; border-radius: 0.12rem; font-size: 0.76em; border: 0.06rem solid rgba(52,152,219,0.2); }
.advantage-list { list-style: none; padding-left: 0; }
.advantage-list li {
color: #444;
padding: 0.44rem 0;
font-size: 0.9em;
display: flex;
align-items: flex-start;
gap: 0.50rem;
line-height: 1.6;
}
.advantage-list li i { color: #3498db; font-size: 0.85em; margin-top: 0.12rem; }
@media (max-width: 48rem) {
main.resume-body { flex-direction: column; }
aside.sidebar { width: 100%; border-right: none; border-bottom: 0.06rem solid #e0e0e0; }
section.main-content { width: 100%; }
header.resume-header .header-inner { flex-direction: column; text-align: center; }
header.resume-header .basic-info .contact { justify-content: center; }
.resume-container { margin: 0.62rem; }
header.resume-header { padding: 1.56rem 1.25rem; }
section.main-content { padding: 1.38rem; }
.section-content .item:hover { margin: 0; padding: 0; padding-bottom: 1.25rem; }
}
@media print {
body { background: white; }
body::before { display: none; }
.theme-switch { display: none; }
.resume-container {
max-width: 100%;
margin: 0;
box-shadow: none;
}
header.resume-header {
background: #2c3e50;
color: white;
-webkit-print-color-adjust: exact;
}
aside.sidebar {
background: #f7f7f7;
border-right: 0.06rem solid #ddd;
}
.section-content .item:hover {
background: transparent;
margin: 0;
padding: 0;
padding-bottom: 1.25rem;
}
a { color: #3498db; text-decoration: underline; }
@page { margin: 12mm; }
}
</style>
</head>
<body>
{{ template "theme-switch" . }}
<div class="resume-container">
<header class="resume-header">
<div class="header-inner">
{{ template "component-avatar" .BasicInfo }}
{{ template "component-basic-info" .BasicInfo }}
</div>
</header>
<main class="resume-body">
<aside class="sidebar">
{{ template "section-summary" . }}
{{ template "section-skills" . }}
</aside>
<section class="main-content">
{{ template "section-core-advantages" . }}
{{ template "section-experience" . }}
{{ template "section-education" . }}
{{ template "section-projects" . }}
</section>
</main>
</div>
{{ template "theme-script" . }}
<script src="/static/js/security.js"></script>
</body>
</html>
{{ end }}
+306
View File
@@ -0,0 +1,306 @@
{{ define "web/contact" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>联系我 - {{.BasicInfo.Name}}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--secondary-color: #2c3e50;
--accent-green: #27ae60;
--text-primary: #2c3e50;
--text-secondary: #555;
--bg-light: #f0f2f5;
--bg-card: #ffffff;
--border-color: #dde3e8;
--shadow-md: 0 0.25rem 1.50rem rgba(0,0,0,0.06);
--radius-lg: 0.75rem;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: var(--bg-light); min-height: 100vh; }
.nav-bar {
background: var(--bg-card);
border-bottom: 0.06rem solid var(--border-color);
padding: 0.88rem 1.25rem;
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 0.06rem 0.19rem rgba(0,0,0,0.04);
}
.nav-bar .nav-content {
max-width: 53.75rem;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-bar .back-link {
color: var(--primary-color);
text-decoration: none;
font-size: 0.95em;
display: flex;
align-items: center;
gap: 0.38rem;
transition: all 0.3s ease;
}
.nav-bar .back-link:hover { gap: 0.62rem; }
.nav-bar .nav-title {
font-size: 1.15em;
font-weight: 600;
color: var(--secondary-color);
display: flex;
align-items: center;
gap: 0.50rem;
}
.nav-bar .nav-title i { color: var(--primary-color); }
.contact-container {
max-width: 53.75rem;
margin: 2.81rem auto;
padding: 0 1.25rem;
}
.contact-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.contact-header {
background: linear-gradient(135deg, var(--secondary-color), #34495e);
padding: 2.81rem 2.50rem;
text-align: center;
color: white;
}
.contact-header h1 {
font-size: 2em;
margin-bottom: 0.50rem;
letter-spacing: -0.03rem;
}
.contact-header p {
opacity: 0.85;
font-size: 0.95em;
}
.contact-body {
padding: 2.50rem;
}
.contact-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(17.50rem, 1fr));
gap: 1.50rem;
}
.contact-item {
background: #fafbfc;
padding: 1.50rem;
border-radius: 0.50rem;
border: 0.06rem solid var(--border-color);
text-align: center;
transition: all 0.3s ease;
}
.contact-item:hover {
background: #f4f6f8;
border-color: var(--primary-color);
transform: translateY(-0.12rem);
box-shadow: 0 0.25rem 0.75rem rgba(52,152,219,0.1);
}
.contact-item .icon {
width: 4rem;
height: 4rem;
background: var(--primary-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
color: white;
font-size: 1.5em;
transition: all 0.3s ease;
}
.contact-item:hover .icon {
transform: scale(1.1);
}
.contact-item h3 {
font-size: 0.95em;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.50rem;
}
.contact-item p {
color: var(--text-secondary);
font-size: 0.9em;
line-height: 1.6;
}
.contact-item a {
color: var(--primary-color);
text-decoration: none;
transition: all 0.3s ease;
}
.contact-item a:hover {
text-decoration: underline;
}
.social-links {
display: flex;
justify-content: center;
gap: 0.75rem;
margin-top: 1.25rem;
}
.social-links a {
width: 2.75rem;
height: 2.75rem;
background: #fafbfc;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.3s ease;
border: 0.06rem solid var(--border-color);
}
.social-links a:hover {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
transform: translateY(-0.12rem);
}
.map-section {
margin-top: 1.88rem;
background: #fafbfc;
border-radius: 0.50rem;
padding: 1.25rem;
border: 0.06rem solid var(--border-color);
}
.map-section h3 {
font-size: 1em;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.94rem;
display: flex;
align-items: center;
gap: 0.50rem;
}
.map-section h3 i { color: var(--primary-color); }
.map-placeholder {
height: 12.50rem;
background: linear-gradient(135deg, #e8ecf0 0%, #dde3e8 100%);
border-radius: 0.38rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.9em;
}
.map-placeholder i {
font-size: 2em;
margin-right: 0.62rem;
opacity: 0.5;
}
@media (max-width: 48rem) {
.contact-container { margin: 1.56rem auto; }
.contact-header { padding: 1.88rem 1.25rem; }
.contact-body { padding: 1.56rem 1.25rem; }
.contact-header h1 { font-size: 1.6em; }
}
</style>
</head>
<body>
<nav class="nav-bar">
<div class="nav-content">
<a href="/" class="back-link">
<i class="fas fa-arrow-left"></i> 返回简历
</a>
<span class="nav-title">
<i class="fas fa-envelope"></i> 联系我
</span>
</div>
</nav>
<div class="contact-container">
<div class="contact-card">
<div class="contact-header">
<h1>与我取得联系</h1>
<p>期待与您交流,欢迎发送邮件或通过以下方式联系我</p>
</div>
<div class="contact-body">
<div class="contact-grid">
<div class="contact-item">
<div class="icon"><i class="fas fa-envelope"></i></div>
<h3>电子邮箱</h3>
<p><a href="mailto:{{.BasicInfo.Email}}">{{.BasicInfo.Email}}</a></p>
</div>
<div class="contact-item">
<div class="icon"><i class="fas fa-phone"></i></div>
<h3>联系电话</h3>
<p>{{.BasicInfo.Phone}}</p>
</div>
<div class="contact-item">
<div class="icon"><i class="fas fa-globe"></i></div>
<h3>个人网站</h3>
<p><a href="{{.BasicInfo.Website}}" target="_blank">{{.BasicInfo.Website}}</a></p>
</div>
<div class="contact-item">
<div class="icon"><i class="fas fa-map-marker-alt"></i></div>
<h3>所在地</h3>
<p>{{.BasicInfo.Location}}</p>
</div>
</div>
<div class="social-links">
<a href="#" title="GitHub" target="_blank"><i class="fab fa-github"></i></a>
<a href="#" title="LinkedIn" target="_blank"><i class="fab fa-linkedin"></i></a>
<a href="#" title="Twitter" target="_blank"><i class="fab fa-twitter"></i></a>
<a href="#" title="Stack Overflow" target="_blank"><i class="fab fa-stack-overflow"></i></a>
</div>
{{ if .BasicInfo.Location }}
<div class="map-section">
<h3><i class="fas fa-map-marker-alt"></i> 工作地点</h3>
<div class="map-placeholder">
<i class="fas fa-map"></i>
<span>{{.BasicInfo.Location}}</span>
</div>
</div>
{{ end }}
</div>
</div>
</div>
</body>
</html>
{{ end }}
+438
View File
@@ -0,0 +1,438 @@
{{ define "web/elegant" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</title>
<meta name="description" content="{{.BasicInfo.Summary}}" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--primary: #1a1a2e;
--secondary: #16213e;
--accent: #c9a962;
--accent-light: #d4bc7e;
--text-dark: #1a1a2e;
--text-body: #3d3d5c;
--text-muted: #7a7a9d;
--bg-cream: #faf8f5;
--bg-white: #ffffff;
--border: #e8e4dc;
--shadow: 0 0.12rem 1.25rem rgba(26, 26, 46, 0.06);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
a { text-decoration: none; color: inherit; }
ul { list-style: none; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg-cream);
color: var(--text-body);
line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
.resume-container {
max-width: 56.25rem;
margin: 3.75rem auto;
background: var(--bg-white);
box-shadow: var(--shadow);
border-radius: 0.25rem;
overflow: hidden;
position: relative;
}
.resume-header {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
padding: 3.75rem 3.75rem 3.12rem;
position: relative;
overflow: hidden;
}
.resume-header::before {
content: '';
position: absolute;
top: -50%;
right: -10%;
width: 25rem;
height: 25rem;
background: radial-gradient(circle, rgba(201, 169, 98, 0.1) 0%, transparent 70%);
border-radius: 50%;
}
.header-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 2rem;
}
.avatar {
width: 6.25rem;
height: 6.25rem;
border-radius: 50%;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Playfair Display', serif;
font-size: 2.62rem;
font-weight: 600;
color: var(--primary);
flex-shrink: 0;
border: 0.19rem solid rgba(201, 169, 98, 0.3);
}
.name-title { flex: 1; }
.name {
font-family: 'Playfair Display', serif;
font-size: 2.25rem;
font-weight: 600;
letter-spacing: 0.06rem;
margin-bottom: 0.50rem;
}
.title {
font-size: 1rem;
color: var(--accent-light);
font-weight: 400;
letter-spacing: 0.12rem;
text-transform: uppercase;
margin-bottom: 1.25rem;
}
.resume-header .contact-row {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
font-size: 0.81rem;
color: rgba(255,255,255,0.8);
}
.resume-header .contact-tag {
display: flex;
align-items: center;
gap: 0.50rem;
}
.resume-header .contact-tag i {
color: var(--accent);
font-size: 0.75rem;
}
.resume-header .contact-tag a {
color: var(--accent-light);
}
.resume-header .meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.94rem;
margin-top: 0.94rem;
font-size: 0.75rem;
color: rgba(255,255,255,0.6);
}
.resume-header .meta-tag {
display: flex;
align-items: center;
gap: 0.38rem;
}
.resume-header .meta-tag i {
color: var(--accent);
font-size: 0.69rem;
}
.resume-body {
padding: 3.12rem 3.75rem;
}
.resume-body-inner {
display: flex;
gap: 2.50rem;
}
.resume-body .sidebar {
width: 18.75rem;
flex-shrink: 0;
}
.resume-body .main-content {
flex: 1;
}
.section {
margin-bottom: 2.50rem;
}
.section:last-child { margin-bottom: 0; }
.section-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.50rem;
}
.section-icon {
width: 2.50rem;
height: 2.50rem;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
font-size: 1rem;
}
.section-title {
font-family: 'Playfair Display', serif;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-dark);
letter-spacing: 0.03rem;
}
.divider {
flex: 1;
height: 0.06rem;
background: linear-gradient(to right, var(--border), transparent);
}
.section-content {
background: var(--bg-cream);
border-radius: 0.38rem;
padding: 1.25rem;
border-left: 0.19rem solid var(--accent);
}
.item {
margin-bottom: 1.50rem;
padding: 1.25rem;
background: var(--bg-white);
border-radius: 0.38rem;
box-shadow: 0 0.06rem 0.50rem rgba(0,0,0,0.04);
transition: all 0.3s ease;
}
.item:last-child { margin-bottom: 0; }
.item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.62rem; }
.item-header .company, .item-header .school, .item-header .project-name {
font-family: 'Playfair Display', serif;
font-weight: 600;
color: var(--text-dark);
font-size: 1rem;
}
.item-header .period {
color: var(--text-muted);
font-size: 0.81rem;
font-family: 'Playfair Display', serif;
font-style: italic;
}
.item-header a {
color: var(--accent);
font-size: 0.81rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.position {
color: var(--accent);
font-weight: 500;
margin-bottom: 0.62rem;
font-size: 0.88rem;
}
.degree {
color: var(--text-body);
margin-bottom: 0.38rem;
font-size: 0.81rem;
}
.gpa {
color: var(--text-muted);
font-size: 0.75rem;
}
.description {
color: var(--text-body);
line-height: 1.8;
margin-bottom: 0.75rem;
font-size: 0.88rem;
}
.highlights { list-style: none; padding-left: 0; }
.highlights li {
color: var(--text-body);
padding: 0.38rem 0;
font-size: 0.88rem;
display: flex;
align-items: flex-start;
gap: 0.62rem;
}
.highlights li i {
color: var(--accent);
font-size: 0.75rem;
margin-top: 0.25rem;
}
.skill-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.skill-category {
background: var(--bg-cream);
padding: 1rem;
border-radius: 0.38rem;
border-left: 0.19rem solid var(--accent);
}
.skill-category-title {
font-family: 'Playfair Display', serif;
font-size: 0.88rem;
font-weight: 600;
color: var(--text-dark);
margin-bottom: 0.75rem;
padding-bottom: 0.50rem;
border-bottom: 0.06rem solid var(--border);
}
.skill-items {
display: flex;
flex-wrap: wrap;
gap: 0.50rem;
}
.skill-tag {
font-size: 0.81rem;
padding: 0.31rem 0.88rem;
background: var(--bg-white);
color: var(--text-body);
border-radius: 1.25rem;
border: 0.06rem solid var(--border);
transition: all 0.2s ease;
}
.skill-tag:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.tech-stack { display: flex; flex-wrap: wrap; gap: 0.38rem; margin-top: 0.62rem; }
.tech-tag {
font-size: 0.69rem;
padding: 0.19rem 0.62rem;
background: #f0ede8;
color: var(--text-muted);
border-radius: 0.19rem;
letter-spacing: 0.03rem;
}
.platforms { display: flex; flex-wrap: wrap; gap: 0.50rem; margin-top: 0.62rem; }
.platform-tag {
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
background: rgba(201, 169, 98, 0.1);
color: var(--accent);
border-radius: 0.25rem;
border: 0.06rem solid rgba(201, 169, 98, 0.2);
}
.advantage-list { list-style: none; padding-left: 0; }
.advantage-list li {
color: var(--text-body);
padding: 0.50rem 0;
font-size: 0.88rem;
display: flex;
align-items: flex-start;
gap: 0.62rem;
line-height: 1.6;
}
.advantage-list li i { color: var(--accent); font-size: 0.88rem; margin-top: 0.12rem; }
.sidebar-summary {
font-family: 'Playfair Display', serif;
font-size: 0.88rem;
line-height: 1.9;
color: var(--text-body);
padding-left: 0.75rem;
border-left: 0.19rem solid var(--accent);
font-style: italic;
}
.achievements {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 0.06rem dashed var(--border);
}
.achievements h4 {
font-family: 'Playfair Display', serif;
font-size: 0.88rem;
font-weight: 600;
color: var(--text-dark);
margin-bottom: 0.50rem;
display: flex;
align-items: center;
gap: 0.38rem;
}
.achievements h4 i {
color: var(--accent);
}
.achievements ul {
padding-left: 1.25rem;
}
.achievements li {
font-size: 0.88rem;
color: var(--text-body);
margin-bottom: 0.31rem;
}
@media (max-width: 48rem) {
.resume-container { margin: 1.25rem; }
.resume-header { padding: 2.50rem 1.88rem; }
.header-content { flex-direction: column; text-align: center; }
.resume-body { padding: 1.88rem; }
.resume-body-inner { flex-direction: column; gap: 1.88rem; }
.resume-body .sidebar { width: 100%; }
.resume-header .contact-row { justify-content: center; }
.resume-header .meta-row { justify-content: center; }
}
</style>
</head>
<body>
{{ template "theme-switch" . }}
<div class="resume-container">
<header class="resume-header">
{{ template "component-avatar" .BasicInfo }}
{{ template "component-basic-info" .BasicInfo }}
</header>
<main class="resume-body">
{{ template "section-core-advantages" . }}
{{ template "section-experience" . }}
{{ template "section-education" . }}
{{ template "section-skills" . }}
{{ template "section-projects" . }}
</main>
</div>
{{ template "theme-script" . }}
<script src="/static/js/security.js"></script>
</body>
</html>
{{ end }}
+360
View File
@@ -0,0 +1,360 @@
{{ define "web/fresh" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--bg-primary: #f7f9f7;
--bg-secondary: #ffffff;
--bg-card: #fefefe;
--border-color: #e8ebe8;
--text-primary: #2c3e50;
--text-secondary: #5d6d7e;
--text-muted: #95a5a6;
--accent-color: #27ae60;
--accent-teal: #1abc9c;
--accent-blue: #3498db;
--accent-orange: #e67e22;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
a { text-decoration: none; color: unset; }
body {
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
position: relative;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 300 300' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
opacity: 0.025;
pointer-events: none;
z-index: 0;
}
::selection { background: rgba(39, 174, 96, 0.25); }
.theme-switch { position: fixed; top: 1.25rem; right: 1.25rem; z-index: 1000; }
.theme-switch button {
background: white;
border: 0.06rem solid var(--border-color);
color: var(--text-primary);
padding: 0.44rem 0.88rem;
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 0.50rem;
box-shadow: 0 0.06rem 0.25rem rgba(0,0,0,0.04);
}
.theme-switch button:hover {
background: var(--accent-color);
color: white;
border-color: var(--accent-color);
box-shadow: 0 0.12rem 0.50rem rgba(39,174,96,0.2);
transform: translateY(-0.06rem);
}
.resume-container {
max-width: 51.25rem;
margin: 2.50rem auto;
background: var(--bg-secondary);
border-radius: 0.25rem;
box-shadow: 0 0.12rem 1rem rgba(0,0,0,0.04);
overflow: hidden;
position: relative;
z-index: 1;
}
header.resume-header {
background: linear-gradient(180deg, #e8f5e9 0%, #f7f9f7 100%);
padding: 2.81rem 2.50rem;
text-align: center;
border-bottom: 0.06rem solid var(--border-color);
}
header.resume-header .avatar img {
width: 8.12rem;
height: 8.12rem;
border-radius: 50%;
border: 0.25rem solid white;
object-fit: cover;
box-shadow: 0 0.25rem 1rem rgba(0,0,0,0.08);
}
header.resume-header .basic-info { display: flex; flex-direction: column; gap: 0.88rem; align-items: center; }
header.resume-header .basic-info .info-header { text-align: center; }
header.resume-header .basic-info .name { font-size: 2em; font-weight: 700; margin-bottom: 0.50rem; color: var(--text-primary); line-height: 1.2; }
header.resume-header .basic-info .title { font-size: 0.95em; color: var(--text-secondary); font-weight: 400; }
.contact-row {
display: flex;
flex-wrap: nowrap;
gap: 0.62rem;
}
.contact-tag {
background: white;
padding: 0.31rem 0.88rem;
border-radius: 0.19rem;
font-size: 0.84em;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.38rem;
border: 0.06rem solid var(--border-color);
white-space: nowrap;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.contact-tag:hover { border-color: var(--accent-color); color: var(--accent-color); }
.contact-tag a { color: var(--text-secondary); text-decoration: none; position: relative; }
.contact-tag a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.06rem;
left: 0;
background: var(--accent-color);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.contact-tag a:hover::after { width: 100%; }
.contact-tag a:hover { color: var(--accent-color); }
.meta-row {
display: flex;
flex-wrap: nowrap;
gap: 0.50rem;
font-size: 0.8em;
color: var(--text-muted);
}
.meta-tag {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.19rem 0.62rem;
background: rgba(255,255,255,0.5);
border-radius: 0.12rem;
white-space: nowrap;
border: 0.06rem solid var(--border-color);
}
.meta-tag i { width: 0.75rem; text-align: center; opacity: 0.5; }
header.resume-header .basic-info .summary {
margin-top: 0.12rem;
padding: 0.88rem 1.12rem;
background: rgba(255,255,255,0.6);
border-radius: 0.25rem;
font-size: 0.88em;
line-height: 1.8;
color: var(--text-secondary);
border-left: 0.19rem solid var(--accent-color);
}
main.resume-body { padding: 2.62rem; }
section { margin-bottom: 2.50rem; }
section:last-child { margin-bottom: 0; }
.section-title {
font-size: 1.05em;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
position: relative;
display: flex;
align-items: center;
gap: 0.62rem;
letter-spacing: 0.02rem;
}
.section-title::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 2.81rem;
height: 0.19rem;
background: var(--accent-color);
border-radius: 0.12rem;
}
.section-title i { color: var(--accent-color); font-size: 0.9em; }
.section-content { display: flex; flex-direction: column; gap: 1.12rem; }
article.item {
background: var(--bg-card);
padding: 1.38rem;
border-radius: 0.25rem;
border: 0.06rem solid var(--border-color);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
article.item::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 0.19rem;
height: 100%;
background: var(--accent-color);
opacity: 0;
transition: opacity 0.3s;
border-radius: 0.12rem 0 0 0.12rem;
}
article.item:hover {
border-color: var(--accent-color);
box-shadow: 0 0.12rem 0.75rem rgba(39,174,96,0.06);
transform: translateY(-0.12rem);
}
article.item:hover::before { opacity: 1; }
.item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.62rem; }
.item-header .company, .item-header .school, .item-header .project-name { font-weight: 600; color: var(--text-primary); font-size: 1em; }
.item-header .period { color: var(--text-muted); font-size: 0.82em; }
.item-header a { color: var(--accent-color); font-size: 0.95em; transition: color 0.3s; position: relative; }
.item-header a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.06rem;
left: 0;
background: var(--accent-color);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.item-header a:hover::after { width: 100%; }
.item-header a:hover { color: #1e8449; }
.position { color: var(--accent-color); font-weight: 500; margin-bottom: 0.62rem; }
.degree { color: var(--text-secondary); margin-bottom: 0.31rem; }
.gpa { color: var(--text-muted); font-size: 0.82em; }
.description { color: var(--text-secondary); line-height: 1.65; margin-bottom: 0.62rem; font-size: 0.92em; }
.highlights { list-style: none; padding-left: 0; }
.highlights li { color: var(--text-secondary); padding: 0.38rem 0; font-size: 0.9em; display: flex; align-items: flex-start; gap: 0.62rem; line-height: 1.6; }
.highlights li i { color: var(--accent-teal); font-size: 0.82em; margin-top: 0.19rem; }
.skill-items { display: flex; flex-wrap: wrap; gap: 0.62rem; }
.skill-tag {
padding: 0.38rem 0.88rem;
background: rgba(39,174,96,0.08);
border: 0.06rem solid rgba(39,174,96,0.2);
border-radius: 0.19rem;
font-size: 0.88em;
color: var(--text-primary);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.skill-tag:hover {
background: var(--accent-color);
color: white;
border-color: var(--accent-color);
transform: translateY(-0.06rem);
}
.tech-stack { display: flex; flex-wrap: wrap; gap: 0.50rem; margin-top: 0.62rem; }
.tech-tag {
padding: 0.25rem 0.75rem;
background: rgba(52,152,219,0.1);
color: var(--accent-blue);
border-radius: 0.19rem;
font-size: 0.82em;
border: 0.06rem solid rgba(52,152,219,0.2);
}
.platforms { display: flex; flex-wrap: wrap; gap: 0.38rem; margin-top: 0.50rem; }
.platform-tag {
padding: 0.19rem 0.62rem;
background: rgba(230,126,34,0.1);
color: var(--accent-orange);
border-radius: 0.19rem;
font-size: 0.78em;
border: 0.06rem solid rgba(230,126,34,0.2);
}
.advantage-list { list-style: none; padding-left: 0; }
.advantage-list li {
color: var(--text-secondary);
padding: 0.50rem 0;
font-size: 0.92em;
display: flex;
align-items: flex-start;
gap: 0.62rem;
line-height: 1.65;
}
.advantage-list li i { color: var(--accent-color); font-size: 0.85em; margin-top: 0.12rem; }
@media (max-width: 48rem) {
.resume-container { margin: 0.94rem; }
header.resume-header { padding: 2rem 1.38rem; }
main.resume-body { padding: 1.75rem 1.38rem; }
.item-header { flex-direction: column; gap: 0.38rem; }
.contact-row { gap: 0.62rem; }
.contact-item { padding: 0.44rem 0.88rem; font-size: 0.82em; }
article.item:hover { transform: translateY(0); }
}
@media print {
body { background: white; }
body::before { display: none; }
.theme-switch { display: none; }
.resume-container {
max-width: 100%;
margin: 0;
box-shadow: none;
border: 0.06rem solid #ddd;
}
header.resume-header {
background: #f5f5f5;
border-bottom: 0.12rem solid var(--accent-color);
}
article.item {
background: #fafafa;
border: 0.06rem solid #eee;
border-left: 0.19rem solid var(--accent-color);
}
article.item:hover {
transform: none;
box-shadow: none;
}
.contact-item { background: transparent; border: 0.06rem solid #ddd; }
a { color: var(--accent-color); text-decoration: underline; }
@page { margin: 15mm; }
}
</style>
</head>
<body>
{{ template "theme-switch" . }}
<div class="resume-container">
<header class="resume-header">
{{ template "component-avatar" .BasicInfo }}
{{ template "component-basic-info" .BasicInfo }}
</header>
<main class="resume-body">
{{ template "section-core-advantages" . }}
{{ template "section-experience" . }}
{{ template "section-education" . }}
{{ template "section-skills" . }}
{{ template "section-projects" . }}
</main>
</div>
{{ template "theme-script" . }}
<script src="/static/js/security.js"></script>
</body>
</html>
{{ end }}
+651
View File
@@ -0,0 +1,651 @@
{{ define "web/home" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ .SystemConfig.WebsiteDesc }}" />
<meta name="keywords" content="{{ .SystemConfig.WebsiteKeywords }}" />
<meta property="og:title" content="{{ .SystemConfig.WebsiteTitle }}" />
<meta property="og:description" content="{{ .SystemConfig.WebsiteDesc }}" />
<meta property="og:url" content="{{ .SystemConfig.WebsiteDomain }}" />
<title>{{ .SystemConfig.WebsiteTitle }}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #6D8B94;
--secondary: #A8B896;
--accent: #E3E5E0;
--text: #333333;
--text-light: #666666;
--bg: #FFFFFF;
--border: #E8E8E8;
--radius: 0.50rem;
}
html { scroll-behavior: smooth; }
body { font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; color: var(--text); background: var(--bg); -webkit-font-smoothing: antialiased; }
.nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(255,255,255,0.95);
backdrop-filter: blur(0.50rem);
border-bottom: 0.06rem solid var(--border);
}
.nav-inner {
max-width: 75rem;
margin: 0 auto;
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: var(--text);
}
.logo-icon {
width: 2.25rem;
height: 2.25rem;
border-radius: var(--radius);
background: linear-gradient(135deg, var(--primary), var(--secondary));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.1rem;
}
.logo-text { font-size: 1.15rem; font-weight: 500; letter-spacing: 0.12rem; }
.nav-links { display: flex; gap: 2.25rem; }
.nav-links a { text-decoration: none; color: var(--text-light); font-size: 0.9rem; transition: color 0.2s; }
.nav-links a:hover { color: var(--primary); }
.hero {
padding: 8.75rem 2rem 7.50rem;
text-align: center;
background: linear-gradient(180deg, var(--bg) 0%, var(--accent) 100%);
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: -50%;
left: -10%;
width: 31.25rem;
height: 31.25rem;
background: radial-gradient(circle, rgba(109,139,148,0.08) 0%, transparent 70%);
border-radius: 50%;
}
.hero::after {
content: '';
position: absolute;
bottom: -30%;
right: -10%;
width: 25rem;
height: 25rem;
background: radial-gradient(circle, rgba(168,184,150,0.08) 0%, transparent 70%);
border-radius: 50%;
}
.hero-title {
font-size: 3.5rem;
font-weight: 300;
letter-spacing: 0.50rem;
margin-bottom: 1rem;
color: var(--text);
}
.hero-subtitle {
font-size: 1.1rem;
color: var(--text-light);
margin-bottom: 0.75rem;
letter-spacing: 0.06rem;
}
.hero-desc {
font-size: 0.95rem;
color: var(--text-light);
margin-bottom: 3rem;
opacity: 0.8;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.50rem;
padding: 0.75rem 2rem;
background: var(--primary);
color: white;
border-radius: var(--radius);
text-decoration: none;
font-size: 0.95rem;
transition: all 0.3s;
}
.btn:hover {
background: #5A757D;
transform: translateY(-0.06rem);
}
.hero-stats {
max-width: 37.50rem;
margin: 3rem auto 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
position: relative;
z-index: 1;
}
.hero-stat-item {
text-align: center;
}
.hero-stat-number {
font-size: 2rem;
font-weight: 300;
color: var(--primary);
margin-bottom: 0.25rem;
}
.hero-stat-label {
font-size: 0.85rem;
color: var(--text-light);
letter-spacing: 0.06rem;
}
.intro {
padding: 3.75rem 2rem;
background: var(--bg);
text-align: center;
}
.intro-grid {
max-width: 56.25rem;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12.50rem, 1fr));
gap: 2rem;
}
.intro-item {
font-size: 0.95rem;
color: var(--text-light);
line-height: 1.6;
}
.intro-item i {
display: block;
font-size: 1.5rem;
color: var(--primary);
margin-bottom: 0.75rem;
}
.resumes {
padding: 5rem 2rem;
max-width: 75rem;
margin: 0 auto;
}
.section-title {
text-align: center;
font-size: 1.4rem;
font-weight: 400;
margin-bottom: 3rem;
color: var(--text);
}
.resume-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(17.50rem, 1fr));
gap: 1.25rem;
}
.resume-card {
background: var(--bg);
border: 0.06rem solid var(--border);
border-radius: var(--radius);
padding: 1.75rem;
text-decoration: none;
color: var(--text);
transition: all 0.3s;
}
.resume-card:hover {
border-color: var(--primary);
box-shadow: 0 0.50rem 1.50rem rgba(109, 139, 148, 0.12);
transform: translateY(-0.12rem);
}
.card-header {
display: flex;
align-items: center;
gap: 0.88rem;
margin-bottom: 1rem;
}
.card-avatar {
width: 3rem;
height: 3rem;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary), var(--secondary));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.2rem;
font-weight: 500;
}
.card-name { font-size: 1.1rem; font-weight: 500; margin-bottom: 0.12rem; }
.card-title { font-size: 0.85rem; color: var(--text-light); }
.card-summary {
font-size: 0.88rem;
color: var(--text-light);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.no-resumes {
text-align: center;
padding: 3.75rem;
color: var(--text-light);
}
.no-resumes i { font-size: 3rem; margin-bottom: 1rem; opacity: 0.5; }
.templates-section {
padding: 5rem 2rem;
background: var(--bg);
}
.section-subtitle {
text-align: center;
font-size: 0.95rem;
color: var(--text-light);
margin-bottom: 3rem;
margin-top: -2rem;
}
.templates-grid {
max-width: 75rem;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(17.50rem, 1fr));
gap: 1.50rem;
}
.template-card {
background: var(--bg);
border: 0.06rem solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: all 0.3s;
cursor: pointer;
}
.template-card:hover {
border-color: var(--primary);
box-shadow: 0 0.50rem 1.50rem rgba(109, 139, 148, 0.12);
transform: translateY(-0.19rem);
}
.template-preview {
height: 11.25rem;
background: #fafafa;
position: relative;
overflow: hidden;
}
.template-name {
padding: 1rem;
text-align: center;
font-size: 0.95rem;
font-weight: 500;
color: var(--text);
border-top: 0.06rem solid var(--border);
}
.preview-line {
height: 0.38rem;
background: #e0e0e0;
border-radius: 0.19rem;
margin-bottom: 0.50rem;
}
.preview-line.long { width: 90%; }
.preview-line.medium { width: 65%; }
.preview-line.short { width: 40%; }
.preview-line.dark { background: #4a5568; }
.modern-preview .preview-header {
height: 3.12rem;
background: #2c3e50;
margin-bottom: 1rem;
}
.modern-preview .preview-body { padding: 0 1rem; }
.tech-preview {
display: flex;
background: #1a1a2e;
}
.tech-preview .preview-sidebar {
width: 5rem;
background: #16213e;
}
.tech-preview .preview-content {
flex: 1;
padding: 1.25rem 1rem;
}
.sidebar-preview { display: flex; background: #f5f7fa; }
.sidebar-preview .preview-left {
width: 5.62rem;
background: #2c3e50;
}
.sidebar-preview .preview-right {
flex: 1;
padding: 1.25rem 1rem;
background: white;
}
.elegant-preview .preview-top {
height: 3.75rem;
background: linear-gradient(135deg, #1a1a2e, #16213e);
}
.elegant-preview .preview-bottom {
padding: 1rem;
background: #faf8f5;
}
.classic-preview .preview-classic-header {
height: 3.75rem;
background: #f8f9fa;
border-bottom: 0.12rem solid #333;
}
.classic-preview .preview-body { padding: 1rem; }
.fresh-preview .preview-fresh-header {
height: 3.12rem;
background: linear-gradient(135deg, #a8e6cf, #88d8b0);
}
.fresh-preview .preview-body { padding: 1rem; }
.features {
padding: 5rem 2rem;
background: var(--accent);
}
.features-grid {
max-width: 75rem;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15.62rem, 1fr));
gap: 1.50rem;
}
.feature-card {
background: var(--bg);
border: 0.06rem solid var(--border);
border-radius: var(--radius);
padding: 1.75rem;
text-align: center;
transition: all 0.3s;
}
.feature-card:hover {
border-color: var(--primary);
box-shadow: 0 0.50rem 1.50rem rgba(109, 139, 148, 0.12);
transform: translateY(-0.12rem);
}
.feature-icon {
width: 3.25rem;
height: 3.25rem;
border-radius: var(--radius);
background: linear-gradient(135deg, var(--primary), var(--secondary));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.3rem;
margin: 0 auto 1rem;
}
.feature-card h3 {
font-size: 1rem;
font-weight: 500;
margin-bottom: 0.50rem;
color: var(--text);
}
.feature-card p {
font-size: 0.85rem;
color: var(--text-light);
line-height: 1.6;
}
.footer {
padding: 2.50rem 2rem;
border-top: 0.06rem solid var(--border);
text-align: center;
}
.footer-brand {
font-size: 1rem;
font-weight: 500;
margin-bottom: 0.50rem;
letter-spacing: 0.12rem;
}
.footer-links {
display: flex;
justify-content: center;
gap: 1.50rem;
font-size: 0.85rem;
color: var(--text-light);
}
.footer-links a {
color: var(--text-light);
text-decoration: none;
transition: color 0.2s;
}
.footer-links a:hover { color: var(--primary); }
@media (max-width: 48rem) {
.nav-links { gap: 1.25rem; }
.hero-title { font-size: 2.5rem; letter-spacing: 0.25rem; }
.hero-subtitle { font-size: 1rem; }
.resume-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<nav class="nav">
<div class="nav-inner">
<a href="/" class="logo">
<div class="logo-icon">
{{ if .SystemConfig.LogoPath }}
<img src="{{.SystemConfig.LogoPath}}" alt="Logo" style="width:1.75rem;height:1.75rem;border-radius:0.38rem;object-fit:cover;">
{{ else }}
<i class="fas fa-file-alt"></i>
{{ end }}
</div>
<span class="logo-text">{{ .SystemConfig.WebsiteTitle }}</span>
</a>
<div class="nav-links">
<a href="/">首页</a>
<a href="#resumes">全部简历</a>
<a href="#about">关于项目</a>
</div>
</div>
</nav>
<section class="hero">
<h1 class="hero-title">{{ .SystemConfig.WebsiteTitle }}</h1>
<p class="hero-subtitle">{{ .SystemConfig.WebsiteDesc }}</p>
<p class="hero-desc">开源、轻量、优雅的简历展示平台</p>
<a href="#resumes" class="btn">
<i class="fas fa-eye"></i> 浏览简历
</a>
<div class="hero-stats">
<div class="hero-stat-item">
<div class="hero-stat-number">{{.Stats.TotalResumes}}</div>
<div class="hero-stat-label">份简历</div>
</div>
<div class="hero-stat-item">
<div class="hero-stat-number">{{.Stats.TotalUsers}}</div>
<div class="hero-stat-label">位用户</div>
</div>
<div class="hero-stat-item">
<div class="hero-stat-number">8+</div>
<div class="hero-stat-label">种模板</div>
</div>
</div>
</section>
<section class="features">
<h2 class="section-title">功能特性</h2>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon"><i class="fas fa-file-alt"></i></div>
<h3>精美简历模板</h3>
<p>多种专业模板,支持自定义主题,打造独特个人主页</p>
</div>
<div class="feature-card">
<div class="feature-icon"><i class="fas fa-shield-alt"></i></div>
<h3>访问控制</h3>
<p>支持密码保护,灵活控制简历可见范围,保护隐私</p>
</div>
<div class="feature-card">
<div class="feature-icon"><i class="fas fa-mobile-alt"></i></div>
<h3>响应式设计</h3>
<p>适配各种设备,随时随地展示你的专业风采</p>
</div>
<div class="feature-card">
<div class="feature-icon"><i class="fas fa-search"></i></div>
<h3>SEO优化</h3>
<p>内置搜索引擎优化,让更多人发现你的简历</p>
</div>
<div class="feature-card">
<div class="feature-icon"><i class="fas fa-robot"></i></div>
<h3>AI智能辅助</h3>
<p>AI润色简历、生成面试题,提升求职成功率</p>
</div>
<div class="feature-card">
<div class="feature-icon"><i class="fas fa-code"></i></div>
<h3>开源免费</h3>
<p>完全开源,轻量高效,支持二次开发</p>
</div>
</div>
</section>
<section class="templates-section">
<h2 class="section-title">多种精美模板</h2>
<p class="section-subtitle">8款风格各异的专业模板,一键切换,打造独特的个人简历</p>
<div class="templates-grid">
<div class="template-card">
<div class="template-preview modern-preview">
<div class="preview-header"></div>
<div class="preview-body">
<div class="preview-line long"></div>
<div class="preview-line short"></div>
<div class="preview-line medium"></div>
<div class="preview-line long"></div>
</div>
</div>
<div class="template-name">现代商务</div>
</div>
<div class="template-card">
<div class="template-preview tech-preview">
<div class="preview-sidebar"></div>
<div class="preview-content">
<div class="preview-line long dark"></div>
<div class="preview-line short dark"></div>
<div class="preview-line medium dark"></div>
</div>
</div>
<div class="template-name">科技极客</div>
</div>
<div class="template-card">
<div class="template-preview sidebar-preview">
<div class="preview-left"></div>
<div class="preview-right">
<div class="preview-line long"></div>
<div class="preview-line medium"></div>
<div class="preview-line short"></div>
</div>
</div>
<div class="template-name">经典侧边栏</div>
</div>
<div class="template-card">
<div class="template-preview elegant-preview">
<div class="preview-top"></div>
<div class="preview-bottom">
<div class="preview-line long"></div>
<div class="preview-line medium"></div>
<div class="preview-line long"></div>
</div>
</div>
<div class="template-name">优雅轻奢</div>
</div>
<div class="template-card">
<div class="template-preview classic-preview">
<div class="preview-classic-header"></div>
<div class="preview-body">
<div class="preview-line long"></div>
<div class="preview-line long"></div>
<div class="preview-line medium"></div>
</div>
</div>
<div class="template-name">传统经典</div>
</div>
<div class="template-card">
<div class="template-preview fresh-preview">
<div class="preview-fresh-header"></div>
<div class="preview-body">
<div class="preview-line long"></div>
<div class="preview-line short"></div>
<div class="preview-line medium"></div>
</div>
</div>
<div class="template-name">清新自然</div>
</div>
</div>
</section>
<section id="resumes" class="resumes">
<h2 class="section-title">简历展示</h2>
<div class="resume-grid">
{{ $visibleResumes := slice }}
{{ range .Resumes }}
{{ if .ShowInHome }}
{{ $visibleResumes = append $visibleResumes . }}
{{ end }}
{{ end }}
{{ $limitedResumes := $visibleResumes }}
{{ if gt (len $limitedResumes) 9 }}
{{ $limitedResumes = first 9 $limitedResumes }}
{{ end }}
{{ range $limitedResumes }}
<a href="/{{ .Route }}" class="resume-card">
<div class="card-header">
<div class="card-avatar">
{{ if .BasicInfo.Name }}{{ substr .BasicInfo.Name 0 1 }}{{ else }}简{{ end }}
</div>
<div>
<div class="card-name">{{ .BasicInfo.Name }}</div>
<div class="card-title">{{ .BasicInfo.Title }}</div>
</div>
</div>
<p class="card-summary">{{ .BasicInfo.Summary }}</p>
</a>
{{ else }}
<div class="no-resumes" style="grid-column: 1 / -1;">
<i class="fas fa-folder-open"></i>
<p>暂无公开简历</p>
</div>
{{ end }}
</div>
{{ if gt (len $visibleResumes) 9 }}
<div style="text-align: center; margin-top: 2rem;">
<span class="text-sm text-slate-500">仅展示前9个简历,共{{ len $visibleResumes }}个</span>
</div>
{{ end }}
</section>
<section id="about" class="resumes" style="padding-top: 0;">
<h2 class="section-title">关于项目</h2>
<div style="text-align: center; color: var(--text-light); line-height: 1.8; max-width: 37.50rem; margin: 0 auto;">
<p>景笺是一个开源的简历展示平台,致力于提供极简、优雅的简历浏览体验。</p>
<p style="margin-top: 0.75rem;">前台仅用于展示简历,无任何登录入口;后台独立私密,仅供管理员使用。</p>
</div>
</section>
<footer class="footer">
<div class="footer-brand">{{ .SystemConfig.WebsiteTitle }}</div>
<div class="footer-links">
<span>© 2026 开源简历平台</span>
<a href="https://github.com/resume-platform/resume-platform" target="_blank">GitHub</a>
</div>
</footer>
</body>
</html>
{{ end }}
+295
View File
@@ -0,0 +1,295 @@
{{ define "web/minimalist" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
a { text-decoration: none; color: unset; }
body {
font-family: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', sans-serif;
background: #fafafa;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.theme-switch { position: fixed; top: 1.25rem; right: 1.25rem; z-index: 1000; }
.theme-switch button {
background: transparent;
border: 0.06rem solid #ddd;
color: #666;
padding: 0.38rem 0.75rem;
border-radius: 0.12rem;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 0.50rem;
font-size: 0.85em;
}
.theme-switch button:hover {
background: #111;
color: white;
border-color: #111;
transform: translateY(-0.06rem);
}
.resume-container {
max-width: 43.75rem;
margin: 4.38rem auto;
background: white;
padding: 3.75rem 3.12rem;
border-top: 0.19rem solid #111;
box-shadow: 0 0.06rem 0.25rem rgba(0,0,0,0.04);
}
header.resume-header { text-align: left; margin-bottom: 3.44rem; padding-bottom: 2.19rem; border-bottom: 0.06rem solid #eee; }
header.resume-header .avatar img {
width: 5.62rem;
height: 5.62rem;
border-radius: 0.12rem;
object-fit: cover;
border: 0.06rem solid #e0e0e0;
}
header.resume-header .basic-info { display: flex; flex-direction: column; gap: 0.88rem; }
header.resume-header .basic-info .info-header { margin-bottom: 0.12rem; }
header.resume-header .basic-info .name { font-size: 2.3em; font-weight: 300; margin-bottom: 0.38rem; color: #111; letter-spacing: -0.06rem; line-height: 1.2; }
header.resume-header .basic-info .title { font-size: 0.9em; color: #999; letter-spacing: 0.19rem; text-transform: uppercase; font-weight: 400; }
header.resume-header .basic-info .contact-row {
display: flex;
flex-wrap: nowrap;
gap: 0.88rem;
font-size: 0.84em;
}
header.resume-header .basic-info .contact-tag {
display: inline-flex;
align-items: center;
gap: 0.38rem;
color: #555;
padding: 0.25rem 0;
white-space: nowrap;
border-bottom: 0.06rem solid transparent;
transition: all 0.3s ease;
}
header.resume-header .basic-info .contact-tag:hover {
color: #111;
border-bottom-color: #ccc;
}
header.resume-header .basic-info .contact-tag i { width: 0.88rem; text-align: center; opacity: 0.5; }
header.resume-header .basic-info .contact-tag a { color: #555; text-decoration: none; position: relative; }
header.resume-header .basic-info .contact-tag a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.06rem;
left: 0;
background: #111;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
header.resume-header .basic-info .contact-tag a:hover::after { width: 100%; }
header.resume-header .basic-info .contact-tag a:hover { color: #111; }
header.resume-header .basic-info .meta-row {
display: flex;
flex-wrap: nowrap;
gap: 0.75rem;
font-size: 0.8em;
color: #777;
}
header.resume-header .basic-info .meta-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
header.resume-header .basic-info .meta-tag i { width: 0.75rem; text-align: center; opacity: 0.4; }
main.resume-body { padding-top: 0.50rem; }
section { margin-bottom: 3.12rem; }
section:last-child { margin-bottom: 0; }
.section-title {
font-size: 0.82em;
font-weight: 600;
color: #111;
letter-spacing: 0.12rem;
text-transform: uppercase;
margin-bottom: 1.56rem;
position: relative;
}
.section-title::after {
content: '';
position: absolute;
top: 50%;
left: 5rem;
right: 0;
height: 0.06rem;
background: #eee;
}
.section-title i { margin-right: 0.62rem; color: #111; font-size: 0.9em; }
.section-content .item { margin-bottom: 2rem; }
.section-content .item:last-child { margin-bottom: 0; }
.item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.38rem; }
.item-header .company, .item-header .school, .item-header .project-name { font-weight: 500; color: #111; font-size: 1em; }
.item-header .period { color: #bbb; font-size: 0.82em; }
.item-header a { color: #666; font-size: 0.9em; position: relative; }
.item-header a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.06rem;
left: 0;
background: #111;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.item-header a:hover::after { width: 100%; }
.item-header a:hover { color: #111; }
.position { color: #666; font-size: 0.88em; margin-bottom: 0.75rem; font-weight: 400; }
.degree { color: #666; font-size: 0.88em; margin-bottom: 0.31rem; }
.gpa { color: #bbb; font-size: 0.82em; }
.description { color: #555; line-height: 1.75; margin-bottom: 0.75rem; font-size: 0.92em; }
.highlights { list-style: none; padding-left: 0; }
.highlights li {
color: #555;
padding: 0.38rem 0;
font-size: 0.9em;
position: relative;
padding-left: 1.12rem;
line-height: 1.65;
transition: padding-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.highlights li:hover { padding-left: 1.38rem; }
.highlights li::before {
content: '';
position: absolute;
left: 0;
top: 0.69rem;
width: 0.19rem;
height: 0.19rem;
background: #111;
border-radius: 50%;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.highlights li:hover::before { transform: scale(1.5); }
.skill-items { display: flex; flex-wrap: wrap; gap: 1.12rem; }
.skill-tag {
padding: 0;
font-size: 0.92em;
color: #555;
border-bottom: 0.06rem solid #ddd;
padding-bottom: 0.12rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.skill-tag:hover { border-color: #111; color: #111; }
.tech-stack { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 0.88rem; }
.tech-tag {
padding: 0;
font-size: 0.82em;
color: #888;
border-bottom: 0.06rem solid #eee;
padding-bottom: 0.12rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.tech-tag:hover { border-color: #111; color: #111; }
.platforms { display: flex; flex-wrap: wrap; gap: 0.62rem; margin-top: 0.50rem; }
.platform-tag {
padding: 0;
font-size: 0.78em;
color: #999;
border-bottom: 0.06rem solid #f0f0f0;
padding-bottom: 0.06rem;
}
.advantage-list { list-style: none; padding-left: 0; }
.advantage-list li {
color: #555;
padding: 0.50rem 0;
font-size: 0.92em;
position: relative;
padding-left: 1.25rem;
line-height: 1.7;
transition: padding-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.advantage-list li:hover { padding-left: 1.50rem; }
.advantage-list li::before {
content: '';
position: absolute;
left: 0;
top: 0.81rem;
width: 0.25rem;
height: 0.25rem;
background: #111;
border-radius: 50%;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.advantage-list li:hover::before { transform: scale(1.5); }
@media (max-width: 48rem) {
.resume-container { margin: 1.25rem; padding: 2.50rem 1.88rem; }
header.resume-header .basic-info .contact { gap: 0.75rem; }
.item-header { flex-direction: column; gap: 0.31rem; }
.section-title::after { display: none; }
header.resume-header { text-align: center; }
.highlights li:hover { padding-left: 1.12rem; }
.advantage-list li:hover { padding-left: 1.25rem; }
}
@media print {
body { background: white; }
.theme-switch { display: none; }
.resume-container {
max-width: 100%;
margin: 0;
padding: 0;
border-top: 0.19rem solid #111;
box-shadow: none;
}
header.resume-header {
padding-bottom: 1.56rem;
margin-bottom: 2.50rem;
}
a { color: #333; text-decoration: underline; }
.highlights li:hover { padding-left: 1.12rem; }
.advantage-list li:hover { padding-left: 1.25rem; }
@page { margin: 20mm; }
}
</style>
</head>
<body>
{{ template "theme-switch" . }}
<div class="resume-container">
<header class="resume-header">
{{ template "component-avatar" .BasicInfo }}
{{ template "component-basic-info" .BasicInfo }}
</header>
<main class="resume-body">
{{ template "section-core-advantages" . }}
{{ template "section-experience" . }}
{{ template "section-education" . }}
{{ template "section-skills" . }}
{{ template "section-projects" . }}
</main>
</div>
{{ template "theme-script" . }}
<script src="/static/js/security.js"></script>
</body>
</html>
{{ end }}
+491
View File
@@ -0,0 +1,491 @@
{{ define "web/modern" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</title>
<meta name="description" content="{{.BasicInfo.Summary}}" />
<meta name="keywords" content="{{.BasicInfo.Title}}, {{.BasicInfo.Name}}, 简历, 作品集, {{range .Skills}}{{.Name}}, {{end}}" />
<meta name="author" content="{{.BasicInfo.Name}}" />
<meta property="og:title" content="{{.BasicInfo.Name}} - {{.BasicInfo.Title}}" />
<meta property="og:description" content="{{.BasicInfo.Summary}}" />
<meta property="og:type" content="profile" />
<meta property="og:url" content="https://zhangsan.dev" />
<link rel="canonical" href="https://zhangsan.dev" />
<link rel="alternate" href="https://zhangsan.dev" hreflang="zh-CN" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--secondary-color: #2c3e50;
--accent-green: #27ae60;
--accent-orange: #e67e22;
--accent-purple: #9b59b6;
--text-primary: #2c3e50;
--text-secondary: #555;
--text-muted: #888;
--bg-light: #f0f2f5;
--bg-card: #ffffff;
--border-color: #dde3e8;
--shadow-sm: 0 0.06rem 0.19rem rgba(0,0,0,0.08);
--shadow-md: 0 0.25rem 1.50rem rgba(0,0,0,0.06);
--shadow-lg: 0 0.50rem 2rem rgba(0,0,0,0.08);
--radius-sm: 0.19rem;
--radius-md: 0.38rem;
--radius-lg: 0.75rem;
--transition-fast: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
a { text-decoration: none; color: unset; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-light);
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
opacity: 0.03;
pointer-events: none;
z-index: 0;
}
html { scroll-behavior: smooth; }
.theme-switch { position: fixed; top: 1.25rem; right: 1.25rem; z-index: 1000; }
.theme-switch button {
background: white;
border: 0.06rem solid #d0d0d0;
color: #444;
padding: 0.44rem 0.88rem;
border-radius: 0.38rem;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 0.50rem;
box-shadow: 0 0.06rem 0.19rem rgba(0,0,0,0.08);
}
.theme-switch button:hover {
background: #2c3e50;
color: white;
border-color: #2c3e50;
transform: translateY(-0.06rem);
box-shadow: 0 0.12rem 0.50rem rgba(0,0,0,0.15);
}
.resume-container {
max-width: 53.75rem;
margin: 2.81rem auto;
background: white;
box-shadow: 0 0.25rem 1.50rem rgba(0,0,0,0.06);
border-radius: 0.25rem;
overflow: hidden;
position: relative;
z-index: 1;
}
header.resume-header {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
padding: 2.81rem 2.50rem;
color: white;
position: relative;
overflow: hidden;
}
header.resume-header::before {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 18.75rem;
height: 18.75rem;
background: radial-gradient(circle, rgba(52, 152, 219, 0.15) 0%, transparent 70%);
border-radius: 50%;
}
header.resume-header::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 0.19rem;
background: linear-gradient(90deg, #3498db, #9b59b6, #3498db);
background-size: 200% 100%;
animation: shimmer 3s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.header-grid {
display: grid;
grid-template-columns: 100% 1fr;
gap: 1.56rem;
align-items: center;
}
header.resume-header .avatar img {
width: 7.50rem;
height: 7.50rem;
border-radius: 0.25rem;
border: 0.19rem solid rgba(255,255,255,0.2);
object-fit: cover;
box-shadow: 0 0.25rem 0.75rem rgba(0,0,0,0.3);
}
header.resume-header .basic-info { display: flex; flex-direction: column; gap: 0.75rem; }
header.resume-header .basic-info .info-header { margin-bottom: 0.12rem; }
header.resume-header .basic-info .name { font-size: 2em; font-weight: 700; margin-bottom: 0.38rem; letter-spacing: -0.03rem; line-height: 1.2; }
header.resume-header .basic-info .title { font-size: 1em; opacity: 0.85; }
header.resume-header .basic-info .contact-row {
display: flex;
flex-wrap: nowrap;
gap: 0.75rem;
font-size: 0.86em;
opacity: 0.92;
}
header.resume-header .basic-info .contact-tag {
display: inline-flex;
align-items: center;
gap: 0.38rem;
padding: 0.31rem 0.75rem;
background: rgba(255,255,255,0.08);
border-radius: 0.19rem;
white-space: nowrap;
transition: all 0.3s ease;
border: 0.06rem solid rgba(255,255,255,0.08);
}
header.resume-header .basic-info .contact-tag:hover {
background: rgba(255,255,255,0.12);
border-color: rgba(255,255,255,0.15);
}
header.resume-header .basic-info .contact-tag i { width: 0.88rem; text-align: center; opacity: 0.75; }
header.resume-header .basic-info .contact-tag a { color: white; text-decoration: none; position: relative; }
header.resume-header .basic-info .contact-tag a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.06rem;
left: 0;
background: white;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
header.resume-header .basic-info .contact-tag a:hover::after { width: 100%; }
header.resume-header .basic-info .meta-row {
display: flex;
flex-wrap: nowrap;
gap: 0.62rem;
font-size: 0.82em;
opacity: 0.78;
}
header.resume-header .basic-info .meta-tag {
display: inline-flex;
align-items: center;
gap: 0.31rem;
padding: 0.25rem 0.62rem;
background: rgba(255,255,255,0.05);
border-radius: 0.12rem;
white-space: nowrap;
}
header.resume-header .basic-info .meta-tag i { width: 0.75rem; text-align: center; opacity: 0.5; }
header.resume-header .basic-info .summary {
margin-top: 0.12rem;
padding: 0.88rem 1.12rem;
background: rgba(255,255,255,0.05);
border-radius: 0.25rem;
font-size: 0.88em;
line-height: 1.8;
border-left: 0.19rem solid #3498db;
}
main.resume-body { padding: 2.38rem 2.50rem; }
section {
margin-bottom: 2.25rem;
opacity: 0;
transform: translateY(1.25rem);
animation: slideUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
section:nth-child(1) { animation-delay: 0.1s; }
section:nth-child(2) { animation-delay: 0.2s; }
section:nth-child(3) { animation-delay: 0.3s; }
section:nth-child(4) { animation-delay: 0.4s; }
section:nth-child(5) { animation-delay: 0.5s; }
section:nth-child(6) { animation-delay: 0.6s; }
section:last-child { margin-bottom: 0; }
@keyframes slideUp {
from { opacity: 0; transform: translateY(1.25rem); }
to { opacity: 1; transform: translateY(0); }
}
.section-title {
font-size: 1.1em;
font-weight: 600;
color: #2c3e50;
margin-bottom: 1.12rem;
padding-bottom: 0.50rem;
border-bottom: 0.12rem solid #3498db;
display: flex;
align-items: center;
gap: 0.62rem;
letter-spacing: 0.03rem;
}
.section-title i { color: #3498db; font-size: 0.9em; }
.section-content .item {
margin-bottom: 1.50rem;
padding: 1.25rem;
background: #fafbfc;
border-radius: 0.25rem;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
border-left: 0.19rem solid transparent;
}
.section-content .item:hover {
background: #f4f6f8;
border-left-color: #3498db;
box-shadow: 0 0.12rem 0.50rem rgba(0,0,0,0.04);
transform: translateX(0.25rem);
}
.item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.50rem; }
.item-header .company, .item-header .school, .item-header .project-name { font-weight: 600; color: #2c3e50; font-size: 1em; }
.item-header .period { color: #888; font-size: 0.85em; }
.item-header a { color: #3498db; font-size: 1em; position: relative; }
.item-header a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.06rem;
left: 0;
background: #3498db;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.item-header a:hover::after { width: 100%; }
.position { color: #3498db; font-weight: 500; margin-bottom: 0.62rem; }
.degree { color: #555; margin-bottom: 0.31rem; }
.gpa { color: #888; font-size: 0.9em; }
.description { color: #555; line-height: 1.6; margin-bottom: 0.62rem; font-size: 0.95em; }
.highlights { list-style: none; padding-left: 0; }
.highlights li { color: #444; padding: 0.31rem 0; font-size: 0.95em; display: flex; align-items: flex-start; gap: 0.50rem; }
.highlights li i { color: #27ae60; font-size: 0.85em; margin-top: 0.25rem; }
.skill-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(17.50rem, 1fr));
gap: 1.25rem;
}
.skill-category {
background: #fafbfc;
padding: 1.12rem;
border-radius: var(--radius-md);
border: 0.06rem solid var(--border-color);
}
.skill-category-title {
font-size: 0.85em;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03rem;
margin-bottom: 0.88rem;
padding-bottom: 0.50rem;
border-bottom: 0.06rem solid var(--border-color);
}
.skill-items {
display: flex;
flex-wrap: wrap;
gap: 0.50rem;
}
.skill-tag {
padding: 0.38rem 0.88rem;
background: #e8ecf0;
border-radius: var(--radius-sm);
font-size: 0.88em;
color: #4a5568;
transition: var(--transition-normal);
border: 0.06rem solid var(--border-color);
position: relative;
overflow: hidden;
}
.skill-tag::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 0.12rem;
background: var(--primary-color);
transition: var(--transition-normal);
}
.skill-tag:hover {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
transform: translateY(-0.06rem);
box-shadow: 0 0.25rem 0.75rem rgba(52,152,219,0.3);
}
.skill-tag:hover::before {
left: 0;
}
.skill-group {
margin-bottom: 1rem;
}
.skill-group:last-child {
margin-bottom: 0;
}
.skill-progress-item {
margin-bottom: 0.75rem;
}
.skill-progress-item:last-child {
margin-bottom: 0;
}
.skill-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.38rem;
font-size: 0.88em;
}
.skill-progress-name {
color: var(--text-primary);
font-weight: 500;
}
.skill-progress-level {
color: var(--primary-color);
font-weight: 600;
font-size: 0.82em;
}
.skill-progress-bar {
height: 0.38rem;
background: #e8ecf0;
border-radius: 0.19rem;
overflow: hidden;
border: 0.06rem solid var(--border-color);
}
.skill-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary-color), var(--accent-green));
border-radius: 0.12rem;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
}
.tech-stack { display: flex; flex-wrap: wrap; gap: 0.38rem; margin-top: 0.62rem; }
.tech-tag { padding: 0.25rem 0.62rem; background: #2c3e50; color: white; border-radius: 0.19rem; font-size: 0.82em; }
.platforms { display: flex; flex-wrap: wrap; gap: 0.38rem; margin-top: 0.50rem; }
.platform-tag { padding: 0.19rem 0.62rem; background: rgba(52,152,219,0.1); color: #3498db; border-radius: 0.19rem; font-size: 0.8em; border: 0.06rem solid rgba(52,152,219,0.2); }
.advantage-list { list-style: none; padding-left: 0; }
.advantage-list li {
color: #444;
padding: 0.50rem 0;
font-size: 0.95em;
display: flex;
align-items: flex-start;
gap: 0.62rem;
line-height: 1.6;
}
.advantage-list li i { color: #3498db; font-size: 0.9em; margin-top: 0.12rem; }
@media (max-width: 48rem) {
.resume-container { margin: 0.94rem; }
header.resume-header { padding: 1.88rem 1.25rem; }
main.resume-body { padding: 1.56rem 1.25rem; }
.item-header { flex-direction: column; gap: 0.31rem; }
header.resume-header .basic-info .contact { gap: 0.62rem; }
.header-grid { grid-template-columns: 1fr; text-align: center; }
header.resume-header .avatar img { margin: 0 auto; }
.section-content .item:hover { transform: translateX(0); }
}
@media print {
body { background: white; }
body::before { display: none; }
.theme-switch { display: none; }
.resume-container {
max-width: 100%;
margin: 0;
box-shadow: none;
border: 0.06rem solid #ddd;
}
header.resume-header {
background: #2c3e50;
color: white;
-webkit-print-color-adjust: exact;
}
.section-content .item {
background: #fafafa;
border-left: 0.19rem solid #3498db;
}
.section-content .item:hover {
transform: none;
box-shadow: none;
}
a { color: #3498db; text-decoration: underline; }
@page { margin: 15mm; }
}
</style>
</head>
<body>
{{ template "theme-switch" . }}
<div class="resume-container">
<header class="resume-header">
<div class="header-grid">
{{ template "component-avatar" .BasicInfo }}
{{ template "component-basic-info" .BasicInfo }}
</div>
</header>
<main class="resume-body">
{{ template "section-core-advantages" . }}
{{ template "section-experience" . }}
{{ template "section-education" . }}
{{ template "section-skills" . }}
{{ template "section-projects" . }}
</main>
</div>
{{ template "theme-script" . }}
<script src="/static/js/security.js"></script>
</body>
</html>
{{ end }}
+261
View File
@@ -0,0 +1,261 @@
{{ define "web/password" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .SystemConfig.WebsiteTitle }} - 访问密码</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #7B8EA2;
--primary-dark: #6B7E92;
--text-primary: #2F343A;
--text-secondary: #6C737C;
--text-muted: #9CA3AF;
--bg: #F8F7F4;
--bg-card: #FFFFFF;
--border-color: #E5E3DF;
--shadow-sm: 0 0.06rem 0.12rem rgba(47, 52, 58, 0.04);
--shadow-md: 0 0.25rem 0.75rem rgba(47, 52, 58, 0.06);
--shadow-lg: 0 0.50rem 1.50rem rgba(47, 52, 58, 0.08);
--radius-sm: 0.38rem;
--radius-md: 0.62rem;
--radius-lg: 0.88rem;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Noto Sans SC', sans-serif;
background-color: var(--bg);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.password-container {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 3rem;
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 26.25rem;
text-align: center;
border: 0.06rem solid var(--border-color);
}
.password-header {
margin-bottom: 2rem;
}
.password-logo {
width: 3.50rem;
height: 3.50rem;
margin: 0 auto 1.25rem;
background: var(--primary);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.8rem;
}
.password-logo img { width: 2.50rem; height: 2.50rem; border-radius: var(--radius-sm); object-cover; }
.password-title {
font-size: 1.15rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.password-subtitle {
font-size: 0.9rem;
color: var(--text-secondary);
}
.password-icon {
width: 4.50rem;
height: 4.50rem;
background: rgba(123, 142, 162, 0.08);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.50rem;
font-size: 2rem;
color: var(--primary);
}
h1 {
font-size: 1.6rem;
color: var(--text-primary);
margin-bottom: 0.50rem;
font-weight: 600;
}
p {
color: var(--text-secondary);
margin-bottom: 2rem;
font-size: 0.95rem;
}
.form-group {
margin-bottom: 1.25rem;
text-align: left;
}
.form-group label {
display: block;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.50rem;
}
.form-group input {
width: 100%;
padding: 0.75rem 0.88rem;
border: 0.06rem solid var(--border-color);
border-radius: var(--radius-md);
font-size: 1rem;
transition: all 0.2s;
outline: none;
background: var(--bg);
}
.form-group input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 0.19rem rgba(123, 142, 162, 0.1);
background: white;
}
.form-group input.error {
border-color: #ef4444;
}
.error-message {
color: #ef4444;
font-size: 0.85rem;
margin-top: 0.38rem;
display: none;
}
.btn {
width: 100%;
padding: 0.81rem;
background: var(--primary);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn:hover {
background: var(--primary-dark);
transform: translateY(-0.06rem);
box-shadow: 0 0.25rem 0.75rem rgba(123, 142, 162, 0.2);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading-spinner {
display: inline-block;
width: 1.12rem;
height: 1.12rem;
border: 0.12rem solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s linear infinite;
margin-right: 0.50rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="password-container">
<div class="password-header">
<div class="password-logo">
{{ if .SystemConfig.LogoPath }}
<img src="{{.SystemConfig.LogoPath}}" alt="Logo">
{{ else }}
<i class="fas fa-file-alt"></i>
{{ end }}
</div>
<div class="password-title">{{ .SystemConfig.WebsiteTitle }}</div>
<div class="password-subtitle">{{ .SystemConfig.WebsiteDesc }}</div>
</div>
<div class="password-icon">
<i class="fas fa-lock"></i>
</div>
<h1>简历已加密</h1>
<p>请输入访问密码以查看这份简历</p>
<div class="form-group">
<label for="password">访问密码</label>
<input type="password" id="password" placeholder="请输入密码" onkeypress="handleKeyPress(event)">
<div class="error-message" id="errorMessage">密码错误,请重试</div>
</div>
<button class="btn" id="submitBtn" onclick="submitPassword()">
<span id="btnText">验证访问</span>
<span class="loading-spinner" id="loadingSpinner" style="display: none;"></span>
</button>
</div>
<script>
const passwordInput = document.getElementById('password');
const submitBtn = document.getElementById('submitBtn');
const btnText = document.getElementById('btnText');
const loadingSpinner = document.getElementById('loadingSpinner');
const errorMessage = document.getElementById('errorMessage');
function handleKeyPress(event) {
if (event.key === 'Enter') {
submitPassword();
}
}
async function submitPassword() {
const password = passwordInput.value.trim();
if (!password) {
return;
}
submitBtn.disabled = true;
btnText.textContent = '验证中...';
loadingSpinner.style.display = 'inline-block';
errorMessage.style.display = 'none';
passwordInput.classList.remove('error');
try {
const response = await fetch(window.location.pathname + '/verify-password', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'password=' + encodeURIComponent(password),
});
const result = await response.json();
if (result.success) {
window.location.reload();
} else {
errorMessage.textContent = result.message;
errorMessage.style.display = 'block';
passwordInput.classList.add('error');
passwordInput.value = '';
passwordInput.focus();
}
} catch (error) {
errorMessage.textContent = '验证失败,请重试';
errorMessage.style.display = 'block';
} finally {
submitBtn.disabled = false;
btnText.textContent = '验证访问';
loadingSpinner.style.display = 'none';
}
}
passwordInput.focus();
</script>
</body>
</html>{{ end }}
+847
View File
@@ -0,0 +1,847 @@
{{ define "web/portfolio" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>项目作品集 - {{.Resume.BasicInfo.Name}}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.8.0/styles/github.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f7fa;
min-height: 100vh;
line-height: 1.65;
}
.nav-bar {
background: white;
border-bottom: 0.06rem solid #e8ecef;
padding: 0.88rem 1.25rem;
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 0.06rem 0.19rem rgba(0,0,0,0.04);
}
.nav-bar .nav-content {
max-width: 75rem;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-bar .back-link {
color: #3498db;
text-decoration: none;
font-size: 0.95em;
display: flex;
align-items: center;
gap: 0.38rem;
transition: all 0.3s ease;
}
.nav-bar .back-link:hover {
color: #2980b9;
gap: 0.62rem;
}
.nav-bar .nav-title {
font-size: 1.15em;
font-weight: 600;
color: #2c3e50;
display: flex;
align-items: center;
gap: 0.50rem;
}
.nav-bar .nav-title i {
color: #3498db;
}
.search-bar {
background: white;
border-bottom: 0.06rem solid #e8ecef;
padding: 0.75rem 1.25rem;
position: sticky;
top: 3.75rem;
z-index: 999;
box-shadow: 0 0.06rem 0.19rem rgba(0,0,0,0.04);
}
.search-bar .search-content {
max-width: 75rem;
margin: 0 auto;
display: flex;
gap: 0.94rem;
align-items: center;
flex-wrap: wrap;
}
.search-input-wrapper {
flex: 1;
min-width: 15.62rem;
max-width: 25rem;
position: relative;
display: flex;
align-items: center;
}
.search-input-wrapper i {
position: absolute;
left: 0.75rem;
color: #999;
font-size: 0.9em;
}
.search-input-wrapper input {
width: 100%;
padding: 0.62rem 2.50rem 0.62rem 2.19rem;
border: 0.06rem solid #e8ecef;
border-radius: 0.38rem;
font-size: 0.95em;
transition: all 0.2s;
font-family: inherit;
}
.search-input-wrapper input:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 0.19rem rgba(52,152,219,0.1);
}
.clear-btn {
position: absolute;
right: 0.75rem;
background: none;
border: none;
color: #999;
cursor: pointer;
padding: 0.25rem;
transition: color 0.2s;
}
.clear-btn:hover {
color: #333;
}
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: 0.50rem;
}
.filter-tag {
padding: 0.38rem 0.75rem;
background: #f0f4f8;
color: #555;
border-radius: 0.25rem;
font-size: 0.85em;
cursor: pointer;
transition: all 0.2s;
border: 0.06rem solid #e8ecef;
display: flex;
align-items: center;
gap: 0.38rem;
}
.filter-tag:hover {
background: #e8f4fd;
color: #3498db;
border-color: #d5eaf9;
}
.filter-tag.active {
background: #3498db;
color: white;
border-color: #3498db;
}
.portfolio-container {
max-width: 75rem;
margin: 0 auto;
display: flex;
min-height: calc(100vh - 3.75rem);
}
.project-sidebar {
width: 17.50rem;
background: white;
border-right: 0.06rem solid #e8ecef;
padding: 1.50rem 0;
position: sticky;
top: 3.75rem;
height: calc(100vh - 3.75rem);
overflow-y: auto;
}
.project-sidebar .sidebar-header {
padding: 0 1.25rem 1rem;
border-bottom: 0.06rem solid #f0f2f5;
margin-bottom: 0.75rem;
}
.project-sidebar .sidebar-header h3 {
font-size: 0.88em;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.03rem;
}
.project-sidebar .project-list {
list-style: none;
padding: 0;
}
.project-sidebar .project-list li {
margin-bottom: 0.25rem;
}
.project-sidebar .project-list li a {
display: flex;
align-items: center;
padding: 0.75rem 1.25rem;
color: #444;
text-decoration: none;
transition: all 0.25s ease;
border-left: 0.19rem solid transparent;
gap: 0.62rem;
}
.project-sidebar .project-list li a:hover {
background: #f8fbfe;
color: #3498db;
}
.project-sidebar .project-list li a.active {
background: #e8f4fd;
color: #3498db;
border-left-color: #3498db;
}
.project-sidebar .project-list li a i {
font-size: 0.85em;
flex-shrink: 0;
}
.project-sidebar .project-list li a .project-name {
font-size: 0.92em;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-sidebar .project-list li a .project-date {
font-size: 0.78em;
color: #999;
margin-top: 0.12rem;
}
.project-main {
flex: 1;
background: white;
padding: 2rem;
overflow-y: auto;
}
.project-main .project-header {
margin-bottom: 2rem;
padding-bottom: 1.25rem;
border-bottom: 0.12rem solid #3498db;
}
.project-main .project-header h1 {
font-size: 1.7em;
font-weight: 700;
color: #2c3e50;
margin-bottom: 0.50rem;
}
.project-main .project-header .project-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.92em;
color: #666;
}
.project-main .project-header .project-meta span {
display: flex;
align-items: center;
gap: 0.38rem;
}
.project-main .project-header .project-meta i {
color: #3498db;
font-size: 0.85em;
}
.project-main .project-header .tech-stack {
display: flex;
flex-wrap: wrap;
gap: 0.38rem;
margin-top: 0.75rem;
}
.project-main .project-header .tech-tag {
padding: 0.25rem 0.62rem;
background: #e8f4fd;
color: #3498db;
border-radius: 0.19rem;
font-size: 0.82em;
border: 0.06rem solid #d5eaf9;
}
.project-main .doc-content {
color: #333;
font-size: 0.98em;
}
.doc-content h2 {
font-size: 1.4em;
font-weight: 600;
color: #2c3e50;
margin: 1.75rem 0 1rem;
padding-bottom: 0.62rem;
border-bottom: 0.06rem solid #e8ecef;
}
.doc-content h3 {
font-size: 1.2em;
font-weight: 600;
color: #34495e;
margin: 1.38rem 0 0.75rem;
}
.doc-content h4 {
font-size: 1.05em;
font-weight: 600;
color: #4a5568;
margin: 1.12rem 0 0.62rem;
}
.doc-content p {
margin-bottom: 0.88rem;
line-height: 1.75;
color: #444;
}
.doc-content ul, .doc-content ol {
margin-bottom: 0.88rem;
padding-left: 1.50rem;
}
.doc-content li {
margin-bottom: 0.38rem;
color: #444;
}
.doc-content blockquote {
border-left: 0.19rem solid #3498db;
padding: 0.88rem 1.25rem;
margin: 1.12rem 0;
background: #f8fbfe;
color: #555;
border-radius: 0 0.25rem 0.25rem 0;
font-style: italic;
}
.doc-content pre {
background: #2d2d2d;
color: #ccc;
padding: 1.12rem 1.38rem;
border-radius: 0.38rem;
overflow-x: auto;
margin: 1.12rem 0;
font-size: 0.88em;
line-height: 1.6;
}
.doc-content pre code {
background: none;
padding: 0;
color: inherit;
}
.doc-content code {
background: #f4f6f8;
padding: 0.12rem 0.38rem;
border-radius: 0.19rem;
font-size: 0.9em;
color: #e74c3c;
font-family: 'Fira Code', 'Monaco', monospace;
}
.doc-content table {
width: 100%;
border-collapse: collapse;
margin: 1.12rem 0;
font-size: 0.92em;
}
.doc-content th, .doc-content td {
border: 0.06rem solid #e8ecef;
padding: 0.75rem 1rem;
text-align: left;
}
.doc-content th {
background: #f8fafc;
font-weight: 600;
color: #2c3e50;
}
.doc-content tr:nth-child(even) {
background: #fafbfc;
}
.doc-content img {
max-width: 100%;
border-radius: 0.38rem;
margin: 0.88rem 0;
box-shadow: 0 0.12rem 0.62rem rgba(0,0,0,0.08);
}
.doc-content a {
color: #3498db;
text-decoration: none;
position: relative;
}
.doc-content a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.06rem;
left: 0;
background: #3498db;
transition: width 0.3s ease;
}
.doc-content a:hover::after {
width: 100%;
}
.doc-content hr {
border: none;
border-top: 0.06rem solid #e8ecef;
margin: 1.75rem 0;
}
.mermaid {
margin: 1.12rem 0;
text-align: center;
}
.mermaid svg {
max-width: 100%;
height: auto;
}
.no-doc {
text-align: center;
padding: 5rem 1.25rem;
color: #888;
}
.no-doc i {
font-size: 4em;
margin-bottom: 1.25rem;
opacity: 0.4;
color: #3498db;
}
.no-doc h3 {
font-size: 1.2em;
font-weight: 500;
margin-bottom: 0.50rem;
color: #555;
}
.no-doc p {
font-size: 0.95em;
color: #999;
}
@media (max-width: 56.25rem) {
.portfolio-container {
flex-direction: column;
}
.project-sidebar {
width: 100%;
border-right: none;
border-bottom: 0.06rem solid #e8ecef;
position: relative;
top: auto;
height: auto;
max-height: 18.75rem;
}
.project-main {
padding: 1.50rem 1.25rem;
}
}
@media (max-width: 48rem) {
.nav-bar {
padding: 0.75rem 0.94rem;
}
.nav-bar .nav-title {
font-size: 1em;
}
.project-sidebar {
padding: 1.12rem 0;
}
.project-sidebar .sidebar-header {
padding: 0 0.94rem 0.75rem;
}
.project-sidebar .project-list li a {
padding: 0.62rem 0.94rem;
}
.project-main {
padding: 1.25rem 0.94rem;
}
.project-main .project-header h1 {
font-size: 1.4em;
}
.project-main .project-header .project-meta {
gap: 0.62rem;
font-size: 0.88em;
}
.doc-content h2 {
font-size: 1.2em;
}
.doc-content h3 {
font-size: 1.05em;
}
.doc-content pre {
padding: 0.75rem;
font-size: 0.8em;
}
}
@media print {
body { background: white; }
.nav-bar { display: none; }
.portfolio-container {
flex-direction: column;
margin: 0;
}
.project-sidebar {
display: none;
}
.project-main {
padding: 0;
box-shadow: none;
}
a { color: #3498db; text-decoration: underline; }
@page { margin: 15mm; }
}
</style>
</head>
<body>
<nav class="nav-bar">
<div class="nav-content">
<a href="/" class="back-link">
<i class="fas fa-arrow-left"></i> 返回简历
</a>
<span class="nav-title">
<i class="fas fa-folder-open"></i> 项目作品集
</span>
</div>
</nav>
<div class="search-bar">
<div class="search-content">
<div class="search-input-wrapper">
<i class="fas fa-search"></i>
<input type="text" id="searchInput" placeholder="搜索项目名称、描述或技术栈..." oninput="handleSearch()">
<button class="clear-btn" id="clearSearch" onclick="clearSearch()" style="display:none;">
<i class="fas fa-times"></i>
</button>
</div>
<div class="filter-tags" id="filterTags">
</div>
</div>
</div>
<div class="portfolio-container">
<aside class="project-sidebar">
<div class="sidebar-header">
<h3>项目列表</h3>
</div>
<ul class="project-list" id="projectList">
</ul>
</aside>
<main class="project-main" id="projectContent">
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.8.0/lib/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: false,
theme: 'default',
flowchart: { htmlLabels: false },
graph: { htmlLabels: false }
});
let allProjects = [];
let currentFilter = '';
let currentSearch = '';
function handleSearch() {
const input = document.getElementById('searchInput');
const clearBtn = document.getElementById('clearSearch');
currentSearch = input.value.toLowerCase().trim();
if (currentSearch) {
clearBtn.style.display = 'block';
} else {
clearBtn.style.display = 'none';
}
filterProjects();
}
function clearSearch() {
const input = document.getElementById('searchInput');
input.value = '';
currentSearch = '';
document.getElementById('clearSearch').style.display = 'none';
filterProjects();
}
function toggleFilter(tag) {
const filterTags = document.querySelectorAll('.filter-tag');
filterTags.forEach(el => {
if (el.dataset.tag === tag) {
el.classList.toggle('active');
if (el.classList.contains('active')) {
currentFilter = tag;
} else {
currentFilter = '';
}
} else {
el.classList.remove('active');
}
});
filterProjects();
}
function filterProjects() {
if (allProjects.length === 0) return;
let filtered = allProjects;
if (currentSearch) {
filtered = filtered.filter(p => {
const name = (p.name || '').toLowerCase();
const desc = (p.description || '').toLowerCase();
const tech = (p.tech_stack || []).join(' ').toLowerCase();
return name.includes(currentSearch) || desc.includes(currentSearch) || tech.includes(currentSearch);
});
}
if (currentFilter) {
filtered = filtered.filter(p => {
const tech = (p.tech_stack || []);
return tech.includes(currentFilter);
});
}
renderProjectList(filtered);
if (filtered.length > 0) {
renderProject(filtered[0], 0);
} else {
document.getElementById('projectContent').innerHTML = '<div class="no-doc"><i class="fas fa-search"></i><h3>未找到匹配的项目</h3><p>尝试调整搜索关键词或筛选条件</p></div>';
}
}
function renderFilterTags() {
const tagContainer = document.getElementById('filterTags');
if (!tagContainer) return;
const techSet = new Set();
allProjects.forEach(p => {
(p.tech_stack || []).forEach(t => techSet.add(t));
});
const tags = Array.from(techSet).slice(0, 6);
if (tags.length === 0) {
tagContainer.innerHTML = '';
return;
}
tagContainer.innerHTML = tags.map(tag =>
`<span class="filter-tag" data-tag="${tag}" onclick="toggleFilter('${tag}')">
<i class="fas fa-tag"></i> ${tag}
</span>`
).join('');
}
async function renderMarkdown(markdown) {
const mermaidBlocks = [];
const processed = markdown.replace(/```mermaid\s*([\s\S]*?)```/g, function(match, content) {
const id = 'mermaid-' + Date.now() + '-' + mermaidBlocks.length;
mermaidBlocks.push({ id: id, content: content.trim() });
return '<div id="' + id + '" class="mermaid"></div>';
});
const html = DOMPurify.sanitize(marked.parse(processed), {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling']
});
const container = document.createElement('div');
container.innerHTML = html;
if (typeof hljs !== 'undefined') {
container.querySelectorAll('pre code').forEach(function(block) {
hljs.highlightElement(block);
});
}
await Promise.all(mermaidBlocks.map(async function(block) {
const el = container.querySelector('#' + block.id);
if (el) {
try {
const result = await mermaid.render(block.id, block.content);
el.innerHTML = result.svg;
} catch (e) {
el.innerHTML = '<div style="color: #e74c3c; padding: 0.62rem; background: #fef5f5; border-radius: 0.25rem;">' + e.message + '</div>';
}
}
}));
return container.innerHTML;
}
async function loadProjects() {
const response = await fetch('/api/portfolio');
const projects = await response.json();
allProjects = projects;
renderFilterTags();
renderProjectList(projects);
if (projects.length > 0) {
renderProject(projects[0], 0);
} else {
document.getElementById('projectContent').innerHTML = '<div class="no-doc"><i class="fas fa-folder-open"></i><h3>暂无项目</h3><p>还没有添加任何作品集项目</p></div>';
}
}
function renderProjectList(projects) {
const list = document.getElementById('projectList');
list.innerHTML = '';
projects.forEach(function(project, index) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '#';
a.onclick = function(e) {
e.preventDefault();
selectProject(index);
};
const icon = document.createElement('i');
icon.className = 'fas fa-file-code';
const info = document.createElement('div');
info.innerHTML = '<div class="project-name">' + (project.name || '未命名项目') + '</div>' + (project.start_date ? '<div class="project-date">' + project.start_date + ' - ' + (project.end_date || '至今') + '</div>' : '');
a.appendChild(icon);
a.appendChild(info);
li.appendChild(a);
list.appendChild(li);
});
}
function selectProject(index) {
const links = document.querySelectorAll('.project-list li a');
links.forEach(function(link, i) {
if (i === index) {
link.classList.add('active');
} else {
link.classList.remove('active');
}
});
let projects = allProjects;
if (currentSearch || currentFilter) {
projects = projects.filter(p => {
const matchSearch = !currentSearch ||
(p.name || '').toLowerCase().includes(currentSearch) ||
(p.description || '').toLowerCase().includes(currentSearch) ||
(p.tech_stack || []).join(' ').toLowerCase().includes(currentSearch);
const matchFilter = !currentFilter || (p.tech_stack || []).includes(currentFilter);
return matchSearch && matchFilter;
});
}
if (projects[index]) {
renderProject(projects[index], index);
}
}
async function renderProject(project, index) {
const content = document.getElementById('projectContent');
content.innerHTML = '<div style="text-align:center;padding:2.50rem;"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
const header = '<div class="project-header">' +
'<h1>' + (project.name || '未命名项目') + '</h1>' +
'<div class="project-meta">' +
(project.start_date ? '<span><i class="fas fa-calendar"></i> ' + project.start_date + ' - ' + (project.end_date || '至今') + '</span>' : '') +
(project.url ? '<span><i class="fas fa-external-link-alt"></i> <a href="' + project.url + '" target="_blank">项目链接</a></span>' : '') +
'</div>' +
(project.tech_stack && project.tech_stack.length ? '<div class="tech-stack">' + project.tech_stack.map(function(t) { return '<span class="tech-tag">' + t + '</span>'; }).join('') + '</div>' : '') +
'</div>';
var docHtml = '';
if (project.doc && project.doc.trim()) {
docHtml = '<div class="doc-content" id="doc-' + index + '"></div>';
} else {
docHtml = '<div class="no-doc"><i class="fas fa-file"></i><h3>该项目暂无详细文档</h3><p>点击左侧项目切换,或在后台添加项目文档</p></div>';
}
content.innerHTML = header + docHtml;
if (project.doc && project.doc.trim()) {
const docDiv = document.getElementById('doc-' + index);
if (docDiv) {
docDiv.innerHTML = await renderMarkdown(project.doc);
}
}
}
document.addEventListener('DOMContentLoaded', loadProjects);
</script>
</body>
</html>
{{ end }}
+321
View File
@@ -0,0 +1,321 @@
{{ define "web/professional" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--bg-primary: #f2f2f2;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--bg-accent: #1a252f;
--bg-accent-light: #243447;
--border-color: #d8d8d8;
--text-primary: #1a1a1a;
--text-secondary: #4a4a4a;
--text-muted: #808080;
--accent-color: #2c3e50;
--accent-gold: #c9a227;
--accent-blue: #3498db;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
a { text-decoration: none; color: unset; }
body {
font-family: 'Georgia', 'Times New Roman', 'SimSun', serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
position: relative;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 150 150' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.05' numOctaves='4'/%3E%3CfeDiffuseLighting in='noise' lighting-color='%23ffffff' surfaceScale='2'%3E%3CfeDistantLight azimuth='45' elevation='60'/%3E%3C/feDiffuseLighting%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
opacity: 0.03;
pointer-events: none;
z-index: 0;
}
::selection { background: rgba(44, 62, 80, 0.2); }
.theme-switch { position: fixed; top: 1.25rem; right: 1.25rem; z-index: 1000; }
.theme-switch button {
background: white;
border: 0.06rem solid var(--border-color);
color: var(--text-primary);
padding: 0.44rem 0.88rem;
border-radius: 0.19rem;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 0.50rem;
}
.theme-switch button:hover {
background: var(--accent-color);
color: white;
border-color: var(--accent-color);
transform: translateY(-0.06rem);
}
.resume-container {
max-width: 48.75rem;
margin: 3.12rem auto;
background: var(--bg-secondary);
box-shadow: 0 0.12rem 0.75rem rgba(0,0,0,0.08);
overflow: hidden;
position: relative;
z-index: 1;
}
header.resume-header {
background: var(--bg-accent);
padding: 2.50rem;
color: white;
text-align: center;
}
header.resume-header .avatar img {
width: 7.81rem;
height: 7.81rem;
border-radius: 50%;
border: 0.25rem solid rgba(255,255,255,0.25);
object-fit: cover;
}
header.resume-header .basic-info { display: flex; flex-direction: column; gap: 0.88rem; align-items: center; }
header.resume-header .basic-info .info-header { text-align: center; }
header.resume-header .basic-info .name { font-size: 2.2em; font-weight: 700; margin-bottom: 0.50rem; letter-spacing: 0.12rem; line-height: 1.2; }
header.resume-header .basic-info .title { font-size: 1em; opacity: 0.88; font-weight: 300; letter-spacing: 0.06rem; }
.contact-row {
display: flex;
flex-wrap: nowrap;
gap: 0.62rem;
font-size: 0.84em;
opacity: 0.85;
}
.contact-tag {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.31rem;
padding: 0.31rem 0.88rem;
background: rgba(255,255,255,0.08);
border-radius: 0.19rem;
white-space: nowrap;
transition: all 0.3s ease;
border: 0.06rem solid rgba(255,255,255,0.08);
}
.contact-tag:hover {
background: rgba(255,255,255,0.12);
border-color: rgba(255,255,255,0.15);
}
.contact-tag i { width: 0.81rem; text-align: center; opacity: 0.7; }
.contact-tag a { color: white; text-decoration: none; position: relative; }
.contact-tag a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.06rem;
left: 0;
background: white;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.contact-tag a:hover::after { width: 100%; }
.contact-tag a:hover { opacity: 0.8; }
.meta-row {
display: flex;
flex-wrap: nowrap;
gap: 0.50rem;
font-size: 0.8em;
opacity: 0.75;
}
.meta-tag {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.25rem 0.62rem;
background: rgba(255,255,255,0.04);
border-radius: 0.12rem;
white-space: nowrap;
}
.meta-tag i { width: 0.75rem; text-align: center; opacity: 0.5; }
main.resume-body { padding: 2.62rem; }
.resume-body-inner { display: flex; gap: 2.62rem; }
aside.sidebar { width: 35%; }
section.main-content { width: 65%; }
section { margin-bottom: 2.19rem; }
section:last-child { margin-bottom: 0; }
.section-title {
font-size: 0.95em;
font-weight: 600;
color: var(--bg-accent);
margin-bottom: 1rem;
padding-bottom: 0.50rem;
border-bottom: 0.12rem solid var(--bg-accent);
display: flex;
align-items: center;
gap: 0.50rem;
text-transform: uppercase;
letter-spacing: 0.06rem;
}
.section-title i { font-size: 0.85em; }
aside.sidebar .section-title { color: var(--accent-color); border-color: var(--accent-color); }
.section-content { display: flex; flex-direction: column; gap: 1rem; }
aside.sidebar .section-content { gap: 0.88rem; }
article.item { padding-bottom: 1rem; border-bottom: 0.06rem solid var(--border-color); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
article.item:last-child { border-bottom: none; padding-bottom: 0; }
article.item:hover { background: #fafafa; padding-left: 0.50rem; margin-left: -0.50rem; margin-right: -0.50rem; padding-right: 0.50rem; }
.item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.31rem; }
.item-header .company, .item-header .school, .item-header .project-name { font-weight: 600; color: var(--text-primary); font-size: 0.98em; }
.item-header .period { color: var(--text-muted); font-size: 0.82em; }
.item-header a { color: var(--accent-blue); font-size: 0.95em; position: relative; }
.item-header a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.06rem;
left: 0;
background: var(--accent-blue);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.item-header a:hover::after { width: 100%; }
.position { color: var(--accent-color); font-weight: 500; margin-bottom: 0.50rem; }
.degree { color: var(--text-secondary); margin-bottom: 0.31rem; }
.gpa { color: var(--text-muted); font-size: 0.82em; }
.description { color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.62rem; font-size: 0.88em; }
.highlights { list-style: none; padding-left: 0; }
.highlights li { color: var(--text-secondary); padding: 0.25rem 0; font-size: 0.88em; display: flex; align-items: flex-start; gap: 0.50rem; line-height: 1.6; }
.highlights li i { color: var(--accent-gold); font-size: 0.78em; margin-top: 0.25rem; }
.skill-items { display: flex; flex-wrap: wrap; gap: 0.44rem; }
.skill-tag {
padding: 0.31rem 0.62rem;
background: rgba(44,62,80,0.06);
border: 0.06rem solid rgba(44,62,80,0.15);
border-radius: 0.12rem;
font-size: 0.82em;
color: var(--accent-color);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.skill-tag:hover {
background: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
.tech-stack { display: flex; flex-wrap: wrap; gap: 0.31rem; margin-top: 0.62rem; }
.tech-tag { padding: 0.19rem 0.56rem; background: var(--bg-accent); color: white; border-radius: 0.12rem; font-size: 0.78em; }
.platforms { display: flex; flex-wrap: wrap; gap: 0.31rem; margin-top: 0.50rem; }
.platform-tag { padding: 0.12rem 0.50rem; background: rgba(52,152,219,0.1); color: var(--accent-blue); border-radius: 0.12rem; font-size: 0.76em; border: 0.06rem solid rgba(52,152,219,0.2); }
.sidebar-summary { color: var(--text-secondary); font-size: 0.88em; line-height: 1.65; }
.advantage-list { list-style: none; padding-left: 0; }
.advantage-list li {
color: var(--text-secondary);
padding: 0.38rem 0;
font-size: 0.88em;
display: flex;
align-items: flex-start;
gap: 0.50rem;
line-height: 1.65;
}
.advantage-list li i { color: var(--accent-color); font-size: 0.82em; margin-top: 0.12rem; }
@media (max-width: 48rem) {
.resume-body-inner { flex-direction: column; gap: 1.88rem; }
aside.sidebar { width: 100%; }
section.main-content { width: 100%; }
.resume-container { margin: 1.25rem; }
header.resume-header { padding: 1.88rem 1.38rem; }
main.resume-body { padding: 1.88rem 1.38rem; }
.contact-line { gap: 0.75rem; }
.item-header { flex-direction: column; gap: 0.31rem; }
article.item:hover { margin: 0; padding: 0; padding-bottom: 1rem; }
}
@media print {
body { background: white; }
body::before { display: none; }
.theme-switch { display: none; }
.resume-container {
max-width: 100%;
margin: 0;
box-shadow: none;
}
header.resume-header {
background: #1a252f;
color: white;
-webkit-print-color-adjust: exact;
}
aside.sidebar {
background: #f7f7f7;
padding-right: 1.25rem;
border-right: 0.06rem solid #ddd;
}
article.item:hover {
background: transparent;
margin: 0;
padding: 0;
padding-bottom: 1rem;
}
a { color: var(--accent-blue); text-decoration: underline; }
@page { margin: 12mm; }
}
</style>
</head>
<body>
{{ template "theme-switch" . }}
<div class="resume-container">
<header class="resume-header">
{{ template "component-avatar" .BasicInfo }}
{{ template "component-basic-info" .BasicInfo }}
</header>
<main class="resume-body">
<div class="resume-body-inner">
<aside class="sidebar">
{{ template "section-summary" . }}
{{ template "section-skills" . }}
{{ template "section-education" . }}
</aside>
<section class="main-content">
{{ template "section-core-advantages" . }}
{{ template "section-experience" . }}
{{ template "section-projects" . }}
</section>
</div>
</main>
</div>
{{ template "theme-script" . }}
<script src="/static/js/security.js"></script>
</body>
</html>
{{ end }}
+545
View File
@@ -0,0 +1,545 @@
{{ define "web/project" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Project.Name}} - {{.Resume.BasicInfo.Name}}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.8.0/styles/github.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
a { text-decoration: none; color: unset; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f7fa;
min-height: 100vh;
line-height: 1.65;
}
.nav-bar {
background: white;
border-bottom: 0.06rem solid #e8ecef;
padding: 0.88rem 1.25rem;
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 0.06rem 0.19rem rgba(0,0,0,0.04);
}
.nav-bar .nav-content {
max-width: 62.50rem;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-bar .back-link {
color: #3498db;
text-decoration: none;
font-size: 0.95em;
display: flex;
align-items: center;
gap: 0.38rem;
transition: all 0.3s ease;
}
.nav-bar .back-link:hover {
color: #2980b9;
gap: 0.62rem;
}
.nav-bar .nav-title {
font-size: 1.1em;
font-weight: 600;
color: #2c3e50;
}
.project-container {
max-width: 62.50rem;
margin: 2.19rem auto;
padding: 0 1.25rem;
display: grid;
grid-template-columns: 17.50rem 1fr;
gap: 1.75rem;
}
.project-sidebar {
background: white;
border-radius: 0.38rem;
padding: 1.38rem;
box-shadow: 0 0.12rem 0.50rem rgba(0,0,0,0.04);
position: sticky;
top: 5rem;
height: fit-content;
}
.project-sidebar .sidebar-title {
font-size: 0.88em;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.03rem;
margin-bottom: 0.88rem;
padding-bottom: 0.50rem;
border-bottom: 0.06rem solid #f0f2f5;
}
.project-sidebar .project-name {
font-size: 1.2em;
font-weight: 700;
color: #2c3e50;
margin-bottom: 0.75rem;
line-height: 1.4;
}
.project-sidebar .meta-item {
display: flex;
align-items: flex-start;
gap: 0.50rem;
margin-bottom: 0.75rem;
font-size: 0.92em;
color: #555;
}
.project-sidebar .meta-item i {
color: #3498db;
font-size: 0.85em;
margin-top: 0.12rem;
flex-shrink: 0;
}
.project-sidebar .meta-item .meta-label {
color: #888;
font-size: 0.85em;
margin-right: 0.38rem;
}
.project-sidebar .tech-stack {
display: flex;
flex-wrap: wrap;
gap: 0.38rem;
margin-top: 0.50rem;
}
.project-sidebar .tech-tag {
padding: 0.25rem 0.62rem;
background: #e8f4fd;
color: #3498db;
border-radius: 0.19rem;
font-size: 0.82em;
border: 0.06rem solid #d5eaf9;
}
.project-sidebar .links {
margin-top: 1.12rem;
padding-top: 1.12rem;
border-top: 0.06rem solid #f0f2f5;
}
.project-sidebar .links a {
display: flex;
align-items: center;
gap: 0.50rem;
color: #3498db;
text-decoration: none;
font-size: 0.92em;
padding: 0.50rem 0;
transition: all 0.3s ease;
border-radius: 0.25rem;
padding-left: 0.62rem;
}
.project-sidebar .links a:hover {
background: #f0f7ff;
padding-left: 1rem;
}
.project-main {
background: white;
border-radius: 0.38rem;
padding: 2rem;
box-shadow: 0 0.12rem 0.50rem rgba(0,0,0,0.04);
}
.project-main .doc-content {
color: #333;
font-size: 0.98em;
}
.doc-content h1 {
font-size: 1.7em;
font-weight: 700;
color: #2c3e50;
margin-bottom: 1.12rem;
padding-bottom: 0.75rem;
border-bottom: 0.12rem solid #3498db;
}
.doc-content h2 {
font-size: 1.4em;
font-weight: 600;
color: #2c3e50;
margin: 1.50rem 0 0.88rem;
padding-bottom: 0.50rem;
border-bottom: 0.06rem solid #e8ecef;
}
.doc-content h3 {
font-size: 1.15em;
font-weight: 600;
color: #34495e;
margin: 1.25rem 0 0.75rem;
}
.doc-content p {
margin-bottom: 0.88rem;
line-height: 1.75;
color: #444;
}
.doc-content ul, .doc-content ol {
margin-bottom: 0.88rem;
padding-left: 1.50rem;
}
.doc-content li {
margin-bottom: 0.38rem;
color: #444;
}
.doc-content blockquote {
border-left: 0.19rem solid #3498db;
padding: 0.75rem 1.12rem;
margin: 1rem 0;
background: #f8fbfe;
color: #555;
border-radius: 0 0.25rem 0.25rem 0;
font-style: italic;
}
.doc-content pre {
background: #2d2d2d;
color: #ccc;
padding: 1rem 1.25rem;
border-radius: 0.38rem;
overflow-x: auto;
margin: 1rem 0;
font-size: 0.88em;
line-height: 1.6;
}
.doc-content pre code {
background: none;
padding: 0;
color: inherit;
}
.doc-content code {
background: #f4f6f8;
padding: 0.12rem 0.38rem;
border-radius: 0.19rem;
font-size: 0.9em;
color: #e74c3c;
font-family: 'Fira Code', 'Monaco', monospace;
}
.doc-content table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
font-size: 0.92em;
}
.doc-content th, .doc-content td {
border: 0.06rem solid #e8ecef;
padding: 0.62rem 0.88rem;
text-align: left;
}
.doc-content th {
background: #f8fafc;
font-weight: 600;
color: #2c3e50;
}
.doc-content tr:nth-child(even) {
background: #fafbfc;
}
.doc-content img {
max-width: 100%;
border-radius: 0.38rem;
margin: 0.75rem 0;
box-shadow: 0 0.12rem 0.50rem rgba(0,0,0,0.08);
}
.doc-content a {
color: #3498db;
text-decoration: none;
position: relative;
}
.doc-content a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.06rem;
left: 0;
background: #3498db;
transition: width 0.3s ease;
}
.doc-content a:hover::after {
width: 100%;
}
.doc-content hr {
border: none;
border-top: 0.06rem solid #e8ecef;
margin: 1.50rem 0;
}
.mermaid-placeholder {
background: #fafbfc;
border: 0.06rem dashed #dde3e8;
border-radius: 0.38rem;
padding: 1.25rem;
margin: 1rem 0;
text-align: center;
color: #888;
}
.mermaid {
margin: 1rem 0;
text-align: center;
}
.mermaid svg {
max-width: 100%;
height: auto;
}
.no-doc {
text-align: center;
padding: 3.75rem 1.25rem;
color: #888;
}
.no-doc i {
font-size: 3em;
margin-bottom: 1rem;
opacity: 0.5;
}
.no-doc p {
font-size: 1em;
}
@media (max-width: 56.25rem) {
.project-container {
grid-template-columns: 1fr;
}
.project-sidebar {
position: static;
order: 2;
}
.project-main {
order: 1;
}
}
@media (max-width: 48rem) {
.nav-bar {
padding: 0.75rem 0.94rem;
}
.nav-bar .nav-title {
font-size: 1em;
}
.project-container {
margin: 1.25rem auto;
padding: 0 0.94rem;
}
.project-sidebar {
padding: 1.12rem;
}
.project-main {
padding: 1.38rem;
}
.doc-content h1 {
font-size: 1.4em;
}
.doc-content h2 {
font-size: 1.2em;
}
.doc-content h3 {
font-size: 1.05em;
}
.doc-content pre {
padding: 0.75rem;
font-size: 0.8em;
}
}
@media print {
body { background: white; }
.nav-bar { display: none; }
.project-container {
grid-template-columns: 1fr;
margin: 0;
padding: 0;
}
.project-sidebar {
position: static;
box-shadow: none;
border: 0.06rem solid #ddd;
}
.project-main {
box-shadow: none;
border: 0.06rem solid #ddd;
border-top: none;
}
a { color: #3498db; text-decoration: underline; }
@page { margin: 15mm; }
}
</style>
</head>
<body>
<nav class="nav-bar">
<div class="nav-content">
<a href="/" class="back-link">
<i class="fas fa-arrow-left"></i> 返回简历
</a>
<span class="nav-title">{{.Project.Name}}</span>
</div>
</nav>
<div class="project-container">
<aside class="project-sidebar">
<div class="project-name">{{.Project.Name}}</div>
<div class="sidebar-title">项目信息</div>
<div class="meta-item">
<i class="fas fa-calendar"></i>
<span>{{.Project.StartDate}} - {{.Project.EndDate}}</span>
</div>
<div class="meta-item">
<i class="fas fa-tags"></i>
<div class="tech-stack">
{{ range .Project.TechStack }}
<span class="tech-tag">{{.}}</span>
{{ end }}
</div>
</div>
{{ if .Project.Description }}
<div class="meta-item">
<i class="fas fa-file-alt"></i>
<span>{{.Project.Description}}</span>
</div>
{{ end }}
<div class="links">
{{ if .Project.URL }}
<a href="{{.Project.URL}}" target="_blank">
<i class="fas fa-external-link-alt"></i> 项目链接
</a>
{{ end }}
{{ if .Project.Doc }}
<a href="#doc-content">
<i class="fas fa-scroll"></i> 项目文档
</a>
{{ end }}
</div>
</aside>
<main class="project-main">
<div id="doc-content" class="doc-content">
{{ if .Project.Doc }}
<div id="markdown-output" data-markdown="{{ .Project.Doc }}"></div>
{{ else }}
<div class="no-doc">
<i class="fas fa-file"></i>
<p>该项目暂无详细文档</p>
</div>
{{ end }}
</div>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.8.0/lib/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: false,
theme: 'default',
flowchart: { htmlLabels: false },
graph: { htmlLabels: false }
});
async function renderMarkdown(markdown) {
const mermaidBlocks = [];
const processed = markdown.replace(/```mermaid\s*([\s\S]*?)```/g, (match, content) => {
const id = 'mermaid-' + Date.now() + '-' + mermaidBlocks.length;
mermaidBlocks.push({ id, content: content.trim() });
return `<div id="${id}" class="mermaid"></div>`;
});
const html = DOMPurify.sanitize(marked.parse(processed), {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling']
});
const container = document.createElement('div');
container.innerHTML = html;
if (typeof hljs !== 'undefined') {
container.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
}
await Promise.all(mermaidBlocks.map(async (block) => {
const el = container.querySelector('#' + block.id);
if (el) {
try {
const { svg } = await mermaid.render(block.id, block.content);
el.innerHTML = svg;
} catch (e) {
el.innerHTML = '<div style="color: #e74c3c; padding: 0.62rem; background: #fef5f5; border-radius: 0.25rem;">' + e.message + '</div>';
}
}
}));
return container.innerHTML;
}
document.addEventListener('DOMContentLoaded', async () => {
const output = document.getElementById('markdown-output');
if (output) {
const markdown = output.getAttribute('data-markdown');
if (markdown && markdown.trim()) {
output.innerHTML = await renderMarkdown(markdown);
}
}
});
</script>
</body>
</html>
{{ end }}
+345
View File
@@ -0,0 +1,345 @@
{{ define "web/sidebar" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</title>
<meta name="description" content="{{.BasicInfo.Summary}}" />
<meta name="keywords" content="{{.BasicInfo.Title}}, {{.BasicInfo.Name}}, 简历, 作品集" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--sidebar-bg: #2c3e50;
--sidebar-text: #ecf0f1;
--sidebar-muted: #95a5a6;
--accent: #3498db;
--accent-light: #5dade2;
--text-primary: #2c3e50;
--text-secondary: #555;
--text-muted: #888;
--bg-main: #f5f7fa;
--bg-card: #ffffff;
--border-color: #e1e8ed;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
a { text-decoration: none; color: inherit; }
ul { list-style: none; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg-main);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.resume-container {
max-width: 68.75rem;
margin: 2.50rem auto;
display: grid;
grid-template-columns: 18.75rem 1fr;
box-shadow: 0 0.25rem 2rem rgba(0,0,0,0.08);
border-radius: 0.75rem;
overflow: hidden;
min-height: calc(100vh - 5rem);
}
.resume-header {
background: var(--sidebar-bg);
color: var(--sidebar-text);
padding: 2.50rem 1.75rem;
}
.avatar-section {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.75rem;
border-bottom: 0.06rem solid rgba(255,255,255,0.1);
}
.avatar {
width: 7.50rem;
height: 7.50rem;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent-light));
margin: 0 auto 1rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.62rem;
color: white;
font-weight: 300;
border: 0.25rem solid rgba(255,255,255,0.15);
}
.sidebar-name {
font-size: 1.38rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.sidebar-title {
font-size: 0.88rem;
color: var(--sidebar-muted);
letter-spacing: 0.06rem;
}
.resume-header .contact-row {
display: flex;
flex-direction: column;
gap: 0.62rem;
margin-top: 1.25rem;
}
.resume-header .contact-tag {
display: flex;
align-items: center;
gap: 0.62rem;
font-size: 0.81rem;
color: var(--sidebar-text);
}
.resume-header .contact-tag i {
width: 1rem;
text-align: center;
color: var(--accent-light);
font-size: 0.75rem;
}
.resume-header .contact-tag a {
color: var(--accent-light);
}
.resume-header .meta-row {
display: flex;
flex-direction: column;
gap: 0.50rem;
margin-top: 1rem;
}
.resume-header .meta-tag {
display: flex;
align-items: center;
gap: 0.50rem;
font-size: 0.75rem;
color: var(--sidebar-muted);
}
.resume-header .summary {
margin-top: 1.25rem;
padding: 1rem;
background: rgba(255,255,255,0.05);
border-radius: 0.38rem;
font-size: 0.81rem;
line-height: 1.7;
color: var(--sidebar-text);
}
.resume-body {
background: var(--bg-card);
}
.resume-body-inner {
display: flex;
flex-direction: column;
}
.resume-body .sidebar {
padding: 2rem 1.75rem;
background: var(--bg-main);
border-bottom: 0.06rem solid var(--border-color);
}
.resume-body .main-content {
padding: 2.50rem 3rem;
}
.section {
margin-bottom: 2rem;
}
.section:last-child { margin-bottom: 0; }
.section-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
padding-bottom: 0.50rem;
border-bottom: 0.12rem solid var(--accent);
display: flex;
align-items: center;
gap: 0.50rem;
}
.section-title i {
color: var(--accent);
font-size: 0.88rem;
}
.section-content {
background: var(--bg-card);
border-radius: 0.38rem;
padding: 1rem;
border: 0.06rem solid var(--border-color);
}
.item {
margin-bottom: 1.25rem;
padding: 1rem;
background: var(--bg-card);
border-radius: 0.38rem;
border-left: 0.19rem solid var(--accent);
transition: all 0.3s ease;
}
.item:last-child { margin-bottom: 0; }
.item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.50rem; }
.item-header .company, .item-header .school, .item-header .project-name { font-weight: 600; color: var(--text-primary); font-size: 0.88rem; }
.item-header .period { color: var(--text-muted); font-size: 0.75rem; }
.item-header a { color: var(--accent); font-size: 0.81rem; }
.position { color: var(--accent); font-weight: 500; margin-bottom: 0.50rem; font-size: 0.81rem; }
.degree { color: var(--text-secondary); margin-bottom: 0.31rem; font-size: 0.75rem; }
.gpa { color: var(--text-muted); font-size: 0.69rem; }
.description { color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.62rem; font-size: 0.81rem; }
.highlights { list-style: none; padding-left: 0; }
.highlights li { color: var(--text-secondary); padding: 0.31rem 0; font-size: 0.81rem; display: flex; align-items: flex-start; gap: 0.50rem; }
.highlights li i { color: #27ae60; font-size: 0.75rem; margin-top: 0.19rem; }
.skill-section {
display: flex;
flex-wrap: wrap;
gap: 0.50rem;
}
.skill-category {
width: 100%;
margin-bottom: 1rem;
}
.skill-category:last-child { margin-bottom: 0; }
.skill-category-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03rem;
margin-bottom: 0.62rem;
padding-bottom: 0.38rem;
border-bottom: 0.06rem solid var(--border-color);
}
.skill-items {
display: flex;
flex-wrap: wrap;
gap: 0.38rem;
}
.skill-tag {
padding: 0.25rem 0.62rem;
background: rgba(52, 152, 219, 0.1);
border-radius: 0.25rem;
font-size: 0.75rem;
color: var(--accent);
border: 0.06rem solid rgba(52, 152, 219, 0.2);
}
.tech-stack { display: flex; flex-wrap: wrap; gap: 0.38rem; margin-top: 0.62rem; }
.tech-tag { padding: 0.19rem 0.50rem; background: var(--bg-main); color: var(--text-secondary); border-radius: 0.25rem; font-size: 0.69rem; border: 0.06rem solid var(--border-color); }
.platforms { display: flex; flex-wrap: wrap; gap: 0.38rem; margin-top: 0.50rem; }
.platform-tag { padding: 0.19rem 0.50rem; background: rgba(52, 152, 219, 0.1); color: var(--accent); border-radius: 0.19rem; font-size: 0.69rem; border: 0.06rem solid rgba(52, 152, 219, 0.2); }
.advantage-list { list-style: none; padding-left: 0; }
.advantage-list li {
color: var(--text-secondary);
padding: 0.50rem 0;
font-size: 0.81rem;
display: flex;
align-items: flex-start;
gap: 0.62rem;
line-height: 1.6;
}
.advantage-list li i { color: var(--accent); font-size: 0.81rem; margin-top: 0.12rem; }
.sidebar-summary {
color: var(--text-secondary);
font-size: 0.81rem;
line-height: 1.7;
padding: 0.75rem;
background: var(--bg-card);
border-radius: 0.38rem;
border-left: 0.19rem solid var(--accent);
}
.achievements {
margin-top: 0.62rem;
padding-top: 0.62rem;
border-top: 0.06rem dashed var(--border-color);
}
.achievements h4 {
font-size: 0.81rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.38rem;
display: flex;
align-items: center;
gap: 0.38rem;
}
.achievements h4 i {
color: var(--accent);
}
.achievements ul {
padding-left: 1.25rem;
}
.achievements li {
font-size: 0.81rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
@media (max-width: 48rem) {
.resume-container {
grid-template-columns: 1fr;
margin: 1.25rem;
min-height: auto;
}
.resume-header { padding: 2rem 1.50rem; }
.resume-body .sidebar { padding: 1.50rem; }
.resume-body .main-content { padding: 1.50rem; }
}
</style>
</head>
<body>
{{ template "theme-switch" . }}
<div class="resume-container">
<header class="resume-header">
{{ template "component-avatar" .BasicInfo }}
{{ template "component-basic-info" .BasicInfo }}
</header>
<main class="resume-body">
{{ template "section-core-advantages" . }}
{{ template "section-experience" . }}
{{ template "section-education" . }}
{{ template "section-skills" . }}
{{ template "section-projects" . }}
</main>
</div>
{{ template "theme-script" . }}
<script src="/static/js/security.js"></script>
</body>
</html>
{{ end }}
+359
View File
@@ -0,0 +1,359 @@
{{ define "web/tech" }}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--bg-card: #1c2128;
--border-color: #30363d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent-color: #58a6ff;
--accent-green: #3fb950;
--accent-orange: #d29922;
--accent-red: #f85149;
--accent-purple: #c792ea;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
position: relative;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
opacity: 0.02;
pointer-events: none;
z-index: 0;
}
::selection { background: rgba(88, 166, 255, 0.3); }
.theme-switch { position: fixed; top: 1.25rem; right: 1.25rem; z-index: 1000; }
.theme-switch button {
background: var(--bg-card);
border: 0.06rem solid var(--border-color);
color: var(--text-primary);
padding: 0.44rem 0.88rem;
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 0.50rem;
font-size: 0.85em;
}
.theme-switch button:hover {
background: var(--bg-tertiary);
border-color: var(--accent-color);
color: var(--accent-color);
transform: translateY(-0.06rem);
}
.resume-container {
max-width: 55rem;
margin: 2.50rem auto;
background: var(--bg-secondary);
border-radius: 0.38rem;
border: 0.06rem solid var(--border-color);
overflow: hidden;
position: relative;
z-index: 1;
}
header.resume-header {
background: linear-gradient(180deg, #1a1f2e 0%, #161b22 100%);
padding: 2.50rem;
border-bottom: 0.06rem solid var(--border-color);
}
.header-inner { display: flex; align-items: flex-start; gap: 1.56rem; }
header.resume-header .avatar img {
width: 7.50rem;
height: 7.50rem;
border-radius: 0.25rem;
border: 0.12rem solid var(--border-color);
object-fit: cover;
background: var(--bg-tertiary);
}
.code-block {
background: var(--bg-primary);
padding: 1.25rem;
border-radius: 0.25rem;
border: 0.06rem solid var(--border-color);
font-family: inherit;
font-size: 0.82em;
line-height: 1.7;
flex: 1;
}
.code-line { display: flex; gap: 0.88rem; margin-bottom: 0.25rem; }
.code-line:last-child { margin-bottom: 0; }
.code-number { color: var(--text-muted); user-select: none; min-width: 1.75rem; text-align: right; }
.code-keyword { color: var(--accent-purple); }
.code-string { color: var(--accent-green); }
.code-function { color: var(--accent-orange); }
.code-variable { color: var(--accent-color); }
.code-comment { color: var(--text-muted); font-style: italic; }
.typewriter {
overflow: hidden;
border-right: 0.12rem solid var(--accent-green);
animation: typing 2.5s steps(40, end), blink-caret 0.75s step-end infinite;
white-space: nowrap;
}
@keyframes typing {
from { width: 0; }
to { width: 100%; }
}
@keyframes blink-caret {
from, to { border-color: transparent; }
50% { border-color: var(--accent-green); }
}
main.resume-body { padding: 2.50rem; }
section { margin-bottom: 2.38rem; }
section:last-child { margin-bottom: 0; }
.section-title {
font-size: 0.95em;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1.12rem;
padding-bottom: 0.62rem;
border-bottom: 0.06rem solid var(--border-color);
display: flex;
align-items: center;
gap: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.06rem;
}
.section-title i { color: var(--accent-color); font-size: 0.9em; }
.section-content { background: var(--bg-primary); border-radius: 0.25rem; border: 0.06rem solid var(--border-color); padding: 1.25rem; }
article.item { padding: 0.88rem 0; border-bottom: 0.06rem dashed var(--border-color); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
article.item:last-child { border-bottom: none; }
article.item:hover { background: rgba(88, 166, 255, 0.04); margin: 0 -1.25rem; padding-left: 1.25rem; padding-right: 1.25rem; border-radius: 0.19rem; }
.item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.50rem; }
.item-header .company, .item-header .school, .item-header .project-name { font-weight: 600; color: var(--text-primary); font-size: 0.95em; }
.item-header .period { color: var(--text-muted); font-size: 0.82em; }
.item-header a { color: var(--accent-color); font-size: 0.9em; transition: color 0.3s; position: relative; }
.item-header a::after {
content: '';
position: absolute;
width: 0;
height: 0.06rem;
bottom: -0.12rem;
left: 0;
background: var(--accent-color);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.item-header a:hover::after { width: 100%; }
.item-header a:hover { color: #79c0ff; }
.position { color: var(--accent-green); font-weight: 500; margin-bottom: 0.50rem; font-size: 0.9em; }
.degree { color: var(--text-secondary); margin-bottom: 0.31rem; font-size: 0.88em; }
.gpa { color: var(--text-muted); font-size: 0.82em; }
.description { color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.62rem; font-size: 0.88em; }
.highlights { list-style: none; padding-left: 0; }
.highlights li { color: var(--text-secondary); padding: 0.31rem 0; font-size: 0.88em; display: flex; align-items: flex-start; gap: 0.62rem; line-height: 1.6; }
.highlights li i { color: var(--accent-green); font-size: 0.78em; margin-top: 0.25rem; }
.skill-items { display: flex; flex-wrap: wrap; gap: 0.50rem; }
.skill-tag {
padding: 0.31rem 0.75rem;
background: var(--bg-tertiary);
border: 0.06rem solid var(--border-color);
border-radius: 0.19rem;
font-size: 0.82em;
color: var(--text-primary);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.skill-tag:hover {
background: var(--accent-color);
border-color: var(--accent-color);
color: white;
}
.tech-stack { display: flex; flex-wrap: wrap; gap: 0.38rem; margin-top: 0.62rem; }
.tech-tag {
padding: 0.19rem 0.56rem;
background: rgba(88, 166, 255, 0.12);
color: var(--accent-color);
border-radius: 0.19rem;
font-size: 0.78em;
border: 0.06rem solid rgba(88, 166, 255, 0.25);
}
.platforms { display: flex; flex-wrap: wrap; gap: 0.38rem; margin-top: 0.50rem; }
.platform-tag {
padding: 0.12rem 0.50rem;
background: rgba(63, 185, 80, 0.1);
color: var(--accent-green);
border-radius: 0.19rem;
font-size: 0.76em;
border: 0.06rem solid rgba(63, 185, 80, 0.2);
}
.advantage-list { list-style: none; padding-left: 0; }
.advantage-list li {
color: var(--text-secondary);
padding: 0.44rem 0;
font-size: 0.88em;
display: flex;
align-items: flex-start;
gap: 0.62rem;
line-height: 1.65;
}
.advantage-list li i { color: var(--accent-color); font-size: 0.82em; margin-top: 0.12rem; }
.skill-progress { margin-bottom: 0.88rem; }
.skill-progress:last-child { margin-bottom: 0; }
.skill-progress-label { display: flex; justify-content: space-between; font-size: 0.82em; margin-bottom: 0.25rem; }
.skill-progress-label span:first-child { color: var(--text-primary); }
.skill-progress-label span:last-child { color: var(--accent-color); }
.skill-progress-bar {
height: 0.38rem;
background: var(--bg-tertiary);
border-radius: 0.19rem;
overflow: hidden;
border: 0.06rem solid var(--border-color);
}
.skill-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-color), var(--accent-green));
border-radius: 0.12rem;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(0.50rem); }
to { opacity: 1; transform: translateY(0); }
}
section { animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
@media (max-width: 48rem) {
.resume-container { margin: 0.94rem; }
header.resume-header { padding: 1.56rem; }
main.resume-body { padding: 1.56rem; }
.header-inner { flex-direction: column; text-align: center; }
.item-header { flex-direction: column; gap: 0.31rem; }
.code-block { font-size: 0.72em; }
}
@media print {
body { background: white; color: #000; }
body::before { display: none; }
.theme-switch { display: none; }
.resume-container {
max-width: 100%;
margin: 0;
border: none;
box-shadow: none;
background: white;
}
header.resume-header {
background: #1a1a1a;
color: white;
-webkit-print-color-adjust: exact;
}
.section-content { background: #f5f5f5; border: 0.06rem solid #ddd; }
article.item:hover {
background: transparent;
margin: 0;
padding: 0.88rem 0;
border-radius: 0;
}
a { color: #333; text-decoration: underline; }
.typewriter { border-right: none; }
@page { margin: 15mm; }
}
</style>
</head>
<body>
{{ template "theme-switch" . }}
<div class="resume-container">
<header class="resume-header">
<div class="header-inner">
{{ template "component-avatar" .BasicInfo }}
<div class="code-block">
<div class="code-line">
<span class="code-number">1</span>
<span><span class="code-keyword">package</span> <span class="code-function">resume</span></span>
</div>
<div class="code-line">
<span class="code-number">2</span>
</div>
<div class="code-line">
<span class="code-number">3</span>
<span><span class="code-keyword">type</span> <span class="code-function">{{.BasicInfo.Name}}</span> <span class="code-keyword">struct</span> {</span>
</div>
<div class="code-line">
<span class="code-number">4</span>
<span>&nbsp;&nbsp;Name <span class="code-keyword">string</span> = <span class="code-string">"{{.BasicInfo.Name}}"</span></span>
</div>
<div class="code-line">
<span class="code-number">5</span>
<span>&nbsp;&nbsp;Title <span class="code-keyword">string</span> = <span class="typewriter code-string">"{{.BasicInfo.Title}}"</span></span>
</div>
<div class="code-line">
<span class="code-number">6</span>
<span>&nbsp;&nbsp;Email <span class="code-keyword">string</span> = <span class="code-string">"{{.BasicInfo.Email}}"</span></span>
</div>
<div class="code-line">
<span class="code-number">7</span>
<span>&nbsp;&nbsp;Location <span class="code-keyword">string</span> = <span class="code-string">"{{.BasicInfo.Location}}"</span></span>
</div>
<div class="code-line">
<span class="code-number">8</span>
<span>}</span>
</div>
<div class="code-line">
<span class="code-number">9</span>
</div>
<div class="code-line">
<span class="code-number">10</span>
<span class="code-comment">// {{.BasicInfo.Summary}}</span>
</div>
</div>
</div>
</header>
<main class="resume-body">
{{ template "section-core-advantages" . }}
{{ template "section-experience" . }}
{{ template "section-education" . }}
{{ template "section-skills-progress" . }}
{{ template "section-projects" . }}
</main>
</div>
{{ template "theme-script" . }}
<script src="/static/js/security.js"></script>
</body>
</html>
{{ end }}