diff --git a/web/css/style.css b/web/css/style.css new file mode 100644 index 0000000..a6f8273 --- /dev/null +++ b/web/css/style.css @@ -0,0 +1,1108 @@ +/* ============================================= + NiuGuide - 仿 LeetCode 深色主题样式 + ============================================= */ + +/* ========= CSS 变量(深色主题) ========= */ +:root { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-card: #1e2748; + --bg-card-hover: #253255; + --bg-nav: #111827; + --bg-input: #1e293b; + + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + + --accent-blue: #3b82f6; + --accent-blue-hover: #2563eb; + --accent-green: #22c55e; + --accent-yellow: #f59e0b; + --accent-red: #ef4444; + --accent-purple: #8b5cf6; + + --border-color: #2d3748; + --border-radius: 8px; + --border-radius-lg: 12px; + + --shadow: 0 4px 6px -1px rgba(0,0,0,0.3); + --shadow-lg: 0 10px 25px rgba(0,0,0,0.4); + + --sidebar-width: 240px; + --header-height: 56px; + --mobile-header-height: 50px; + --mobile-tabs-height: 56px; + + --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +/* ========= 重置 & 基础 ========= */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; +} + +body { + font-family: var(--font-family); + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; +} + +a { color: var(--accent-blue); text-decoration: none; } +a:hover { color: var(--accent-blue-hover); } + +/* 滚动条 */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +/* ========= 按钮 ========= */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + font-size: 14px; + font-family: var(--font-family); + cursor: pointer; + transition: all 0.2s; + background: var(--bg-card); + color: var(--text-primary); +} +.btn:hover { background: var(--bg-card-hover); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn-primary { + background: var(--accent-blue); + color: #fff; + border-color: var(--accent-blue); +} +.btn-primary:hover { background: var(--accent-blue-hover); } + +.btn-danger { + background: var(--accent-red); + color: #fff; + border-color: var(--accent-red); +} +.btn-danger:hover { opacity: 0.9; } + +.btn-success { + background: var(--accent-green); + color: #fff; + border-color: var(--accent-green); +} + +.btn-text { + background: transparent; + border: none; + color: var(--text-secondary); + padding: 6px 10px; +} +.btn-text:hover { color: var(--text-primary); background: rgba(255,255,255,0.05); } + +.btn-sm { padding: 4px 10px; font-size: 13px; } +.btn-lg { padding: 12px 24px; font-size: 16px; } + +.btn-icon { + width: 36px; + height: 36px; + padding: 0; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* ========= 输入框 ========= */ +input, textarea, select { + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + color: var(--text-primary); + padding: 10px 14px; + font-size: 14px; + font-family: var(--font-family); + width: 100%; + transition: border-color 0.2s; + outline: none; +} +input:focus, textarea:focus, select:focus { + border-color: var(--accent-blue); +} +textarea { resize: vertical; min-height: 100px; } +select { cursor: pointer; } +select option { background: var(--bg-card); } + +/* ========= 布局 ========= */ +.app-layout { + display: flex; + min-height: 100vh; +} + +/* 侧边栏 */ +.sidebar { + width: var(--sidebar-width); + background: var(--bg-nav); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + height: 100vh; + z-index: 100; + transition: transform 0.3s; +} + +.sidebar-logo { + padding: 20px; + font-size: 20px; + font-weight: 700; + display: flex; + align-items: center; + gap: 10px; + border-bottom: 1px solid var(--border-color); +} +.sidebar-logo i { color: var(--accent-blue); } + +.sidebar-nav { + flex: 1; + overflow-y: auto; + padding: 12px 0; +} + +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 20px; + color: var(--text-secondary); + font-size: 14px; + transition: all 0.2s; + cursor: pointer; + border-left: 3px solid transparent; +} +.nav-item:hover { + color: var(--text-primary); + background: rgba(255,255,255,0.03); +} +.nav-item.active { + color: var(--accent-blue); + background: rgba(59,130,246,0.08); + border-left-color: var(--accent-blue); +} +.nav-item i { width: 20px; text-align: center; font-size: 16px; } + +.nav-divider { + height: 1px; + background: var(--border-color); + margin: 8px 16px; +} + +.sidebar-footer { + padding: 12px 16px; + border-top: 1px solid var(--border-color); +} + +/* 隐藏状态(登录页使用) */ +.hidden { display: none !important; } + +/* 登录页全宽 */ +.main-wrapper.full-width { + margin-left: 0; +} + +/* 主内容区 */ +.main-wrapper { + flex: 1; + margin-left: var(--sidebar-width); + min-height: 100vh; +} + +.main-content { + padding: 24px; + padding-top: calc(var(--header-height) + 24px); + max-width: 1200px; + width: 100%; +} + + +/* 桌面顶部栏 */ +.desktop-header { + height: var(--header-height); + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + position: fixed; + top: 0; + right: 0; + left: var(--sidebar-width); + z-index: 50; +} +.header-breadcrumb { font-size: 16px; font-weight: 500; } +.header-user { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-secondary); + font-size: 14px; +} +.header-user i { font-size: 24px; } + +/* 移动端头部 */ +.mobile-header { + display: none; + height: var(--mobile-header-height); + background: var(--bg-nav); + border-bottom: 1px solid var(--border-color); + align-items: center; + justify-content: space-between; + padding: 0 12px; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; +} +.header-title { font-weight: 600; font-size: 16px; } + +/* 移动端 Tab */ +.mobile-tabs { + display: none; + height: var(--mobile-tabs-height); + background: var(--bg-nav); + border-top: 1px solid var(--border-color); + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 100; + justify-content: space-around; + align-items: center; +} +.tab-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 6px 12px; + color: var(--text-muted); + cursor: pointer; + font-size: 11px; + transition: color 0.2s; +} +.tab-item i { font-size: 20px; } +.tab-item.active { color: var(--accent-blue); } +.tab-item:hover { color: var(--text-secondary); } + +/* ========= 卡片 ========= */ +.card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + padding: 20px; + transition: all 0.2s; +} +.card:hover { border-color: rgba(59,130,246,0.3); } +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} +.card-title { font-size: 16px; font-weight: 600; } + +/* ========= 统计卡片 ========= */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + padding: 20px; + text-align: center; +} +.stat-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 12px; + font-size: 20px; +} +.stat-value { + font-size: 28px; + font-weight: 700; + margin-bottom: 4px; +} +.stat-label { + color: var(--text-muted); + font-size: 13px; +} + +/* ========= 页面容器 ========= */ +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 12px; +} +.page-title { font-size: 22px; font-weight: 700; } +.page-subtitle { color: var(--text-secondary); font-size: 14px; margin-top: 4px; } + +/* ========= 搜索栏 ========= */ +.search-bar { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; +} +.search-bar input { + flex: 1; + min-width: 200px; +} +.search-bar select { + width: auto; + min-width: 120px; +} + +/* ========= 分类网格 ========= */ +.category-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 16px; +} +.category-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + padding: 24px 16px; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} +.category-card:hover { + border-color: var(--accent-blue); + transform: translateY(-2px); + box-shadow: var(--shadow); +} +.category-card i { + font-size: 32px; + color: var(--accent-blue); + margin-bottom: 12px; +} +.category-card .name { + font-size: 15px; + font-weight: 500; + margin-bottom: 6px; +} +.category-card .count { + color: var(--text-muted); + font-size: 13px; +} + +/* ========= 题目列表 ========= */ +.question-list { display: flex; flex-direction: column; gap: 8px; } +.question-item { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 14px 18px; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.question-item:hover { + border-color: var(--accent-blue); + background: var(--bg-card-hover); +} +.question-item .q-left { + flex: 1; + min-width: 0; +} +.question-item .q-title { + font-size: 15px; + font-weight: 500; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.question-item .q-meta { + display: flex; + gap: 10px; + font-size: 12px; + color: var(--text-muted); + flex-wrap: wrap; +} +.question-item .q-right { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} +.tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + background: rgba(59,130,246,0.1); + color: var(--accent-blue); +} +.tag-green { background: rgba(34,197,94,0.1); color: var(--accent-green); } +.tag-yellow { background: rgba(245,158,11,0.1); color: var(--accent-yellow); } +.tag-red { background: rgba(239,68,68,0.1); color: var(--accent-red); } +.tag-gray { background: rgba(100,116,139,0.15); color: var(--text-muted); } + +/* ========= 分页 ========= */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 20px; +} +.page-btn { + padding: 6px 12px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--bg-card); + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} +.page-btn:hover { background: var(--bg-card-hover); } +.page-btn.active { + background: var(--accent-blue); + color: #fff; + border-color: var(--accent-blue); +} +.page-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ========= 题目详情 ========= */ +.question-detail { max-width: 800px; } +.detail-header { margin-bottom: 24px; } +.detail-title { font-size: 24px; font-weight: 700; margin-bottom: 12px; } +.detail-tags { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; } + +.section-block { + margin-bottom: 24px; +} +.section-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); +} +.section-content { + font-size: 15px; + line-height: 1.8; + white-space: pre-wrap; + color: var(--text-secondary); +} + +/* 掌握状态选择 */ +.mastery-selector { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.mastery-btn { + padding: 8px 16px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--bg-card); + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} +.mastery-btn:hover { border-color: var(--accent-blue); } +.mastery-btn.active { border-color: currentColor; background: rgba(255,255,255,0.05); color: var(--text-primary); } + +/* ========= 评论 ========= */ +.comment-item { + padding: 14px 0; + border-bottom: 1px solid var(--border-color); +} +.comment-item:last-child { border-bottom: none; } +.comment-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 13px; +} +.comment-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--accent-blue); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 12px; + font-weight: 600; +} +.comment-user { font-weight: 500; color: var(--text-primary); } +.comment-time { color: var(--text-muted); } +.comment-content { font-size: 14px; color: var(--text-secondary); line-height: 1.6; } + +/* ========= 表单 ========= */ +.form-group { + margin-bottom: 16px; +} +.form-label { + display: block; + font-size: 14px; + font-weight: 500; + margin-bottom: 6px; + color: var(--text-secondary); +} +.form-actions { + display: flex; + gap: 12px; + margin-top: 24px; +} + +/* ========= 登录页面 ========= */ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-primary); + padding: 20px; +} +.login-box { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + padding: 40px; + width: 100%; + max-width: 420px; + box-shadow: var(--shadow-lg); +} +.login-logo { + text-align: center; + margin-bottom: 32px; +} +.login-logo i { + font-size: 48px; + color: var(--accent-blue); +} +.login-logo h1 { + font-size: 24px; + font-weight: 700; + margin-top: 12px; +} +.login-logo p { + color: var(--text-muted); + font-size: 14px; + margin-top: 4px; +} +.login-tabs { + display: flex; + margin-bottom: 24px; + border-bottom: 1px solid var(--border-color); +} +.login-tab { + flex: 1; + padding: 10px; + text-align: center; + cursor: pointer; + color: var(--text-muted); + font-size: 15px; + font-weight: 500; + border-bottom: 2px solid transparent; + transition: all 0.2s; +} +.login-tab.active { + color: var(--accent-blue); + border-bottom-color: var(--accent-blue); +} + +/* ========= Toast ========= */ +#toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 8px; +} +.toast { + padding: 12px 20px; + border-radius: var(--border-radius); + font-size: 14px; + color: #fff; + transform: translateX(120%); + transition: transform 0.3s; + max-width: 360px; +} +.toast.show { transform: translateX(0); } +.toast-info { background: var(--accent-blue); } +.toast-success { background: var(--accent-green); } +.toast-error { background: var(--accent-red); } + +/* ========= Modal ========= */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + transition: opacity 0.3s; + padding: 20px; +} +.modal-overlay.show { opacity: 1; } +.modal-box { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + padding: 28px; + max-width: 480px; + width: 100%; +} +.modal-box p { font-size: 15px; margin-bottom: 20px; color: var(--text-secondary); } +.modal-actions { display: flex; gap: 12px; justify-content: flex-end; } +.modal-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; } + +/* ========= Spinner ========= */ +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} +.spinner-lg { + display: block; + width: 40px; + height: 40px; + border: 3px solid var(--border-color); + border-top-color: var(--accent-blue); + border-radius: 50%; + animation: spin 0.6s linear infinite; + margin: 40px auto; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.page-loading, .page-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: var(--text-muted); +} +.page-error i { font-size: 48px; color: var(--accent-red); margin-bottom: 16px; } +.page-error p { margin-bottom: 16px; } + +/* ========= 空状态 ========= */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} +.empty-state i { font-size: 48px; margin-bottom: 16px; opacity: 0.5; } +.empty-state p { font-size: 15px; } + +/* ========= 排行榜 ========= */ +.rank-item { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + margin-bottom: 8px; +} +.rank-num { + width: 32px; + text-align: center; + font-weight: 700; + font-size: 16px; + color: var(--text-muted); +} +.rank-num.top1 { color: #f59e0b; } +.rank-num.top2 { color: #94a3b8; } +.rank-num.top3 { color: #b45309; } +.rank-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--accent-blue); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 14px; + font-weight: 600; +} +.rank-info { flex: 1; } +.rank-name { font-weight: 500; font-size: 14px; } +.rank-value { color: var(--text-muted); font-size: 13px; } + +/* ========= 面经卡 ========= */ +.exp-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; +} +.exp-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + padding: 20px; + cursor: pointer; + transition: all 0.2s; +} +.exp-card:hover { border-color: var(--accent-blue); } +.exp-company { + font-size: 16px; + font-weight: 600; + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 8px; +} +.exp-meta { + display: flex; + gap: 12px; + flex-wrap: wrap; + font-size: 13px; + color: var(--text-muted); + margin-bottom: 10px; +} +.exp-content { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.exp-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 12px; + font-size: 13px; + color: var(--text-muted); +} +.exp-actions { display: flex; gap: 12px; } +.exp-actions i { cursor: pointer; } +.exp-actions i:hover { color: var(--accent-blue); } +.exp-actions .liked { color: var(--accent-red); } + +/* ========= 论坛帖子 ========= */ +.forum-item { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 16px 20px; + cursor: pointer; + transition: all 0.2s; + margin-bottom: 8px; +} +.forum-item:hover { border-color: var(--accent-blue); } +.forum-title { + font-size: 16px; + font-weight: 500; + margin-bottom: 8px; +} +.forum-meta { + display: flex; + gap: 16px; + font-size: 13px; + color: var(--text-muted); + flex-wrap: wrap; +} +.forum-stats { + display: flex; + gap: 16px; + font-size: 13px; + color: var(--text-muted); +} + +/* ========= 面试模式 ========= */ +.interview-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 12px; +} +.interview-progress { + display: flex; + align-items: center; + gap: 12px; +} +.progress-bar { + width: 200px; + height: 6px; + background: var(--bg-input); + border-radius: 3px; + overflow: hidden; +} +.progress-fill { + height: 100%; + background: var(--accent-blue); + border-radius: 3px; + transition: width 0.3s; +} + +/* ========= 热力图 ========= */ +.heatmap-wrapper { + overflow-x: auto; + padding: 10px 0; +} +.heatmap-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 3px; + max-width: 350px; +} +.heatmap-cell { + aspect-ratio: 1; + min-width: 14px; + min-height: 14px; + border-radius: 2px; + background: var(--bg-card); +} + +.heatmap-cell.l0 { background: var(--bg-input); } +.heatmap-cell.l1 { background: rgba(34,197,94,0.2); } +.heatmap-cell.l2 { background: rgba(34,197,94,0.4); } +.heatmap-cell.l3 { background: rgba(34,197,94,0.6); } +.heatmap-cell.l4 { background: rgba(34,197,94,0.8); } + +/* ========= Tab 切换 ========= */ +.tabs { + display: flex; + gap: 0; + margin-bottom: 20px; + border-bottom: 1px solid var(--border-color); +} +.tab { + padding: 10px 20px; + cursor: pointer; + color: var(--text-muted); + font-size: 14px; + font-weight: 500; + border-bottom: 2px solid transparent; + transition: all 0.2s; +} +.tab:hover { color: var(--text-secondary); } +.tab.active { + color: var(--accent-blue); + border-bottom-color: var(--accent-blue); +} + +/* ========= 评论区 ========= */ +.comment-form { + display: flex; + gap: 12px; + margin-bottom: 16px; +} +.comment-form textarea { flex: 1; min-height: 60px; } + +/* ========= 响应式 ========= */ +/* 平板:小于 1024px */ +@media (max-width: 1024px) { + .main-content { padding: 20px; padding-top: calc(var(--header-height) + 20px); } +} + +/* 手机:小于 768px */ +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + } + .sidebar.open { + transform: translateX(0); + } + + .main-wrapper { + margin-left: 0; + padding-bottom: var(--mobile-tabs-height); + } + + .desktop-header { display: none; } + .mobile-header { display: flex; } + .mobile-tabs { display: flex; } + + .main-content { + padding: 16px; + padding-top: calc(var(--mobile-header-height) + 16px); + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .category-grid { + grid-template-columns: repeat(2, 1fr); + } + + .exp-grid { + grid-template-columns: 1fr; + } + + .page-header { + flex-direction: column; + align-items: flex-start; + } + + .search-bar { + flex-direction: column; + } + .search-bar input { min-width: 100%; } + + .login-box { padding: 28px 20px; } + + .detail-title { font-size: 20px; } + + .progress-bar { width: 120px; } +} + +/* 小手机:小于 480px */ +@media (max-width: 480px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + .stat-card { padding: 14px; } + .stat-value { font-size: 22px; } + + .question-item { padding: 12px 14px; } +} + +/* ========= 工具类 ========= */ +.flex { display: flex; } +.flex-col { flex-direction: column; } +.flex-wrap { flex-wrap: wrap; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.gap-2 { gap: 8px; } +.gap-3 { gap: 12px; } +.gap-4 { gap: 16px; } +.mb-2 { margin-bottom: 8px; } +.mb-3 { margin-bottom: 12px; } +.mb-4 { margin-bottom: 16px; } +.mt-4 { margin-top: 16px; } +.text-center { text-align: center; } +.text-muted { color: var(--text-muted); } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.w-full { width: 100%; } + + +/* ========= 答案隐藏(点击展示) ========= */ +.answer-hidden { + color: transparent; + text-shadow: 0 0 10px var(--text-muted); + cursor: pointer; + user-select: none; + position: relative; + transition: all 0.3s; + padding-bottom: 36px; +} +.answer-hidden::after { + content: '👆 点击显示答案'; + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + color: var(--accent-blue); + font-size: 13px; + text-shadow: none; + z-index: 2; + opacity: 0.8; + transition: opacity 0.2s; + white-space: nowrap; +} +.answer-hidden:hover::after { + opacity: 1; +} +.answer-hidden.revealed { + color: var(--text-secondary); + text-shadow: none; + cursor: auto; + user-select: auto; + padding-bottom: 0; +} +.answer-hidden.revealed::after { + display: none; +} + + + +/* ========= 过渡动画 ========= */ +.fade-in { + + animation: fadeIn 0.3s ease; +} +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ========= 移动端侧栏遮罩 ========= */ +.sidebar-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.5); + z-index: 99; +} +.sidebar-overlay.show { display: block; } + +@media (min-width: 769px) { + .sidebar-overlay { display: none !important; } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..88641ca --- /dev/null +++ b/web/index.html @@ -0,0 +1,49 @@ + + + + + + NiuGuide - Java 面试指南 + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/js/api.js b/web/js/api.js new file mode 100644 index 0000000..8807a60 --- /dev/null +++ b/web/js/api.js @@ -0,0 +1,142 @@ +/** + * 统一 API 请求封装 + * 自动携带 Token(Authorization 头),适配 Sa-Token 认证 + * 统一错误处理 + */ +const API = { + /** + * 从 localStorage 获取 token + */ + getToken() { + return localStorage.getItem('token'); + }, + + /** + * 保存 token + */ + setToken(token) { + localStorage.setItem('token', token); + }, + + /** + * 清除 token + */ + clearToken() { + localStorage.removeItem('token'); + }, + + /** + * 是否已登录(有 token) + */ + isLoggedIn() { + return !!this.getToken(); + }, + + /** + * 核心请求方法 + * @param {string} method - HTTP 方法 + * @param {string} url - 请求路径(相对于 API_BASE) + * @param {object} [body] - 请求体(可选) + * @param {object} [params] - URL 查询参数(可选) + * @returns {Promise} 统一返回 Result.data + */ + async request(method, url, body, params) { + // 构建完整 URL + let fullUrl = CONFIG.API_BASE + url; + + // 拼接查询参数 + if (params) { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + query.append(key, value); + } + }); + const qs = query.toString(); + if (qs) fullUrl += '?' + qs; + } + + // 构建请求头 + const headers = {}; + const token = this.getToken(); + if (token) { + headers['Authorization'] = token; + } + + // 构建请求配置 + const options = { method, headers }; + + // 处理请求体 + if (body !== undefined && body !== null) { + // 如果是 FormData,不设置 Content-Type + if (body instanceof FormData) { + options.body = body; + } else { + headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify(body); + } + } + + try { + const resp = await fetch(fullUrl, options); + const result = await resp.json(); + + // 业务失败处理 + if (result.code !== 200) { + // 401 未登录,清除 token 并跳转登录 + if (result.code === 401) { + this.clearToken(); + // 延迟跳转避免干扰 + setTimeout(() => { + if (window.APP) { + window.APP.navigate('/login'); + } + }, 100); + } + throw new Error(result.msg || '请求失败'); + } + + return result.data; + } catch (err) { + // 网络错误友好提示 + if (err.message === 'Failed to fetch') { + throw new Error('网络连接失败,请检查后端服务是否启动'); + } + throw err; + } + }, + + // ---- 便捷方法 ---- + + /** GET 请求 */ + get(url, params) { + return this.request('GET', url, null, params); + }, + + /** POST 请求(JSON body) */ + post(url, body) { + return this.request('POST', url, body); + }, + + /** DELETE 请求 */ + del(url) { + return this.request('DELETE', url); + }, + + /** 文件上传(FormData) */ + upload(url, formData) { + const token = this.getToken(); + const headers = {}; + if (token) { + headers['Authorization'] = token; + } + return fetch(CONFIG.API_BASE + url, { + method: 'POST', + headers, + body: formData + }).then(r => r.json()).then(result => { + if (result.code !== 200) throw new Error(result.msg); + return result.data; + }); + } +}; diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..95221bb --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,154 @@ +/** + * 应用入口 + * 初始化布局、路由、事件绑定 + */ +const APP = { + /** 当前用户信息缓存 */ + currentUser: null, + + /** 初始化应用 */ + async init() { + // 渲染基础布局 + this.renderLayout(); + + // 注册路由 + this.registerRoutes(); + + // 路由前置钩子:登录检查 + router.beforeEach((path) => { + // 公开页面无需登录 + const publicPaths = ['/login']; + + if (publicPaths.some(p => path.startsWith(p))) return true; + + if (!API.isLoggedIn()) { + router.navigate('/login'); + return false; + } + return true; + }); + + // 绑定全局事件 + this.bindEvents(); + + // 启动路由 + router.start(); + + // 如果已登录,获取用户信息 + if (API.isLoggedIn()) { + try { + this.currentUser = await API.get('/api/auth/me'); + this.updateUserInfo(); + } catch (e) { + // token 失效,跳转登录 + router.navigate('/login'); + } + } + }, + + /** 当前路由是否为登录页 */ + isLoginPage() { + const hash = window.location.hash || ''; + return hash === '#/login' || hash === '#/' || hash === ''; + }, + + /** 渲染基础布局 */ + renderLayout() { + const app = $('#app'); + app.innerHTML = ` +
+ ${renderSidebar()} + ${renderDesktopHeader()} + ${renderMobileHeader()} +
+
+
+
+ `; + // 移动端底部导航直接挂到 body,避免 position: fixed 受父容器影响 + document.body.insertAdjacentHTML('beforeend', renderMobileTabs()); + }, + + + + /** 注册所有路由 */ + registerRoutes() { + router.register('/login', () => renderLogin()); + router.register('/dashboard', () => renderDashboard()); + router.register('/categories', () => renderCategories()); + router.register('/questions', (params) => renderQuestionList(params)); + router.register('/question/:id', (params) => renderQuestionDetail(params)); + router.register('/random', () => renderRandom()); + router.register('/interview', () => renderInterview()); + router.register('/favorites', () => renderFavorites()); + router.register('/review', () => renderReview()); + + router.register('/profile', () => renderProfile()); + + }, + + /** 绑定全局事件 */ + bindEvents() { + // 移动端菜单切换 + $('#menuToggle')?.addEventListener('click', () => { + $('#sidebar').classList.toggle('open'); + $('#sidebarOverlay').classList.toggle('show'); + }); + + // 遮罩层关闭侧栏 + $('#sidebarOverlay')?.addEventListener('click', () => { + $('#sidebar').classList.remove('open'); + $('#sidebarOverlay').classList.remove('show'); + }); + + // 移动端 Tab 点击 + document.addEventListener('click', e => { + const tab = e.target.closest('.tab-item'); + if (tab && tab.dataset.route) { + router.navigate(tab.dataset.route); + } + }); + + // 答案点击展示(点击模糊的答案区域显示内容) + document.addEventListener('click', e => { + const answerEl = e.target.closest('.answer-hidden:not(.revealed)'); + if (answerEl) { + answerEl.classList.add('revealed'); + } + }); + + }, + + /** 更新用户信息显示 */ + updateUserInfo() { + if (this.currentUser) { + const nickname = this.currentUser.nickname || this.currentUser.username; + const el = $('#headerNickname'); + if (el) el.textContent = nickname; + } + }, + + /** 退出登录 */ + async logout() { + try { + await API.post('/api/auth/logout'); + } catch (e) { /* ignore */ } + API.clearToken(); + this.currentUser = null; + router.navigate('/login'); + }, + + /** 更新面包屑 */ + setBreadcrumb(text) { + const el = $('#breadcrumb'); + if (el) el.textContent = text; + }, + + /** 获取当前用户 ID */ + getUserId() { + return this.currentUser?.id; + } +}; + +// 启动应用 +document.addEventListener('DOMContentLoaded', () => APP.init()); diff --git a/web/js/components.js b/web/js/components.js new file mode 100644 index 0000000..af20205 --- /dev/null +++ b/web/js/components.js @@ -0,0 +1,104 @@ +/** + * 公共组件:侧边栏导航、顶部导航、移动端底部 Tab + */ + +/** 侧边栏导航 HTML */ +function renderSidebar() { + return ` +
+
+ + NiuGuide +
+
+ + + 仪表盘 + + + + 题库分类 + + + + 随机刷题 + + + + 面试模式 + + + + 我的收藏 + + + + 待复习 + +
+ + + + 个人中心 + +
+
+ +
+
`; +} + +/** 移动端底部 Tab 导航 HTML */ +function renderMobileTabs() { + return ` +
+
+ + 首页 +
+
+ + 题库 +
+
+ + 刷题 +
+
+ + 我的 +
+
`; +} + +/** 移动端顶部栏 HTML */ +function renderMobileHeader() { + return ` +
+ + NiuGuide + +
`; +} + +/** 桌面端顶部栏 HTML */ +function renderDesktopHeader() { + return ` +
+
+ 仪表盘 +
+
+ + + 用户 + +
+
`; +} diff --git a/web/js/config.js b/web/js/config.js new file mode 100644 index 0000000..ced9b6b --- /dev/null +++ b/web/js/config.js @@ -0,0 +1,34 @@ +/** + * 全局配置文件 + * 集中管理 API 基础地址等常量 + */ +const CONFIG = { + // 后端 API 基础地址,根据实际部署修改 + API_BASE: 'http://154.219.109.125:8080', + // 分页默认值 + PAGE_SIZE: 20, + // 掌握状态映射 + MASTERY_MAP: { + 0: '未学', + 1: '不会', + 2: '不熟', + 3: '已掌握' + }, + MASTERY_COLOR: { + 0: '#6b7280', // 灰色 + 1: '#ef4444', // 红色 + 2: '#f59e0b', // 黄色 + 3: '#22c55e' // 绿色 + }, + // 难度映射 + DIFFICULTY_MAP: { + 1: '简单', + 2: '中等', + 3: '困难' + }, + DIFFICULTY_COLOR: { + 1: '#22c55e', + 2: '#f59e0b', + 3: '#ef4444' + } +}; diff --git a/web/js/pages/categories.js b/web/js/pages/categories.js new file mode 100644 index 0000000..957d918 --- /dev/null +++ b/web/js/pages/categories.js @@ -0,0 +1,40 @@ +/** + * 题库分类页面 + */ +async function renderCategories() { + const main = $('#main-content'); + APP.setBreadcrumb('题库分类'); + main.innerHTML = '
'; + + try { + const categories = await API.get('/api/questions/categories'); + const icons = ['fa-java', 'fa-leaf', 'fa-database', 'fa-cloud', 'fa-cubes', 'fa-cog', 'fa-code', 'fa-terminal', 'fa-network-wired', 'fa-shield-alt', 'fa-microchip', 'fa-laptop-code', 'fa-server', 'fa-tools', 'fa-layer-group']; + + main.innerHTML = ` +
+
+
+

题库分类

+

共 ${categories.length} 个分类

+
+
+
+ ${categories.map((c, i) => ` +
+ +
${c.category}
+
${c.count} 题
+
+ `).join('')} +
+
`; + + $$('.category-card').forEach(card => { + card.addEventListener('click', () => { + router.navigate(`/questions?category=${encodeURIComponent(card.dataset.category)}`); + }); + }); + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} diff --git a/web/js/pages/dashboard.js b/web/js/pages/dashboard.js new file mode 100644 index 0000000..4a660c4 --- /dev/null +++ b/web/js/pages/dashboard.js @@ -0,0 +1,58 @@ +/** + * 首页仪表盘页面 + * 展示学习统计、打卡信息 + */ +async function renderDashboard() { + const main = $('#main-content'); + APP.setBreadcrumb('仪表盘'); + main.innerHTML = '
'; + + try { + const stats = await API.get('/api/record/stats'); + + main.innerHTML = ` +
+
+
+

仪表盘

+

总览你的学习进度

+
+
+ + +
+
+
+ +
+
${stats.totalQuestions}
+
题目总数
+
+
+
+ +
+
${stats.masteredCount}
+
已掌握
+
+
+
+ +
+
${stats.unfamiliarCount}
+
待复习
+
+
+
+ +
+
${stats.favoriteCount}
+
收藏
+
+
+
`; + + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} diff --git a/web/js/pages/experience.js b/web/js/pages/experience.js new file mode 100644 index 0000000..35bd200 --- /dev/null +++ b/web/js/pages/experience.js @@ -0,0 +1,115 @@ +/** + * 面经广场页面 + */ +async function renderExperience() { + const main = $('#main-content'); + APP.setBreadcrumb('面经广场'); + main.innerHTML = '
'; + try { + const data = await API.get('/api/experience/list'); + const list = data || data?.records || []; + main.innerHTML = ` +
+
+

面经广场

分享你的面试经验

+ +
+
+ + + +
+
${list.map(e => ` +
+
${e.company || '未知公司'}
+
+ ${e.position ? ` ${e.position}` : ''} + ${e.tag ? `${e.tag}` : ''} + ${formatDate(e.createdAt)} +
+
${escapeHtml(e.content || '')}
+
+
+ ${e.likeCount || 0} + +
+ ${e.nickname || '匿名'} +
+
`).join('')}
+ ${list.length === 0 ? '

暂无面经

' : ''} +
`; + + $('#expGrid').addEventListener('click', e => { + const card = e.target.closest('.exp-card'); + if (card && !e.target.closest('.exp-actions')) router.navigate(`/experience/${card.dataset.id}`); + }); + + $$('[data-like]').forEach(el => { + el.addEventListener('click', async (e) => { + e.stopPropagation(); + try { await API.post(`/api/experience/like/${el.dataset.like}`); showToast('操作成功', 'success'); router.resolve(); } + catch (err) { showToast(err.message, 'error'); } + }); + }); + + $$('[data-fav]').forEach(el => { + el.addEventListener('click', async (e) => { + e.stopPropagation(); + try { await API.post(`/api/experience/favorite/${el.dataset.fav}`); showToast('操作成功', 'success'); router.resolve(); } + catch (err) { showToast(err.message, 'error'); } + }); + }); + + $('#searchExpBtn').addEventListener('click', async () => { + const company = $('#searchCompany').value; + const tag = $('#searchTag').value; + try { + const data = await API.get('/api/experience/list', { company: company || undefined, tag: tag || undefined }); + const list = data || data?.records || []; + const grid = $('#expGrid'); + grid.innerHTML = list.map(e => ` +
+
${e.company || '未知公司'}
+
+ ${e.position ? ` ${e.position}` : ''} + ${e.tag ? `${e.tag}` : ''} + ${formatDate(e.createdAt)} +
+
${escapeHtml(e.content || '')}
+
${e.likeCount || 0}
${e.nickname || '匿名'}
+
`).join(''); + } catch (err) { showToast(err.message, 'error'); } + }); + + $('#addExpBtn').addEventListener('click', () => showExperienceForm()); + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} + +function showExperienceForm(editData) { + const overlay = h('div', 'modal-overlay'); + overlay.innerHTML = ` +
+

${editData ? '编辑面经' : '发布面经'}

+
+
+
+
+
+
`; + document.body.appendChild(overlay); + setTimeout(() => overlay.classList.add('show'), 10); + const close = () => { overlay.classList.remove('show'); setTimeout(() => overlay.remove(), 300); }; + overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); + $('#expCancel').addEventListener('click', close); + $('#expSubmit').addEventListener('click', async () => { + const data = { company: $('#expCompany').value.trim(), position: $('#expPosition').value.trim(), tag: $('#expTag').value.trim(), content: $('#expContent').value.trim() }; + if (!data.company || !data.content) { showToast('请填写公司和内容', 'error'); return; } + setLoading($('#expSubmit')); + try { + await (editData ? API.post(`/api/experience/update/${editData.id}`, data) : API.post('/api/experience/add', data)); + close(); showToast(editData ? '保存成功' : '发布成功', 'success'); router.resolve(); + } catch (err) { showToast(err.message, 'error'); } finally { setLoading($('#expSubmit'), false); } + }); +} diff --git a/web/js/pages/experienceDetail.js b/web/js/pages/experienceDetail.js new file mode 100644 index 0000000..1fdaa6b --- /dev/null +++ b/web/js/pages/experienceDetail.js @@ -0,0 +1,82 @@ +/** + * 面经详情页面 + */ +async function renderExperienceDetail(params) { + const main = $('#main-content'); + APP.setBreadcrumb('面经详情'); + main.innerHTML = '
'; + + const id = params.id; + if (!id) { router.navigate('/experience'); return; } + + try { + const e = await API.get(`/api/experience/detail/${id}`); + main.innerHTML = ` +
+ +
+
+ ${e.company || '未知公司'} +
+
+ ${e.position ? ` ${e.position}` : ''} + ${e.tag ? `${e.tag}` : ''} + ${formatDate(e.createdAt)} + ${e.nickname || '匿名'} +
+
${escapeHtml(e.content || '')}
+
+
+ + ${e.likeCount || 0} + + + + +
+ ${e.userId === APP.getUserId() ? ` +
+ + +
` : ''} +
+
+
`; + + $('#detailLike').addEventListener('click', async () => { + if (!API.isLoggedIn()) { showToast('请先登录', 'error'); return; } + try { + await API.post(`/api/experience/like/${id}`); + showToast('操作成功', 'success'); + router.resolve(); + } catch (err) { showToast(err.message, 'error'); } + }); + + $('#detailFav').addEventListener('click', async () => { + if (!API.isLoggedIn()) { showToast('请先登录', 'error'); return; } + try { + await API.post(`/api/experience/favorite/${id}`); + showToast('操作成功', 'success'); + router.resolve(); + } catch (err) { showToast(err.message, 'error'); } + }); + + $('#delExpBtn')?.addEventListener('click', async () => { + const ok = await showConfirm('确定删除此面经?'); + if (ok) { + try { + await API.del(`/api/experience/${id}`); + showToast('已删除', 'success'); + router.navigate('/experience'); + } catch (err) { showToast(err.message, 'error'); } + } + }); + + $('#editExpBtn')?.addEventListener('click', () => showExperienceForm(e)); + + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} diff --git a/web/js/pages/favorites.js b/web/js/pages/favorites.js new file mode 100644 index 0000000..71f5373 --- /dev/null +++ b/web/js/pages/favorites.js @@ -0,0 +1,16 @@ +/** + * 我的收藏页面 + * 展示用户收藏的所有题目 + */ +async function renderFavorites() { + const main = $('#main-content'); + APP.setBreadcrumb('我的收藏'); + main.innerHTML = '
'; + + try { + const questions = await API.get('/api/questions/favorites'); + renderQuestionListPage(main, questions, '我的收藏', '还没有收藏题目,去题库浏览吧'); + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} diff --git a/web/js/pages/forum.js b/web/js/pages/forum.js new file mode 100644 index 0000000..37db02a --- /dev/null +++ b/web/js/pages/forum.js @@ -0,0 +1,162 @@ +/** + * 论坛页面 + * 展示帖子列表,按分类筛选,发帖,详情 + */ +async function renderForum() { + const main = $('#main-content'); + APP.setBreadcrumb('论坛'); + main.innerHTML = '
'; + + try { + const data = await API.get('/api/forum/list', { category: '' }); + const posts = data || data?.records || []; + + const categories = ['全部', '技术讨论', '面试经验', '求职交流', '资源分享', '其他']; + + main.innerHTML = ` +
+
+
+

论坛

+

交流技术,分享经验

+
+ +
+
+ ${categories.map((c, i) => ` +
${c}
+ `).join('')} +
+
+ ${posts.map(p => ` +
+
${p.title}
+
+ ${p.category || '其他'} + ${p.nickname || '匿名'} + ${formatDate(p.createdAt)} +
+
+ ${p.commentCount || 0} + ${p.viewCount || 0} +
+
+ `).join('')} + ${posts.length === 0 ? '

暂无帖子

' : ''} +
+
`; + + // Tab 切换分类 + $$('#forumTabs .tab').forEach(tab => { + tab.addEventListener('click', async () => { + $$('#forumTabs .tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + const category = tab.dataset.category; + try { + const data = await API.get('/api/forum/list', { category }); + const list = data || data?.records || []; + renderPosts(list); + } catch (err) { showToast(err.message, 'error'); } + }); + }); + + // 点击帖子 + $('#postList').addEventListener('click', e => { + const item = e.target.closest('.forum-item'); + if (item) router.navigate(`/forum/post/${item.dataset.id}`); + }); + + // 发帖 + $('#addPostBtn').addEventListener('click', () => showForumForm()); + + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} + +function renderPosts(posts) { + const container = $('#postList'); + container.innerHTML = posts.map(p => ` +
+
${p.title}
+
+ ${p.category || '其他'} + ${p.nickname || '匿名'} + ${formatDate(p.createdAt)} +
+
+ ${p.commentCount || 0} + ${p.viewCount || 0} +
+
+ `).join('') || '

暂无帖子

'; + // 重新绑定点击 + container.querySelectorAll('.forum-item').forEach(item => { + item.addEventListener('click', () => { + router.navigate(`/forum/post/${item.dataset.id}`); + }); + }); +} + +/** 发帖表单 */ +function showForumForm(editData) { + const overlay = h('div', 'modal-overlay'); + const categories = ['技术讨论', '面试经验', '求职交流', '资源分享', '其他']; + overlay.innerHTML = ` +
+

${editData ? '编辑帖子' : '发布帖子'}

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
`; + document.body.appendChild(overlay); + setTimeout(() => overlay.classList.add('show'), 10); + + const close = () => { overlay.classList.remove('show'); setTimeout(() => overlay.remove(), 300); }; + overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); + $('#postCancel').addEventListener('click', close); + + $('#postSubmit').addEventListener('click', async () => { + const data = { + title: $('#postTitle').value.trim(), + category: $('#postCategory').value, + content: $('#postContent').value.trim() + }; + if (!data.title) { showToast('请输入标题', 'error'); return; } + if (!data.content) { showToast('请输入内容', 'error'); return; } + + const btn = $('#postSubmit'); + setLoading(btn); + try { + if (editData) { + await API.post(`/api/forum/update/${editData.id}`, data); + } else { + await API.post('/api/forum/add', data); + } + close(); + showToast(editData ? '保存成功' : '发布成功', 'success'); + router.resolve(); + } catch (err) { + showToast(err.message, 'error'); + } finally { + setLoading(btn, false); + } + }); +} diff --git a/web/js/pages/forumPost.js b/web/js/pages/forumPost.js new file mode 100644 index 0000000..1eed860 --- /dev/null +++ b/web/js/pages/forumPost.js @@ -0,0 +1,87 @@ +/** + * 论坛帖子详情页面 + */ +async function renderForumPost(params) { + const main = $('#main-content'); + APP.setBreadcrumb('帖子详情'); + main.innerHTML = '
'; + const id = params.id; + if (!id) { router.navigate('/forum'); return; } + try { + const post = await API.get(`/api/forum/detail/${id}`); + main.innerHTML = ` +
+ +
+

${post.title}

+
+ ${post.category || '其他'} + ${post.nickname || '匿名'} + ${formatDate(post.createdAt)} + ${post.viewCount || 0} +
+
${escapeHtml(post.content || '')}
+ ${post.userId === APP.getUserId() ? ` +
+ + +
` : ''} +
+
+
回复 (${post.commentCount || 0})
+
+ + +
+
+
+
`; + loadReplies(id); + $('#delPostBtn')?.addEventListener('click', async () => { + if (await showConfirm('确定删除此帖子?')) { + try { await API.del(`/api/forum/${id}`); showToast('已删除', 'success'); router.navigate('/forum'); } + catch (err) { showToast(err.message, 'error'); } + } + }); + $('#editPostBtn')?.addEventListener('click', () => showForumForm(post)); + $('#replySubmit').addEventListener('click', async () => { + const content = $('#replyInput').value.trim(); + if (!content) { showToast('请输入回复内容', 'error'); return; } + try { await API.post('/api/forum/comment/add', { postId: parseInt(id), content }); $('#replyInput').value = ''; showToast('回复成功', 'success'); loadReplies(id); } + catch (err) { showToast(err.message, 'error'); } + }); + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} + +async function loadReplies(postId) { + const container = $('#replyList'); + if (!container) return; + try { + const replies = await API.get(`/api/forum/comment/list/${postId}`); + container.innerHTML = replies.length === 0 + ? '

暂无回复

' + : replies.map(r => ` +
+
+
${(r.nickname || '?')[0]}
+ ${r.nickname || '匿名'} + ${formatDate(r.createdAt)} + ${r.userId === APP.getUserId() ? `` : ''} +
+
${escapeHtml(r.content)}
+
`).join(''); + $$('[data-del-reply]').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + if (await showConfirm('确定删除此回复?')) { + try { await API.del(`/api/forum/comment/${btn.dataset.delReply}`); showToast('已删除', 'success'); loadReplies(postId); } + catch (err) { showToast(err.message, 'error'); } + } + }); + }); + } catch (err) { + container.innerHTML = `

${err.message}

`; + } +} diff --git a/web/js/pages/interview.js b/web/js/pages/interview.js new file mode 100644 index 0000000..ff26dfd --- /dev/null +++ b/web/js/pages/interview.js @@ -0,0 +1,151 @@ +/** + * 面试模式页面 + * 逐题展示,带进度条,可前后切换 + */ +let interviewState = { questions: [], currentIndex: 0 }; + +async function renderInterview() { + const main = $('#main-content'); + APP.setBreadcrumb('面试模式'); + main.innerHTML = '
'; + + // 初始化/重置状态 + interviewState = { questions: [], currentIndex: 0 }; + + try { + const data = await API.get('/api/questions/interview'); + interviewState.questions = data; + + if (!data || data.length === 0) { + main.innerHTML = '

暂无可用题目

'; + return; + } + + showInterviewQuestion(); + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} + +function showInterviewQuestion() { + const { questions, currentIndex } = interviewState; + const q = questions[currentIndex]; + const total = questions.length; + const progress = ((currentIndex + 1) / total) * 100; + + const diffCls = ['', 'tag-green', 'tag-yellow', 'tag-red'][q.difficulty] || ''; + + const main = $('#main-content'); + main.innerHTML = ` +
+
+

面试模式

+
+ ${currentIndex + 1}/${total} +
+
+
+ +
+
+
+ ${q.category ? `${q.category}` : ''} + ${CONFIG.DIFFICULTY_MAP[q.difficulty] || ''} +
+
+ + +
+
+
+ +
+

${q.title}

+
+ +
+
+
+ 掌握状态 +
+ ${[0,1,2,3].map(s => ` + + `).join('')} +
+
+ +
+
+ + ${q.shortAnswer ? ` +
+
📌 简短答案
+
${escapeHtml(q.shortAnswer)}
+
` : ''} + + ${q.detailAnswer ? ` +
+
📖 详细答案
+
${escapeHtml(q.detailAnswer)}
+
` : ''} + + + ${currentIndex === total - 1 ? ` +
+ +

面试完成!

+

共 ${total} 道题

+ +
` : ''} +
`; + + // 事件绑定 + $$('.mastery-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const status = parseInt(btn.dataset.status); + try { + await API.post('/api/record/update', { questionId: q.id, masteryStatus: status }); + $$('.mastery-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + showToast('状态已更新', 'success'); + } catch (err) { showToast(err.message, 'error'); } + }); + }); + + $('#favBtn')?.addEventListener('click', async () => { + try { + await API.post(`/api/record/favorite/${q.id}`); + showToast('操作成功', 'success'); + showInterviewQuestion(); + } catch (err) { showToast(err.message, 'error'); } + }); + + $('#prevBtn')?.addEventListener('click', () => { + if (interviewState.currentIndex > 0) { + interviewState.currentIndex--; + showInterviewQuestion(); + } + }); + + $('#nextBtn')?.addEventListener('click', () => { + if (interviewState.currentIndex < interviewState.questions.length - 1) { + interviewState.currentIndex++; + showInterviewQuestion(); + } + }); + + $('#restartInterview')?.addEventListener('click', () => { + interviewState.currentIndex = 0; + showInterviewQuestion(); + }); +} diff --git a/web/js/pages/leaderboard.js b/web/js/pages/leaderboard.js new file mode 100644 index 0000000..84b40ed --- /dev/null +++ b/web/js/pages/leaderboard.js @@ -0,0 +1,85 @@ +/** + * 排行榜页面 + * 展示各种排名:学习分钟、刷题数、打卡天数、掌握度、覆盖率、贡献率 + */ +async function renderLeaderboard() { + const main = $('#main-content'); + APP.setBreadcrumb('排行榜'); + main.innerHTML = '
'; + + try { + const [minutesRank, questionsRank, streakRank, masteryRank, coverage, contribution] = await Promise.all([ + API.get('/api/leaderboard/minutes').catch(() => []), + API.get('/api/leaderboard/questions').catch(() => []), + API.get('/api/leaderboard/streak').catch(() => []), + API.get('/api/leaderboard/mastery').catch(() => []), + API.get('/api/leaderboard/coverage').catch(() => []), + API.get('/api/leaderboard/contribution').catch(() => []) + ]); + + const tabNames = ['学习时长', '刷题数', '打卡天数', '掌握度', '覆盖度', '贡献率']; + const tabData = [minutesRank, questionsRank, streakRank, masteryRank, coverage, contribution]; + const tabEmoji = ['⏱️', '📝', '🔥', '📚', '🎯', '💡']; + + main.innerHTML = ` +
+
+
+

排行榜

+

看看谁在学习路上走得更远

+
+
+
+ ${tabNames.map((name, i) => ` +
${tabEmoji[i]} ${name}
+ `).join('')} +
+
+
`; + + // 选中第一个 tab + renderLeaderboardContent(0, tabData); + + // Tab 切换 + $$('#lbTabs .tab').forEach(tab => { + tab.addEventListener('click', () => { + $$('#lbTabs .tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + renderLeaderboardContent(parseInt(tab.dataset.tab), tabData); + }); + }); + + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} + +function renderLeaderboardContent(index, tabData) { + const container = $('#lbContent'); + const data = tabData[index] || []; + const valueLabels = ['分钟', '题', '天', '题', '%', '%']; + + if (!data || data.length === 0) { + container.innerHTML = '

暂无排行数据

'; + return; + } + + container.innerHTML = ` +
+ ${data.map((item, i) => { + const rankClass = i === 0 ? 'top1' : i === 1 ? 'top2' : i === 2 ? 'top3' : ''; + const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : ''; + return ` +
+
${medal || (i + 1)}
+
${(item.nickname || item.username || '?')[0]}
+
+
${item.nickname || item.username || '匿名'}
+
+
+ ${item.value || item.count || 0} ${valueLabels[index] || ''} +
+
`; + }).join('')} +
`; +} diff --git a/web/js/pages/login.js b/web/js/pages/login.js new file mode 100644 index 0000000..75311e4 --- /dev/null +++ b/web/js/pages/login.js @@ -0,0 +1,104 @@ +/** + * 登录/注册页面 + */ +async function renderLogin() { + const main = $('#main-content'); + if (API.isLoggedIn()) { + router.navigate('/dashboard'); + return; + } + APP.setBreadcrumb('登录'); + main.innerHTML = ` +
+
+
+ +

NiuGuide

+

Java 面试指南 · 学习平台

+
+
+
登录
+
注册
+
+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
`; + + $$('.login-tab').forEach(tab => { + tab.addEventListener('click', () => { + $$('.login-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + $('#loginForm').classList.toggle('hidden', tab.dataset.tab !== 'login'); + $('#registerForm').classList.toggle('hidden', tab.dataset.tab === 'login'); + }); + }); + + $('#loginForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const data = { username: fd.get('username'), password: fd.get('password') }; + const btn = e.target.querySelector('button[type="submit"]'); + setLoading(btn); + try { + const res = await API.post('/api/auth/login', data); + API.setToken(res.token); + APP.currentUser = res; + APP.updateUserInfo(); + showToast('登录成功', 'success'); + router.navigate('/dashboard'); + } catch (err) { + showToast(err.message, 'error'); + } finally { + setLoading(btn, false); + } + }); + + $('#registerForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const data = { + username: fd.get('username'), + password: fd.get('password'), + nickname: fd.get('nickname') || undefined + }; + const btn = e.target.querySelector('button[type="submit"]'); + setLoading(btn); + try { + const res = await API.post('/api/auth/register', data); + API.setToken(res.token); + APP.currentUser = res; + APP.updateUserInfo(); + showToast('注册成功', 'success'); + router.navigate('/dashboard'); + } catch (err) { + showToast(err.message, 'error'); + } finally { + setLoading(btn, false); + } + }); +} diff --git a/web/js/pages/profile.js b/web/js/pages/profile.js new file mode 100644 index 0000000..ea6d18f --- /dev/null +++ b/web/js/pages/profile.js @@ -0,0 +1,98 @@ +/** + * 个人中心页面 + * 展示用户信息,修改昵称、头像 + */ +async function renderProfile() { + const main = $('#main-content'); + APP.setBreadcrumb('个人中心'); + main.innerHTML = '
'; + + try { + // 获取用户信息 + const user = await API.get('/api/auth/me'); + + main.innerHTML = ` +
+
+
+ ${user.avatar ? ` + ` + : `
+ ${(user.nickname || user.username || '?')[0]} +
` + } + + +
+

${user.nickname || user.username}

+

${user.username}

+
+ +
+
+ 修改昵称 +
+
+ + +
+
+ +
+
+ 账号信息 +
+
+
+ 用户名 + ${user.username} +
+
+ 注册时间 + ${formatDate(user.createdAt)} +
+
+
+ +
+ +
+
`; + + // 退出登录 + $('#logoutBtnProfile').addEventListener('click', () => APP.logout()); + + // 修改昵称 + $('#saveNicknameBtn').addEventListener('click', async () => { + const nickname = $('#nicknameInput').value.trim(); + if (!nickname) { showToast('请输入昵称', 'error'); return; } + try { + await API.post('/api/auth/nickname', { nickname }); + APP.currentUser.nickname = nickname; + APP.updateUserInfo(); + showToast('昵称已更新', 'success'); + } catch (err) { showToast(err.message, 'error'); } + }); + + // 修改头像 + $('#changeAvatarBtn').addEventListener('click', () => $('#avatarInput').click()); + $('#avatarInput').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + const fd = new FormData(); + fd.append('avatar', file); + try { + await API.upload('/api/auth/avatar', fd); + showToast('头像已更新', 'success'); + router.resolve(); + } catch (err) { showToast(err.message, 'error'); } + }); + + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} diff --git a/web/js/pages/questionDetail.js b/web/js/pages/questionDetail.js new file mode 100644 index 0000000..23ec03a --- /dev/null +++ b/web/js/pages/questionDetail.js @@ -0,0 +1,190 @@ +/** + * 题目详情/答题页面 + * 题目信息、答案、掌握状态、收藏、评论 + */ +async function renderQuestionDetail(params) { + const main = $('#main-content'); + APP.setBreadcrumb('题目详情'); + main.innerHTML = '
'; + + const questionId = params.id; + if (!questionId) { + router.navigate('/categories'); + return; + } + + try { + // 记录浏览 + API.post(`/api/record/view/${questionId}`).catch(() => {}); + + // 获取题目详情 + const q = await API.get(`/api/questions/${questionId}`); + // 获取评论数 + let commentCount = 0; + try { commentCount = await API.get(`/api/comment/count/${questionId}`); } catch(e) {} + // 获取评论列表 + let comments = []; + try { comments = await API.get(`/api/comment/list/${questionId}`); } catch(e) {} + + const diffCls = ['', 'tag-green', 'tag-yellow', 'tag-red'][q.difficulty] || ''; + + main.innerHTML = ` +
+
+

${q.title}

+
+ ${q.category ? `${q.category}` : ''} + ${q.subCategory ? `${q.subCategory}` : ''} + ${CONFIG.DIFFICULTY_MAP[q.difficulty] || ''} + ${q.tags ? q.tags.split(',').map(t => `${t.trim()}`).join('') : ''} +
+
+ 重要程度: ${'⭐'.repeat(q.importance || 0)} + 出现频率: ${'🔥'.repeat(q.frequency || 0)} +
+
+ + +
+
+
+ 掌握状态 +
+ ${[0,1,2,3].map(s => ` + + `).join('')} +
+
+
+ +
+
+
+ + + ${q.shortAnswer ? ` +
+
简短答案
+
${escapeHtml(q.shortAnswer)}
+
` : ''} + + + ${q.detailAnswer ? ` +
+
详细答案
+
${escapeHtml(q.detailAnswer)}
+
` : ''} + + + ${q.followUpQuestions ? ` +
+
追问
+
${q.followUpQuestions.split('|').map((q,i) => `${i+1}. ${q}`).join('\n')}
+
` : ''} + + + +
+
+ 评论 (${commentCount}) +
+
+ + +
+
+ ${comments.length === 0 ? '

暂无评论,快来抢沙发吧

' : + comments.map(c => ` +
+
+
${(c.nickname || '?')[0]}
+ ${c.nickname || '匿名'} + ${formatDate(c.createdAt)} + ${c.userId === APP.getUserId() ? ` + ` : ''} +
+
${escapeHtml(c.content)}
+
+ `).join('')} +
+
+
`; + + // 掌握状态点击 + $$('.mastery-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const status = parseInt(btn.dataset.status); + try { + await API.post('/api/record/update', { questionId: parseInt(questionId), masteryStatus: status }); + $$('.mastery-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + showToast('状态已更新', 'success'); + } catch (err) { + showToast(err.message, 'error'); + } + }); + }); + + // 收藏切换 + $('#favBtn').addEventListener('click', async () => { + try { + await API.post(`/api/record/favorite/${questionId}`); + router.resolve(); + showToast('操作成功', 'success'); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // 发表评论 + $('#commentSubmit').addEventListener('click', async () => { + const input = $('#commentInput'); + const content = input.value.trim(); + if (!content) { showToast('请输入评论内容', 'error'); return; } + try { + await API.post('/api/comment/add', { questionId: parseInt(questionId), content }); + input.value = ''; + showToast('评论成功', 'success'); + router.resolve(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // 删除评论 + $$('[data-del-comment]').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = btn.dataset.delComment; + const ok = await showConfirm('确定删除此评论?'); + if (ok) { + try { + await API.del(`/api/comment/${id}`); + showToast('已删除', 'success'); + router.resolve(); + } catch (err) { + showToast(err.message, 'error'); + } + } + }); + }); + + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} + +/** HTML 转义 */ +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} diff --git a/web/js/pages/questionList.js b/web/js/pages/questionList.js new file mode 100644 index 0000000..c464aba --- /dev/null +++ b/web/js/pages/questionList.js @@ -0,0 +1,145 @@ +/** + * 题目列表页面 + * 支持按分类筛选、关键字搜索、分页 + */ +async function renderQuestionList(params) { + const main = $('#main-content'); + APP.setBreadcrumb('题目列表'); + + // 从 URL 获取分类参数 + const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || ''); + const currentCategory = urlParams.get('category') || ''; + const searchKeyword = urlParams.get('keyword') || ''; + + main.innerHTML = ` +
+
+
+

${currentCategory || '全部题目'}

+
+
+
+ + +
+
+
+
`; + + // 加载分类选项 + try { + const categories = await API.get('/api/questions/categories'); + const sel = $('#categorySelect'); + categories.forEach(c => { + const opt = document.createElement('option'); + opt.value = c.category; + opt.textContent = c.category; + if (c.category === currentCategory) opt.selected = true; + sel.appendChild(opt); + }); + } catch (e) { /* ignore */ } + + // 加载题目列表 + let currentPage = 1; + let totalPages = 1; + + async function loadQuestions(page = 1) { + const cat = $('#categorySelect')?.value || currentCategory; + const keyword = $('#searchInput')?.value || ''; + + currentPage = page; + + try { + if (keyword) { + // 搜索模式 + const data = await API.get('/api/questions/search', { keyword }); + renderQuestions(data, 1, data.length); + totalPages = 1; + renderPagination(); + } else { + const data = await API.get('/api/questions/list', { + category: cat || undefined, + page, + size: CONFIG.PAGE_SIZE + }); + renderQuestions(data.records, data.current, data.total); + totalPages = data.pages; + renderPagination(); + } + } catch (err) { + $('#questionList').innerHTML = `

${err.message}

`; + } + } + + function renderQuestions(records, current, total) { + const container = $('#questionList'); + if (!records || records.length === 0) { + container.innerHTML = '

暂无题目

'; + return; + } + container.innerHTML = ` +
+ ${records.map(q => { + const diffCls = ['', 'tag-green', 'tag-yellow', 'tag-red'][q.difficulty] || ''; + const masteryCls = ['tag-gray', 'tag-red', 'tag-yellow', 'tag-green'][q.masteryStatus] || ''; + const masteryText = CONFIG.MASTERY_MAP[q.masteryStatus] || ''; + return ` +
+
+
${q.title}
+
+ ${q.category ? `${q.category}` : ''} + ${q.subCategory ? `${q.subCategory}` : ''} + ${CONFIG.DIFFICULTY_MAP[q.difficulty] || ''} + ${masteryText ? `${masteryText}` : ''} +
+
+
+ ${q.isFavorite ? '' : ''} + +
+
`; + }).join('')} +
`; + + // 点击进入详情 + $$('.question-item').forEach(item => { + item.addEventListener('click', () => { + router.navigate(`/question/${item.dataset.id}`); + }); + }); + } + + function renderPagination() { + const container = $('#pagination'); + if (totalPages <= 1) { + container.innerHTML = ''; + return; + } + let html = ``; + for (let i = 1; i <= totalPages; i++) { + if (i === 1 || i === totalPages || Math.abs(i - currentPage) <= 2) { + html += ``; + } else if (Math.abs(i - currentPage) === 3) { + html += ``; + } + } + html += ``; + container.innerHTML = html; + + container.addEventListener('click', e => { + const btn = e.target.closest('.page-btn'); + if (btn && btn.dataset.page) { + loadQuestions(parseInt(btn.dataset.page)); + } + }); + } + + // 搜索防抖 + $('#searchInput').addEventListener('input', debounce(() => loadQuestions(1), 400)); + $('#categorySelect').addEventListener('change', () => loadQuestions(1)); + + await loadQuestions(1); +} diff --git a/web/js/pages/random.js b/web/js/pages/random.js new file mode 100644 index 0000000..479591b --- /dev/null +++ b/web/js/pages/random.js @@ -0,0 +1,67 @@ +/** + * 随机刷题页面 + */ +async function renderRandom() { + const main = $('#main-content'); + APP.setBreadcrumb('随机刷题'); + main.innerHTML = '
'; + + try { + const q = await API.get('/api/questions/random'); + const diffCls = ['', 'tag-green', 'tag-yellow', 'tag-red'][q.difficulty] || ''; + + main.innerHTML = ` +
+
+

随机刷题

+ +
+
+

${q.title}

+
+ ${q.category ? `${q.category}` : ''} + ${CONFIG.DIFFICULTY_MAP[q.difficulty] || ''} +
+
+
+
+
+ 掌握状态 +
+ ${[0,1,2,3].map(s => ` + + `).join('')} +
+
+ +
+
+ ${q.shortAnswer ? `
📌 简短答案
${escapeHtml(q.shortAnswer)}
` : ''} + ${q.detailAnswer ? `
📖 详细答案
${escapeHtml(q.detailAnswer)}
` : ''} + +
`; + + $$('.mastery-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const status = parseInt(btn.dataset.status); + try { + await API.post('/api/record/update', { questionId: q.id, masteryStatus: status }); + $$('.mastery-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + showToast('状态已更新', 'success'); + } catch (err) { showToast(err.message, 'error'); } + }); + }); + + $('#favBtn').addEventListener('click', async () => { + try { await API.post(`/api/record/favorite/${q.id}`); showToast('操作成功', 'success'); renderRandom(); } + catch (err) { showToast(err.message, 'error'); } + }); + + $('#nextRandom').addEventListener('click', () => renderRandom()); + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} diff --git a/web/js/pages/review.js b/web/js/pages/review.js new file mode 100644 index 0000000..b8a049a --- /dev/null +++ b/web/js/pages/review.js @@ -0,0 +1,74 @@ +/** + * 待复习页面 + * 展示掌握状态为"不会"(1)或"不熟"(2)的题目 + */ +async function renderReview() { + const main = $('#main-content'); + APP.setBreadcrumb('待复习'); + main.innerHTML = '
'; + + try { + const questions = await API.get('/api/questions/review'); + renderQuestionListPage(main, questions, '待复习', '没有待复习的题目,继续保持!', '需要复习的题目会自动出现在这里'); + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} + +/** + * 通用题目列表渲染 + */ +function renderQuestionListPage(main, questions, title, emptyText, subtitle) { + if (!questions || questions.length === 0) { + main.innerHTML = ` +
+
+

${title}

+
+
+ +

${emptyText}

+
+
`; + return; + } + + main.innerHTML = ` +
+
+
+

${title}

+ ${subtitle ? `

${subtitle}

` : ''} +
+ 共 ${questions.length} 题 +
+
+ ${questions.map(q => { + const diffCls = ['', 'tag-green', 'tag-yellow', 'tag-red'][q.difficulty] || ''; + const masteryCls = ['tag-gray', 'tag-red', 'tag-yellow', 'tag-green'][q.masteryStatus] || ''; + const masteryText = CONFIG.MASTERY_MAP[q.masteryStatus] || ''; + return ` +
+
+
${q.title}
+
+ ${q.category ? `${q.category}` : ''} + ${CONFIG.DIFFICULTY_MAP[q.difficulty] || ''} + ${masteryText ? `${masteryText}` : ''} +
+
+
+ ${q.isFavorite ? '' : ''} + +
+
`; + }).join('')} +
+
`; + + $$('.question-item').forEach(item => { + item.addEventListener('click', () => { + router.navigate(`/question/${item.dataset.id}`); + }); + }); +} diff --git a/web/js/pages/stats.js b/web/js/pages/stats.js new file mode 100644 index 0000000..2fe9535 --- /dev/null +++ b/web/js/pages/stats.js @@ -0,0 +1,153 @@ +/** + * 学习统计页面 + * 展示详细学习数据:趋势图、覆盖度、贡献率 + */ +async function renderStats() { + const main = $('#main-content'); + APP.setBreadcrumb('学习统计'); + main.innerHTML = '
'; + + try { + const [stats, checkin, trend, mastery, coverage, contribution] = await Promise.all([ + API.get('/api/record/stats'), + API.get('/api/checkin/info'), + API.get('/api/leaderboard/trend'), + API.get('/api/leaderboard/mastery-overview'), + API.get('/api/leaderboard/coverage'), + API.get('/api/leaderboard/contribution') + ]); + + main.innerHTML = ` +
+
+

学习统计

+
+ +
+
+
${stats.totalQuestions}
+
总题目数
+
+
+
${stats.masteredCount}
+
已掌握
+
+
+
${stats.unfamiliarCount}
+
待复习
+
+
+
${stats.favoriteCount}
+
收藏
+
+
+
${checkin.totalCheckinDays || 0}
+
总打卡天数
+
+
+
${checkin.currentStreak || 0}
+
连续打卡
+
+
+ +
+
+ 学习趋势 +
+ +
+ +
+
+
+ 分类覆盖度 +
+ +
+
+
+ 掌握概览 +
+ +
+
+
`; + + // 渲染图表 + if (typeof Chart !== 'undefined') { + // 趋势 + new Chart(document.getElementById('statsTrendChart'), { + type: 'line', + data: { + labels: (trend || []).map(t => t.date), + datasets: [{ + label: '学习分钟', + data: (trend || []).map(t => t.minutes), + borderColor: '#3b82f6', + backgroundColor: 'rgba(59,130,246,0.1)', + fill: true, + tension: 0.3 + }] + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { labels: { color: '#94a3b8' } } }, + scales: { + x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(255,255,255,0.05)' } }, + y: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(255,255,255,0.05)' }, beginAtZero: true } + } + } + }); + + // 覆盖度 + new Chart(document.getElementById('coverageChart'), { + type: 'radar', + data: { + labels: (coverage || []).map(c => c.category), + datasets: [{ + label: '覆盖度', + data: (coverage || []).map(c => Math.round((c.masteredCount / c.totalCount) * 100)), + backgroundColor: 'rgba(59,130,246,0.2)', + borderColor: '#3b82f6', + pointBackgroundColor: '#3b82f6' + }] + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { labels: { color: '#94a3b8' } } }, + scales: { + r: { + ticks: { color: '#94a3b8', backdropColor: 'transparent' }, + grid: { color: 'rgba(255,255,255,0.1)' } + } + } + } + }); + + // 掌握概览 + new Chart(document.getElementById('statsMasteryChart'), { + type: 'doughnut', + data: { + labels: ['已掌握', '不熟/不会', '未学习'], + datasets: [{ + data: [mastery.masteredCount || 0, mastery.unfamiliarCount || 0, mastery.unknownCount || 0], + backgroundColor: ['#22c55e', '#f59e0b', '#64748b'], + borderWidth: 0 + }] + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { color: '#94a3b8', padding: 12 } + } + } + } + }); + } + + } catch (err) { + main.innerHTML = `

${err.message}

`; + } +} diff --git a/web/js/router.js b/web/js/router.js new file mode 100644 index 0000000..45f50cd --- /dev/null +++ b/web/js/router.js @@ -0,0 +1,141 @@ +/** + * 前端 Hash 路由 + * 支持嵌套布局(侧边栏+内容区) + */ +class Router { + constructor() { + this.routes = {}; + this.beforeHooks = []; + } + + /** 注册路由 */ + register(path, handler) { + this.routes[path] = handler; + } + + /** 注册前置钩子 */ + beforeEach(hook) { + this.beforeHooks.push(hook); + } + + /** 切换登录/非登录布局 */ + toggleLayout(isLogin) { + document.querySelector('.sidebar')?.classList.toggle('hidden', isLogin); + document.querySelector('.desktop-header')?.classList.toggle('hidden', isLogin); + document.querySelector('.mobile-header')?.classList.toggle('hidden', isLogin); + document.querySelector('.mobile-tabs')?.classList.toggle('hidden', isLogin); + document.querySelector('.main-wrapper')?.classList.toggle('full-width', isLogin); + } + + /** 导航到指定路径 */ + navigate(path) { + this.toggleLayout(path === '/login'); + window.location.hash = '#' + path; + } + + /** 获取当前 hash 路径(去掉 #) */ + getCurrentPath() { + const hash = window.location.hash || '#/dashboard'; + return hash.replace(/^#/, ''); + } + + /** 解析路径中的参数(支持 /question/:id 形式,自动处理 ?query=param) */ + matchRoute(path) { + // 分离路径和查询参数 + const [basePath, queryString] = path.split('?'); + const params = {}; + if (queryString) { + new URLSearchParams(queryString).forEach((value, key) => { + params[key] = value; + }); + } + + // 精确匹配 + if (this.routes[basePath]) return { handler: this.routes[basePath], params }; + + // 参数匹配:查找形如 /question/:id 的路由 + for (const pattern of Object.keys(this.routes)) { + const patternParts = pattern.split('/'); + const pathParts = basePath.split('/'); + if (patternParts.length !== pathParts.length) continue; + + let matched = true; + for (let i = 0; i < patternParts.length; i++) { + if (patternParts[i].startsWith(':')) { + params[patternParts[i].slice(1)] = pathParts[i]; + } else if (patternParts[i] !== pathParts[i]) { + matched = false; + break; + } + } + if (matched) return { handler: this.routes[pattern], params }; + } + + return null; + } + + + /** 启动路由监听 */ + start() { + window.addEventListener('hashchange', () => this.resolve()); + this.resolve(); + } + + /** 解析并渲染当前路由 */ + async resolve() { + const path = this.getCurrentPath(); + + // 根据当前路径切换布局(登录页不显示导航栏) + this.toggleLayout(path === '/login'); + + const matched = this.matchRoute(path); + const mainContent = $('#main-content'); + + if (!matched) { + this.navigate('/dashboard'); + return; + } + + // 执行前置钩子(如登录检查) + for (const hook of this.beforeHooks) { + const result = hook(path, matched); + if (result === false) return; + } + + // 更新导航高亮 + this.updateNavActive(path); + + + try { + mainContent.innerHTML = '
'; + await matched.handler(matched.params); + } catch (err) { + mainContent.innerHTML = ` +
+ +

${err.message}

+ +
`; + } + } + + /** 更新导航高亮 */ + updateNavActive(path) { + $$('.nav-item').forEach(item => { + const href = item.getAttribute('href'); + if (href) { + const active = path.startsWith(href); + item.classList.toggle('active', active); + } + }); + $$('.tab-item').forEach(item => { + const href = item.dataset.route; + if (href) { + item.classList.toggle('active', path.startsWith(href)); + } + }); + } +} + +// 创建全局路由实例 +const router = new Router(); diff --git a/web/js/utils.js b/web/js/utils.js new file mode 100644 index 0000000..69f857d --- /dev/null +++ b/web/js/utils.js @@ -0,0 +1,134 @@ +/** + * 工具函数集合 + */ + +// DOM 快捷操作 +const $ = (sel, ctx = document) => ctx.querySelector(sel); +const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)]; + +/** + * 创建带 class 的 DOM 元素 + * @param {string} tag - 标签名 + * @param {string|string[]} [classes] - class 名或数组 + * @param {object} [attrs] - 属性键值对 + * @returns {HTMLElement} + */ +function h(tag, classes, attrs) { + const el = document.createElement(tag); + if (classes) { + const arr = Array.isArray(classes) ? classes : [classes]; + arr.filter(Boolean).forEach(c => el.classList.add(...c.split(' '))); + } + if (attrs) { + Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v)); + } + return el; +} + +/** + * 创建文本节点 + */ +function txt(text) { + return document.createTextNode(text); +} + +/** + * 清空元素 + */ +function empty(el) { + while (el.firstChild) el.removeChild(el.firstChild); +} + +/** + * 格式化日期 YYYY-MM-DD HH:mm + */ +function formatDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** + * 格式化日期 YYYY-MM-DD + */ +function formatDateShort(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; +} + +/** + * 显示 Toast 提示 + */ +function showToast(msg, type = 'info') { + const container = $('#toast-container'); + if (!container) return; + const toast = h('div', `toast toast-${type}`); + toast.textContent = msg; + container.appendChild(toast); + setTimeout(() => toast.classList.add('show'), 10); + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 2500); +} + +/** + * 显示确认对话框 + * @returns {Promise} + */ +function showConfirm(msg) { + return new Promise(resolve => { + const modal = h('div', 'modal-overlay'); + modal.innerHTML = ` +
+

${msg}

+
+ + +
+
`; + document.body.appendChild(modal); + setTimeout(() => modal.classList.add('show'), 10); + modal.addEventListener('click', e => { + if (e.target === modal || e.target.dataset.cancel) { + modal.classList.remove('show'); + setTimeout(() => modal.remove(), 300); + resolve(false); + } else if (e.target.dataset.confirm) { + modal.classList.remove('show'); + setTimeout(() => modal.remove(), 300); + resolve(true); + } + }); + }); +} + +/** + * 加载状态切换 + */ +function setLoading(el, loading = true) { + if (loading) { + el.dataset.originalHtml = el.innerHTML; + el.innerHTML = ''; + el.disabled = true; + } else { + if (el.dataset.originalHtml) { + el.innerHTML = el.dataset.originalHtml; + } + el.disabled = false; + } +} + +/** + * 防抖 + */ +function debounce(fn, delay = 300) { + let timer; + return function (...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + }; +}