chore: add and update gitignore rules for project files
add a comprehensive gitignore file to exclude unnecessary files like dependencies, runtime data, uploads, local configs and debug temporary files
This commit is contained in:
@@ -0,0 +1,961 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="八音盒">
|
||||
<meta name="theme-color" content="#0f0e17">
|
||||
<title>八音盒</title>
|
||||
|
||||
<!-- PWA Manifest (inline via JS for single-file demo) -->
|
||||
<style>
|
||||
/* ── Design Tokens ─────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #0f0e17;
|
||||
--surface: #1a1928;
|
||||
--surface2: #232235;
|
||||
--border: rgba(255,255,255,0.07);
|
||||
--gold: #e8b86d;
|
||||
--gold-dim: #a07840;
|
||||
--text: #e8e6f0;
|
||||
--text-dim: #7a7890;
|
||||
--radius: 16px;
|
||||
--bar-h: 88px;
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* ── Reset ─────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, 'PingFang SC', sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────── */
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────── */
|
||||
.header {
|
||||
padding: 20px 20px 12px;
|
||||
padding-top: calc(20px + env(safe-area-inset-top, 0px));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text);
|
||||
}
|
||||
.header-title span { color: var(--gold); }
|
||||
.header-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ── View Toggle ─────────────────────────────────────────── */
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--surface);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
margin: 0 20px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.view-btn {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
padding: 7px 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.view-btn.active {
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Now Playing Card (mini) ─────────────────────────────── */
|
||||
.now-playing-hero {
|
||||
margin: 0 20px 20px;
|
||||
padding: 20px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
/* Signature: subtle inner glow on playing state */
|
||||
transition: box-shadow 0.6s;
|
||||
}
|
||||
.now-playing-hero.playing {
|
||||
box-shadow: 0 0 40px rgba(232, 184, 109, 0.08);
|
||||
}
|
||||
|
||||
/* ── Vinyl Disc ──────────────────────────────────────────── */
|
||||
/* SIGNATURE: rotating vinyl with subtle groove rings */
|
||||
.disc-wrap {
|
||||
position: relative;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.disc {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%,
|
||||
#fff 0%, #fff 7%,
|
||||
transparent 7.5%, transparent 14%,
|
||||
rgba(255,255,255,0.03) 14%, rgba(255,255,255,0.03) 14.5%,
|
||||
transparent 15%, transparent 29%,
|
||||
rgba(255,255,255,0.03) 29%, rgba(255,255,255,0.03) 29.5%,
|
||||
transparent 30%, transparent 44%,
|
||||
rgba(255,255,255,0.03) 44%, rgba(255,255,255,0.03) 44.5%,
|
||||
transparent 45%),
|
||||
conic-gradient(from 0deg,
|
||||
#1a1928 0deg, #222130 60deg, #1a1928 120deg,
|
||||
#222130 180deg, #1a1928 240deg, #222130 300deg, #1a1928 360deg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: spin 8s linear infinite;
|
||||
animation-play-state: paused;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
}
|
||||
.disc.spinning { animation-play-state: running; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.disc-art {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(255,255,255,0.1);
|
||||
background: var(--surface2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
font-size: 20px;
|
||||
}
|
||||
.disc-art-placeholder {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
border: 2px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
/* ── Track Info ──────────────────────────────────────────── */
|
||||
.track-info {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.track-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.track-artist {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Progress ────────────────────────────────────────────── */
|
||||
.progress-wrap {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.progress-bar {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: var(--surface2);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.progress-bar::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--gold);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px rgba(232,184,109,0.4);
|
||||
}
|
||||
.progress-bar::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.time-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Controls ────────────────────────────────────────────── */
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
width: 100%;
|
||||
}
|
||||
.ctrl-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s, transform 0.1s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.ctrl-btn:active { transform: scale(0.9); }
|
||||
.ctrl-btn.active-mode { color: var(--gold); }
|
||||
.ctrl-btn svg { display: block; }
|
||||
|
||||
.play-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
min-width: 56px;
|
||||
min-height: 56px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--gold);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.1s, background 0.2s;
|
||||
box-shadow: 0 4px 16px rgba(232,184,109,0.3);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.play-btn:active { transform: scale(0.94); }
|
||||
|
||||
/* ── Volume ──────────────────────────────────────────────── */
|
||||
.volume-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.vol-icon { color: var(--text-dim); flex-shrink: 0; }
|
||||
.volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: var(--surface2);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Playlist ────────────────────────────────────────────── */
|
||||
.playlist-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px;
|
||||
padding-bottom: calc(16px + var(--safe-bottom));
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.playlist-wrap::-webkit-scrollbar { display: none; }
|
||||
|
||||
.playlist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.playlist-item:last-child { border-bottom: none; }
|
||||
.playlist-item:active { opacity: 0.6; }
|
||||
.playlist-item.active .item-title { color: var(--gold); }
|
||||
|
||||
.item-num {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.item-num.playing-icon { color: var(--gold); }
|
||||
|
||||
.item-info { flex: 1; min-width: 0; }
|
||||
.item-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.item-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.item-duration {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Mini Bar (bottom, when in list view) ─────────────────── */
|
||||
/* We use a single-view approach so no mini bar needed */
|
||||
|
||||
/* ── Loading / Empty ─────────────────────────────────────── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 20px;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.empty-state .icon { font-size: 36px; margin-bottom: 12px; }
|
||||
|
||||
/* ── Toast ───────────────────────────────────────────────── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: calc(100px + var(--safe-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(10px);
|
||||
background: rgba(35,34,53,0.95);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
z-index: 100;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-title">
|
||||
<!-- 禅坐图标 -->
|
||||
<svg width="28" height="28" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||
style="display:inline-block;vertical-align:middle;margin-right:8px;margin-bottom:2px;">
|
||||
<!-- aura ring -->
|
||||
<circle cx="50" cy="44" r="32" stroke="#e8b86d" stroke-width="1.2" opacity="0.25"/>
|
||||
<!-- ground shadow -->
|
||||
<ellipse cx="50" cy="84" rx="22" ry="4" fill="rgba(232,184,109,0.12)"/>
|
||||
<!-- left leg -->
|
||||
<path d="M33 66 C24 65 16 70 14 78 C12 84 16 88 24 87 C32 86 42 79 44 72 L44 67 Z" fill="#e8e6f0"/>
|
||||
<!-- right leg -->
|
||||
<path d="M67 66 C76 65 84 70 86 78 C88 84 84 88 76 87 C68 86 58 79 56 72 L56 67 Z" fill="#e8e6f0"/>
|
||||
<!-- torso -->
|
||||
<path d="M44 67 C44 67 40 58 40 50 C40 40 44 34 48 30 L50 29 L52 30 C56 34 60 40 60 50 C60 58 56 67 56 67 C53 74 47 74 44 67 Z" fill="#e8e6f0"/>
|
||||
<!-- hands / mudra -->
|
||||
<ellipse cx="50" cy="72" rx="10" ry="4" fill="#e8e6f0"/>
|
||||
<!-- neck -->
|
||||
<rect x="46" y="25" width="8" height="7" rx="2" fill="#e8e6f0"/>
|
||||
<!-- head -->
|
||||
<ellipse cx="50" cy="17" rx="11" ry="12" fill="#e8e6f0"/>
|
||||
<!-- hair bun -->
|
||||
<ellipse cx="50" cy="6" rx="5" ry="4" fill="#e8e6f0"/>
|
||||
<!-- closed eyes -->
|
||||
<path d="M44 16 Q47 13 50 16" stroke="#0f0e17" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<path d="M50 16 Q53 13 56 16" stroke="#0f0e17" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<!-- sound waves left -->
|
||||
<path d="M34 36 Q28 33 28 29 Q28 25 34 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.6"/>
|
||||
<path d="M31 40 Q22 36 22 29 Q22 22 31 18" stroke="#e8b86d" stroke-width="0.9" stroke-linecap="round" fill="none" opacity="0.35"/>
|
||||
<!-- sound waves right -->
|
||||
<path d="M66 36 Q72 33 72 29 Q72 25 66 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.6"/>
|
||||
<path d="M69 40 Q78 36 78 29 Q78 22 69 18" stroke="#e8b86d" stroke-width="0.9" stroke-linecap="round" fill="none" opacity="0.35"/>
|
||||
<!-- crown dot -->
|
||||
<circle cx="50" cy="7" r="1.5" fill="#e8b86d" opacity="0.8"/>
|
||||
</svg>
|
||||
八<span>音</span>盒
|
||||
</div>
|
||||
<div class="header-count" id="trackCount">0 首</div>
|
||||
</div>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" id="btnPlayer" onclick="switchView('player')">正在播放</button>
|
||||
<button class="view-btn" id="btnList" onclick="switchView('list')">播放列表</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Player View ── -->
|
||||
<div id="viewPlayer">
|
||||
|
||||
<div class="now-playing-hero" id="heroCard">
|
||||
<!-- Disc -->
|
||||
<div class="disc-wrap">
|
||||
<div class="disc" id="disc">
|
||||
<div class="disc-art-placeholder" id="discArt">
|
||||
<svg width="36" height="36" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M33 66 C24 65 16 70 14 78 C12 84 16 88 24 87 C32 86 42 79 44 72 L44 67 Z" fill="rgba(232,184,109,0.85)"/>
|
||||
<path d="M67 66 C76 65 84 70 86 78 C88 84 84 88 76 87 C68 86 58 79 56 72 L56 67 Z" fill="rgba(232,184,109,0.85)"/>
|
||||
<path d="M44 67 C44 67 40 58 40 50 C40 40 44 34 48 30 L50 29 L52 30 C56 34 60 40 60 50 C60 58 56 67 56 67 C53 74 47 74 44 67 Z" fill="rgba(232,184,109,0.85)"/>
|
||||
<ellipse cx="50" cy="72" rx="10" ry="4" fill="rgba(232,184,109,0.85)"/>
|
||||
<rect x="46" y="25" width="8" height="7" rx="2" fill="rgba(232,184,109,0.85)"/>
|
||||
<ellipse cx="50" cy="17" rx="11" ry="12" fill="rgba(232,184,109,0.85)"/>
|
||||
<ellipse cx="50" cy="6" rx="5" ry="4" fill="rgba(232,184,109,0.85)"/>
|
||||
<path d="M44 16 Q47 13 50 16" stroke="#1a1928" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M50 16 Q53 13 56 16" stroke="#1a1928" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M34 36 Q28 33 28 29 Q28 25 34 22" stroke="rgba(232,184,109,0.5)" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M66 36 Q72 33 72 29 Q72 25 66 22" stroke="rgba(232,184,109,0.5)" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="5" r="2" fill="rgba(232,184,109,0.9)"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Track Info -->
|
||||
<div class="track-info">
|
||||
<div class="track-title" id="trackTitle">尚未选择曲目</div>
|
||||
<div class="track-artist" id="trackArtist">—</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="progress-wrap">
|
||||
<input type="range" class="progress-bar" id="progressBar" min="0" max="100" value="0"
|
||||
oninput="onProgressInput(this.value)" onchange="onProgressChange(this.value)">
|
||||
<div class="time-row">
|
||||
<span id="timeCurrent">0:00</span>
|
||||
<span id="timeTotal">0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="controls">
|
||||
<!-- Shuffle -->
|
||||
<button class="ctrl-btn" id="btnShuffle" onclick="toggleShuffle()" title="随机">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="16 3 21 3 21 8"/>
|
||||
<path d="M4 4 C8 4 10 8 12 12 C14 16 16 20 20 20"/>
|
||||
<polyline points="16 21 21 21 21 16"/>
|
||||
<path d="M4 20 C8 20 10 16 12 12 C14 8 16 4 20 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Prev -->
|
||||
<button class="ctrl-btn" onclick="prevTrack()" title="上一首">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 6h2v12H6V6zm3.5 6 8.5 6V6l-8.5 6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Play/Pause -->
|
||||
<button class="play-btn" id="playBtn" onclick="togglePlay()" title="播放/暂停">
|
||||
<svg id="iconPlay" width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<svg id="iconPause" width="26" height="26" viewBox="0 0 24 24" fill="currentColor" style="display:none">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Next -->
|
||||
<button class="ctrl-btn" onclick="nextTrack()" title="下一首">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 6h2v12h-2V6zm-10 0v12l8.5-6L6 6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Repeat -->
|
||||
<button class="ctrl-btn" id="btnRepeat" onclick="cycleRepeat()" title="循环">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
|
||||
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Volume -->
|
||||
<div class="volume-row">
|
||||
<span class="vol-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<input type="range" class="volume-slider" id="volSlider" min="0" max="1" step="0.01" value="1"
|
||||
oninput="setVolume(this.value)">
|
||||
<span class="vol-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM18.5 12c0-2.77-1.5-5.18-3.5-6.5v13c2-.82 3.5-3.43 3.5-6.5z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── List View ── -->
|
||||
<div id="viewList" style="display:none; flex:1; overflow:hidden; display:none; flex-direction:column;">
|
||||
<div class="playlist-wrap" id="playlistWrap">
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="icon">🎶</div>
|
||||
<div>暂无曲目<br>请配置 OSS 音乐列表</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<!-- Hidden Audio -->
|
||||
<audio id="audio" preload="metadata"></audio>
|
||||
|
||||
<script>
|
||||
// ─── Config ─────────────────────────────────────────────────
|
||||
// 修改这里来接入你的 OSS 文件列表
|
||||
// 每条: { title, artist, src, cover(可选) }
|
||||
const PLAYLIST = [
|
||||
{
|
||||
title: "花与你",
|
||||
artist: "Affloret",
|
||||
src: "https://android-o1-images.oss-cn-shanghai.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/file_cabinet/090_%E8%8A%B1%E4%B8%8E%E4%BD%A0_%E8%8A%B1%E4%B8%8E%E5%BF%83%E4%BA%8BAffloret.m4a",
|
||||
cover: ""
|
||||
},
|
||||
{
|
||||
title: "心跳花火",
|
||||
artist: "Affloret",
|
||||
src: "https://android-o1-images.oss-cn-shanghai.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/file_cabinet/091_%E5%BF%83%E8%B7%B3%E8%8A%B1%E7%81%AB_%E8%8A%B1%E4%B8%8E%E5%BF%83%E4%BA%8BAffloret.m4a",
|
||||
cover: ""
|
||||
},
|
||||
{
|
||||
title: "Dear",
|
||||
artist: "lecca",
|
||||
src: "https://android-o1-images.oss-cn-shanghai.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/file_cabinet/072_Dear_lecca.m4a",
|
||||
cover: ""
|
||||
},
|
||||
{
|
||||
title: "Happy (2021 ver.)",
|
||||
artist: "ハニースパイス Re..",
|
||||
src: "https://android-o1-images.oss-cn-shanghai.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/file_cabinet/075_Happy(2021ver.)_%E3%83%8F%E3%83%8B%E3%83%BC%E3%82%B9%E3%83%91%E3%82%A4%E3%82%B9Re..m4a",
|
||||
cover: ""
|
||||
},
|
||||
];
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────
|
||||
let currentIdx = 0;
|
||||
let isPlaying = false;
|
||||
let isSeeking = false;
|
||||
let repeatMode = 0; // 0=顺序 1=单曲 2=列表循环
|
||||
let shuffleOn = false;
|
||||
let shuffleOrder = [];
|
||||
|
||||
const audio = document.getElementById('audio');
|
||||
|
||||
// ─── Init ────────────────────────────────────────────────────
|
||||
function init() {
|
||||
document.getElementById('trackCount').textContent = PLAYLIST.length + ' 首';
|
||||
buildShuffleOrder();
|
||||
if (PLAYLIST.length > 0) loadTrack(0, false);
|
||||
renderPlaylist();
|
||||
setupMediaSession();
|
||||
registerSW();
|
||||
}
|
||||
|
||||
// ─── Track Loading ───────────────────────────────────────────
|
||||
function loadTrack(idx, autoplay) {
|
||||
currentIdx = idx;
|
||||
const t = PLAYLIST[idx];
|
||||
if (!t) return;
|
||||
|
||||
audio.src = t.src;
|
||||
audio.load();
|
||||
|
||||
document.getElementById('trackTitle').textContent = t.title || '未知曲目';
|
||||
document.getElementById('trackArtist').textContent = t.artist || '未知歌手';
|
||||
document.getElementById('timeCurrent').textContent = '0:00';
|
||||
document.getElementById('timeTotal').textContent = '0:00';
|
||||
document.getElementById('progressBar').value = 0;
|
||||
|
||||
// Cover art
|
||||
const artEl = document.getElementById('discArt');
|
||||
if (t.cover) {
|
||||
artEl.innerHTML = `<img src="${t.cover}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">`;
|
||||
} else {
|
||||
artEl.innerHTML = '<svg width="36" height="36" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M33 66 C24 65 16 70 14 78 C12 84 16 88 24 87 C32 86 42 79 44 72 L44 67 Z" fill="rgba(232,184,109,0.85)"/><path d="M67 66 C76 65 84 70 86 78 C88 84 84 88 76 87 C68 86 58 79 56 72 L56 67 Z" fill="rgba(232,184,109,0.85)"/><path d="M44 67 C44 67 40 58 40 50 C40 40 44 34 48 30 L50 29 L52 30 C56 34 60 40 60 50 C60 58 56 67 56 67 C53 74 47 74 44 67 Z" fill="rgba(232,184,109,0.85)"/><ellipse cx="50" cy="72" rx="10" ry="4" fill="rgba(232,184,109,0.85)"/><rect x="46" y="25" width="8" height="7" rx="2" fill="rgba(232,184,109,0.85)"/><ellipse cx="50" cy="17" rx="11" ry="12" fill="rgba(232,184,109,0.85)"/><ellipse cx="50" cy="6" rx="5" ry="4" fill="rgba(232,184,109,0.85)"/><path d="M44 16 Q47 13 50 16" stroke="#1a1928" stroke-width="2" stroke-linecap="round"/><path d="M50 16 Q53 13 56 16" stroke="#1a1928" stroke-width="2" stroke-linecap="round"/><path d="M34 36 Q28 33 28 29 Q28 25 34 22" stroke="rgba(232,184,109,0.5)" stroke-width="2" stroke-linecap="round"/><path d="M66 36 Q72 33 72 29 Q72 25 66 22" stroke="rgba(232,184,109,0.5)" stroke-width="2" stroke-linecap="round"/><circle cx="50" cy="5" r="2" fill="rgba(232,184,109,0.9)"/></svg>';
|
||||
}
|
||||
|
||||
updatePlaylistHighlight();
|
||||
updateMediaSessionMetadata();
|
||||
|
||||
if (autoplay) playAudio();
|
||||
}
|
||||
|
||||
// ─── Play/Pause ──────────────────────────────────────────────
|
||||
function togglePlay() {
|
||||
if (!PLAYLIST.length) { showToast('播放列表为空'); return; }
|
||||
if (!audio.src || audio.src === location.href) { loadTrack(0, true); return; }
|
||||
isPlaying ? pauseAudio() : playAudio();
|
||||
}
|
||||
|
||||
function playAudio() {
|
||||
audio.play().then(() => {
|
||||
isPlaying = true;
|
||||
updatePlayUI(true);
|
||||
}).catch(e => {
|
||||
console.warn('Play failed', e);
|
||||
showToast('播放失败,请检查音频地址');
|
||||
});
|
||||
}
|
||||
|
||||
function pauseAudio() {
|
||||
audio.pause();
|
||||
isPlaying = false;
|
||||
updatePlayUI(false);
|
||||
}
|
||||
|
||||
function updatePlayUI(playing) {
|
||||
document.getElementById('iconPlay').style.display = playing ? 'none' : 'block';
|
||||
document.getElementById('iconPause').style.display = playing ? 'block' : 'none';
|
||||
const disc = document.getElementById('disc');
|
||||
disc.classList.toggle('spinning', playing);
|
||||
document.getElementById('heroCard').classList.toggle('playing', playing);
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Navigation ──────────────────────────────────────────────
|
||||
function nextTrack() {
|
||||
if (!PLAYLIST.length) return;
|
||||
if (repeatMode === 1) { audio.currentTime = 0; if (isPlaying) playAudio(); return; }
|
||||
let next;
|
||||
if (shuffleOn) {
|
||||
const pos = shuffleOrder.indexOf(currentIdx);
|
||||
next = shuffleOrder[(pos + 1) % shuffleOrder.length];
|
||||
} else {
|
||||
next = (currentIdx + 1) % PLAYLIST.length;
|
||||
}
|
||||
loadTrack(next, isPlaying);
|
||||
}
|
||||
|
||||
function prevTrack() {
|
||||
if (!PLAYLIST.length) return;
|
||||
if (audio.currentTime > 3) { audio.currentTime = 0; return; }
|
||||
let prev;
|
||||
if (shuffleOn) {
|
||||
const pos = shuffleOrder.indexOf(currentIdx);
|
||||
prev = shuffleOrder[(pos - 1 + shuffleOrder.length) % shuffleOrder.length];
|
||||
} else {
|
||||
prev = (currentIdx - 1 + PLAYLIST.length) % PLAYLIST.length;
|
||||
}
|
||||
loadTrack(prev, isPlaying);
|
||||
}
|
||||
|
||||
// ─── Shuffle / Repeat ────────────────────────────────────────
|
||||
function buildShuffleOrder() {
|
||||
shuffleOrder = [...Array(PLAYLIST.length).keys()];
|
||||
for (let i = shuffleOrder.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]];
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShuffle() {
|
||||
shuffleOn = !shuffleOn;
|
||||
if (shuffleOn) buildShuffleOrder();
|
||||
document.getElementById('btnShuffle').classList.toggle('active-mode', shuffleOn);
|
||||
showToast(shuffleOn ? '随机播放 开' : '随机播放 关');
|
||||
}
|
||||
|
||||
const repeatLabels = ['顺序播放', '单曲循环', '列表循环'];
|
||||
function cycleRepeat() {
|
||||
repeatMode = (repeatMode + 1) % 3;
|
||||
const btn = document.getElementById('btnRepeat');
|
||||
btn.classList.toggle('active-mode', repeatMode > 0);
|
||||
// Show repeat-one indicator
|
||||
btn.innerHTML = repeatMode === 1
|
||||
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/><text x="11" y="14" font-size="6" fill="currentColor" stroke="none" text-anchor="middle">1</text></svg>`
|
||||
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>`;
|
||||
showToast(repeatLabels[repeatMode]);
|
||||
}
|
||||
|
||||
// ─── Progress ────────────────────────────────────────────────
|
||||
function onProgressInput(val) {
|
||||
isSeeking = true;
|
||||
updateSliderFill(document.getElementById('progressBar'), val);
|
||||
}
|
||||
function onProgressChange(val) {
|
||||
if (audio.duration) audio.currentTime = (val / 100) * audio.duration;
|
||||
isSeeking = false;
|
||||
}
|
||||
|
||||
function setVolume(val) {
|
||||
audio.volume = val;
|
||||
updateSliderFill(document.getElementById('volSlider'), val * 100);
|
||||
}
|
||||
|
||||
function updateSliderFill(el, pct) {
|
||||
const fill = 'linear-gradient(to right, var(--gold) 0%, var(--gold) ' + pct + '%, var(--surface2) ' + pct + '%, var(--surface2) 100%)';
|
||||
el.style.background = fill;
|
||||
}
|
||||
|
||||
function fmtTime(s) {
|
||||
if (isNaN(s) || s < 0) return '0:00';
|
||||
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
||||
return m + ':' + String(sec).padStart(2, '0');
|
||||
}
|
||||
|
||||
// ─── Audio Events ────────────────────────────────────────────
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
if (isSeeking) return;
|
||||
const pct = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0;
|
||||
document.getElementById('progressBar').value = pct;
|
||||
updateSliderFill(document.getElementById('progressBar'), pct);
|
||||
document.getElementById('timeCurrent').textContent = fmtTime(audio.currentTime);
|
||||
// Update Media Session position
|
||||
if ('mediaSession' in navigator && audio.duration) {
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: audio.duration,
|
||||
playbackRate: audio.playbackRate,
|
||||
position: audio.currentTime
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
document.getElementById('timeTotal').textContent = fmtTime(audio.duration);
|
||||
});
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
isPlaying = false;
|
||||
updatePlayUI(false);
|
||||
if (repeatMode === 1) {
|
||||
audio.currentTime = 0; playAudio();
|
||||
} else if (repeatMode === 2 || currentIdx < PLAYLIST.length - 1) {
|
||||
nextTrack();
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('play', () => { isPlaying = true; updatePlayUI(true); });
|
||||
audio.addEventListener('pause', () => { isPlaying = false; updatePlayUI(false); });
|
||||
|
||||
// ─── Playlist UI ─────────────────────────────────────────────
|
||||
function renderPlaylist() {
|
||||
const wrap = document.getElementById('playlistWrap');
|
||||
const empty = document.getElementById('emptyState');
|
||||
if (!PLAYLIST.length) { empty.style.display = 'block'; return; }
|
||||
empty.style.display = 'none';
|
||||
|
||||
// Remove old items
|
||||
wrap.querySelectorAll('.playlist-item').forEach(el => el.remove());
|
||||
|
||||
PLAYLIST.forEach((t, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'playlist-item' + (i === currentIdx ? ' active' : '');
|
||||
div.dataset.idx = i;
|
||||
div.innerHTML = `
|
||||
<div class="item-num" id="itemNum${i}">${i + 1}</div>
|
||||
<div class="item-info">
|
||||
<div class="item-title">${escHtml(t.title || '未知曲目')}</div>
|
||||
<div class="item-sub">${escHtml(t.artist || '未知歌手')}</div>
|
||||
</div>
|
||||
<div class="item-duration" id="itemDur${i}">—</div>
|
||||
`;
|
||||
div.addEventListener('click', () => {
|
||||
if (i === currentIdx) { togglePlay(); }
|
||||
else { loadTrack(i, true); switchView('player'); }
|
||||
});
|
||||
wrap.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function updatePlaylistHighlight() {
|
||||
document.querySelectorAll('.playlist-item').forEach((el, i) => {
|
||||
el.classList.toggle('active', i === currentIdx);
|
||||
const num = document.getElementById('itemNum' + i);
|
||||
if (num) {
|
||||
num.textContent = (i === currentIdx && isPlaying) ? '▶' : (i + 1);
|
||||
num.className = 'item-num' + (i === currentIdx ? ' playing-icon' : '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── View Switch ─────────────────────────────────────────────
|
||||
function switchView(v) {
|
||||
const isPlayer = v === 'player';
|
||||
document.getElementById('viewPlayer').style.display = isPlayer ? 'block' : 'none';
|
||||
const listView = document.getElementById('viewList');
|
||||
listView.style.display = isPlayer ? 'none' : 'flex';
|
||||
document.getElementById('btnPlayer').classList.toggle('active', isPlayer);
|
||||
document.getElementById('btnList').classList.toggle('active', !isPlayer);
|
||||
}
|
||||
|
||||
// ─── Media Session API (锁屏控制) ────────────────────────────
|
||||
function setupMediaSession() {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
navigator.mediaSession.setActionHandler('play', () => playAudio());
|
||||
navigator.mediaSession.setActionHandler('pause', () => pauseAudio());
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => nextTrack());
|
||||
navigator.mediaSession.setActionHandler('previoustrack',() => prevTrack());
|
||||
navigator.mediaSession.setActionHandler('seekto', (e) => {
|
||||
if (e.seekTime !== undefined && audio.duration) {
|
||||
audio.currentTime = e.seekTime;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateMediaSessionMetadata() {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
const t = PLAYLIST[currentIdx];
|
||||
if (!t) return;
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: t.title || '未知曲目',
|
||||
artist: t.artist || '未知歌手',
|
||||
album: '八音盒',
|
||||
artwork: t.cover ? [
|
||||
{ src: t.cover, sizes: '512x512', type: 'image/jpeg' }
|
||||
] : []
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Service Worker (PWA 后台播放) ──────────────────────────
|
||||
// iOS Safari 要求: 页面首次用户交互后才能播放,后台播放依赖 Media Session
|
||||
// SW 主要用于缓存页面资源使其可离线安装为 PWA
|
||||
function registerSW() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
// 实际部署时把 SW 代码写成 /sw.js 文件
|
||||
// 这里生成一个内联 blob SW 用于演示
|
||||
const swCode = `
|
||||
const CACHE = 'music-box-v1';
|
||||
self.addEventListener('install', e => {
|
||||
self.skipWaiting();
|
||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/'])));
|
||||
});
|
||||
self.addEventListener('activate', e => {
|
||||
e.waitUntil(clients.claim());
|
||||
});
|
||||
self.addEventListener('fetch', e => {
|
||||
// 音频文件不缓存(走网络),页面资源走缓存优先
|
||||
if (e.request.url.includes('.mp3') || e.request.url.includes('oss')) {
|
||||
e.respondWith(fetch(e.request));
|
||||
return;
|
||||
}
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(r => r || fetch(e.request))
|
||||
);
|
||||
});
|
||||
`;
|
||||
const blob = new Blob([swCode], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
navigator.serviceWorker.register(url).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PWA Manifest ────────────────────────────────────────────
|
||||
(function injectManifest() {
|
||||
const manifest = {
|
||||
name: '八音盒',
|
||||
short_name: '八音盒',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#0f0e17',
|
||||
theme_color: '#0f0e17',
|
||||
icons: [
|
||||
{ src: 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#0f0e17"/><circle cx="50" cy="44" r="32" stroke="#e8b86d" stroke-width="1.2" opacity="0.25" fill="none"/><path d="M33 66 C24 65 16 70 14 78 C12 84 16 88 24 87 C32 86 42 79 44 72 L44 67 Z" fill="#e8e6f0"/><path d="M67 66 C76 65 84 70 86 78 C88 84 84 88 76 87 C68 86 58 79 56 72 L56 67 Z" fill="#e8e6f0"/><path d="M44 67 C44 67 40 58 40 50 C40 40 44 34 48 30 L50 29 L52 30 C56 34 60 40 60 50 C60 58 56 67 56 67 C53 74 47 74 44 67 Z" fill="#e8e6f0"/><ellipse cx="50" cy="72" rx="10" ry="4" fill="#e8e6f0"/><rect x="46" y="25" width="8" height="7" rx="2" fill="#e8e6f0"/><ellipse cx="50" cy="17" rx="11" ry="12" fill="#e8e6f0"/><ellipse cx="50" cy="6" rx="5" ry="4" fill="#e8e6f0"/><path d="M34 36 Q28 33 28 29 Q28 25 34 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.7"/><path d="M66 36 Q72 33 72 29 Q72 25 66 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.7"/><circle cx="50" cy="7" r="1.5" fill="#e8b86d"/></svg>'), sizes: '192x192', type: 'image/svg+xml' }
|
||||
]
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'manifest'; link.href = url;
|
||||
document.head.appendChild(link);
|
||||
})();
|
||||
|
||||
// ─── Utils ───────────────────────────────────────────────────
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function showToast(msg) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => el.classList.remove('show'), 1800);
|
||||
}
|
||||
|
||||
// ─── Start ───────────────────────────────────────────────────
|
||||
init();
|
||||
// init slider fills
|
||||
setTimeout(() => {
|
||||
updateSliderFill(document.getElementById('progressBar'), 0);
|
||||
updateSliderFill(document.getElementById('volSlider'), 100);
|
||||
}, 50);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1221,6 +1221,46 @@ const lessons=[
|
||||
exp:"每一次重新进入股市,风险意识都比上一次更强——从热点个股到宽基ETF,从频繁操作到条件单等待,从无仓位上限到六层金字塔。未来随着临近退休,防御仓位会进一步提高,进攻仓位继续收缩。这个方向是正确的,但节奏需要和实际需求挂钩,不能盲目加速。",
|
||||
theory:"Inflation Risk(通胀风险)——不像市场下跌那样显眼,但对退休资产的长期侵蚀是真实的。中国历史通胀均值约2%~3%,实际需要名义收益率5%~6%才能保住购买力。如果风险收缩太快,全部转向防御资产,收益率可能跌到4%以下,看起来在赚钱,实际上购买力在下降。建议设动态收缩标准:每五年评估一次,根据当时的通胀率、退休时间表、现金流需求,决定防御仓位提高多少。不是线性收缩,是阶梯式的、有触发条件的收缩。Life-Cycle Investing核心原则:防御化的节奏要匹配你的负债端(退休后需要多少现金流),而不是主观上'觉得应该更稳了'。",
|
||||
tags:["Inflation Risk","通胀侵蚀","Life-Cycle Investing","阶梯式收缩"]
|
||||
},
|
||||
{
|
||||
theme:"第二十一课 · 六层仓位金字塔——风险分层是组合的骨架",
|
||||
en:"仓位大小和风险特征完全反向对应。越稳的给越多钱,越进攻的给越少钱。",
|
||||
zh:"仓位像盖楼打地基,越稳的层给的钱越多。大盘宽基是地基,10万打底稳稳当当;卫星博弈是楼顶的花园,1万够用,塌了也不影响整栋楼。每一层的亏损即使全亏完,也伤不到下面几层——这就是分层最大的价值,不是赚多少,是亏得起。",
|
||||
exp:"2026年6月采摘节重新界定仓位分层,从大盘宽基到卫星博弈,金额从10万递减到1万,形成六级风险金字塔:大盘宽基¥10万 / 主题宽基¥8万 / 进取成长¥5万 / 优质进攻¥3.5万 / 弹性进攻¥2.5万 / 卫星博弈¥1万。通讯ETF定位在优质进攻层(¥3.5万),不是随手试错,是有真实信念支撑的标的。这个分层不是静态的,会随生命周期动态调整——临近退休,防御层比重上升,进攻层比重下降。",
|
||||
theory:"Risk Pyramid(风险金字塔)——标准机构组合管理框架,底层是低风险高流动性资产,越往上风险越高、仓位越小。这个设计的价值不只是控制风险,而是让每一层的亏损即使最大化,也不会动摇下面几层的稳定性。六层架构比三层更精细,说明对持仓特征已经有了清晰的分类能力,不再是感性的配置,而是结构性的设计。Life-Cycle Investing补充:六层结构本身是动态的,防御层的比重应随退休时间临近而阶梯式上升,不是一劳永逸的固定比例。",
|
||||
tags:["Risk Pyramid","仓位分层","风险金字塔","六级架构","Life-Cycle"]
|
||||
},
|
||||
{
|
||||
theme:"第二十二课 · 锚定偏差——卖飞了是危险的买入动机",
|
||||
en:"如果从来没有持有过它,今天第一次看到,你会买吗?",
|
||||
zh:"追回一个之前卖飞了的标的,动机里混杂着信念和遗憾。过滤方法很简单:假设今天是第一次看到它,你会买吗?如果答案是会,才是真信念;如果犹豫,说明冲动里有锚定的成分。补充风险:估值基准可能重置,原来的伏击价格可能永远触发不了。",
|
||||
exp:"通讯ETF设了条件单但一直涨,半年没有触发买入。采摘节考虑是否重新伏击时,发现追回的动机里混杂着两种成分:一是对这个板块真实的信念,二是对'卖便宜了'的遗憾。这两种动机看起来相似,但驱动力完全不同。在做任何分析之前,必须先过一道过滤:如果从来没有持有过它,今天第一次看到,你会买吗?答案是会,才继续分析;答案是不确定,说明追回来的冲动里有锚定的成分。补充风险:光模块板块在AI算力爆发背景下可能进入新估值区间,原来的伏击价格可能永远触发不了——这时需要判断是坚持等回调,还是接受新定价基准上移伏击价格。",
|
||||
theory:"Anchoring Bias(锚定偏差)——Kahneman研究:人类决策天然会被第一个接触到的数字锚定。卖出价格成为锚点后,'追回来'的冲动看起来像逻辑,实际上是情绪。市场估值基准重置风险:某些板块在结构性利好驱动下会进入全新的估值区间,原来基于历史价格设定的伏击点可能永久失效。正确的应对不是硬等,而是重新评估:这个价格对应的是旧逻辑还是新逻辑?两个答案对应两种截然不同的操作。",
|
||||
tags:["Anchoring Bias","锚定偏差","卖飞追回陷阱","估值基准重置"]
|
||||
},
|
||||
{
|
||||
theme:"第二十三课 · 系统的终点是直觉专家——规则内化为本能",
|
||||
en:"系统最高境界是成熟在胸,手到擒来。到了哪一步就走哪一步操作。",
|
||||
zh:"投资系统就像开车,刚开始每一步都要想,十年后全部变肌肉记忆。达到这个境界靠的不是时间本身,而是系统化训练的积累——日志、条件单、复盘,把随机经验转化为真正的成长。没有日志,经验无法转化为进步,只是记忆在时间里的漂移。",
|
||||
exp:"现阶段的系统需要刻意思考才能执行:查条件单、看仓位层级、对照多因子框架、写日志、复盘。每一个步骤都是有意识的动作。目标是10年后这些动作变成本能,不需要每次从头推演,规则已经编码进直觉——失败了怎么走,成功了怎么走,大盘跌了怎么走,大盘涨了怎么走,每种情景都有预设的路径,执行不需要临时判断。达到这个境界的条件,不是时间的积累,而是系统化训练的积累。日志、条件单、采摘节复盘,是这个训练的基础设施。",
|
||||
theory:"Intuitive Expertise(直觉专家)——心理学家Klein研究:真正的专家直觉不是凭感觉,而是从系统化训练中提炼出来的模式识别能力。象棋大师看棋盘不需要逐步计算,是因为见过几十万个棋局,模式已经内化。投资的直觉专家同理。重要前提:直觉专家的直觉来自系统化训练,不是随机经验的积累。10年的随机经验只是一年经验的10次重复;10年的系统化记录和复盘,才是10年的真实成长。没有日志,经验无法转化为进步,只是记忆在时间里的漂移。",
|
||||
tags:["Intuitive Expertise","直觉专家","系统化训练","模式内化"]
|
||||
},
|
||||
{
|
||||
theme:"第二十四课 · 现金是期权,不是负担",
|
||||
en:"现金放着就是未来盈利的期权。他没有之前想象中的白白躺在账户里。有大用处。",
|
||||
zh:"判断对了但子弹不够,这是最难接受的一种输——输在准备上,不是输在判断上。现金不是等待中死去的钱,是等待中增值的权利。目标是把空闲现金仓提升至总仓位的20%,宁可不触发,也不提前消耗子弹。",
|
||||
exp:"纳指科技ETF那笔操作,条件单触发、方向正确、执行完整,结果盈利。但回补时现金仓不足,只能降低金额。判断对了,子弹却不够用,这是最难承受的一种输——输在准备上,不是输在判断上。此后开始重新理解现金仓的本质:不是等待中死去的钱,而是等待中增值的权利。现金仓管理被列为采摘节的核心任务,目标是把空闲现金仓提升至总仓位的20%。条件单设置开始偏向保守,宁可不触发,也不提前消耗子弹。现金仓不足的三种模式:①子弹打在了优先级更低的目标上;②仓位上限设太满没给回补留空间;③月度子弹节奏和市场机会节奏不匹配。",
|
||||
theory:"Option Value of Cash(现金的期权价值)——现金持有者拥有一张在任意时刻以任意价格买入任意资产的权利,这张权利没有行权成本,没有到期日。它的价值在危机时刻达到最高,恰恰是别人最缺现金的那一天。Capacity Constraint(容量约束):判断正确但资源不足,是一种不出现在账面上的机会成本损失。需要补充的批判:偏保守不是无限制的保守,第一档哨兵单需要设在合理但可触发的位置,否则等待本身会变成另一种形式的踏空——不是追高的踏空,是等待过度的踏空。",
|
||||
tags:["Option Value of Cash","Dry Powder","期权价值","Capacity Constraint","现金仓三种不足"]
|
||||
},
|
||||
{
|
||||
theme:"第二十五课 · 热点与非热点——聚合替代追逐",
|
||||
en:"大家都知道的热点,早就不是最好的进入时机了。万变不离其宗,就是纳指科技、科创50、中证500这些聚合股票。",
|
||||
zh:"追热点的本质是在消息已经定价之后入场。正确做法:热点用聚合型宽基ETF间接持有,指数自动做热点权重调整;非热点只选分红型蓝筹,焊着收分红,不依赖市场情绪。不认识的猎物不开枪,第一次遇到的猎物把打击距离拉远。",
|
||||
exp:"两次追热点的教训:①航空航天ETF——举国体制非商业化,政策节奏无法预测,入场时消息已经定价,小仓位亏损。②中概互联网ETF——期待市场把腾讯阿里当AI公司定价,但市场定的是互联网平台估值,两套逻辑不匹配,仓位不小,一路下跌。形成的新原则:热点用聚合型宽基ETF(纳指科技、科创50、中证500)间接持有,指数自动做热点权重调整;非热点只选分红型蓝筹(中国石化、中国神华、中国银行),焊着收分红,不依赖市场情绪。稀土和电力设备下半年不碰。",
|
||||
theory:"Catalyst Mismatch(催化剂错配):买入理由是期待A逻辑定价,市场实际采用B逻辑定价,两套逻辑不匹配,持有期间不会产生预期收益。Narrative Fallacy(叙事谬误):好故事不等于好投资。一个逻辑成立,不等于市场会在你预期的时间内为它定价。市场是前瞻性定价机器——当热点成为所有人都在讨论的话题,预期已经被定价了大半,你看到的是别人走过的路的终点,不是起点。Smart Beta via Index(指数替代选热点):通过持有聚合指数,间接持有所有热点的受益,同时不承担单一热点判断失误的风险。",
|
||||
tags:["Catalyst Mismatch","Narrative Fallacy","叙事谬误","聚合替代追逐","Smart Beta"]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1821,6 +1861,10 @@ renderLesson();
|
||||
<div class="p4-toc-item" data-ch="13"><span class="p4-toc-marker">⑭</span><span class="p4-toc-title">软件工程师的投资系统</span></div>
|
||||
<div class="p4-toc-item" data-ch="14"><span class="p4-toc-marker">⑮</span><span class="p4-toc-title">系统的骨架</span></div>
|
||||
<div class="p4-toc-item" data-ch="15"><span class="p4-toc-marker">⑯</span><span class="p4-toc-title">赢得不干净的那一次</span></div>
|
||||
<div class="p4-toc-item" data-ch="16"><span class="p4-toc-marker">⑰</span><span class="p4-toc-title">散弹枪与狙击枪</span></div>
|
||||
<div class="p4-toc-item" data-ch="17"><span class="p4-toc-marker">⑱</span><span class="p4-toc-title">三个隐蔽点</span></div>
|
||||
<div class="p4-toc-item" data-ch="18"><span class="p4-toc-marker">⑲</span><span class="p4-toc-title">采摘节</span></div>
|
||||
<div class="p4-toc-item" data-ch="19"><span class="p4-toc-marker">⑳</span><span class="p4-toc-title">热点与非热点</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2156,6 +2200,88 @@ const p4chapters = [
|
||||
"盈利是结果,干净是过程。结果好,不代表过程没有问题。把过程里的问题找出来,是下一次赢得更干净的唯一方式。",
|
||||
"告诫后来者: 在每一次盈利之后,也要认真复盘。问自己:这次赢得干净吗?如果不干净,原因是什么?好结果会掩盖坏过程,而坏过程,终有一天会在没有好结果的时候,完整地呈现出来。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "散弹枪与狙击枪",
|
||||
marker: "⑰",
|
||||
paras: [
|
||||
"如果要用最简单的语言,解释我的投资方法经历了什么样的转变,我会说:我从一个散弹枪手,变成了一个狙击手。",
|
||||
"这不是隐喻的优劣之争,而是两种根本不同的作战哲学。",
|
||||
"从前,我每天把枪拿出来,走进森林,听说哪里有猎物,就朝那个方向走过去。感觉是猎物了,开一枪。感觉还有猎物,再开一枪。回家的时候,子弹用了不少,背篓里却没什么东西。算下来,子弹的成本,远远超过了猎获的价值。",
|
||||
"这就是散弹枪打法的本质问题:不是枪法不准,而是开枪太多。",
|
||||
"每一颗子弹都有成本。显性的成本是手续费和价差,隐性的成本是注意力、判断力,以及每一次错误决策之后需要重建的信心。当你把精力分散在几十个方向上,表面上看起来勤奋,实际上是在用最高的代价,换取最低的期望回报。行为金融学的研究早已证实:交易最频繁的散户,年均收益率比交易最少的散户低6.5个百分点。勤奋,在这里是一个陷阱,不是优势。",
|
||||
"后来,我换了一套作战方式。",
|
||||
"我在森林里找了三个隐蔽点,分别叫中国、海外和防御。每一个隐蔽点,都是猎物必经之路——不是随机选择的,而是经过仔细勘察之后确定的战略阵地。然后,我趴下来。",
|
||||
"趴着,是这套方法最反人性的部分。什么都不做,在心理上等同于浪费时间。大多数人无法忍受这种沉默,会忍不住起身,走动,找点什么事情做。但对狙击手来说,在猎物没有进入射程之前起身,才是真正的危险——不只是暴露位置,而是打乱了整个作战节奏。",
|
||||
"我用条件单来维持这种趴伏状态。在平静的夜晚,根据每种猎物的特性,计算好打击距离,设定好每一枪的触发价格和仓位大小,然后把枪放在那里,让它自动开火。",
|
||||
"不同的猎物,有不同的计算方式。带翅膀的飞禽,要用溢价率来判断它是否在合理的高度;善于奔跑的走兽,要用PE分位数看它是否已经跑到了估值的洼地;懂得潜水的水生动物,要用股息率判断它是否已经沉到了值得捕捞的深度。统一用箱体价格来瞄准所有猎物,就像用同一把标尺测量飞禽的速度和鱼的深度——工具用错了地方。",
|
||||
"四枪的节奏,是这套打法的核心设计。第一枪在-10%开火,轻仓试探,这是哨兵枪;第二枪在-20%,加大仓位,这是主力枪;第三枪在-30%,是对极端情景的备用;第四枪尽量不开,留着看它是否真的需要。子弹是宝贵的,猎物挣扎五分钟自己倒下了,你还留着两颗子弹,这是最好的结局。",
|
||||
"但这里有一处必须坦诚的局限:四枪之间的间距,应该根据猎物的特性来设定,不是所有猎物都适合10%的步长。低波动的猎物可以步长小一些;高速奔跑的猎物,步长太小会让你在它下跌的途中把子弹全部打光,等不到真正的底部。",
|
||||
"还有两条铁律,是我用真实的代价换来的。不认识的猎物,不开枪。也许它看起来很诱人,但如果你不了解它的习性,你的第一枪很可能惊动了它,让它跑得更远。更糟糕的是,也许那根本不是猎物。第一次遇到的猎物,把打击距离拉远。不是-10%,而是-20%。你对它的了解打了折扣,安全边际就要相应地加宽。宁可少赚,不可不慎。",
|
||||
"从散弹枪到狙击枪,不只是方法的改变,是心态的根本转变。以前,我害怕错过;现在,我更害怕打错。以前,我把每天开枪当成努力;现在,我把趴着等待当成工作。以前,我认为子弹用完就叫做积极;现在,我知道子弹留在手里,才叫做准备。",
|
||||
"告诫后来者: 开枪之前,先问自己三个问题。这只猎物,我认识吗?它现在进入射程了吗?我剩下的子弹,够不够应对更好的机会?三个问题都有满意的答案,再扣动扳机。",
|
||||
"---"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "三个隐蔽点",
|
||||
marker: "⑱",
|
||||
paras: [
|
||||
"狙击手在出发之前,要做的第一件事不是磨枪,而是选阵地。",
|
||||
"我选了三个隐蔽点。",
|
||||
"一个叫中国,一个叫海外,一个叫防御。每一个都是猎物必经之路,每一个都有它独特的气候和季节规律。重要的是,我没有试图判断哪一个隐蔽点今天猎物最多——那是一个即使是最老练的猎人也常常判断错误的问题。我的选择,是在三个地方同时布点,让时间和市场来决定下一只猎物从哪里经过。",
|
||||
"这是真正的分散逻辑。不是因为无知而分散,而是因为清醒而分散。",
|
||||
"Buffett说,分散是对无知的对冲。这句话是对的,但有一个前提——他的分散标准,是基于深度研究之后仍然无法确定的不确定性,不是基于懒惰或者贪多。我的三个隐蔽点,每一个都有明确的逻辑支撑,不是随机撒网,是有意识的战略布局。这两种分散,有本质的区别。",
|
||||
"隐蔽点没有好坏之分,只有时机的好坏之分。秋天大雁南飞,东边的隐蔽点是最佳位置;冬天狗熊出没,北边的林子更有机会。某一年龙卷风接连来袭,哪个隐蔽点都不安全。市场也是如此——中国有消费降级的周期,美国有选举年的震荡,防御资产有它自己的利率敏感性。阵地都没有问题,问题在于那一年的天气。",
|
||||
"我不打算调整这三个位置。至少在现阶段,它们给我机会均等的布局——当其中一个阵地沉寂的时候,另外两个往往有猎物经过。这种低相关性,是组合里真正的稳定器。",
|
||||
"有一句话值得认真对待:在不确定性最高的时候,最优仓位趋近于零。当你搞不清天气和季节,最好的做法是趴着,什么都不做。什么都不做,是一个主动的选择,不是被动的等待。等待本身,是这套系统最重要的工作内容之一。",
|
||||
"偶尔,会有一只迷路的猎物,反常规地经过你的隐蔽点。2026年3月那次全球大跌,对很多人是灾难,对我是一只突然闯入的大猎物。它不在任何预测模型里,但我在那里,我有子弹,我趴着——这就是全部的条件。",
|
||||
"退休之后,我可能会缩减到一个更安静的隐蔽点——少一些进攻性的布局,多一些风调雨顺的日子。防御型的阵地,猎物少一些,但天气稳定,偶尔还有日光浴。对那个年纪的我来说,这或许是最合适的节奏。",
|
||||
"但有一点需要认真对待:防御不等于无风险。每一个隐蔽点都有它自己的风险,只是风险的形态不同。退休后的阵地,需要的不是没有风险,而是风险的形态符合那个阶段对现金流稳定性的需求。",
|
||||
"告诫后来者: 选阵地,不是选哪里猎物最多,而是选哪里你最有把握在猎物经过时准确击发。了解自己的能力边界,比了解市场的机会,更重要。阵地选好了,趴着等,这是这套方法最难也最关键的部分。",
|
||||
"---"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "采摘节",
|
||||
marker: "⑲",
|
||||
paras: [
|
||||
"在我的投资系统里,有一个节点叫做采摘节。",
|
||||
"不是每天,不是每周,而是每半年一次。时间通常定在六月底和十二月底。这个设计本身,就是一种表态:市场的节奏不是我的节奏,我有自己的节奏。",
|
||||
"采摘节的第一个问题,永远是:我想要多少现金仓?",
|
||||
"不是先看哪只股票涨了,不是先算账面盈利,而是先确定目标。这个顺序至关重要。大多数人是反过来的——看到涨了的就卖,卖完了发现手里多了些钱,然后找下一个买入目标。这是结果在驱动决策。我选择让目标驱动决策:先定20%的现金仓比例,再推导哪些仓位需要减,减多少。",
|
||||
"从前,我把现金仓看成是没有被使用的资金,是等待中的浪费。这个认知,在某一次机会来临却子弹不足之后,彻底改变了。那次,条件单触发,方向正确,判断准确,但回补的资金不够,只能缩减仓位。账面上依然是盈利,但那个盈利,比它本可以达到的数字,少了一截。这种损失不出现在任何报表里,但它是真实的。它有一个名字,叫做机会成本。",
|
||||
"从那之后,我把现金仓理解成一张期权。它赋予我在任何时刻以任何价格买入任何资产的权利。这张期权没有行权成本,没有到期日。它的价值,在市场最恐慌的时候达到顶点,恰恰是别人最缺现金的那一天。",
|
||||
"采摘节的第二个任务,是做减法。三十个持仓,是这套系统走到今天积累下来的数字。每一个进入的时候都有理由,但不是每一个今天还有同样充分的理由留着。采摘节的核心问题只有一个:如果今天重新建仓,你还会买这个吗?答案是否定的,就是退出候选。不是因为它亏了,而是因为它占据了仓位、占据了注意力,却没有占据你的真实信念。",
|
||||
"第三个任务,是重构伏击价格。从单一的箱体价格,升级到多因子判断——PE分位数、溢价率、股息率、均线,根据不同的猎物类型,选择不同的瞄准方式。箱体告诉你价格在哪里,估值告诉你价值在哪里。用价格瞄准,你打的是位置;用价值瞄准,你打的是意义。",
|
||||
"还有一项容易被忽视的工作:逐一审查所有未触发的条件单。一张等待了六个月却没有触发的伏击单,有两种可能:一是原来的逻辑依然成立,市场只是还没有给你那个价格;二是底层逻辑已经发生了变化,那个价格可能永远不会再来。这两种情况需要不同的应对,而区分它们的唯一方式,是在采摘节认真地重新审视。",
|
||||
"最后一个任务,是拿出盈利的10%,奖励自己。",
|
||||
"这条是议程里最重要的,没有之一。不是因为钱的多少,而是因为这个动作本身的意义。它告诉你的大脑:这套系统是真实运转的,是有收获的,是值得继续维护的。采摘节不只是工作,它是你和自己的系统之间的一次仪式。仪式感,是长期坚持的燃料。",
|
||||
"采摘节结束的那个晚上,我会去吃一顿好的。不是庆祝赚了多少,而是庆祝又认真地走完了一个完整的周期。",
|
||||
"告诫后来者: 给你的投资系统设置一个有节奏的呼吸节点。不是为了频繁操作,而是为了让每一个持仓,在每一个时间点,都还有它存在的理由。采摘节的价值,不在于那一天做了什么,而在于你有这样一天。",
|
||||
"---"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "热点与非热点",
|
||||
marker: "⑳",
|
||||
paras: [
|
||||
"森林里每隔一段时间,就会出现一种奇特的现象。",
|
||||
"某一块区域突然变得热闹起来。猎人们蜂拥而至,议论纷纷,说那里有稀有的猎物,有人已经打到了,有人正在赶去。整片森林都在谈论同一个名字。",
|
||||
"我有过两次追进去的经历。",
|
||||
"第一次是航空航天ETF。那段时间,相关新闻铺天盖地,政策利好接连出台,市场情绪高涨。我以为这是中国版的SpaceX时刻,追了进去。后来才明白,中国的航空航天是举国体制,不是商业化市场。它的估值逻辑不是由市场供需决定的,而是由政策节奏决定的。政策什么时候给信号,市场什么时候反应,这个节奏,局外人无从预测。进去的时机已经是消息兑现之后,最好的定价时机早已过去。幸好仓位小,代价有限。",
|
||||
"第二次是中概互联网ETF。这一次,我以为自己想得很清楚——腾讯是中国互联网的基础设施,阿里是中国商业的底层平台,它们都有AI业务,是未来的受益者。逻辑严密,研究充分,仓位也不算小。但我犯了一个更隐蔽的错误。",
|
||||
"我买入的理由,是期待市场把它们当作AI公司来定价。但市场在这段时间给它们的定价,是互联网平台公司——一个增长放缓、竞争加剧的行业框架。我预期的定价逻辑,和市场实际采用的定价逻辑,不是同一个。公司本身没有问题,但我等待的那个市场认可,迟迟没有到来。",
|
||||
"这两次经历,让我想清楚了一件关于热点的根本性问题:大家都知道的热点,早就不是最好的进入时机了。",
|
||||
"市场是一台高效的定价机器。当一个主题成为所有人都在讨论的话题,当财经媒体开始用\"风口\"这个词,当你在饭桌上听到有人推荐某只股票,这个信息的定价,往往已经发生了大半。你看到的,是别人已经走过的路的终点,不是起点。",
|
||||
"真正的机会,往往安静得让人不安。",
|
||||
"明白了这一点,我的应对方式变了。热点,我不再追单个板块。我用聚合型的宽基ETF来间接持有热点的受益——纳指科技、科创50、中证500。这些指数自动做了一件我做不到的事:当某个热点板块涨了,它在指数里的权重上升,我自然地获益;当热点退潮,权重下降,我的损失也被其他成分稀释。我通过持有指数,持有了所有热点,同时不需要为\"今年是半导体还是AI、是光模块还是机器人\"这个问题赌注。",
|
||||
"非热点,我只选分红型蓝筹。中国石化、中国神华、中国银行——这些公司不需要热点来证明自己的价值。它们的价值来自稳定的现金流和持续的股息分配。如果焊在手上,最坏的结果是每年收分红。这是一种特殊的安全感:不依赖市场情绪,只依赖公司盈利。",
|
||||
"进可攻,退可守。",
|
||||
"这不是一个聪明的策略,而是一个诚实的策略。它承认了一个事实:我没有能力比市场更早知道下一个热点是什么,也没有时间逐一研究每一个新兴板块。既然如此,就用系统替代判断——指数替代选热点,分红替代判断估值。",
|
||||
"有一点值得坦诚地说:这套方法,会让你错过一些真正的大机会。当某个板块从0涨到100,你持有的指数只涨了30。但它同样会保护你,当那个板块从100跌回30的时候,你的指数可能只跌了10。对于一个把投资定义为终身事业的人来说,少赚一些,是可以接受的代价。赔进去,才是真正的代价。",
|
||||
"告诫后来者: 当你听说某个热点,问自己三个问题。第一,这个消息,有多少人已经知道了?第二,你比知道这件事的其他人,有什么额外的优势?第三,如果你的判断是错的,你能承受多大的损失?三个问题都有满意的答案,再考虑进场。如果答案是\"大家都知道了\"\"没有额外优势\"\"损失不确定\",那就让这只热点猎物,从你的隐蔽点旁边走过去。"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "OSS 文件柜前端鉴权配置",
|
||||
"enable_auth_comment": "true: 启用三层鉴权(cookie/GUID/token); false: 完全放开访问",
|
||||
"enable_auth": true,
|
||||
"max_age_days_comment": "鉴权 cookie 最大有效天数(1~365),过期后需重新获取入口授权",
|
||||
"max_age_days": 30,
|
||||
"cookieName_comment": "鉴权 Cookie 名称(gate 外层 cookie,不等于业务登录态)",
|
||||
"cookieName": "oss_file_cabinet_gate",
|
||||
"requireGuid_comment": "true:入口必须带 guid 参数,否则直接拒绝;false:允许仅凭 token 或已有会话访问",
|
||||
"requireGuid": true,
|
||||
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
|
||||
"guidParam": "guid",
|
||||
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换,不在前端硬编码",
|
||||
"guidValue": "db8a_2f1c-90e7-45a-a(d2-c3e4f50890"
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OSS 文件柜</title>
|
||||
<link rel="stylesheet" href="oss_file_cabinet.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="loginScreen" class="login-screen">
|
||||
<div class="login-card">
|
||||
<div class="login-icon">🗂️</div>
|
||||
<h1>OSS 文件柜</h1>
|
||||
<p class="login-subtitle">请输入账号和密码登录</p>
|
||||
<div class="login-form">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="loginUsername" autocomplete="username" autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="loginPassword" autocomplete="current-password">
|
||||
</div>
|
||||
<button id="loginBtn" class="btn-primary">登录</button>
|
||||
<div id="loginError" class="login-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app" class="finder-app" style="display:none;">
|
||||
<div class="finder-window">
|
||||
<div class="finder-titlebar">
|
||||
<div class="window-buttons">
|
||||
<span class="btn-close"></span>
|
||||
<span class="btn-mini"></span>
|
||||
<span class="btn-max"></span>
|
||||
</div>
|
||||
<div class="finder-title">OSS 文件柜</div>
|
||||
<div class="finder-actions">
|
||||
<button id="btnThemeToggle" class="icon-btn" title="切换浅色/深色模式">🌓</button>
|
||||
<button id="btnLogout" class="icon-btn" title="退出登录">⎋</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finder-toolbar">
|
||||
<div class="nav-buttons">
|
||||
<button id="btnBack" class="nav-btn" title="返回">◀</button>
|
||||
<button id="btnForward" class="nav-btn" title="前进">▶</button>
|
||||
</div>
|
||||
<div class="path-bar" id="pathBar"></div>
|
||||
<div class="view-switcher">
|
||||
<button class="view-btn active" data-view="icon" title="图标视图">▦</button>
|
||||
<button class="view-btn" data-view="list" title="列表视图">☰</button>
|
||||
<button class="view-btn" data-view="column" title="分栏视图">▥</button>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="搜索">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finder-body">
|
||||
<div class="finder-sidebar">
|
||||
<div class="sidebar-section-title">个人收藏</div>
|
||||
<div class="sidebar-item active" data-target="home">
|
||||
<span class="sidebar-icon">🏠</span><span>全部文件</span>
|
||||
</div>
|
||||
<div class="sidebar-item" data-target="trash">
|
||||
<span class="sidebar-icon">🗑️</span><span>废纸篓</span>
|
||||
</div>
|
||||
<div class="sidebar-item" data-target="images">
|
||||
<span class="sidebar-icon">🖼️</span><span>图片</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finder-content">
|
||||
<div id="fileArea" class="file-area icon-view">
|
||||
<div class="empty-state" style="display:none;">
|
||||
<div class="empty-icon">📂</div>
|
||||
<div class="empty-title">空文件夹</div>
|
||||
<div class="empty-hint">拖拽文件到此处或点击「上传」</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="finder-statusbar">
|
||||
<span id="statusInfo">0 个项目</span>
|
||||
<span id="statusSelect"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dropOverlay" class="drop-overlay">
|
||||
<div class="drop-box">
|
||||
<div class="drop-icon">⬆️</div>
|
||||
<div class="drop-text">松开以将文件上传到当前目录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="filePreview" class="modal" style="display:none;">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content image-preview">
|
||||
<button class="modal-close" id="closePreview">✕</button>
|
||||
<div id="previewContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="contextMenu" class="context-menu" style="display:none;">
|
||||
<div class="cm-item" data-action="open">打开</div>
|
||||
<div class="cm-item" data-action="preview">预览</div>
|
||||
<div class="cm-item" data-action="rename">重命名</div>
|
||||
<div class="cm-item" data-action="info">显示简介</div>
|
||||
<div class="cm-sep"></div>
|
||||
<div class="cm-item" data-action="copy">复制</div>
|
||||
<div class="cm-item" data-action="move">移动到…</div>
|
||||
<div class="cm-item" data-action="download">下载</div>
|
||||
<div class="cm-item" data-action="compress">压缩</div>
|
||||
<div class="cm-item" data-action="share">分享临时链接…</div>
|
||||
<div class="cm-item" data-action="raw_url">原始OSS链接(不建议)</div>
|
||||
<div class="cm-sep"></div>
|
||||
<div class="cm-item danger" data-action="delete">移到废纸篓</div>
|
||||
</div>
|
||||
|
||||
<div id="folderContextMenu" class="context-menu" style="display:none;">
|
||||
<div class="cm-item" data-action="new_folder">新建文件夹</div>
|
||||
<div class="cm-item" data-action="upload">上传文件…</div>
|
||||
<div class="cm-sep"></div>
|
||||
<div class="cm-item" data-action="paste">粘贴</div>
|
||||
<div class="cm-item" data-action="refresh">刷新</div>
|
||||
</div>
|
||||
|
||||
<div id="trashContextMenu" class="context-menu" style="display:none;">
|
||||
<div class="cm-item" data-action="restore">放回</div>
|
||||
<div class="cm-item danger" data-action="empty_perm">彻底删除</div>
|
||||
</div>
|
||||
|
||||
<div id="modalDialog" class="modal" style="display:none;">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-title" id="modalTitle"></div>
|
||||
<div class="modal-body" id="modalBody"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-ghost" id="modalCancel">取消</button>
|
||||
<button class="btn-primary" id="modalOk">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="file" id="fileInput" multiple style="display:none;">
|
||||
<script src="oss_file_cabinet.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,688 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--window-bg: #ffffff;
|
||||
--sidebar-bg: #ececec;
|
||||
--header-bg: #f0f0f2;
|
||||
--text: #1d1d1f;
|
||||
--text-secondary: #6e6e73;
|
||||
--border: rgba(0,0,0,0.08);
|
||||
--selection: #0a84ff;
|
||||
--selection-bg: rgba(10,132,255,0.14);
|
||||
--selection-border: rgba(10,132,255,0.35);
|
||||
--hover-bg: rgba(0,0,0,0.04);
|
||||
--danger: #ff3b30;
|
||||
--shadow: 0 10px 40px rgba(0,0,0,0.12), 0 2px 10px rgba(0,0,0,0.08);
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
}
|
||||
|
||||
:root.theme-dark {
|
||||
--bg: #1e1e1e;
|
||||
--window-bg: #2c2c2e;
|
||||
--sidebar-bg: #232325;
|
||||
--header-bg: #2a2a2c;
|
||||
--text: #f5f5f7;
|
||||
--text-secondary: #a1a1a6;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--selection: #0a84ff;
|
||||
--selection-bg: rgba(10,132,255,0.25);
|
||||
--selection-border: rgba(10,132,255,0.5);
|
||||
--hover-bg: rgba(255,255,255,0.05);
|
||||
--shadow: 0 10px 40px rgba(0,0,0,0.5), 0 2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button { font-family: inherit; cursor: pointer; border: none; outline: none; background: none; color: inherit; }
|
||||
input, select, textarea { font-family: inherit; outline: none; }
|
||||
|
||||
/* ===== 登录页 ===== */
|
||||
.login-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: linear-gradient(180deg, #6b7dff 0%, #b066ff 50%, #ff6ec7 100%);
|
||||
}
|
||||
|
||||
:root.theme-dark .login-screen { background: linear-gradient(180deg, #1a1d2e 0%, #2a1d3a 50%, #3a1d2e 100%); }
|
||||
|
||||
.login-card {
|
||||
width: 320px;
|
||||
padding: 32px 28px;
|
||||
background: var(--window-bg);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
}
|
||||
|
||||
.login-icon { font-size: 48px; margin-bottom: 8px; }
|
||||
.login-card h1 { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
|
||||
.login-subtitle { font-size: 12px; color: var(--text-secondary); margin-bottom: 20px; }
|
||||
|
||||
.login-form { text-align: left; }
|
||||
.form-group { margin-bottom: 12px; }
|
||||
.form-group label { display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.form-group input:focus { border-color: var(--selection); }
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 9px 16px;
|
||||
background: var(--selection);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-top: 6px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.88; }
|
||||
.btn-ghost {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn-ghost:hover { background: var(--hover-bg); }
|
||||
|
||||
.login-error { margin-top: 12px; color: var(--danger); font-size: 12px; text-align: center; min-height: 16px; }
|
||||
|
||||
/* ===== Finder 窗口 ===== */
|
||||
.finder-app { width: 100%; height: 100vh; padding: 12px; }
|
||||
|
||||
.finder-window {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--window-bg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.finder-titlebar {
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
background: var(--header-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.window-buttons { display: flex; gap: 8px; }
|
||||
.window-buttons span {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
.btn-close { background: #ff5f57; }
|
||||
.btn-mini { background: #febc2e; }
|
||||
.btn-max { background: #28c840; }
|
||||
|
||||
.finder-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.finder-actions { display: flex; gap: 6px; }
|
||||
.icon-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.icon-btn:hover { background: var(--hover-bg); color: var(--text); }
|
||||
|
||||
/* ===== Toolbar ===== */
|
||||
.finder-toolbar {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
gap: 12px;
|
||||
background: var(--header-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-buttons { display: flex; gap: 4px; }
|
||||
.nav-btn {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.nav-btn:hover { background: var(--hover-bg); color: var(--text); }
|
||||
|
||||
.path-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
min-height: 28px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.crumb {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.crumb.active { color: var(--text); font-weight: 500; }
|
||||
.crumb:hover:not(.active) { background: var(--hover-bg); color: var(--text); }
|
||||
.crumb-sep { color: var(--text-secondary); opacity: 0.5; }
|
||||
|
||||
.view-switcher {
|
||||
display: flex;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
}
|
||||
.view-btn {
|
||||
width: 28px; height: 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.view-btn:hover { color: var(--text); }
|
||||
.view-btn.active { background: var(--window-bg); color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,0.06); }
|
||||
|
||||
.search-box input {
|
||||
width: 180px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.search-box input:focus { border-color: var(--selection); }
|
||||
|
||||
/* ===== Body ===== */
|
||||
.finder-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.finder-sidebar {
|
||||
width: 180px;
|
||||
background: var(--sidebar-bg);
|
||||
padding: 10px 6px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-section-title {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 10px 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
margin: 1px 0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.sidebar-item:hover { background: var(--hover-bg); }
|
||||
.sidebar-item.active { background: var(--selection); color: #fff; }
|
||||
.sidebar-icon { font-size: 14px; }
|
||||
|
||||
.finder-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--window-bg);
|
||||
}
|
||||
|
||||
/* ===== 文件区 ===== */
|
||||
.file-area {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 18px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 图标视图 */
|
||||
.icon-view {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
.icon-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 6px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background 0.12s;
|
||||
user-select: none;
|
||||
}
|
||||
.icon-item:hover { background: var(--hover-bg); }
|
||||
.icon-item.selected { background: var(--selection-bg); }
|
||||
.icon-wrap {
|
||||
width: 64px; height: 64px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 34px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 12px;
|
||||
background: var(--file-icon-bg, #f0f0f0);
|
||||
overflow: hidden;
|
||||
}
|
||||
.icon-wrap.file-red { background: #ffebee; }
|
||||
.icon-wrap.file-blue { background: #e3f2fd; }
|
||||
.icon-wrap.file-green { background: #e8f5e9; }
|
||||
.icon-wrap.file-orange { background: #fff3e0; }
|
||||
.icon-wrap.file-purple { background: #f3e5f5; }
|
||||
.icon-wrap.file-pink { background: #fce4ec; }
|
||||
.icon-wrap.file-yellow { background: #fffde7; }
|
||||
.icon-wrap.file-image { background: #e8eaf6; }
|
||||
.icon-wrap.file-text { background: #f5f5f5; }
|
||||
.icon-wrap.file-gray { background: #f0f0f0; }
|
||||
.icon-thumb {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.icon-name {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
max-width: 80px;
|
||||
word-break: break-all;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 列表视图 */
|
||||
.list-table {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.list-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) 100px 180px 100px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
.list-row:hover { background: var(--hover-bg); }
|
||||
.list-row.selected { background: var(--selection-bg); }
|
||||
.list-header {
|
||||
background: var(--header-bg);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1;
|
||||
}
|
||||
.list-header:hover { background: var(--header-bg); }
|
||||
.col-name { display: flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.col-size, .col-modified, .col-kind { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.row-icon {
|
||||
width: 22px; height: 22px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 13px;
|
||||
border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
background: var(--file-icon-bg, #f0f0f0);
|
||||
}
|
||||
.row-icon.file-red { background: #ffebee; }
|
||||
.row-icon.file-blue { background: #e3f2fd; }
|
||||
.row-icon.file-green { background: #e8f5e9; }
|
||||
.row-icon.file-orange { background: #fff3e0; }
|
||||
.row-icon.file-purple { background: #f3e5f5; }
|
||||
.row-icon.file-pink { background: #fce4ec; }
|
||||
.row-icon.file-yellow { background: #fffde7; }
|
||||
.row-icon.file-image { background: #e8eaf6; }
|
||||
.row-icon.file-text { background: #f5f5f5; }
|
||||
.row-icon.file-gray { background: #f0f0f0; }
|
||||
|
||||
/* 分栏视图 */
|
||||
.file-area.column-view {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
}
|
||||
.column {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.column:last-child { border-right: none; }
|
||||
.column-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
user-select: none;
|
||||
}
|
||||
.column-row:hover { background: var(--hover-bg); }
|
||||
.column-row.selected { background: var(--selection); color: #fff; }
|
||||
.column-hint { flex: 2; display: flex; align-items: center; justify-content: center; }
|
||||
.column-empty { color: var(--text-secondary); font-size: 12px; }
|
||||
.col-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.column-info-panel { padding: 20px; text-align: center; }
|
||||
.column-info-name { font-size: 14px; font-weight: 600; margin-bottom: 12px; word-break: break-all; }
|
||||
.column-info-row { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.file-area.column-view { overflow-x: auto; }
|
||||
.column { min-width: 180px; max-width: 280px; }
|
||||
|
||||
/* 状态栏 */
|
||||
.finder-statusbar {
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px;
|
||||
background: var(--header-bg);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||||
.empty-title { font-size: 15px; margin-bottom: 4px; font-weight: 500; color: var(--text); opacity: 0.7; }
|
||||
.empty-hint { font-size: 12px; }
|
||||
|
||||
/* 拖拽蒙层 */
|
||||
.drop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10,132,255,0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
border: 3px dashed var(--selection);
|
||||
}
|
||||
.drop-box {
|
||||
background: var(--window-bg);
|
||||
padding: 32px 48px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.drop-icon { font-size: 48px; margin-bottom: 8px; }
|
||||
.drop-text { font-size: 14px; color: var(--text); }
|
||||
|
||||
/* 选框 */
|
||||
.selection-rect {
|
||||
position: fixed;
|
||||
border: 1px solid var(--selection);
|
||||
background: var(--selection-bg);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 998;
|
||||
}
|
||||
|
||||
/* ===== 右键菜单 ===== */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
min-width: 200px;
|
||||
background: var(--window-bg);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.25), 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 4px;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.cm-item {
|
||||
padding: 7px 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.cm-item:hover { background: var(--selection); color: #fff; }
|
||||
.cm-item.danger { color: var(--danger); }
|
||||
.cm-item.danger:hover { background: var(--danger); color: #fff; }
|
||||
.cm-sep {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* ===== 弹窗 ===== */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: var(--window-bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
padding: 20px 24px;
|
||||
min-width: 360px;
|
||||
max-width: 520px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
.modal-body {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.modal-body label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.modal-body label:first-child { margin-top: 0; }
|
||||
.modal-body input, .modal-body select {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.modal-body input:focus, .modal-body select:focus { border-color: var(--selection); }
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.modal-footer .btn-primary, .modal-footer .btn-ghost {
|
||||
width: auto;
|
||||
min-width: 72px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 简介 */
|
||||
.info-row {
|
||||
display: flex;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label {
|
||||
width: 80px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.info-value {
|
||||
flex: 1;
|
||||
color: var(--text);
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 分享输出 */
|
||||
.share-output { margin-top: 8px; }
|
||||
.share-title { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.share-url-box {
|
||||
padding: 8px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', Menlo, monospace;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 图片预览 */
|
||||
.modal-content.image-preview {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
min-width: auto;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: visible;
|
||||
}
|
||||
.modal-content.image-preview img {
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
transition: transform 0.08s ease-out;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--window-bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.modal-close:hover { background: var(--selection); color: #fff; }
|
||||
|
||||
/* ===== Toast ===== */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--window-bg);
|
||||
color: var(--text);
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||
font-size: 13px;
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
.toast-success { border-left: 3px solid #28c840; }
|
||||
.toast-error { border-left: 3px solid var(--danger); }
|
||||
.toast-info { border-left: 3px solid var(--selection); }
|
||||
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.3); }
|
||||
:root.theme-dark ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); }
|
||||
:root.theme-dark ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.finder-sidebar { width: 140px; }
|
||||
.list-row { grid-template-columns: minmax(180px, 1fr) 80px 140px 80px; font-size: 11px; }
|
||||
.search-box input { width: 120px; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -206,6 +206,14 @@ textarea {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.weather-data-timestamp {
|
||||
font-size: 11px;
|
||||
color: rgba(63, 95, 45, 0.5);
|
||||
margin-bottom: 4px;
|
||||
font-family: "Consolas", "Monaco", monospace;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
align-self: flex-end;
|
||||
background: transparent;
|
||||
|
||||
@@ -567,6 +567,17 @@ const renderWeather = weather => {
|
||||
const adviceText = escapeHtml(weather.advice_text || '暂无建议')
|
||||
const weatherIcon = getWeatherIcon(weather.icon)
|
||||
|
||||
// 转换 data_gateway 更新时间为北京时间
|
||||
const formatBeijingTime = (utcStr) => {
|
||||
if (!utcStr) return ''
|
||||
const d = new Date(utcStr.replace(' ', 'T') + '+00:00')
|
||||
if (isNaN(d.getTime())) return ''
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds())}`
|
||||
}
|
||||
const dataTimestamp = formatBeijingTime(weather._data_gateway_updated_at)
|
||||
const dataSourceLabel = dataTimestamp ? `数据更新:${dataTimestamp}` : ''
|
||||
|
||||
// 检查温度是否已经包含°C,如果没有则添加
|
||||
if (temp && !temp.includes('°C')) {
|
||||
temp = temp + '°C'
|
||||
@@ -602,6 +613,7 @@ const renderWeather = weather => {
|
||||
|
||||
<div class="weather-advice">
|
||||
<div class="advice-text" id="advice-text">${adviceText}</div>
|
||||
${dataSourceLabel ? `<div class="weather-data-timestamp">${escapeHtml(dataSourceLabel)}</div>` : ''}
|
||||
<button class="expand-btn" id="expand-advice-btn">展开</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"_comment": "私人剪贴板工具的前端鉴权配置",
|
||||
"enable_auth_comment": "true:启用鉴权;false:关闭鉴权并放开访问",
|
||||
"enable_auth": true,
|
||||
"max_age_days_comment": "鉴权 Cookie 有效期(天),范围建议 1~365",
|
||||
"max_age_days": 30,
|
||||
"max_age_days_comment": "鉴权 Cookie 有效期(天),1天后过期需重新登录",
|
||||
"max_age_days": 1,
|
||||
"cookieName_comment": "鉴权 Cookie 名称,建议保持默认",
|
||||
"cookieName": "private_clipboard_gate",
|
||||
"requireGuid_comment": "true:入口必须带 guid 参数;false:允许仅凭 token 或会话访问",
|
||||
@@ -11,5 +11,5 @@
|
||||
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
|
||||
"guidParam": "guid",
|
||||
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换",
|
||||
"guidValue": "SD9KK-LW32S-9KJJH-JJH43-LJ432J"
|
||||
"guidValue": "db8a2f1c-90e7-4b5a-a1d2-c3e4f5067890"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,68 @@ body {
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
/* ===== 登录界面 ===== */
|
||||
.pc-auth-card {
|
||||
max-width: 360px;
|
||||
margin: 120px auto 0;
|
||||
padding: 32px 28px;
|
||||
background: #1a1d24;
|
||||
border: 1px solid #2a2d35;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.pc-auth-card h2 {
|
||||
margin: 0 0 24px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pc-auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.pc-auth-form input {
|
||||
padding: 10px 12px;
|
||||
background: #0f1115;
|
||||
border: 1px solid #2a2d35;
|
||||
border-radius: 6px;
|
||||
color: #e6e8ee;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.pc-auth-form input:focus {
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
.pc-auth-status {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
.pc-auth-status.ok {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
/* ===== 顶部栏 ===== */
|
||||
.pc-topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pc-topbar-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pc-btn-logout {
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
background: #2a2d35;
|
||||
border: 1px solid #3a3d45;
|
||||
}
|
||||
.pc-btn-logout:hover {
|
||||
background: #3a3d45;
|
||||
}
|
||||
|
||||
.pc-main {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -736,6 +736,99 @@ const createFolder = async () => {
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
// ===== set_token 处理:必须在 clearUrlQuery 之前 =====
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const setToken = params.get('set_token')
|
||||
if (setToken) {
|
||||
localStorage.setItem('toolbox_creds_token', setToken)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
clearUrlQuery()
|
||||
|
||||
// ===== 登录逻辑 =====
|
||||
const $auth = $('pc-auth')
|
||||
const $app = $('pc-app')
|
||||
const $status = $('pc-auth-status')
|
||||
const $user = $('pc-login-user')
|
||||
const $pass = $('pc-login-pass')
|
||||
|
||||
async function doLogin() {
|
||||
try {
|
||||
const username = $user.value.trim()
|
||||
const password = $pass.value
|
||||
const res = await fetch(apiBase + '/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.ok) throw new Error(data.error || '登录失败')
|
||||
$auth.style.display = 'none'
|
||||
$app.style.display = ''
|
||||
initApp()
|
||||
} catch (e) {
|
||||
$status.textContent = String(e.message || e)
|
||||
$status.style.display = ''
|
||||
$status.className = 'pc-auth-status'
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
try {
|
||||
await fetch(apiBase + '/auth/logout', { method: 'POST', credentials: 'same-origin' })
|
||||
} catch {}
|
||||
$app.style.display = 'none'
|
||||
$auth.style.display = ''
|
||||
$pass.value = ''
|
||||
$status.textContent = '已注销'
|
||||
$status.className = 'pc-auth-status ok'
|
||||
$status.style.display = ''
|
||||
}
|
||||
|
||||
// 自动填充
|
||||
async function tryAutoFill() {
|
||||
try {
|
||||
const token = localStorage.getItem('toolbox_creds_token') || ''
|
||||
if (!token) return
|
||||
const res = await fetch(apiBase + '/auth/local_creds?token=' + encodeURIComponent(token), {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
if (data && data.ok && data.creds) {
|
||||
if (data.creds.username) $user.value = data.creds.username
|
||||
if (data.creds.password) $pass.value = data.creds.password
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$('pc-btn-login').onclick = doLogin
|
||||
$('pc-btn-logout').onclick = doLogout
|
||||
$user.removeAttribute('readonly')
|
||||
$pass.removeAttribute('readonly')
|
||||
$pass.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin() })
|
||||
|
||||
// 先检查是否已登录
|
||||
try {
|
||||
const res = await fetch(apiBase + '/auth/me', { credentials: 'same-origin', cache: 'no-store' })
|
||||
const data = await res.json()
|
||||
if (data && data.ok) {
|
||||
$auth.style.display = 'none'
|
||||
$app.style.display = ''
|
||||
initApp()
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 未登录,尝试自动填充
|
||||
await tryAutoFill()
|
||||
}
|
||||
|
||||
const initApp = async () => {
|
||||
clearUrlQuery()
|
||||
const isMobile = detectMobile()
|
||||
const refreshBtn = $('pc-refresh')
|
||||
|
||||
@@ -9,7 +9,23 @@
|
||||
<link rel="stylesheet" href="/tools/private_clipboard/clipboard.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="pc-main">
|
||||
<!-- 登录界面 -->
|
||||
<section id="pc-auth" class="pc-auth-card">
|
||||
<h2>私人剪贴板 - 登录</h2>
|
||||
<div class="pc-auth-form" autocomplete="off">
|
||||
<input id="pc-login-user" placeholder="用户名" autocomplete="off" readonly />
|
||||
<input id="pc-login-pass" placeholder="密码" type="password" autocomplete="off" readonly />
|
||||
<button id="pc-btn-login" class="btn">登录</button>
|
||||
</div>
|
||||
<div id="pc-auth-status" class="pc-auth-status" style="display:none"></div>
|
||||
</section>
|
||||
|
||||
<!-- 主应用界面 -->
|
||||
<main id="pc-app" class="pc-main" style="display:none">
|
||||
<div class="pc-topbar">
|
||||
<span class="pc-topbar-title">私人剪贴板</span>
|
||||
<button id="pc-btn-logout" class="btn pc-btn-logout" type="button">注销</button>
|
||||
</div>
|
||||
<section class="pc-input">
|
||||
<div id="paste-zone" class="pc-paste-zone pc-desktop-only" tabindex="0">
|
||||
<div class="pc-paste-text">在这里粘贴文字 / 图片 / 文件</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>打谷场 - 塔防合集</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{
|
||||
--bg:#0d1117;--card-bg:#161b22;--border:#30363d;
|
||||
--accent:#e94560;--accent2:#4caf50;--gold:#f0c040;
|
||||
--text:#e6edf3;--text2:#8b949e;
|
||||
}
|
||||
body{
|
||||
font-family:'Segoe UI','Microsoft YaHei',sans-serif;
|
||||
background:var(--bg);color:var(--text);min-height:100vh;
|
||||
}
|
||||
header{
|
||||
text-align:center;padding:40px 20px 20px;
|
||||
background:linear-gradient(180deg, #1a1a2e 0%, var(--bg) 100%);
|
||||
border-bottom:1px solid var(--border);
|
||||
}
|
||||
header h1{font-size:32px;color:var(--accent);margin-bottom:8px}
|
||||
header p{font-size:14px;color:var(--text2)}
|
||||
.container{
|
||||
max-width:900px;margin:0 auto;padding:24px 16px;
|
||||
display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
|
||||
gap:16px;
|
||||
}
|
||||
.card{
|
||||
background:var(--card-bg);border:1px solid var(--border);
|
||||
border-radius:10px;padding:20px;cursor:pointer;
|
||||
transition:all 0.2s;text-decoration:none;color:var(--text);
|
||||
position:relative;overflow:hidden;
|
||||
}
|
||||
.card:hover{transform:translateY(-3px);border-color:var(--accent);box-shadow:0 4px 20px rgba(233,69,96,0.2)}
|
||||
.card .icon{font-size:36px;margin-bottom:12px}
|
||||
.card h3{font-size:16px;margin-bottom:6px}
|
||||
.card .diff{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;margin-bottom:8px}
|
||||
.card .diff.easy{background:#4caf50;color:#fff}
|
||||
.card .diff.hell{background:#e94560;color:#fff}
|
||||
.card .tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:10px}
|
||||
.card .tag{font-size:10px;padding:2px 6px;border-radius:3px;background:rgba(255,255,255,0.06);color:var(--text2)}
|
||||
.card .desc{font-size:12px;color:var(--text2);line-height:1.5}
|
||||
.card.coming-soon{opacity:0.5;cursor:default}
|
||||
.card.coming-soon:hover{transform:none;border-color:var(--border);box-shadow:none}
|
||||
.card.coming-soon .badge{position:absolute;top:10px;right:10px;font-size:10px;background:var(--border);color:var(--text2);padding:2px 8px;border-radius:10px}
|
||||
footer{
|
||||
text-align:center;padding:20px;color:var(--text2);font-size:12px;
|
||||
border-top:1px solid var(--border);margin-top:20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>打谷场</h1>
|
||||
<p>军事风自定义路线塔防游戏合集 - 多个难度版本,自由绘制敌军路径</p>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<!-- 简单版 -->
|
||||
<a class="card" href="simple.html">
|
||||
<div class="icon">🎯</div>
|
||||
<h3>简单版</h3>
|
||||
<span class="diff easy">入门</span>
|
||||
<p class="desc">标准难度,500金币起步,20生命值。BOSS每10波出现。适合新手熟悉游戏机制。</p>
|
||||
<div class="tags">
|
||||
<span class="tag">金币500</span><span class="tag">生命20</span><span class="tag">BOSS=10波</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 地狱版 -->
|
||||
<a class="card" href="hell.html">
|
||||
<div class="icon">💀</div>
|
||||
<h3>地狱版</h3>
|
||||
<span class="diff hell">地狱</span>
|
||||
<p class="desc">极难难度!300金币紧巴巴,仅10生命值。BOSS每5波就来,敌人1.5倍数量+1.3倍速度,塔造价涨30%。</p>
|
||||
<div class="tags">
|
||||
<span class="tag">金币300</span><span class="tag">生命10</span><span class="tag">BOSS=5波</span><span class="tag">1.5x敌军</span><span class="tag">1.3x塔价</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 占位:更多版本 -->
|
||||
<div class="card coming-soon">
|
||||
<div class="icon">⚡</div>
|
||||
<h3>闪电战</h3>
|
||||
<span class="badge">即将推出</span>
|
||||
<p class="desc">敌人速度翻倍,但塔攻击力也翻倍。快节奏极限操作。</p>
|
||||
<div class="tags">
|
||||
<span class="tag">2x速度</span><span class="tag">2x伤害</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card coming-soon">
|
||||
<div class="icon">♾️</div>
|
||||
<h3>无尽模式</h3>
|
||||
<span class="badge">即将推出</span>
|
||||
<p class="desc">无波次限制,敌人无限刷新,难度持续递增,挑战最高击杀数。</p>
|
||||
<div class="tags">
|
||||
<span class="tag">无限波</span><span class="tag">排行榜</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card coming-soon">
|
||||
<div class="icon">🌙</div>
|
||||
<h3>噩梦版</h3>
|
||||
<span class="badge">即将推出</span>
|
||||
<p class="desc">比地狱更恐怖。0金币起步,杀敌才得金币。每波随机出特殊兵种。</p>
|
||||
<div class="tags">
|
||||
<span class="tag">0金币</span><span class="tag">特殊兵种</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card coming-soon">
|
||||
<div class="icon">🛡️</div>
|
||||
<h3>坚守阵地</h3>
|
||||
<span class="badge">即将推出</span>
|
||||
<p class="desc">固定路线+预设塔布局,纯策略防御。能用有限的塔撑多久?</p>
|
||||
<div class="tags">
|
||||
<span class="tag">固定路线</span><span class="tag">预设塔</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>所有版本离线可玩 · 存档永久保留在浏览器中 · 无贴图无音效极简风格</p>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user