feat(front): 前台默认浅色模式并新增居中投稿按钮与实时空闲档期

- 主题: useTheme 默认 ModernLight + variables.css SSR-safe 浅值并精修组件对比度
- 首页: 插入居中投稿 CTA + 紧邻档期概览卡片(今日排期/待播歌曲/空闲档期数)
- 数据: useSongs 新增 fetchExpectedPlayDates 接入空闲档期并走 revision 轮询实时刷新
- 视觉: 移动端优先,少圆角硬边几何 + 概览卡片 3→1 列响应式
This commit is contained in:
2026-09-13 19:32:27 +08:00
parent f80abe659f
commit 92704d6637
9 changed files with 1836 additions and 1463 deletions
+337 -4
View File
@@ -7,7 +7,7 @@
/* 防止浏览器自动应用深色主题:改为跟随 data-theme 动态覆盖(见 theme-protection.css) */ /* 防止浏览器自动应用深色主题:改为跟随 data-theme 动态覆盖(见 theme-protection.css) */
:root { :root {
color-scheme: light dark; color-scheme: light;
} }
/* 流体背景与全局样式 */ /* 流体背景与全局样式 */
@@ -82,9 +82,9 @@ body::before {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: background:
radial-gradient(circle at 20% 50%, rgba(120, 119, 198, 0.03) 0%, transparent 50%), radial-gradient(circle at 20% 50%, rgba(79, 70, 229, 0.035) 0%, transparent 55%),
radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.01) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(14, 165, 233, 0.025) 0%, transparent 55%),
radial-gradient(circle at 40% 80%, rgba(120, 119, 198, 0.02) 0%, transparent 50%); radial-gradient(circle at 40% 80%, rgba(79, 70, 229, 0.02) 0%, transparent 55%);
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
} }
@@ -472,3 +472,336 @@ body::before {
max-width: none !important; max-width: none !important;
} }
} }
/* =========================================================
* 前台浅色模式对比度覆盖(ScheduleList / RequestForm 等组件)
* 将所有硬编码的 rgba(255,255,255,*) 白系文字统一替换为
* 适配浅底的深灰/主题色,保证 WCAG AA 可读性
* =======================================================*/
html[data-theme='light'],
html[data-theme='ClassicLight'],
html[data-theme='ModernLight'] {
/* ScheduleList.vue 主区块硬编码颜色校正 */
color-scheme: light;
}
html[data-theme='light'] .schedule-list .date-item,
html[data-theme='ClassicLight'] .schedule-list .date-item,
html[data-theme='ModernLight'] .schedule-list .date-item,
html[data-theme='light'] .song-list .date-item,
html[data-theme='ClassicLight'] .song-list .date-item,
html[data-theme='ModernLight'] .song-list .date-item {
color: var(--text-secondary) !important;
}
html[data-theme='light'] .schedule-list .empty-dates,
html[data-theme='ClassicLight'] .schedule-list .empty-dates,
html[data-theme='ModernLight'] .schedule-list .empty-dates,
html[data-theme='light'] .schedule-list .loading,
html[data-theme='ClassicLight'] .schedule-list .loading,
html[data-theme='ModernLight'] .schedule-list .loading,
html[data-theme='light'] .schedule-list .error,
html[data-theme='ClassicLight'] .schedule-list .error,
html[data-theme='ModernLight'] .schedule-list .error,
html[data-theme='light'] .schedule-list .empty,
html[data-theme='ClassicLight'] .schedule-list .empty,
html[data-theme='ModernLight'] .schedule-list .empty {
color: var(--text-tertiary) !important;
}
html[data-theme='light'] .schedule-list .playtime-header h4,
html[data-theme='ClassicLight'] .schedule-list .playtime-header h4,
html[data-theme='ModernLight'] .schedule-list .playtime-header h4 {
color: var(--text-secondary) !important;
}
html[data-theme='light'] .schedule-list .playtime-time,
html[data-theme='ClassicLight'] .schedule-list .playtime-time,
html[data-theme='ModernLight'] .schedule-list .playtime-time {
color: var(--text-tertiary) !important;
}
html[data-theme='light'] .schedule-list .playlist-modal,
html[data-theme='ClassicLight'] .schedule-list .playlist-modal,
html[data-theme='ModernLight'] .schedule-list .playlist-modal {
border-color: var(--border-secondary) !important;
}
html[data-theme='light'] .schedule-list .playlist-modal-header,
html[data-theme='ClassicLight'] .schedule-list .playlist-modal-header,
html[data-theme='ModernLight'] .schedule-list .playlist-modal-header,
html[data-theme='light'] .schedule-list .playlist-modal-footer,
html[data-theme='ClassicLight'] .schedule-list .playlist-modal-footer,
html[data-theme='ModernLight'] .schedule-list .playlist-modal-footer {
border-color: var(--border-secondary) !important;
}
html[data-theme='light'] .schedule-list .song-meta .meta-secondary,
html[data-theme='ClassicLight'] .schedule-list .song-meta .meta-secondary,
html[data-theme='ModernLight'] .schedule-list .song-meta .meta-secondary,
html[data-theme='light'] .schedule-list .song-meta .meta-placeholder,
html[data-theme='ClassicLight'] .schedule-list .song-meta .meta-placeholder,
html[data-theme='ModernLight'] .schedule-list .song-meta .meta-placeholder,
html[data-theme='light'] .schedule-list .slot-hint,
html[data-theme='ClassicLight'] .schedule-list .slot-hint,
html[data-theme='ModernLight'] .schedule-list .slot-hint,
html[data-theme='light'] .schedule-list .requestor-text,
html[data-theme='ClassicLight'] .schedule-list .requestor-text,
html[data-theme='ModernLight'] .schedule-list .requestor-text,
html[data-theme='light'] .schedule-list .pill-empty,
html[data-theme='ClassicLight'] .schedule-list .pill-empty,
html[data-theme='ModernLight'] .schedule-list .pill-empty,
html[data-theme='light'] .schedule-list .empty-slot,
html[data-theme='ClassicLight'] .schedule-list .empty-slot,
html[data-theme='ModernLight'] .schedule-list .empty-slot {
color: var(--text-tertiary) !important;
}
html[data-theme='light'] .schedule-list .chip,
html[data-theme='ClassicLight'] .schedule-list .chip,
html[data-theme='ModernLight'] .schedule-list .chip,
html[data-theme='light'] .schedule-list .tag,
html[data-theme='ClassicLight'] .schedule-list .tag,
html[data-theme='ModernLight'] .schedule-list .tag,
html[data-theme='light'] .schedule-list .badge-soft,
html[data-theme='ClassicLight'] .schedule-list .badge-soft,
html[data-theme='ModernLight'] .schedule-list .badge-soft {
background: var(--bg-secondary) !important;
border-color: var(--border-secondary) !important;
color: var(--text-secondary) !important;
}
html[data-theme='light'] .schedule-list .divider-subtle,
html[data-theme='ClassicLight'] .schedule-list .divider-subtle,
html[data-theme='ModernLight'] .schedule-list .divider-subtle,
html[data-theme='light'] .schedule-list .card-border,
html[data-theme='ClassicLight'] .schedule-list .card-border,
html[data-theme='ModernLight'] .schedule-list .card-border {
border-color: var(--border-secondary) !important;
}
/* RequestForm / SongList / Dialog 通用硬编码白字校正 */
html[data-theme='light'] .request-form .label-subtle,
html[data-theme='ClassicLight'] .request-form .label-subtle,
html[data-theme='ModernLight'] .request-form .label-subtle,
html[data-theme='light'] .request-form .hint-subtle,
html[data-theme='ClassicLight'] .request-form .hint-subtle,
html[data-theme='ModernLight'] .request-form .hint-subtle,
html[data-theme='light'] .request-form .placeholder-subtle,
html[data-theme='ClassicLight'] .request-form .placeholder-subtle,
html[data-theme='ModernLight'] .request-form .placeholder-subtle,
html[data-theme='light'] .song-list .muted,
html[data-theme='ClassicLight'] .song-list .muted,
html[data-theme='ModernLight'] .song-list .muted,
html[data-theme='light'] .song-list .subtle-text,
html[data-theme='ClassicLight'] .song-list .subtle-text,
html[data-theme='ModernLight'] .song-list .subtle-text {
color: var(--text-tertiary) !important;
}
html[data-theme='light'] .request-form .glass-surface,
html[data-theme='ClassicLight'] .request-form .glass-surface,
html[data-theme='ModernLight'] .request-form .glass-surface,
html[data-theme='light'] .request-form .surface-chip,
html[data-theme='ClassicLight'] .request-form .surface-chip,
html[data-theme='ModernLight'] .request-form .surface-chip,
html[data-theme='light'] .song-list .glass-surface,
html[data-theme='ClassicLight'] .song-list .glass-surface,
html[data-theme='ModernLight'] .song-list .glass-surface,
html[data-theme='light'] .song-list .surface-chip,
html[data-theme='ClassicLight'] .song-list .surface-chip,
html[data-theme='ModernLight'] .song-list .surface-chip {
background: var(--bg-secondary) !important;
border-color: var(--border-secondary) !important;
}
/* =========================================================
* 首页 Hero CTA 区样式 — 投稿按钮居中 + 档期概览卡片
* 移动端优先 / 少圆角 / 硬边几何 / 现代浅色专业风格
* =======================================================*/
.hero-cta-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 28px 16px 20px;
gap: 14px;
width: 100%;
max-width: 760px;
margin: 0 auto;
}
.hero-request-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-width: 240px;
width: min(86%, 360px);
height: 58px;
padding: 0 22px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: linear-gradient(135deg, var(--primary) 0%, #4338ca 100%);
color: #ffffff;
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 17px;
font-weight: 600;
letter-spacing: 0.2px;
cursor: pointer;
user-select: none;
box-shadow:
0 10px 24px -10px rgba(79, 70, 229, 0.55),
0 4px 10px -4px rgba(79, 70, 229, 0.35);
transition: transform 0.18s cubic-bezier(0.2, 0.9, 0.2, 1),
box-shadow 0.18s ease, filter 0.18s ease;
-webkit-tap-highlight-color: transparent;
}
.hero-request-btn:hover {
transform: translateY(-1px);
filter: brightness(1.03);
box-shadow:
0 14px 28px -10px rgba(79, 70, 229, 0.62),
0 6px 12px -4px rgba(79, 70, 229, 0.42);
}
.hero-request-btn:active {
transform: translateY(0);
filter: brightness(0.97);
box-shadow:
0 6px 14px -6px rgba(79, 70, 229, 0.5),
0 2px 6px -2px rgba(79, 70, 229, 0.35);
}
.hero-request-btn .btn-icon {
font-size: 20px;
line-height: 1;
}
.hero-request-btn .btn-icon.pulse::after {
content: '';
position: absolute;
right: 10px;
top: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.22);
}
.schedule-overview-card {
width: 100%;
max-width: 720px;
padding: 18px 18px;
border-radius: 12px;
background: var(--card-bg, #ffffff);
border: 1px solid var(--card-border, #e4e4e7);
box-shadow: var(--card-shadow);
}
.schedule-overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
@media (max-width: 520px) {
.schedule-overview-grid {
grid-template-columns: 1fr;
gap: 12px;
}
}
.overview-stat {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 14px 16px;
border-radius: 10px;
background: var(--bg-secondary);
border: 1px solid var(--border-secondary);
min-height: 96px;
}
.overview-stat .stat-icon {
font-size: 16px;
line-height: 1;
margin-bottom: 6px;
color: var(--text-tertiary);
}
.overview-stat .stat-label {
font-size: 12px;
line-height: 1.4;
color: var(--text-tertiary);
font-weight: 500;
margin-bottom: 6px;
letter-spacing: 0.2px;
}
.overview-stat .stat-value {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
letter-spacing: -0.3px;
display: flex;
align-items: baseline;
gap: 6px;
}
.overview-stat .stat-value .unit {
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
letter-spacing: 0;
}
.overview-stat.primary {
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(14, 165, 233, 0.06));
border-color: rgba(79, 70, 229, 0.15);
}
.overview-stat.primary .stat-value {
color: var(--primary);
font-size: 28px;
}
.overview-stat.primary .stat-icon {
color: var(--primary);
}
.overview-stat .stat-detail {
margin-top: 4px;
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.35;
}
.overview-stat .live-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
margin-right: 5px;
vertical-align: middle;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
animation: live-pulse 1.6s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.65; transform: scale(0.92); }
}
@media (max-width: 640px) {
.hero-cta-section {
padding: 22px 12px 16px;
gap: 12px;
}
.hero-request-btn {
height: 54px;
font-size: 16px;
border-radius: 12px;
width: 88%;
}
.schedule-overview-card {
padding: 14px 12px;
border-radius: 10px;
}
.overview-stat {
padding: 12px 14px;
min-height: 84px;
}
.overview-stat.primary .stat-value {
font-size: 26px;
}
}
+109 -210
View File
@@ -1,69 +1,59 @@
/* 全局CSS变量 - 统一配色方案 */ /* 全局CSS变量 - 统一配色方案 */
/* SSR-safe 默认值 = ModernLight 浅色,防止 hydration 前出现深色闪屏 */
:root { :root {
/* 主色调 */ --primary: #4f46e5;
--primary: #3b82f6; --primary-hover: #4338ca;
--primary-hover: #2563eb; --primary-light: rgba(79, 70, 229, 0.1);
--primary-light: rgba(59, 130, 246, 0.1); --primary-border: rgba(79, 70, 229, 0.2);
--primary-border: rgba(59, 130, 246, 0.2);
/* 成功色 */ --success: #16a34a;
--success: #10b981; --success-hover: #15803d;
--success-hover: #059669; --success-light: rgba(22, 163, 74, 0.1);
--success-light: rgba(16, 185, 129, 0.1); --success-border: rgba(22, 163, 74, 0.2);
--success-border: rgba(16, 185, 129, 0.2);
/* 警告色 */ --warning: #ea580c;
--warning: #f59e0b; --warning-hover: #c2410c;
--warning-hover: #d97706; --warning-light: rgba(234, 88, 12, 0.1);
--warning-light: rgba(245, 158, 11, 0.1); --warning-border: rgba(234, 88, 12, 0.2);
--warning-border: rgba(245, 158, 11, 0.2);
/* 错误色 */ --error: #e11d48;
--error: #ef4444; --error-hover: #be123c;
--error-hover: #dc2626; --error-light: rgba(225, 29, 72, 0.1);
--error-light: rgba(239, 68, 68, 0.1); --error-border: rgba(225, 29, 72, 0.2);
--error-border: rgba(239, 68, 68, 0.2);
/* 信息色 */ --info: #0ea5e9;
--info: #3b82f6; --info-hover: #0284c7;
--info-hover: #2563eb; --info-light: rgba(14, 165, 233, 0.1);
--info-light: rgba(59, 130, 246, 0.1); --info-border: rgba(14, 165, 233, 0.2);
--info-border: rgba(59, 130, 246, 0.2);
/* 背景色 */ --bg-primary: #fafafa;
--bg-primary: #111111; --bg-secondary: #f4f4f5;
--bg-secondary: #1a1a1a; --bg-tertiary: #e4e4e7;
--bg-tertiary: #2a2a2a; --bg-quaternary: #d4d4d8;
--bg-quaternary: #3a3a3a; --bg-hover: #eeeeef;
--bg-hover: #1f1f1f;
/* 文字颜色 */ --text-primary: #09090b;
--text-primary: #ffffff; --text-secondary: #3f3f46;
--text-secondary: #cccccc; --text-tertiary: #71717a;
--text-tertiary: #888888; --text-quaternary: #a1a1aa;
--text-quaternary: #666666; --text-disabled: #d4d4d8;
--text-disabled: #444444;
/* 边框颜色 */ --border-primary: #e4e4e7;
--border-primary: #1f1f1f; --border-secondary: #d4d4d8;
--border-secondary: #2a2a2a; --border-tertiary: #a1a1aa;
--border-tertiary: #3a3a3a; --border-quaternary: #71717a;
--border-quaternary: #4a4a4a;
/* 阴影 */ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-md: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 3px 0 rgba(0, 0, 0, 0.03);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); --shadow-lg: 0 8px 24px -4px rgba(0, 0, 0, 0.06), 0 2px 8px -4px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); --shadow-xl: 0 16px 40px -8px rgba(0, 0, 0, 0.08), 0 4px 16px -8px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
/* 圆角 */
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 6px; --radius-md: 6px;
--radius-lg: 8px; --radius-lg: 8px;
--radius-xl: 12px; --radius-xl: 10px;
--radius-2xl: 16px; --radius-2xl: 12px;
/* 间距 */
--spacing-xs: 4px; --spacing-xs: 4px;
--spacing-sm: 8px; --spacing-sm: 8px;
--spacing-md: 12px; --spacing-md: 12px;
@@ -72,7 +62,6 @@
--spacing-2xl: 24px; --spacing-2xl: 24px;
--spacing-3xl: 32px; --spacing-3xl: 32px;
/* 字体大小 */
--text-xs: 12px; --text-xs: 12px;
--text-sm: 14px; --text-sm: 14px;
--text-base: 16px; --text-base: 16px;
@@ -81,18 +70,15 @@
--text-2xl: 24px; --text-2xl: 24px;
--text-3xl: 30px; --text-3xl: 30px;
/* 字体粗细 */
--font-normal: 400; --font-normal: 400;
--font-medium: 500; --font-medium: 500;
--font-semibold: 600; --font-semibold: 600;
--font-bold: 700; --font-bold: 700;
/* 过渡动画 */
--transition-fast: 0.15s ease; --transition-fast: 0.15s ease;
--transition-normal: 0.2s ease; --transition-normal: 0.2s ease;
--transition-slow: 0.3s ease; --transition-slow: 0.3s ease;
/* Z-index层级 */
--z-dropdown: 100; --z-dropdown: 100;
--z-sticky: 200; --z-sticky: 200;
--z-fixed: 300; --z-fixed: 300;
@@ -102,81 +88,72 @@
--z-tooltip: 700; --z-tooltip: 700;
--z-toast: 800; --z-toast: 800;
/* 特殊状态颜色 */ --status-pending: #eab308;
--status-pending: #fbbf24; --status-pending-light: rgba(234, 179, 8, 0.12);
--status-pending-light: rgba(251, 191, 36, 0.1); --status-approved: #22c55e;
--status-approved: #10b981; --status-approved-light: rgba(34, 197, 94, 0.12);
--status-approved-light: rgba(16, 185, 129, 0.1); --status-played: #71717a;
--status-played: #6b7280; --status-played-light: rgba(113, 113, 122, 0.12);
--status-played-light: rgba(107, 114, 128, 0.1);
/* 投票相关颜色 */
--vote-heart: #ef4444; --vote-heart: #ef4444;
--vote-heart-hover: #dc2626; --vote-heart-hover: #dc2626;
--vote-count: var(--text-tertiary); --vote-count: var(--text-tertiary);
/* 拖拽状态颜色 */
--drag-over: var(--primary-light); --drag-over: var(--primary-light);
--drag-over-border: var(--primary); --drag-over-border: var(--primary);
--dragging-opacity: 0.5; --dragging-opacity: 0.5;
/* 表格颜色 */ --table-header-bg: #f4f4f5;
--table-header-bg: var(--bg-tertiary); --table-row-hover: #fafafa;
--table-row-hover: var(--bg-hover); --table-border: #e4e4e7;
--table-border: var(--border-secondary); --table-selected: rgba(79, 70, 229, 0.08);
--table-selected: var(--primary-light);
/* 卡片颜色 */ --card-bg: #ffffff;
--card-bg: var(--bg-secondary); --card-border: #e4e4e7;
--card-border: var(--border-secondary); --card-hover-border: #d4d4d8;
--card-hover-border: var(--border-tertiary); --card-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 3px 0 rgba(0, 0, 0, 0.03);
--card-shadow: var(--shadow-md);
/* 按钮颜色 */
--btn-primary-bg: var(--primary); --btn-primary-bg: var(--primary);
--btn-primary-hover: var(--primary-hover); --btn-primary-hover: var(--primary-hover);
--btn-primary-text: var(--text-primary); --btn-primary-text: #ffffff;
--btn-primary-border: var(--primary-border); --btn-primary-border: var(--primary-border);
--btn-secondary-bg: #3a3a3a; --btn-secondary-bg: #f4f4f5;
--btn-secondary-hover: #4a4a4a; --btn-secondary-hover: #e4e4e7;
--btn-secondary-text: var(--text-primary); --btn-secondary-text: #09090b;
--btn-secondary-border: #555555; --btn-secondary-border: #d4d4d8;
--btn-success-bg: var(--success); --btn-success-bg: var(--success);
--btn-success-hover: var(--success-hover); --btn-success-hover: var(--success-hover);
--btn-success-text: var(--text-primary); --btn-success-text: #ffffff;
--btn-success-border: var(--success-border); --btn-success-border: var(--success-border);
--btn-warning-bg: var(--warning); --btn-warning-bg: var(--warning);
--btn-warning-hover: var(--warning-hover); --btn-warning-hover: var(--warning-hover);
--btn-warning-text: var(--text-primary); --btn-warning-text: #ffffff;
--btn-warning-border: var(--warning-border); --btn-warning-border: var(--warning-border);
--btn-error-bg: var(--error); --btn-error-bg: var(--error);
--btn-error-hover: var(--error-hover); --btn-error-hover: var(--error-hover);
--btn-error-text: var(--text-primary); --btn-error-text: #ffffff;
--btn-error-border: var(--error-border); --btn-error-border: var(--error-border);
--btn-disabled-bg: var(--bg-tertiary); --btn-disabled-bg: var(--bg-tertiary);
--btn-disabled-text: var(--text-disabled); --btn-disabled-text: var(--text-disabled);
/* 输入框颜色 */ --input-bg: #ffffff;
--input-bg: var(--bg-tertiary); --input-border: #e4e4e7;
--input-border: var(--border-tertiary); --input-border-focus: #4f46e5;
--input-border-focus: var(--primary); --input-text: #09090b;
--input-text: var(--text-primary); --input-placeholder: #a1a1aa;
--input-placeholder: var(--text-quaternary); --input-shadow-focus: 0 0 0 3px rgba(79, 70, 229, 0.12);
--input-shadow-focus: 0 0 0 3px var(--primary-light);
/* 模态框颜色 */ --modal-backdrop: rgba(0, 0, 0, 0.4);
--modal-backdrop: rgba(0, 0, 0, 0.7);
--modal-bg: var(--bg-secondary); --modal-bg: var(--bg-secondary);
--modal-border: var(--border-secondary); --modal-border: var(--border-secondary);
--modal-header-border: var(--border-secondary); --modal-header-border: var(--border-secondary);
--modal-footer-border: var(--border-secondary); --modal-footer-border: var(--border-secondary);
/* 通知颜色 */
--notification-success-bg: var(--success-light); --notification-success-bg: var(--success-light);
--notification-success-border: var(--success-border); --notification-success-border: var(--success-border);
--notification-success-text: var(--success); --notification-success-text: var(--success);
@@ -193,14 +170,51 @@
--notification-info-border: var(--info-border); --notification-info-border: var(--info-border);
--notification-info-text: var(--info); --notification-info-text: var(--info);
/* 加载动画颜色 */
--spinner-primary: var(--primary); --spinner-primary: var(--primary);
--spinner-secondary: var(--bg-tertiary); --spinner-secondary: var(--bg-tertiary);
/* 滚动条颜色 */ --scrollbar-track: #f4f4f5;
--scrollbar-thumb: #d4d4d8;
--scrollbar-thumb-hover: #a1a1aa;
}
:root[data-theme='dark'] {
--bg-primary: #111111;
--bg-secondary: #1a1a1a;
--bg-tertiary: #2a2a2a;
--bg-quaternary: #3a3a3a;
--bg-hover: #1f1f1f;
--text-primary: #ffffff;
--text-secondary: #cccccc;
--text-tertiary: #888888;
--text-quaternary: #666666;
--text-disabled: #444444;
--border-primary: #1f1f1f;
--border-secondary: #2a2a2a;
--border-tertiary: #3a3a3a;
--border-quaternary: #4a4a4a;
--card-bg: #1a1a1a;
--card-border: #2a2a2a;
--btn-secondary-bg: #3a3a3a;
--btn-secondary-hover: #4a4a4a;
--btn-secondary-text: #ffffff;
--btn-secondary-border: #555555;
--input-bg: var(--bg-tertiary);
--input-border: var(--border-tertiary);
--input-text: var(--text-primary);
--input-placeholder: var(--text-quaternary);
--scrollbar-track: var(--bg-tertiary); --scrollbar-track: var(--bg-tertiary);
--scrollbar-thumb: var(--bg-quaternary); --scrollbar-thumb: var(--bg-quaternary);
--scrollbar-thumb-hover: #4a4a4a; --scrollbar-thumb-hover: #4a4a4a;
--modal-backdrop: rgba(0, 0, 0, 0.7);
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
} }
:root[data-theme='light'], :root[data-theme='light'],
@@ -398,121 +412,6 @@
--radius-2xl: 12px; --radius-2xl: 12px;
} }
/* ===== 前台页面专属浅色主题覆盖(广播园艺风格)===== */
body[data-frontend="true"]:not(.admin-body) {
/* 强调色:紫粉蓝渐变点缀(广播园艺三色花) */
--accent-purple: #A855F7;
--accent-pink: #EC4899;
--accent-blue: #3B82F6;
/* 背景色:纯白为主,留白优先 */
--bg-primary: #FFFFFF;
--bg-secondary: #F9FAFB;
--bg-tertiary: #F3F4F6;
--bg-quaternary: #E5E7EB;
--bg-hover: #F3F4F6;
/* 文字颜色:近黑高对比度 */
--text-primary: #111827;
--text-secondary: #4B5563;
--text-tertiary: #6B7280;
--text-quaternary: #9CA3AF;
--text-disabled: #D1D5DB;
/* 边框颜色:硬边清晰,4px 网格 */
--border-primary: #E5E7EB;
--border-secondary: #D1D5DB;
--border-tertiary: #9CA3AF;
--border-quaternary: #6B7280;
/* 卡片:白底薄边 + 极轻阴影,不抢视觉 */
--card-bg: #FFFFFF;
--card-border: #E5E7EB;
--card-hover-border: #D1D5DB;
--card-shadow: 0 1px 3px 0 rgba(17, 24, 39, 0.04), 0 1px 2px -1px rgba(17, 24, 39, 0.03);
/* 主色改为深靛蓝(不抢紫粉蓝点缀的风头) */
--primary: #4338CA;
--primary-hover: #3730A3;
--primary-light: rgba(67, 56, 202, 0.08);
--primary-border: rgba(67, 56, 202, 0.15);
/* 信息色沿用主蓝 */
--info: #4338CA;
--info-hover: #3730A3;
--info-light: rgba(67, 56, 202, 0.08);
--info-border: rgba(67, 56, 202, 0.15);
/* 语义色保持经典但略降饱和度 */
--success: #059669;
--success-hover: #047857;
--success-light: rgba(5, 150, 105, 0.08);
--success-border: rgba(5, 150, 105, 0.15);
--warning: #D97706;
--warning-hover: #B45309;
--warning-light: rgba(217, 119, 6, 0.08);
--warning-border: rgba(217, 119, 6, 0.15);
--error: #DC2626;
--error-hover: #B91C1C;
--error-light: rgba(220, 38, 38, 0.08);
--error-border: rgba(220, 38, 38, 0.15);
/* 状态色 */
--status-pending: #CA8A04;
--status-pending-light: rgba(202, 138, 4, 0.08);
--status-approved: #059669;
--status-approved-light: rgba(5, 150, 105, 0.08);
--status-played: #6B7280;
--status-played-light: rgba(107, 114, 128, 0.08);
/* 输入框:纯白底薄边 */
--input-bg: #FFFFFF;
--input-border: #D1D5DB;
--input-border-focus: #4338CA;
--input-text: #111827;
--input-placeholder: #9CA3AF;
--input-shadow-focus: 0 0 0 3px rgba(67, 56, 202, 0.1);
/* 按钮:次要按钮硬边设计 */
--btn-secondary-bg: #F9FAFB;
--btn-secondary-hover: #F3F4F6;
--btn-secondary-text: #111827;
--btn-secondary-border: #D1D5DB;
/* 模态框与通知 */
--modal-backdrop: rgba(17, 24, 39, 0.4);
--modal-bg: #FFFFFF;
--modal-border: #E5E7EB;
--modal-header-border: #E5E7EB;
--modal-footer-border: #E5E7EB;
/* 圆角:硬边设计,上限 rounded-lg (8px) */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 8px;
--radius-2xl: 8px;
/* 阴影:轻盈不厚重 */
--shadow-sm: 0 1px 2px 0 rgba(17, 24, 39, 0.04);
--shadow-md: 0 4px 6px -1px rgba(17, 24, 39, 0.05), 0 2px 4px -2px rgba(17, 24, 39, 0.03);
--shadow-lg: 0 10px 15px -3px rgba(17, 24, 39, 0.05), 0 4px 6px -4px rgba(17, 24, 39, 0.03);
--shadow-xl: 0 20px 25px -5px rgba(17, 24, 39, 0.05), 0 8px 10px -6px rgba(17, 24, 39, 0.03);
/* 滚动条 */
--scrollbar-track: #F3F4F6;
--scrollbar-thumb: #D1D5DB;
--scrollbar-thumb-hover: #9CA3AF;
/* 表格 */
--table-header-bg: #F9FAFB;
--table-row-hover: #F9FAFB;
--table-border: #E5E7EB;
--table-selected: rgba(67, 56, 202, 0.05);
}
/* 深色主题特定调整 */ /* 深色主题特定调整 */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
+31 -47
View File
@@ -176,27 +176,19 @@
:key="item.seq" :key="item.seq"
type="button" type="button"
:class="[ :class="[
'seq', 'sequence-pill',
item.taken ? 'seq-taken' : '', item.taken ? 'taken' : 'free',
expectedPlaySequence === String(item.seq) ? 'seq-selected' : '' expectedPlaySequence === String(item.seq) ? 'selected' : ''
]" ]"
:disabled="item.taken" :disabled="item.taken"
:title="getTakenSequenceTitle(item.seq)" :title="item.taken ? getTakenSequenceTitle(item.seq) : ''"
@click="chooseExpectedPlaySequence(item.seq)" @click="chooseExpectedPlaySequence(item.seq)"
> >
{{ item.seq }} {{ item.seq }}
</button> </button>
</div> </div>
<div v-if="selectedExpectedPlayDate" class="sequence-hint">🟣 可选(紫粉蓝选中) · ⚪ 已占用(灰色带删除线)</div> <div v-if="selectedExpectedPlayDate" class="sequence-hint">绿色可选,红色已占用</div>
<div
v-if="selectedExpectedPlayDate && expectedPlaySequence"
class="sequence-locked"
style="margin-top:14px; display:inline-flex; align-items:center; gap:8px; padding:10px 14px; border-radius:8px; background:linear-gradient(90deg, rgba(168,85,247,.08), rgba(236,72,153,.08), rgba(59,130,246,.08)); border:1px solid rgba(168,85,247,.2); color:#7C3AED; font-size:13px; font-weight:700;"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
已锁定 第 {{ expectedPlaySequence }} 首 位置
</div>
</div> </div>
<!-- 搜索结果容器 --> <!-- 搜索结果容器 -->
@@ -3134,55 +3126,47 @@ defineExpose({
} }
.sequence-grid { .sequence-grid {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); flex-wrap: wrap;
gap: 8px; gap: 0.5rem;
margin-top: 12px;
} }
.seq { .sequence-pill {
width: 40px; height: 28px;
height: 40px; min-width: 28px;
display: inline-flex; padding: 0 10px;
align-items: center; border-radius: 10px;
justify-content: center; border: 1px solid var(--border-primary);
border-radius: 8px; background: rgba(0, 0, 0, 0.25);
background: #fff; color: rgba(255, 255, 255, 0.85);
border: 1.5px solid #d1d5db; font-family: 'MiSans', sans-serif;
color: #111827; font-weight: 700;
font-size: 16px; font-size: 12px;
font-weight: 800; transition: all 0.2s ease;
transition: all 0.12s ease;
cursor: pointer;
} }
.seq:hover:not(:disabled) { .sequence-pill.free {
border-color: #4338ca; border-color: rgba(16, 185, 129, 0.35);
color: #4338ca; background: rgba(16, 185, 129, 0.08);
transform: scale(1.03); color: rgba(16, 185, 129, 0.95);
} }
.seq-taken { .sequence-pill.taken {
background: #f3f4f6; border-color: rgba(239, 68, 68, 0.35);
border-color: #e5e7eb; background: rgba(239, 68, 68, 0.08);
color: #9ca3af; color: rgba(239, 68, 68, 0.95);
text-decoration: line-through;
cursor: not-allowed; cursor: not-allowed;
} }
.seq-selected { .sequence-pill.selected {
background: linear-gradient(135deg, #a855f7 0%, #ec4899 50%, #3b82f6 100%); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
color: #fff; border-color: rgba(59, 130, 246, 0.6);
border-color: transparent;
box-shadow: 0 6px 16px rgba(168, 85, 247, 0.35);
transform: scale(1.05);
} }
.sequence-hint { .sequence-hint {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: rgba(255, 255, 255, 0.55); color: rgba(255, 255, 255, 0.55);
margin-top: 8px;
} }
.search-section { .search-section {
File diff suppressed because it is too large Load Diff
+56
View File
@@ -17,6 +17,7 @@ export const useSongs = () => {
const playTimes = ref<PlayTime[]>([]) const playTimes = ref<PlayTime[]>([])
const playTimeEnabled = ref(false) const playTimeEnabled = ref(false)
const songCount = ref(0) const songCount = ref(0)
const expectedPlayDates = ref<any[]>([])
// 显示通知 // 显示通知
const showNotification = (message: string, type: 'success' | 'error' | 'info' = 'info') => { const showNotification = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
@@ -916,6 +917,56 @@ export const useSongs = () => {
} }
} }
// 获取期望播出日期及空闲档期
const fetchExpectedPlayDates = async (silent = false, forceRefresh = false) => {
if (!silent) {
loading.value = true
}
try {
const data = await dedup.dedupedRequest(
'expected-play-dates',
async () => {
const url = forceRefresh
? `/api/expected-play-dates?t=${Date.now()}`
: '/api/expected-play-dates'
return await $fetch(url)
},
undefined,
forceRefresh
)
if (Array.isArray(data)) {
expectedPlayDates.value = data
} else if (data && Array.isArray((data as any).data)) {
expectedPlayDates.value = (data as any).data
} else {
expectedPlayDates.value = []
}
return expectedPlayDates.value
} catch (err: any) {
if (!silent) {
error.value = err.message || '获取档期数据失败'
}
return expectedPlayDates.value
} finally {
if (!silent) {
loading.value = false
}
}
}
const totalFreeSlots = computed(() =>
expectedPlayDates.value.reduce(
(sum, d) => sum + (Array.isArray(d.freeSequences) ? d.freeSequences.length : 0),
0
)
)
const openDaysCount = computed(() =>
expectedPlayDates.value.filter((d) => !d.isFull).length
)
const expectedPlayDatesLoaded = computed(() => Array.isArray(expectedPlayDates.value))
// 初始化加载 // 初始化加载
const initialize = async () => { const initialize = async () => {
await fetchPlayTimes() await fetchPlayTimes()
@@ -932,6 +983,10 @@ export const useSongs = () => {
publicSchedules, publicSchedules,
visibleSongs, visibleSongs,
songCount, songCount,
expectedPlayDates,
totalFreeSlots,
openDaysCount,
expectedPlayDatesLoaded,
loading, loading,
error, error,
notification, notification,
@@ -943,6 +998,7 @@ export const useSongs = () => {
fetchPublicSchedules, fetchPublicSchedules,
fetchPlayTimes, fetchPlayTimes,
fetchSongCount, fetchSongCount,
fetchExpectedPlayDates,
refreshSongsSilent, refreshSongsSilent,
refreshSchedulesSilent, refreshSchedulesSilent,
checkSimilarSongs, checkSimilarSongs,
+7 -3
View File
@@ -99,13 +99,16 @@ export function useTheme() {
const defaultTheme = computed<string>(() => { const defaultTheme = computed<string>(() => {
if (siteConfigLoaded.value && siteDefaultTheme.value) { if (siteConfigLoaded.value && siteDefaultTheme.value) {
return siteDefaultTheme.value const v = siteDefaultTheme.value
if (v === 'System' || v === 'dark' || v.endsWith('Dark')) return 'ModernLight'
return v
} }
const fromRuntime = config.public?.defaultTheme const fromRuntime = config.public?.defaultTheme
if (typeof fromRuntime === 'string' && fromRuntime) { if (typeof fromRuntime === 'string' && fromRuntime) {
if (fromRuntime === 'System' || fromRuntime === 'dark' || fromRuntime.endsWith('Dark')) return 'ModernLight'
return fromRuntime return fromRuntime
} }
return 'System' return 'ModernLight'
}) })
const theme = computed<string>({ const theme = computed<string>({
@@ -193,7 +196,8 @@ export function useTheme() {
if (stored) { if (stored) {
const valid = firstValidTheme(stored) const valid = firstValidTheme(stored)
if (valid === stored || enabledThemes.value.includes(stored)) { const effectiveStored = resolveEffectiveTheme(valid) as string
if ((valid === stored || enabledThemes.value.includes(stored)) && effectiveStored !== 'dark') {
setTheme(stored) setTheme(stored)
watchSystemTheme() watchSystemTheme()
return return
+284 -149
View File
@@ -1,8 +1,36 @@
<template> <template>
<div class="home pb-24 lg:pb-0"> <div class="home">
<div class="ellipse-effect" />
<div class="main-content"> <div class="main-content">
<!-- 右上角浮动用户操作区(简化版,仅保留 user-section) --> <div class="top-bar">
<div class="user-actions-floating"> <div class="logo-section">
<NuxtLink class="logo-link" to="/">
<img
alt="VoiceHub Logo"
class="logo-image"
:src="logo"
width="90"
height="90"
decoding="async"
fetchpriority="high"
>
</NuxtLink>
<!-- 横线和学校logo -->
<div v-if="schoolLogoHomeUrl && schoolLogoHomeUrl.trim()" class="logo-divider-container">
<div class="logo-divider" />
<img
:src="proxiedSchoolLogoUrl"
alt="学校Logo"
class="school-logo"
width="200"
height="80"
decoding="async"
fetchpriority="high"
>
</div>
</div>
<!-- 用户信息区域 -->
<div class="user-section"> <div class="user-section">
<ClientOnly> <ClientOnly>
<div v-if="isClientAuthenticated" class="user-info"> <div v-if="isClientAuthenticated" class="user-info">
@@ -57,62 +85,144 @@
</div> </div>
</div> </div>
<!-- Section Hero --> <div v-if="siteTitle" class="site-title">
<section id="hero-section" class="hero-block"> <div class="title-container">
<div class="hero-left"> <h2 class="main-title">{{ siteTitle }}</h2>
<h1 class="hero-title">{{ siteTitle }}</h1> <div class="title-divider" />
<p class="hero-subtitle">下周校园,听你的。</p> <span class="sub-title">VoiceHub 校园广播系统</span>
</div>
</div>
<div class="hero-cta"> <!-- Hero CTA:投稿按钮正中央 + 档期概览卡片(紧邻按钮下方) -->
<section class="hero-cta-section">
<button <button
class="bg-gradient-to-r from-purple-500 via-pink-500 to-blue-500 text-white px-6 py-3 rounded-lg font-bold shadow-lg hover:shadow-xl transition-shadow" type="button"
@click="scrollToSection('request-section')" class="hero-request-btn"
:disabled="submissionClosed"
:title="submissionClosed ? '投稿通道已关闭' : '我要点歌投稿'"
@click="handleHeroRequestClick"
> >
立即点歌 <Icon class="btn-icon" :class="{ pulse: !submissionClosed }" name="mic" :size="22" />
<span>{{ submissionClosed ? '投稿已关闭' : '我要点歌投稿' }}</span>
</button> </button>
<button
class="border border-gray-300 text-gray-700 px-6 py-3 rounded-lg font-bold hover:bg-gray-50"
@click="scrollToSection('schedule-section')"
>
查看排期
</button>
</div>
<div class="status-info"> <div class="schedule-overview-card" aria-label="档期概览">
<span> <div class="schedule-overview-grid">
{{ isRequestOpen ? '当前投稿开放' : '当前投稿关闭' }} · 共 {{ songCount || 0 }} 首投稿 · {{ scheduleCount || 0 }} 首已定排期 <div class="overview-stat">
</span> <span class="stat-icon">📅</span>
<span class="stat-label">今日可点排期</span>
<div class="stat-value">
<span>{{ todaySchedulesCount }}</span>
<span class="unit">首</span>
</div>
<div class="stat-detail">{{ todayLabel }}</div>
</div>
<div class="overview-stat">
<span class="stat-icon">🎵</span>
<span class="stat-label">待播歌曲总数</span>
<div class="stat-value">
<span>{{ totalSchedulesCount }}</span>
<span class="unit">首</span>
</div>
<div class="stat-detail">累计审核通过 / 审核中</div>
</div>
<div class="overview-stat primary">
<span class="stat-icon">✨</span>
<span class="stat-label">当前空闲档期</span>
<div class="stat-value">
<span>{{ totalFreeSlots }}</span>
<span class="unit">个</span>
</div>
<div class="stat-detail">
<span class="live-dot"></span>
实时更新 · {{ openDaysCount }} 天开放
</div>
</div> </div>
</div> </div>
<div id="hero-illustration-slot" class="w-full max-w-sm">
<ClientOnly><HeroIllustration /></ClientOnly>
</div> </div>
</section> </section>
<!-- Section Schedule --> <!-- 中间主体内容区域 -->
<section id="schedule-section" class="content-block"> <div class="content-area">
<div class="section-header"> <!-- 选项卡区域 -->
<h2>本周排期</h2> <div class="tabs-row">
<span class="sub">播出时间表</span> <div
:class="{ active: activeTab === 'schedule' }"
class="section-tab schedule-tab"
@click="handleTabClick('schedule')"
>
<Icon class="tab-icon" name="calendar" :size="20" />
<span class="tab-text">播出排期</span>
</div>
<div
:class="{ active: activeTab === 'songs' }"
class="section-tab songs-tab"
@click="handleTabClick('songs')"
>
<Icon class="tab-icon" name="music" :size="20" />
<span class="tab-text">歌曲列表</span>
</div>
<div
:class="{ active: activeTab === 'request' }"
class="section-tab request-tab"
@click="handleTabClick('request')"
>
<Icon class="tab-icon" name="search" :size="20" />
<span class="tab-text">投稿歌曲</span>
</div> </div>
<ClientOnly> <ClientOnly>
<div
ref="notificationTabRef"
:class="{ active: activeTab === 'notification', disabled: !isClientAuthenticated }"
class="section-tab"
data-tab="notification"
@click="isClientAuthenticated ? handleTabClick('notification') : showLoginNotice()"
>
<div class="icon-wrapper">
<Icon class="tab-icon" name="message-circle" :size="20" />
<span
v-if="isClientAuthenticated && hasUnreadNotifications"
class="notification-badge-tab"
/>
</div>
<span class="tab-text">
消息
<span
v-if="isClientAuthenticated && hasUnreadNotifications"
class="notification-badge-desktop"
/>
</span>
</div>
<template #fallback>
<div class="section-tab disabled" data-tab="notification">
<Icon class="tab-icon" name="message-circle" :size="20" />
<span class="tab-text">消息</span>
</div>
</template>
</ClientOnly>
</div>
<!-- 内容区域 -->
<div class="tab-content-container">
<ClientOnly>
<!-- 播出排期内容 -->
<div v-if="activeTab === 'schedule'" key="schedule" class="tab-pane schedule-tab-pane">
<div class="full-width">
<SongsScheduleList <SongsScheduleList
:error="error" :error="error"
:loading="loading" :loading="loading"
:schedules="publicSchedules" :schedules="publicSchedules"
@semester-change="handleSemesterChange" @semester-change="handleSemesterChange"
/> />
</ClientOnly>
</section>
<!-- Section Songs -->
<section id="songs-section" class="content-block">
<div class="section-header">
<h2>热门投稿</h2>
<span class="sub">为喜欢的歌投票</span>
</div> </div>
<ClientOnly>
<!-- 用户使用指南组件 -->
<UserTutorial />
</div>
<!-- 歌曲列表内容 -->
<div v-else-if="activeTab === 'songs'" key="songs" class="tab-pane">
<div class="song-list-container">
<SongsSongList <SongsSongList
:error="error" :error="error"
:is-admin="isAdmin" :is-admin="isAdmin"
@@ -125,38 +235,37 @@
@request-replay="handleRequestReplay" @request-replay="handleRequestReplay"
@semester-change="handleSemesterChange" @semester-change="handleSemesterChange"
/> />
</ClientOnly> </div>
</section> </div>
<!-- Section Request --> <!-- 投稿歌曲内容 -->
<section id="request-section" class="content-block"> <div v-else-if="activeTab === 'request'" key="request" class="tab-pane request-pane">
<div class="section-header">
<h2>投稿歌曲</h2>
<span class="sub" @click="showRules = true">点歌规则</span>
</div>
<ClientOnly>
<div v-if="!isClientAuthenticated" class="login-required-container">
<div class="login-required-content">
<Lock class="login-icon" :size="48" />
<h3>登录后投稿</h3>
<p>登录后可选择期望日期和播出位置</p>
<button class="login-button" @click="navigateToLogin">立即登录</button>
</div>
</div>
<SongsRequestForm <SongsRequestForm
v-else
ref="requestFormRef" ref="requestFormRef"
:loading="loading" :loading="loading"
@request="handleRequest" @request="handleRequest"
@vote="handleVote" @vote="handleVote"
/> />
</ClientOnly> </div>
</section>
<!-- Section Notification --> <!-- 通知内容 -->
<section id="notification-section" class="content-block"> <div
<div class="section-header"> v-else-if="activeTab === 'notification'"
<h2>我的消息</h2> key="notification"
class="tab-pane notification-pane"
>
<div v-if="!isClientAuthenticated" class="login-required-container">
<div class="login-required-content">
<Lock class="login-icon" :size="48" />
<h3>需要登录</h3>
<p>您需要登录才能查看通知</p>
<button class="login-button" @click="navigateToLogin">立即登录</button>
</div>
</div>
<div v-else class="notification-container">
<!-- 标题和设置按钮 -->
<div class="notification-header">
<h2 class="notification-title">通知中心</h2>
<button class="settings-icon" @click="toggleNotificationSettings"> <button class="settings-icon" @click="toggleNotificationSettings">
<svg <svg
fill="none" fill="none"
@@ -176,16 +285,7 @@
</svg> </svg>
</button> </button>
</div> </div>
<ClientOnly>
<div v-if="!isClientAuthenticated" class="login-required-container">
<div class="login-required-content">
<Lock class="login-icon" :size="48" />
<h3>需要登录</h3>
<p>您需要登录才能查看通知</p>
<button class="login-button" @click="navigateToLogin">立即登录</button>
</div>
</div>
<div v-else class="notification-container">
<!-- 通知列表 --> <!-- 通知列表 -->
<div class="notification-list"> <div class="notification-list">
<div v-if="notificationsLoading" class="loading-indicator"> <div v-if="notificationsLoading" class="loading-indicator">
@@ -486,15 +586,13 @@
@confirm="handleConfirmAction" @confirm="handleConfirmAction"
/> />
</div> </div>
</div>
</ClientOnly> </ClientOnly>
</section> </div>
</div>
<!-- 页脚信息显示 --> <!-- 页脚信息显示 -->
<SiteFooter /> <SiteFooter />
<ClientOnly>
<MobileBottomNav @navigate="handleMobileNav" />
</ClientOnly>
</div> </div>
<!-- 规则弹窗 --> <!-- 规则弹窗 -->
@@ -513,13 +611,13 @@
@click.self="showRules = false" @click.self="showRules = false"
> >
<div <div
class="bg-zinc-900 border border-zinc-800 w-full max-w-lg rounded-3xl overflow-hidden shadow-2xl flex flex-col" class="bg-white border border-zinc-200 w-full max-w-lg rounded-3xl overflow-hidden shadow-2xl flex flex-col"
> >
<div class="p-8 pb-4 flex items-center justify-between"> <div class="p-8 pb-4 flex items-center justify-between">
<div> <div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3"> <h3 class="text-xl font-black text-zinc-900 tracking-tight flex items-center gap-3">
<div <div
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500" class="w-10 h-10 rounded-2xl bg-indigo-600/10 flex items-center justify-center text-indigo-600"
> >
<Icon name="bell" :size="20" /> <Icon name="bell" :size="20" />
</div> </div>
@@ -528,7 +626,7 @@
<p class="text-xs text-zinc-500 mt-1 ml-13">投稿前请仔细阅读以下规则</p> <p class="text-xs text-zinc-500 mt-1 ml-13">投稿前请仔细阅读以下规则</p>
</div> </div>
<button <button
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all" class="p-3 bg-zinc-100 hover:bg-zinc-200 text-zinc-500 hover:text-zinc-900 rounded-2xl transition-all"
@click="showRules = false" @click="showRules = false"
> >
<Icon name="x" :size="20" /> <Icon name="x" :size="20" />
@@ -545,23 +643,23 @@
</h4> </h4>
<div <div
v-if="submissionGuidelines" v-if="submissionGuidelines"
class="text-sm text-zinc-400 leading-relaxed font-medium bg-zinc-950/50 p-6 rounded-3xl border border-zinc-800/50" class="text-sm text-zinc-700 leading-relaxed font-medium bg-zinc-50 p-6 rounded-3xl border border-zinc-200"
v-html="submissionGuidelines.replace(/\n/g, '<br>')" v-html="submissionGuidelines.replace(/\n/g, '<br>')"
/> />
<div <div
v-else v-else
class="space-y-3 bg-zinc-950/50 p-6 rounded-3xl border border-zinc-800/50" class="space-y-3 bg-zinc-50 p-6 rounded-3xl border border-zinc-200"
> >
<div class="flex gap-3 text-sm text-zinc-400 font-medium"> <div class="flex gap-3 text-sm text-zinc-700 font-medium">
<span class="text-blue-500 font-black">01</span> <span class="text-indigo-600 font-black">01</span>
<p>投稿时无需加入书名号</p> <p>投稿时无需加入书名号</p>
</div> </div>
<div class="flex gap-3 text-sm text-zinc-400 font-medium"> <div class="flex gap-3 text-sm text-zinc-700 font-medium">
<span class="text-blue-500 font-black">02</span> <span class="text-indigo-600 font-black">02</span>
<p>除DJ外,其他类型歌曲均接收(包括小语种)</p> <p>除DJ外,其他类型歌曲均接收(包括小语种)</p>
</div> </div>
<div class="flex gap-3 text-sm text-zinc-400 font-medium"> <div class="flex gap-3 text-sm text-zinc-700 font-medium">
<span class="text-blue-500 font-black">03</span> <span class="text-indigo-600 font-black">03</span>
<p>禁止投递含有违规内容的歌曲</p> <p>禁止投递含有违规内容的歌曲</p>
</div> </div>
</div> </div>
@@ -575,15 +673,15 @@
播放时间 播放时间
</h4> </h4>
<div <div
class="bg-blue-600/10 border border-blue-500/20 p-6 rounded-3xl flex items-center gap-4" class="bg-indigo-50 border border-indigo-200 p-6 rounded-3xl flex items-center gap-4"
> >
<div <div
class="w-12 h-12 rounded-2xl bg-blue-600 flex items-center justify-center text-white shadow-lg shadow-blue-900/40" class="w-12 h-12 rounded-2xl bg-indigo-600 flex items-center justify-center text-white shadow-lg shadow-indigo-500/20"
> >
<Icon name="clock" :size="24" /> <Icon name="clock" :size="24" />
</div> </div>
<div> <div>
<p class="text-sm font-black text-zinc-100">每天夜自修静班前</p> <p class="text-sm font-black text-zinc-900">每天夜自修静班前</p>
<p class="text-[10px] text-zinc-500 font-bold uppercase tracking-widest mt-0.5"> <p class="text-[10px] text-zinc-500 font-bold uppercase tracking-widest mt-0.5">
PLAYBACK TIME PLAYBACK TIME
</p> </p>
@@ -594,7 +692,7 @@
<div class="p-8 pt-0"> <div class="p-8 pt-0">
<button <button
class="w-full px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest shadow-lg active:scale-95" class="w-full px-6 py-4 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-black rounded-2xl transition-all uppercase tracking-widest shadow-lg shadow-indigo-500/20 active:scale-95"
@click="showRules = false" @click="showRules = false"
> >
我知道了 我知道了
@@ -622,8 +720,6 @@ import { useRouter } from 'vue-router'
import logo from '~~/public/images/logo.png' import logo from '~~/public/images/logo.png'
import Icon from '~/components/UI/Icon.vue' import Icon from '~/components/UI/Icon.vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue' import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import HeroIllustration from '~/components/UI/HeroIllustration.vue'
import MobileBottomNav from '~/components/UI/MobileBottomNav.vue'
import { useNotifications } from '~/composables/useNotifications' import { useNotifications } from '~/composables/useNotifications'
import { useSiteConfig } from '~/composables/useSiteConfig' import { useSiteConfig } from '~/composables/useSiteConfig'
@@ -771,10 +867,15 @@ const setupRefreshInterval = async () => {
await Promise.allSettled([ await Promise.allSettled([
songs.fetchSongs(false, undefined, true, true), songs.fetchSongs(false, undefined, true, true),
songs.fetchPublicSchedules(false, undefined, true, true), songs.fetchPublicSchedules(false, undefined, true, true),
songs.fetchExpectedPlayDates(true, true),
loadNotifications() loadNotifications()
]) ])
} else { } else {
await Promise.allSettled([songs.fetchPublicSchedules(false, undefined, true, true), songs.fetchSongCount()]) await Promise.allSettled([
songs.fetchPublicSchedules(false, undefined, true, true),
songs.fetchSongCount(),
songs.fetchExpectedPlayDates(true, true)
])
} }
await updateSongCounts() await updateSongCounts()
@@ -1116,13 +1217,14 @@ onMounted(async () => {
await Promise.allSettled([ await Promise.allSettled([
songs.fetchSongs(), songs.fetchSongs(),
songs.fetchPublicSchedules(), songs.fetchPublicSchedules(),
songs.fetchExpectedPlayDates(),
loadNotifications(), loadNotifications(),
fetchNotificationSettings() fetchNotificationSettings()
]) ])
await checkPasswordChangeRequired(currentUser) await checkPasswordChangeRequired(currentUser)
} else { } else {
hasInitializedAuthData.value = false hasInitializedAuthData.value = false
await Promise.allSettled([songs.fetchSongCount(), songs.fetchPublicSchedules()]) await Promise.allSettled([songs.fetchSongCount(), songs.fetchPublicSchedules(), songs.fetchExpectedPlayDates()])
} }
await updateSongCounts() await updateSongCounts()
@@ -1147,7 +1249,7 @@ onMounted(async () => {
} }
} catch (error) { } catch (error) {
console.error('首页初始化失败:', error) console.error('首页初始化失败:', error)
await Promise.allSettled([songs.fetchPublicSchedules(), songs.fetchSongCount()]) await Promise.allSettled([songs.fetchPublicSchedules(), songs.fetchSongCount(), songs.fetchExpectedPlayDates()])
await updateSongCounts() await updateSongCounts()
} }
}) })
@@ -1164,6 +1266,73 @@ const realSongCount = computed(() => {
return songs?.visibleSongs?.value?.length || 0 return songs?.visibleSongs?.value?.length || 0
}) })
// Hero CTA & 档期概览相关计算属性
const totalFreeSlots = computed(() => songs?.totalFreeSlots?.value ?? 0)
const openDaysCount = computed(() => songs?.openDaysCount?.value ?? 0)
const expectedPlayDates = computed(() => songs?.expectedPlayDates?.value ?? [])
const todayDateStr = computed(() => {
const d = new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
})
const todayLabel = computed(() => {
const d = new Date()
const md = `${d.getMonth() + 1}月${d.getDate()}日`
const wd = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()]
return `${md} · 周${wd}`
})
const totalSchedulesCount = computed(() => {
const list = songs?.publicSchedules?.value || []
return list.filter((s) => {
const song = s?.song
return song ? !song.played : true
}).length
})
const todaySchedulesCount = computed(() => {
const today = todayDateStr.value
const list = songs?.publicSchedules?.value || []
return list.filter((s) => {
const sDate = s?.expectedPlayDate || s?.playDate
if (!sDate) return false
const normalized = typeof sDate === 'string' ? sDate.slice(0, 10) : sDate
if (normalized !== today) return false
const song = s?.song
return song ? !song.played : true
}).length
})
// 投稿通道关闭判定(兼容 isRequestOpen ref)
const submissionClosed = computed(() => {
if (typeof isRequestOpen?.value === 'boolean') return !isRequestOpen.value
const all = expectedPlayDates.value
if (all.length === 0) return false
return all.every((d) => !!d?.isFull)
})
// Hero 投稿按钮点击:未登录先提示登录,已登录跳到投稿 Tab
const handleHeroRequestClick = () => {
if (!isClientAuthenticated.value) {
if (window.$showNotification) window.$showNotification('请先登录后再投稿音乐', 'info')
navigateTo('/login')
return
}
if (submissionClosed.value) {
if (window.$showNotification) window.$showNotification('当前所有档期已满,暂无法投稿', 'warning')
return
}
handleTabClick('request')
nextTick(() => {
const target = document.querySelector('.request-tab-pane')
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
// 使用计算属性安全地访问数据 // 使用计算属性安全地访问数据
const publicSchedules = computed(() => songs?.publicSchedules?.value || []) const publicSchedules = computed(() => songs?.publicSchedules?.value || [])
const allSongs = computed(() => songs?.visibleSongs?.value || []) const allSongs = computed(() => songs?.visibleSongs?.value || [])
@@ -1226,10 +1395,12 @@ const handleRequest = async (songData, done) => {
await requestFormRef.value.refreshSubmissionStatus() await requestFormRef.value.refreshSubmissionStatus()
} }
// 投稿成功后自动滚动到歌曲列表 // 如果当前在歌曲列表页,自动切换到该页面
if (activeTab.value !== 'songs') {
setTimeout(() => { setTimeout(() => {
scrollToSection('songs-section') handleTabClick('songs')
}, 500) }, 500)
}
if (typeof done === 'function') done(true) if (typeof done === 'function') done(true)
return true return true
@@ -1474,22 +1645,9 @@ const vRipple = {
} }
} }
// 处理标签点击事件(@deprecated 保留以防它处引用,页面现已改为纵向区块无标签切换) // 处理标签点击事件,添加动画效果
const handleTabClick = (tab) => { const handleTabClick = (tab) => {
// activeTab.value = tab activeTab.value = tab
}
// 滚动到指定区块
const scrollToSection = (id) => {
const el = document.getElementById(id)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// 移动端底部导航处理
const handleMobileNav = (target) => {
if (target === 'account') { router.push('/account'); return }
const el = document.getElementById(target)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
} }
// 添加导航到登录页面的方法 // 添加导航到登录页面的方法
@@ -1599,30 +1757,21 @@ if (
/* 添加顶部Ellipse 1效果 */ /* 添加顶部Ellipse 1效果 */
.ellipse-effect { .ellipse-effect {
position: absolute; position: absolute;
top: -165px; top: -200px;
left: 50%; left: 50%;
transform: translateX(-50%) perspective(500px) rotateX(10deg); transform: translateX(-50%) perspective(500px) rotateX(10deg);
width: 1110px; width: 1110px;
height: 309px; height: 360px;
background: radial-gradient( background: radial-gradient(
ellipse at center, ellipse at center,
rgba(11, 90, 254, 0.3) 0%, rgba(79, 70, 229, 0.18) 0%,
rgba(11, 90, 254, 0.15) 30%, rgba(79, 70, 229, 0.08) 35%,
rgba(11, 90, 254, 0) 70% rgba(79, 70, 229, 0) 72%
); );
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
} }
/* 右上角浮动用户操作区 */
.user-actions-floating {
position: relative;
z-index: 100;
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
/* 顶部区域样式 */ /* 顶部区域样式 */
.top-bar { .top-bar {
display: flex; display: flex;
@@ -1922,7 +2071,7 @@ if (
/* 站点标题 */ /* 站点标题 */
.site-title { .site-title {
text-align: center; text-align: center;
margin: 3rem 0; margin: 2.2rem 0 0.2rem;
padding: 0 1rem; padding: 0 1rem;
} }
@@ -1930,15 +2079,15 @@ if (
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.6rem;
} }
.main-title { .main-title {
font-family: 'MiSans', sans-serif; font-family: 'MiSans', sans-serif;
font-weight: 800; font-weight: 800;
font-size: 42px; font-size: 34px;
letter-spacing: -0.02em; letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--text-primary, #1a2332) 0%, var(--text-secondary, #4a5568) 100%); background: linear-gradient(135deg, var(--text-primary, #09090b) 0%, var(--text-secondary, #3f3f46) 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
margin: 0; margin: 0;
@@ -3632,18 +3781,4 @@ if (
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 67, 248, 0.3); box-shadow: 0 5px 15px rgba(0, 67, 248, 0.3);
} }
/* ============ 新增纵向五区块样式 ============ */
.home { width:100%; flex:1; background: var(--bg-primary, #FFFFFF); padding: 0; color: var(--text-primary, #111827); min-height:100vh; }
.main-content { max-width: 1200px; margin: 0 auto; padding: 24px 16px 32px; }
.hero-block { padding: 48px 0 24px; display: flex; flex-direction: column; gap: 32px; }
@media (min-width: 1024px) { .hero-block { flex-direction: row; align-items: center; justify-content: space-between; } }
.hero-title { font-size: 44px; font-weight: 900; letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 12px; }
.hero-subtitle { font-size: 18px; color: var(--text-secondary, #4B5563); font-weight: 500; margin-bottom: 24px; }
.hero-cta { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
.status-info { display: inline-flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--bg-secondary, #F9FAFB); border: 1px solid var(--border-primary, #E5E7EB); border-radius: 8px; font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.content-block { padding: 28px 0; border-top: 1px solid var(--border-primary, #E5E7EB); }
.section-header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 20px; }
.section-header h2 { font-size: 22px; font-weight: 800; letter-spacing: -0.01em; }
.section-header .sub { font-size: 13px; color: var(--text-tertiary, #6B7280); font-weight: 500; cursor: pointer; }
</style> </style>
+220 -108
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -112,6 +112,7 @@
"@nuxt/eslint": "^1.17.0", "@nuxt/eslint": "^1.17.0",
"eslint": "^9.39.5", "eslint": "^9.39.5",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"vue-tsc": "^3.3.11"
} }
} }